@moondeck/station 0.0.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -2
  3. package/bin/uai-host.mjs +130 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/0005_host_env_vars.sql +5 -0
  10. package/db/migrations/0006_host_project_env.sql +9 -0
  11. package/db/migrations/0007_host_github_token_kind.sql +7 -0
  12. package/db/migrations/0008_host_mcp_connections.sql +18 -0
  13. package/db/migrations/0009_host_agent_sessions.sql +15 -0
  14. package/db/migrations/0010_host_engine_accounts.sql +13 -0
  15. package/db/migrations/0011_codex_thread_id.sql +5 -0
  16. package/db/migrations/0012_agent_session_attach_key.sql +4 -0
  17. package/db/migrations/0013_github_credential_generations.sql +11 -0
  18. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  19. package/db/migrations/0015_host_settings.sql +9 -0
  20. package/db/migrations/0016_task_environment.sql +2 -0
  21. package/db/migrations/0017_engine_account_scope.sql +7 -0
  22. package/db/migrations/0018_engine_account_position.sql +3 -0
  23. package/db/migrations/0019_engine_account_store_ref.sql +3 -0
  24. package/db/migrations/0020_user_credential_store_ref.sql +6 -0
  25. package/db/migrations/0021_project_env_store_ref.sql +3 -0
  26. package/db/migrations/0022_task_project_ids.sql +5 -0
  27. package/db/migrations/0023_project_env_org.sql +4 -0
  28. package/db/migrations/0024_task_env_mode.sql +9 -0
  29. package/db/migrations/0025_project_env_authority.sql +147 -0
  30. package/db/migrations/0026_project_env_replica.sql +150 -0
  31. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  32. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  33. package/db/migrations/0029_task_shared_files_mode.sql +5 -0
  34. package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
  35. package/db/migrations/0031_first_turn_receipts.sql +9 -0
  36. package/db/migrations/meta/_journal.json +230 -0
  37. package/db/schema.ts +871 -0
  38. package/images/standard/Dockerfile +347 -0
  39. package/images/standard/README.md +167 -0
  40. package/images/standard/container/code-server-settings.json +63 -0
  41. package/images/standard/container/corepack-version +1 -0
  42. package/images/standard/container/uai-init +704 -0
  43. package/images/standard/container/uai-materialize-runtimes +1527 -0
  44. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  45. package/images/standard/tool-versions +2 -0
  46. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  47. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  48. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  49. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  50. package/lib/agent-cli.ts +1507 -0
  51. package/lib/agent-errors.ts +22 -0
  52. package/lib/agent.ts +672 -0
  53. package/lib/agents/attach-compatibility.ts +51 -0
  54. package/lib/agents/claude.ts +918 -0
  55. package/lib/agents/codex-token-usage.fixture.json +130 -0
  56. package/lib/agents/codex.ts +1392 -0
  57. package/lib/agents/cursor-period-usage.ts +253 -0
  58. package/lib/agents/cursor.ts +562 -0
  59. package/lib/agents/dispatch.ts +78 -0
  60. package/lib/agents/durable-proc.ts +334 -0
  61. package/lib/agents/factory.ts +57 -0
  62. package/lib/agents/grok.ts +381 -0
  63. package/lib/agents/kimi.ts +329 -0
  64. package/lib/agents/machine-durable.ts +604 -0
  65. package/lib/agents/mock.ts +133 -0
  66. package/lib/agents/mode.ts +64 -0
  67. package/lib/agents/opencode.ts +508 -0
  68. package/lib/agents/output-source.ts +3 -0
  69. package/lib/agents/proc.ts +221 -0
  70. package/lib/agents/rate-limit.ts +337 -0
  71. package/lib/agents/registry.ts +182 -0
  72. package/lib/agents/session-state.ts +197 -0
  73. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  74. package/lib/agents/transport.ts +741 -0
  75. package/lib/agents/types.ts +254 -0
  76. package/lib/agents/uai.ts +106 -0
  77. package/lib/agents/usage.ts +44 -0
  78. package/lib/apple-one-off.ts +277 -0
  79. package/lib/apple-runtime-recycle.ts +237 -0
  80. package/lib/apple-uninstall-teardown.ts +224 -0
  81. package/lib/assignment-evidence.ts +94 -0
  82. package/lib/attachments.ts +48 -0
  83. package/lib/browser-testing.ts +1537 -0
  84. package/lib/capture-flow.ts +332 -0
  85. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  86. package/lib/cloud-credential-host.ts +512 -0
  87. package/lib/cloud-credential-leases.ts +2587 -0
  88. package/lib/cloud-credential-runtime.ts +1357 -0
  89. package/lib/cloud-state.ts +56 -0
  90. package/lib/codex-auth.ts +456 -0
  91. package/lib/command-db.ts +310 -0
  92. package/lib/compose-network-recovery.ts +283 -0
  93. package/lib/connection-secret-redaction.ts +25 -0
  94. package/lib/container-runtime.ts +1470 -0
  95. package/lib/credential-cleanup-inventory.ts +490 -0
  96. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  97. package/lib/credential-consumers/engines.ts +797 -0
  98. package/lib/credential-consumers/files.ts +934 -0
  99. package/lib/credential-consumers/github.ts +702 -0
  100. package/lib/credential-consumers/mcp.ts +389 -0
  101. package/lib/credential-consumers/task-files.ts +127 -0
  102. package/lib/credential-consumers/task-secrets.ts +10 -0
  103. package/lib/credential-store-pickup.ts +149 -0
  104. package/lib/credential-store-scope.ts +112 -0
  105. package/lib/credential-store.ts +932 -0
  106. package/lib/credentials-activation-authority.ts +709 -0
  107. package/lib/credentials-purge.ts +1006 -0
  108. package/lib/custom-connection-capture.ts +395 -0
  109. package/lib/custom-connection-launch.ts +384 -0
  110. package/lib/custom-connection-runtime.ts +141 -0
  111. package/lib/db.ts +80 -0
  112. package/lib/disk-pressure.ts +42 -0
  113. package/lib/docker-exec.ts +156 -0
  114. package/lib/engine-accounts.ts +1970 -0
  115. package/lib/engine-connect.ts +321 -0
  116. package/lib/engine-credential-env.ts +52 -0
  117. package/lib/engine-login.ts +2933 -0
  118. package/lib/engines.ts +1342 -0
  119. package/lib/enrollment-state.ts +251 -0
  120. package/lib/env-file.ts +246 -0
  121. package/lib/env-schema.ts +937 -0
  122. package/lib/env.ts +195 -0
  123. package/lib/first-turn-receipts.ts +62 -0
  124. package/lib/fs-aliases.ts +49 -0
  125. package/lib/git-clone-credentials.ts +334 -0
  126. package/lib/git-diff.ts +530 -0
  127. package/lib/git-identity.ts +715 -0
  128. package/lib/github-git-auth.ts +207 -0
  129. package/lib/github-origin.ts +28 -0
  130. package/lib/github-tokens.ts +2439 -0
  131. package/lib/host-cloud-url.ts +62 -0
  132. package/lib/host-config.ts +279 -0
  133. package/lib/host-env.ts +424 -0
  134. package/lib/host-logs.ts +965 -0
  135. package/lib/internal-connections.ts +5 -0
  136. package/lib/keyed-promise-tail.ts +23 -0
  137. package/lib/launch-mode.ts +25 -0
  138. package/lib/lease.ts +239 -0
  139. package/lib/legacy-host.ts +769 -0
  140. package/lib/legacy-runtime-retirement.ts +424 -0
  141. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  142. package/lib/machine-exec.ts +238 -0
  143. package/lib/machine-gateway-tunnel.ts +344 -0
  144. package/lib/machine-keys.ts +65 -0
  145. package/lib/machine-project-env-loader.ts +152 -0
  146. package/lib/machine-provider-aws.ts +923 -0
  147. package/lib/machine-provider-local.ts +338 -0
  148. package/lib/machine-provider.ts +90 -0
  149. package/lib/managed-activation-watcher.ts +72 -0
  150. package/lib/managed-install-owner-watcher.ts +55 -0
  151. package/lib/managed-operation-drain.ts +57 -0
  152. package/lib/managed-runtime.ts +4296 -0
  153. package/lib/managed-update-scheduler.ts +125 -0
  154. package/lib/mcp-capture.ts +865 -0
  155. package/lib/mcp-config-lock.ts +2 -0
  156. package/lib/mcp-connections.ts +959 -0
  157. package/lib/mcp-gateway.ts +2594 -0
  158. package/lib/obs.ts +512 -0
  159. package/lib/openrouter-budget.ts +247 -0
  160. package/lib/orchestrator.ts +11409 -0
  161. package/lib/person-capture.ts +327 -0
  162. package/lib/preview-ports.ts +85 -0
  163. package/lib/preview-sidecar.ts +331 -0
  164. package/lib/project-env-authority.ts +1319 -0
  165. package/lib/project-env-feature.ts +74 -0
  166. package/lib/project-env-legacy-tags.ts +130 -0
  167. package/lib/project-env-loader.ts +242 -0
  168. package/lib/project-env-replica.ts +2094 -0
  169. package/lib/project-env-schema-capture.ts +342 -0
  170. package/lib/project-env-staging.ts +148 -0
  171. package/lib/project-env-task-policy.ts +176 -0
  172. package/lib/project-env-task-replica.ts +1752 -0
  173. package/lib/release-feed.ts +61 -0
  174. package/lib/release-manifest.ts +710 -0
  175. package/lib/release-trust.ts +46 -0
  176. package/lib/repo-clone.ts +38 -0
  177. package/lib/routing/shadow.ts +261 -0
  178. package/lib/runner-boot.ts +10 -0
  179. package/lib/runtime-activation-tail.ts +279 -0
  180. package/lib/runtime-archive.ts +1216 -0
  181. package/lib/runtime-authority.ts +166 -0
  182. package/lib/runtime-guard.ts +36 -0
  183. package/lib/runtime-provider-state.ts +169 -0
  184. package/lib/runtime-reconnect.ts +86 -0
  185. package/lib/runtime-state.ts +1033 -0
  186. package/lib/safe-http.ts +364 -0
  187. package/lib/secrets-proxy/address.ts +95 -0
  188. package/lib/secrets-proxy/audit.ts +30 -0
  189. package/lib/secrets-proxy/ca-store.ts +200 -0
  190. package/lib/secrets-proxy/ca.ts +470 -0
  191. package/lib/secrets-proxy/container-steps.ts +138 -0
  192. package/lib/secrets-proxy/der.ts +273 -0
  193. package/lib/secrets-proxy/engine.ts +1633 -0
  194. package/lib/secrets-proxy/host.ts +93 -0
  195. package/lib/secrets-proxy/hostname.ts +57 -0
  196. package/lib/secrets-proxy/placeholders.ts +196 -0
  197. package/lib/secrets-proxy/policy.ts +150 -0
  198. package/lib/secrets-proxy/readiness.ts +148 -0
  199. package/lib/secrets-proxy/runtime.ts +582 -0
  200. package/lib/secrets-proxy/scrub.ts +559 -0
  201. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  202. package/lib/secrets-proxy/task-scripts.ts +262 -0
  203. package/lib/secrets-proxy/task-trust.ts +605 -0
  204. package/lib/secrets-proxy/trust-watch.ts +80 -0
  205. package/lib/secrets.ts +71 -0
  206. package/lib/shared-files-s3.ts +976 -0
  207. package/lib/shared-files.ts +156 -0
  208. package/lib/sidecar-assign.ts +49 -0
  209. package/lib/sidecar-guard.ts +58 -0
  210. package/lib/sidecar-resume.ts +178 -0
  211. package/lib/sidecar.ts +69 -0
  212. package/lib/signin-box-credentials.ts +465 -0
  213. package/lib/signin-box.ts +114 -0
  214. package/lib/skill-index-example.fixture.ts +56 -0
  215. package/lib/skill-layout.ts +417 -0
  216. package/lib/skills.ts +489 -0
  217. package/lib/ssh.ts +401 -0
  218. package/lib/standard-image-cleanup.ts +117 -0
  219. package/lib/standard-image-pin.ts +207 -0
  220. package/lib/standard-image.ts +1697 -0
  221. package/lib/stopped-task-status-queue.ts +120 -0
  222. package/lib/task-container-cli.ts +269 -0
  223. package/lib/task-credential-cleanup.ts +752 -0
  224. package/lib/task-diff-sandbox.ts +464 -0
  225. package/lib/task-diff.ts +91 -0
  226. package/lib/task-env-forwarder.ts +228 -0
  227. package/lib/task-env-source.ts +249 -0
  228. package/lib/task-environment/anchor.ts +31 -0
  229. package/lib/task-environment/apple-container.ts +860 -0
  230. package/lib/task-environment/docker.ts +1344 -0
  231. package/lib/task-environment/index.ts +466 -0
  232. package/lib/task-environment/legacy-adoption.ts +467 -0
  233. package/lib/task-environment/machine-environment.ts +61 -0
  234. package/lib/task-environment/machine-task-up.ts +1227 -0
  235. package/lib/task-environment/machine.ts +974 -0
  236. package/lib/task-environment/registry.ts +59 -0
  237. package/lib/task-environment/self-identity.ts +20 -0
  238. package/lib/task-environment/self.ts +566 -0
  239. package/lib/task-environment/types.ts +642 -0
  240. package/lib/task-environment/workspace-files.ts +685 -0
  241. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  242. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  243. package/lib/task-files-machine-retirement.ts +116 -0
  244. package/lib/task-files-readiness.ts +127 -0
  245. package/lib/task-gateway-port.ts +22 -0
  246. package/lib/task-gateway-protocol.ts +190 -0
  247. package/lib/task-gateway-recovery.ts +47 -0
  248. package/lib/task-gateway-state.ts +512 -0
  249. package/lib/task-identity.ts +19 -0
  250. package/lib/task-inventory.ts +682 -0
  251. package/lib/task-machine-credentials-activation.ts +95 -0
  252. package/lib/task-machine-swap-safety.ts +729 -0
  253. package/lib/task-person-credentials.ts +76 -0
  254. package/lib/task-project-ops-host.ts +147 -0
  255. package/lib/task-project-ops.ts +1062 -0
  256. package/lib/task-secret-staging.ts +16 -0
  257. package/lib/task-status.ts +46 -0
  258. package/lib/task-token.ts +46 -0
  259. package/lib/token-broker-adopt.ts +344 -0
  260. package/lib/token-broker-lambda.ts +105 -0
  261. package/lib/token-broker-local.ts +392 -0
  262. package/lib/token-broker-revision.ts +310 -0
  263. package/lib/token-broker-ssm.ts +525 -0
  264. package/lib/token-broker.ts +218 -0
  265. package/lib/token-lanes/openai-oauth.ts +123 -0
  266. package/lib/token-lanes/registry.ts +45 -0
  267. package/lib/token-lanes/types.ts +163 -0
  268. package/lib/token-lanes/xai-oauth.ts +165 -0
  269. package/lib/transcript.ts +128 -0
  270. package/lib/tunnel-registry.ts +220 -0
  271. package/lib/tunnel-runtime.ts +235 -0
  272. package/lib/ulid.ts +16 -0
  273. package/lib/user-bus-env.ts +116 -0
  274. package/lib/user-credentials-store.ts +435 -0
  275. package/lib/vnode-pressure.ts +142 -0
  276. package/package.json +115 -8
  277. package/runner/runner.mjs +442 -0
  278. package/scripts/agent/_common.sh +633 -0
  279. package/scripts/agent/env-hook.sh +626 -0
  280. package/scripts/agent/moondeck-cli.sh +5 -0
  281. package/scripts/agent/task-down.sh +291 -0
  282. package/scripts/agent/task-status.sh +70 -0
  283. package/scripts/agent/task-up.sh +3761 -0
  284. package/scripts/agent/uai-env-exec.sh +26 -0
  285. package/scripts/install/darwin.ts +1048 -0
  286. package/scripts/install/linux.ts +958 -0
  287. package/scripts/install/types.ts +78 -0
  288. package/scripts/install/util.ts +254 -0
  289. package/scripts/install/win.ts +143 -0
  290. package/src/activation-respawn.ts +155 -0
  291. package/src/apple-tunnel-route.ts +104 -0
  292. package/src/bridge-diagnostics.ts +39 -0
  293. package/src/capture-wire.ts +102 -0
  294. package/src/cli.ts +2585 -0
  295. package/src/cloud-credentials.ts +2144 -0
  296. package/src/cloud-origins.ts +23 -0
  297. package/src/credential-location.ts +248 -0
  298. package/src/credential-wire-validation.ts +25 -0
  299. package/src/custom-connections.ts +237 -0
  300. package/src/engine-account-paths.ts +11 -0
  301. package/src/engine-models.ts +39 -0
  302. package/src/engine-usage.ts +104 -0
  303. package/src/enrollment-next-steps.ts +24 -0
  304. package/src/event-outbox.ts +185 -0
  305. package/src/index.ts +2907 -0
  306. package/src/load-env.ts +97 -0
  307. package/src/machine-tunnel-route.ts +156 -0
  308. package/src/main.ts +6363 -0
  309. package/src/never-proxied-domains.ts +31 -0
  310. package/src/outbound-frame.ts +31 -0
  311. package/src/paths.ts +81 -0
  312. package/src/project-env-latest.ts +130 -0
  313. package/src/project-env-policy.ts +396 -0
  314. package/src/project-env-wording.ts +53 -0
  315. package/src/protocol.ts +5550 -0
  316. package/src/runtime-bootstrap.ts +165 -0
  317. package/src/station-identity.ts +96 -0
  318. package/src/task-env-names.ts +28 -0
  319. package/src/task-secrets.ts +75 -0
  320. package/src/tunnel-dial.ts +102 -0
  321. package/src/ui/server.ts +852 -0
  322. package/src/ui/types.ts +173 -0
  323. package/src/workspace-path-policy.ts +41 -0
  324. package/ui/app.js +1102 -0
  325. package/ui/index.html +90 -0
  326. package/ui/style.css +685 -0
  327. package/ui/uai-favicon.svg +21 -0
  328. package/ui/uai-logo-black.svg +9 -0
  329. package/ui/uai-wheel.svg +9 -0
  330. package/bin/moondeck-station.mjs +0 -3
@@ -0,0 +1,56 @@
1
+ /**
2
+ * In-memory cloud (WSS bridge) connection state for the local UI (ADR-028).
3
+ *
4
+ * The bridge client in `src/main.ts` is the single writer; the local
5
+ * `/api/cloud` handler is a reader. Module-level singleton — there is exactly
6
+ * one bridge connection per host process. Never persisted.
7
+ */
8
+
9
+ export type CloudConnectionState =
10
+ | "connected"
11
+ | "reconnecting"
12
+ | "disconnected";
13
+
14
+ export interface CloudState {
15
+ state: CloudConnectionState;
16
+ /** Epoch ms of the last successful connect, or null if never connected. */
17
+ lastConnectedAt: number | null;
18
+ /** Count of reconnect attempts since process start. */
19
+ reconnectCount: number;
20
+ /** Most recent connection error message, or null. */
21
+ lastError: string | null;
22
+ }
23
+
24
+ const state: CloudState = {
25
+ state: "disconnected",
26
+ lastConnectedAt: null,
27
+ reconnectCount: 0,
28
+ lastError: null,
29
+ };
30
+
31
+ /** Snapshot for `/api/cloud`. */
32
+ export function getCloudState(): CloudState {
33
+ return { ...state };
34
+ }
35
+
36
+ export function markConnected(): void {
37
+ state.state = "connected";
38
+ state.lastConnectedAt = Date.now();
39
+ }
40
+
41
+ /** The socket dropped and we will retry. Increments the reconnect counter. */
42
+ export function markReconnecting(error?: string | null): void {
43
+ state.state = "reconnecting";
44
+ state.reconnectCount += 1;
45
+ if (error !== undefined) state.lastError = error;
46
+ }
47
+
48
+ /** Stopped for good (shutdown or fatal auth failure) — no retry. */
49
+ export function markDisconnected(error?: string | null): void {
50
+ state.state = "disconnected";
51
+ if (error !== undefined) state.lastError = error;
52
+ }
53
+
54
+ export function markCloudError(message: string): void {
55
+ state.lastError = message;
56
+ }
@@ -0,0 +1,456 @@
1
+ /**
2
+ * Codex credential re-injection — the symmetric partner to the GitHub reinject
3
+ * in {@link ./github-tokens} (ADR-027/033 → host 0.8.1).
4
+ *
5
+ * The host owner's `~/.codex` is docker-cp'd into each task container ONCE, at
6
+ * task-up (`scripts/agent/task-up.sh`). When the owner re-logs into Codex,
7
+ * `codex login` revokes and rewrites `~/.codex/auth.json` on the HOST —
8
+ * already-running containers keep their stale copy, so a re-login otherwise
9
+ * only helps NEW tasks. The live sweep refreshes the credential-bearing copy
10
+ * while deliberately preserving the container's `config.toml`: browser/MCP
11
+ * setup owns that live file and serializes its updates with agent spawning.
12
+ *
13
+ * That revoke-then-rewrite is CLIENT-SIDE and scoped to the LOCAL codex_home —
14
+ * not a server-side "one session per account" policy. `codex login` calls
15
+ * `clear_existing_auth_before_login` (openai/codex `cli/src/login.rs`), which
16
+ * revokes only whatever token `revocable_token()` (`login/src/auth/revoke.rs`)
17
+ * finds in THIS SAME codex_home's `auth.json` via `logout_with_revoke`
18
+ * (`login/src/auth/manager.rs`) — it never looks at, let alone reaches, another
19
+ * login's session. Separate logins are independent authorization_code+PKCE
20
+ * grants with independent refresh lineages (source analysis corroborated by a
21
+ * community-verified workaround, not an empirical run — full writeup:
22
+ * `docs/pricing-economics.md` R8/R10).
23
+ *
24
+ * What DOES matter for concurrency: refresh rotation is single-use, and a
25
+ * reused refresh token comes back `refresh_token_reused` — a TERMINAL
26
+ * `Exhausted` failure the client never retries. This file's docker-cp pattern
27
+ * already gives every task container its own independent filesystem copy of
28
+ * ONE lineage with nothing to reconcile them, so the instant any one
29
+ * container's own `codex` process refreshes, every sibling copy is a stale
30
+ * member of a now-rotated family and its next refresh is terminal, not
31
+ * self-healing — containers must never refresh this credential themselves.
32
+ *
33
+ * Three triggers, covering every path:
34
+ * - {@link watchCodexAuth} — fs.watch on `~/.codex`, for `codex login` run
35
+ * from a terminal while the host stays up.
36
+ * - {@link reinjectCodexRunningTasks} at host start — for the desktop
37
+ * "Connect Codex" flow, which writes `~/.codex` then restarts the host
38
+ * (durable sessions reattach to containers that still hold the old creds).
39
+ * Sequenced AFTER boot recovery (main.ts awaits recoveryComplete()).
40
+ * - {@link injectCodexIntoContainer} — recovery calls it right after
41
+ * `docker start` of an exited container, before agents can respawn.
42
+ *
43
+ * Copies go ONLY into containers docker confirms are RUNNING. A DB-active row
44
+ * can be a stopped container (boot, pre-recovery), and `docker cp` into a
45
+ * stopped container SUCCEEDS while the `docker exec … chown` that follows
46
+ * cannot run — stranding auth.json/config.toml mode 0600 owned by the host
47
+ * uid (macOS 501:20), unreadable to the container's `node` (1000:1000), and
48
+ * Codex dies at startup. Every docker step's exit status is checked; failures
49
+ * surface as actionable errors (never credential contents — only file NAMES
50
+ * and docker stderr are logged).
51
+ *
52
+ * Codex auth is host-wide (the operator's single `~/.codex`, not per-user), so
53
+ * this targets ALL running tasks — matching task-up, which copies it into every
54
+ * container regardless of roster.
55
+ */
56
+ import { existsSync, mkdtempSync, readFileSync, rmSync, watch, writeFileSync } from "node:fs";
57
+ import { homedir, tmpdir } from "node:os";
58
+ import { dirname, join } from "node:path";
59
+
60
+ import { isNull } from "drizzle-orm";
61
+
62
+ import { onOwnCredentials, whileOnOwnCredentials } from "./cloud-credential-host";
63
+ import { getDb, schema } from "./db";
64
+ import { dockerCli } from "./docker-exec";
65
+ import { brokeredFilesFor } from "./token-broker";
66
+ import {
67
+ listRunningTaskAppContainerNames,
68
+ taskAppContainerName,
69
+ taskContainerCli,
70
+ } from "./task-container-cli";
71
+ import { runtimeAuthorityDockerExecArgs } from "./runtime-authority";
72
+
73
+ /** The exact set task-up.sh copies into `/home/node/.codex`. */
74
+ const CODEX_ITEMS = [
75
+ "auth.json",
76
+ "config.toml",
77
+ "AGENTS.md",
78
+ "version.json",
79
+ "installation_id",
80
+ "rules",
81
+ ] as const;
82
+ /** Live auth refresh must not overwrite MCP configuration in a running task. */
83
+ const LIVE_CODEX_ITEMS = CODEX_ITEMS.filter((item) => item !== "config.toml");
84
+ const EXEC_TIMEOUT_MS = 15_000;
85
+
86
+ /** Injectable seams (defaults hit the real DB / docker / fs) so the copy logic
87
+ * is testable without either. */
88
+ export interface CodexDeps {
89
+ exec?: (
90
+ args: string[],
91
+ ) => Promise<{ status: number | null; stdout: string; stderr: string }>;
92
+ /** DB-active task containers — may include stopped ones; docker decides. */
93
+ activeTaskContainers?: () => string[];
94
+ fileExists?: (path: string) => boolean;
95
+ /** Reads the host's auth.json for brokering (tests inject; never the real one). */
96
+ readFile?: (path: string) => string;
97
+ }
98
+
99
+ function ownerCodexDir(): string {
100
+ return join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".codex");
101
+ }
102
+
103
+ const defaultExec: NonNullable<CodexDeps["exec"]> = async (args) => {
104
+ const res = await taskContainerCli(args, { timeoutMs: EXEC_TIMEOUT_MS });
105
+ return { status: res.status, stdout: res.stdout, stderr: res.stderr };
106
+ };
107
+
108
+ function defaultActiveTaskContainers(): string[] {
109
+ return getDb()
110
+ .select({ taskId: schema.hostTasks.taskId })
111
+ .from(schema.hostTasks)
112
+ .where(isNull(schema.hostTasks.endedAt))
113
+ .all()
114
+ .map((r) => taskAppContainerName(r.taskId));
115
+ }
116
+
117
+ /**
118
+ * The containers docker reports as RUNNING right now, or null when docker
119
+ * itself didn't answer — the caller must then skip injection rather than
120
+ * docker-cp into containers of unknown state.
121
+ */
122
+ async function dockerRunningNames(
123
+ exec: NonNullable<CodexDeps["exec"]>,
124
+ ): Promise<Set<string> | null> {
125
+ // The list verb differs per backend (docker ps vs apple container list);
126
+ // the shared helper owns that translation. The injected exec seam is kept
127
+ // for tests, which script docker-shaped ps answers.
128
+ if (exec === defaultExec) {
129
+ const names = await listRunningTaskAppContainerNames();
130
+ return names === null ? null : new Set(names);
131
+ }
132
+ const res = await exec(["ps", "--filter", "status=running", "--format", "{{.Names}}"]);
133
+ if (res.status !== 0) return null;
134
+ return new Set(
135
+ res.stdout
136
+ .split("\n")
137
+ .map((l) => l.trim())
138
+ .filter(Boolean),
139
+ );
140
+ }
141
+
142
+ /**
143
+ * Re-copy `~/.codex/<items>` into one container — mirrors task-up.sh exactly:
144
+ * ensure the dir (root), cp each item that exists, chown back to node:node
145
+ * (docker cp preserves the HOST uid/gid, and 0600 files owned by macOS
146
+ * 501:20 are unreadable to the container's node). Any nonzero step throws
147
+ * with the step name and docker's stderr — never file contents.
148
+ */
149
+ async function copyCodexInto(
150
+ container: string,
151
+ deps: CodexDeps,
152
+ items: readonly (typeof CODEX_ITEMS)[number][] = CODEX_ITEMS,
153
+ ): Promise<void> {
154
+ const exec = deps.exec ?? defaultExec;
155
+ const exists = deps.fileExists ?? existsSync;
156
+ const dir = ownerCodexDir();
157
+ const must = async (args: string[], step: string): Promise<void> => {
158
+ const res = await exec(args);
159
+ if (res.status !== 0) {
160
+ const detail = res.stderr.trim().slice(0, 200);
161
+ throw new Error(
162
+ `${step} failed (${res.status === null ? "docker timeout" : `exit ${res.status}`})` +
163
+ (detail ? `: ${detail}` : ""),
164
+ );
165
+ }
166
+ };
167
+ await must(
168
+ [
169
+ "exec",
170
+ "-u",
171
+ "root",
172
+ ...runtimeAuthorityDockerExecArgs(),
173
+ container,
174
+ "/bin/mkdir",
175
+ "-p",
176
+ "/home/node/.codex",
177
+ ],
178
+ "mkdir /home/node/.codex",
179
+ );
180
+ for (const item of items) {
181
+ const src = join(dir, item);
182
+ if (!exists(src)) continue;
183
+ // ADR-135: the task's copy never carries the rotating refresh token — N
184
+ // copies racing to refresh it revoke the whole family. cp needs a real
185
+ // file, so write the brokered variant to a private temp file and cp that.
186
+ if (item === "auth.json") {
187
+ let brokered: string | null = null;
188
+ try {
189
+ const raw = (deps.readFile ?? ((p: string) => readFileSync(p, "utf8")))(src);
190
+ const out = brokeredFilesFor(
191
+ { kind: "codex", authKind: "config-dir", id: "default", scope: "host" },
192
+ { "auth.json": raw },
193
+ );
194
+ brokered = out["auth.json"] !== raw ? out["auth.json"]! : null;
195
+ } catch {
196
+ brokered = null;
197
+ }
198
+ if (brokered !== null) {
199
+ const tmp = join(mkdtempSync(join(tmpdir(), "uai-codex-auth-")), "auth.json");
200
+ writeFileSync(tmp, brokered, { mode: 0o600 });
201
+ try {
202
+ await must(["cp", tmp, `${container}:/home/node/.codex/auth.json`], "docker cp auth.json (brokered)");
203
+ } finally {
204
+ rmSync(dirname(tmp), { recursive: true, force: true });
205
+ }
206
+ continue;
207
+ }
208
+ }
209
+ await must(["cp", src, `${container}:/home/node/.codex/`], `docker cp ${item}`);
210
+ }
211
+ await must(
212
+ [
213
+ "exec",
214
+ "-u",
215
+ "root",
216
+ ...runtimeAuthorityDockerExecArgs(),
217
+ container,
218
+ "/bin/chown",
219
+ "-R",
220
+ "node:node",
221
+ "/home/node/.codex",
222
+ ],
223
+ "chown -R node:node /home/node/.codex",
224
+ );
225
+ }
226
+
227
+ /**
228
+ * Inject the owner's `~/.codex` into ONE container known to be running.
229
+ * Recovery calls this immediately after `docker start`, BEFORE uai-init and
230
+ * any agent respawn, so a recovered container never runs Codex against
231
+ * host-uid-owned credentials. Returns false after logging an actionable
232
+ * error (no secrets) — recovery of the task itself must not fail over Codex.
233
+ */
234
+ export async function injectCodexIntoContainer(
235
+ container: string,
236
+ deps: CodexDeps = {},
237
+ ): Promise<boolean> {
238
+ const exists = deps.fileExists ?? existsSync;
239
+ if (!exists(join(ownerCodexDir(), "auth.json"))) return true; // nothing to inject
240
+ try {
241
+ // ADR-154: no default slot once the runner begins leaving its own
242
+ // credentials; leaving waits for a copy under way.
243
+ await whileOnOwnCredentials(() => copyCodexInto(container, deps));
244
+ return true;
245
+ } catch (err) {
246
+ console.error(
247
+ `[codex] inject into ${container}: ${err instanceof Error ? err.message : err} — ` +
248
+ `Codex in this task will fail until fixed (run: docker exec -u root ${container} ` +
249
+ `chown -R node:node /home/node/.codex, or restart the host)`,
250
+ );
251
+ return false;
252
+ }
253
+ }
254
+
255
+ /**
256
+ * ADR-121: deliver the DEFAULT Codex slot into a MACHINE task over the
257
+ * environment transport. The legacy paths (task-up docker cp, recovery
258
+ * inject, the reinject sweep) all speak docker grammar, so a machine task's
259
+ * codex agent ran without auth.json and looped 'Reconnecting' (live
260
+ * 2026-08-27, first EC2 task). Files land as the ssh user (node), so no
261
+ * chown pass is needed. Returns true when there was nothing to inject.
262
+ */
263
+ export async function injectCodexIntoMachine(
264
+ environment: {
265
+ exec(request: {
266
+ argv: readonly [string, ...string[]];
267
+ projectEnvProfile: "maintenance" | "task";
268
+ timeoutMs: number;
269
+ maxOutputBytes: number;
270
+ }): Promise<{ exitCode: number | null; stderr: Uint8Array }>;
271
+ copy(request: {
272
+ direction: "into";
273
+ source: string;
274
+ destination: string;
275
+ }): Promise<void>;
276
+ },
277
+ deps: CodexDeps = {},
278
+ ): Promise<boolean> {
279
+ // ADR-154: no default slot once the runner begins leaving its own
280
+ // credentials (a start on them holds its permit through this).
281
+ if (!onOwnCredentials()) return true;
282
+ const exists = deps.fileExists ?? existsSync;
283
+ const dir = ownerCodexDir();
284
+ if (!exists(join(dir, "auth.json"))) return true; // nothing to inject
285
+ try {
286
+ const made = await environment.exec({
287
+ projectEnvProfile: "maintenance",
288
+ argv: ["/bin/mkdir", "-p", "/home/node/.codex"],
289
+ timeoutMs: EXEC_TIMEOUT_MS,
290
+ maxOutputBytes: 64 * 1024,
291
+ });
292
+ if (made.exitCode !== 0) {
293
+ throw new Error(
294
+ `mkdir /home/node/.codex exited ${made.exitCode}: ${Buffer.from(made.stderr).toString("utf8").trim()}`,
295
+ );
296
+ }
297
+ for (const item of CODEX_ITEMS) {
298
+ const source = join(dir, item);
299
+ if (!exists(source)) continue;
300
+ await environment.copy({
301
+ direction: "into",
302
+ source,
303
+ destination: `/home/node/.codex/${item}`,
304
+ });
305
+ }
306
+ return true;
307
+ } catch (err) {
308
+ console.error(
309
+ `[codex] machine inject: ${err instanceof Error ? err.message : err} — Codex in this task will fail until the next ensure retries`,
310
+ );
311
+ return false;
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Refresh the freshly (re)logged-in Codex files in every task container that
317
+ * is both DB-active AND confirmed running by docker. `config.toml` is excluded:
318
+ * copying the host's file over a live task races the orchestrator's managed MCP
319
+ * writer and can erase the browser definition after readiness was established.
320
+ * Recovery/start injection still uses the full item set above, before agents
321
+ * can spawn. No-op when there is no `~/.codex/auth.json` or no active tasks;
322
+ * skipped entirely (with a warning) when docker doesn't answer. Per-container
323
+ * failures are surfaced and don't stop the sweep.
324
+ */
325
+ export async function reinjectCodexRunningTasks(deps: CodexDeps = {}): Promise<void> {
326
+ // ADR-154: no default slot once the runner begins leaving its own
327
+ // credentials. Leaving waits for a sweep under way, and no copy starts
328
+ // after it began.
329
+ await whileOnOwnCredentials(() => reinjectOwnCodex(deps));
330
+ }
331
+
332
+ async function reinjectOwnCodex(deps: CodexDeps): Promise<void> {
333
+ const exists = deps.fileExists ?? existsSync;
334
+ if (!exists(join(ownerCodexDir(), "auth.json"))) return;
335
+ const active = (deps.activeTaskContainers ?? defaultActiveTaskContainers)();
336
+ if (active.length === 0) return;
337
+ const running = await dockerRunningNames(deps.exec ?? defaultExec);
338
+ if (running === null) {
339
+ console.warn(
340
+ "[codex] reinject skipped — docker didn't answer `docker ps`; will retry on the next trigger",
341
+ );
342
+ return;
343
+ }
344
+ const targets = active.filter((c) => running.has(c));
345
+ const stopped = active.length - targets.length;
346
+ if (stopped > 0) {
347
+ console.log(
348
+ `[codex] ${stopped} active task(s) not running — skipped (recovery injects them at docker start)`,
349
+ );
350
+ }
351
+ if (targets.length === 0) return;
352
+ console.log(`[codex] refreshing auth in ${targets.length} running task(s)`);
353
+ for (const container of targets) {
354
+ if (!onOwnCredentials()) return;
355
+ try {
356
+ await copyCodexInto(container, deps, LIVE_CODEX_ITEMS);
357
+ } catch (err) {
358
+ console.error(
359
+ `[codex] reinject into ${container} failed: ${err instanceof Error ? err.message : err}`,
360
+ );
361
+ }
362
+ }
363
+ }
364
+
365
+ let watcher: ReturnType<typeof watch> | null = null;
366
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
367
+
368
+ /**
369
+ * Watch `~/.codex` for `auth.json` changes (a re-login) and re-inject into
370
+ * running tasks, debounced (a login writes several files). Idempotent; a
371
+ * missing `~/.codex` dir is a no-op — the host-start reinject already handles
372
+ * a first login that flips Codex available. UAI_CODEX_REINJECT=0 disables it.
373
+ */
374
+ export function watchCodexAuth(canReinject: () => boolean = () => true): void {
375
+ if (watcher || process.env.UAI_CODEX_REINJECT === "0") return;
376
+ const dir = ownerCodexDir();
377
+ if (!existsSync(dir)) return;
378
+ try {
379
+ watcher = watch(dir, (_event, filename) => {
380
+ // filename is null on some platforms — then we can't tell, so proceed.
381
+ if (filename && filename !== "auth.json") return;
382
+ if (debounceTimer) clearTimeout(debounceTimer);
383
+ debounceTimer = setTimeout(() => {
384
+ if (canReinject()) void reinjectCodexRunningTasks();
385
+ }, 2_000);
386
+ debounceTimer.unref?.();
387
+ });
388
+ } catch (err) {
389
+ console.warn(
390
+ `[codex] could not watch ${dir}: ${err instanceof Error ? err.message : err}`,
391
+ );
392
+ }
393
+ }
394
+
395
+ /** Test/teardown hook. */
396
+ export function stopWatchingCodexAuth(): void {
397
+ watcher?.close();
398
+ watcher = null;
399
+ if (debounceTimer) {
400
+ clearTimeout(debounceTimer);
401
+ debounceTimer = null;
402
+ }
403
+ }
404
+
405
+ /**
406
+ * ADR-135: re-push the host's default Grok sign-in (`~/.grok/auth.json`,
407
+ * brokered) into every running task, after the owned-host loop refreshed it.
408
+ * Same sweep shape as the Codex reinject; only auth.json travels.
409
+ */
410
+ export async function reinjectGrokRunningTasks(deps: CodexDeps = {}): Promise<void> {
411
+ // ADR-154: as for Codex, only while the runner is on its own credentials.
412
+ await whileOnOwnCredentials(() => reinjectOwnGrok(deps));
413
+ }
414
+
415
+ async function reinjectOwnGrok(deps: CodexDeps): Promise<void> {
416
+ const exists = deps.fileExists ?? existsSync;
417
+ const src = join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".grok", "auth.json");
418
+ if (!exists(src)) return;
419
+ const active = (deps.activeTaskContainers ?? defaultActiveTaskContainers)();
420
+ if (active.length === 0) return;
421
+ const running = await dockerRunningNames(deps.exec ?? defaultExec);
422
+ if (running === null) return;
423
+ const exec = deps.exec ?? defaultExec;
424
+ let brokered: string;
425
+ try {
426
+ const raw = readFileSync(src, "utf8");
427
+ brokered = brokeredFilesFor(
428
+ { kind: "grok", authKind: "config-dir", id: "default", scope: "host" },
429
+ { "auth.json": raw },
430
+ )["auth.json"]!;
431
+ } catch {
432
+ return;
433
+ }
434
+ const dir = mkdtempSync(join(tmpdir(), "uai-grok-auth-"));
435
+ const tmp = join(dir, "auth.json");
436
+ try {
437
+ writeFileSync(tmp, brokered, { mode: 0o600 });
438
+ for (const container of active.filter((c) => running.has(c))) {
439
+ if (!onOwnCredentials()) break;
440
+ const steps: Array<[string[], string]> = [
441
+ [["exec", "-u", "root", ...runtimeAuthorityDockerExecArgs(), container, "/bin/mkdir", "-p", "/home/node/.grok"], "mkdir"],
442
+ [["cp", tmp, `${container}:/home/node/.grok/auth.json`], "docker cp auth.json (brokered)"],
443
+ [["exec", "-u", "root", ...runtimeAuthorityDockerExecArgs(), container, "/bin/chown", "-R", "node:node", "/home/node/.grok"], "chown"],
444
+ ];
445
+ for (const [args, step] of steps) {
446
+ const res = await exec(args);
447
+ if (res.status !== 0) {
448
+ console.error(`[grok] reinject into ${container}: ${step} failed (exit ${res.status ?? "none"})`);
449
+ break;
450
+ }
451
+ }
452
+ }
453
+ } finally {
454
+ rmSync(dir, { recursive: true, force: true });
455
+ }
456
+ }