@moondeck/station 0.0.1 → 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 (326) 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 +115 -8
  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
  326. package/bin/moondeck-station.mjs +0 -3
@@ -0,0 +1,1970 @@
1
+ import { ENGINE_ACCOUNT_PATHS } from "../src/engine-account-paths";
2
+ /**
3
+ * Engine accounts (ADR-076) — multiple credentials per engine kind, selected
4
+ * by ENV at each `docker exec`, rotated automatically with rate-limit failover.
5
+ *
6
+ * An account resolves to a small env map merged into the agent's exec:
7
+ * - Claude (env account): CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY.
8
+ * - Codex (config-dir account): CODEX_HOME=<per-account container dir>.
9
+ * - OpenCode (config-dir account): OPENCODE_DATA_DIR=<per-account container dir>.
10
+ *
11
+ * `accounts(kind) = [synthesized default] + [DB extras]`. The DEFAULT is the
12
+ * legacy single slot (`.env.local` var, or `~/.codex` / `~/.local/share/opencode`
13
+ * dir) — so with zero extras behavior is identical to before and `available()`
14
+ * is unchanged. EXTRA accounts live in `host_engine_accounts`, sealed with the
15
+ * host master key (env kind) or as an isolated host dir (config-dir kind).
16
+ *
17
+ * Isolation invariants (ADR-015/024) are preserved: accounts never leave the
18
+ * host, the operator's live dirs are never bind-mounted (extras are per-task
19
+ * writable copies), and every `docker cp` is followed by `chown -R node:node`.
20
+ *
21
+ * Rotation state (cooldown / last-used) is in-memory only; a host restart
22
+ * clears cooldowns (a restarted host retries every account, which is correct —
23
+ * caps reset over time).
24
+ */
25
+
26
+ import {
27
+ HOST_ACCOUNT_SCOPE,
28
+ isEngineAccountScope,
29
+ isEngineAccountLabel,
30
+ } from "../src/protocol";
31
+ import {
32
+ chmodSync,
33
+ existsSync,
34
+ mkdirSync,
35
+ mkdtempSync,
36
+ readdirSync,
37
+ readFileSync,
38
+ renameSync,
39
+ rmSync,
40
+ statSync,
41
+ writeFileSync,
42
+ } from "node:fs";
43
+ import { homedir, tmpdir } from "node:os";
44
+ import { join } from "node:path";
45
+
46
+ import { eq } from "drizzle-orm";
47
+
48
+ import {
49
+ configuredCredentialStore,
50
+ credentialStorePrefix,
51
+ isParameterPathSegment,
52
+ parameterName,
53
+ queueStoreWrite,
54
+ settleStoreWrites,
55
+ type CredentialStore,
56
+ } from "./credential-store";
57
+ import { engineAccountListPaths } from "./credential-store-scope";
58
+ import type { EngineCaptureMaterial } from "./capture-flow";
59
+ import { getDb, schema } from "./db";
60
+ import { brokeredFilesFor } from "./token-broker";
61
+ import {
62
+ taskContainerBackend,
63
+ taskContainerCli,
64
+ } from "./task-container-cli";
65
+ import { RUNTIME_AUTHORITY_ENV, runtimeAuthorityDockerExecArgs } from "./runtime-authority";
66
+ import type { TaskEnvironmentHandle } from "./task-environment/types";
67
+ import { openAesGcm, sealAesGcm } from "./secrets";
68
+ import { newId } from "./ulid";
69
+
70
+ const EXEC_TIMEOUT_MS = 15_000;
71
+
72
+ /** Engine kinds that participate in the account model. Other kinds resolve to
73
+ * no accounts (the orchestrator then injects no account env — old behavior). */
74
+ export type AccountAuthKind = "env" | "config-dir";
75
+
76
+ interface KindDescriptor {
77
+ authKind: AccountAuthKind;
78
+ /** Default (legacy-slot) host dir for config-dir kinds. */
79
+ defaultHostDir?: (owner: string) => string;
80
+ /** Default (legacy-slot) container dir for config-dir kinds. */
81
+ defaultContainerDir?: string;
82
+ /** Env var that selects a config-dir account's dir in-container. */
83
+ selectVar?: string;
84
+ /** Per-account container dir prefix for config-dir extras. */
85
+ containerDirPrefix?: string;
86
+ /** Per-account host dir builder for config-dir extras. */
87
+ extraHostDir?: (owner: string, id: string) => string;
88
+ }
89
+
90
+ const KINDS: Record<string, KindDescriptor> = {
91
+ claude: { authKind: "env" },
92
+ // API-key engines: an extra account is one env var. The default slot stays
93
+ // the host's own `.env.local` key (the adapters read process.env), so these
94
+ // never synthesize a default — `defaultAvailable` is false for them.
95
+ // Grok: a subscription sign-in is a config dir (`grok login --device-auth`
96
+ // writes auth.json; GROK_HOME selects it in-container). Pasted xAI keys
97
+ // remain `env` rows (XAI_API_KEY) — rows carry their own authKind.
98
+ grok: {
99
+ authKind: "config-dir",
100
+ defaultHostDir: (owner) => join(owner, ".grok"),
101
+ ...ENGINE_ACCOUNT_PATHS.grok,
102
+ selectVar: "GROK_HOME",
103
+ extraHostDir: (owner, id) => join(owner, `.grok-acct-${id}`),
104
+ },
105
+ cursor: { authKind: "env" },
106
+ codex: {
107
+ authKind: "config-dir",
108
+ defaultHostDir: (owner) => join(owner, ".codex"),
109
+ ...ENGINE_ACCOUNT_PATHS.codex,
110
+ selectVar: "CODEX_HOME",
111
+ extraHostDir: (owner, id) => join(owner, `.codex-acct-${id}`),
112
+ },
113
+ opencode: {
114
+ authKind: "config-dir",
115
+ defaultHostDir: (owner) => join(owner, ".local", "share", "opencode"),
116
+ ...ENGINE_ACCOUNT_PATHS.opencode,
117
+ selectVar: "OPENCODE_DATA_DIR",
118
+ extraHostDir: (owner, id) => join(owner, ".local", "share", `opencode-acct-${id}`),
119
+ },
120
+ };
121
+
122
+ /** The env var an extra API-key account injects, per kind (the same vars the
123
+ * adapters read for the host's own key). Claude is special-cased in
124
+ * `addEngineAccount` (token vs. key); Codex writes an auth.json instead. */
125
+ const API_KEY_ENV_VARS: Record<string, string> = {
126
+ grok: "XAI_API_KEY",
127
+ cursor: "CURSOR_API_KEY",
128
+ };
129
+ const API_KEY_ENGINE_LABELS: Record<string, string> = {
130
+ grok: "Grok",
131
+ cursor: "Cursor",
132
+ };
133
+
134
+ /**
135
+ * Whether ANY stored account exists for `kind`, in any scope. The
136
+ * advertisement fallback (ADR-131 D3): an adapter's `available()` only sees
137
+ * the host's own credential, so a shared host whose owner never signed in to
138
+ * Codex hid the kind from every workspace that had connected its own account
139
+ * — the cloud then refused the crew with "host does not support agent kind".
140
+ * A stored account is a usable credential; the cloud scopes it per caller.
141
+ */
142
+ export function hasStoredEngineAccounts(kind: string): boolean {
143
+ if (!KINDS[kind]) return false;
144
+ try {
145
+ return getDb()
146
+ .select()
147
+ .from(schema.engineAccounts)
148
+ .all()
149
+ .some((row) => row.kind === kind);
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ /** Public (secret-free) account descriptor for the host UI. */
156
+ export interface EngineAccountInfo {
157
+ id: string;
158
+ kind: string;
159
+ label: string;
160
+ authKind: AccountAuthKind;
161
+ isDefault: boolean;
162
+ /** ADR-131 D3: "host" or "org:<orgId>". */
163
+ scope: string;
164
+ }
165
+
166
+ /** A fully-resolved account, ready to inject / provision (host-trusted; the
167
+ * execEnv holds decrypted secrets — never log it). */
168
+ export interface ResolvedEngineAccount {
169
+ id: string;
170
+ kind: string;
171
+ label: string;
172
+ isDefault: boolean;
173
+ /** ADR-131 D3: "host" or "org:<orgId>". */
174
+ scope: string;
175
+ authKind: AccountAuthKind;
176
+ /** Env merged into the agent's docker exec. Empty for the default env
177
+ * account (the ambient `.env.local` credential is account #1). */
178
+ execEnv: Record<string, string>;
179
+ /** config-dir accounts only: host dir to copy + container dir target. */
180
+ configDir?: { hostDir: string; containerDir: string };
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Seams (tests inject).
185
+ // ---------------------------------------------------------------------------
186
+
187
+ export interface AccountSeams {
188
+ ownerHome: () => string;
189
+ procEnv: NodeJS.ProcessEnv;
190
+ fileExists: (p: string) => boolean;
191
+ }
192
+
193
+ function defaultSeams(): AccountSeams {
194
+ return {
195
+ ownerHome: () => process.env.UAI_OWNER_HOME?.trim() || homedir(),
196
+ procEnv: process.env,
197
+ fileExists: existsSync,
198
+ };
199
+ }
200
+
201
+ function withDefaults(seams: Partial<AccountSeams>): AccountSeams {
202
+ return { ...defaultSeams(), ...seams };
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Sealing (same pack format as host-env.ts).
207
+ // ---------------------------------------------------------------------------
208
+
209
+ function packSecret(value: string): string {
210
+ const sealed = sealAesGcm(value);
211
+ return `${sealed.ct.toString("base64")}.${sealed.nonce.toString("base64")}`;
212
+ }
213
+
214
+ function unpackSecret(enc: string): string {
215
+ const dot = enc.indexOf(".");
216
+ if (dot === -1) throw new Error("malformed secret_enc");
217
+ const ct = Buffer.from(enc.slice(0, dot), "base64");
218
+ const nonce = Buffer.from(enc.slice(dot + 1), "base64");
219
+ return openAesGcm(ct, nonce);
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Scopes (ADR-131 Decision 3).
224
+ // ---------------------------------------------------------------------------
225
+
226
+ /**
227
+ * Every account has a scope. `host` is the owner's: the default slot plus
228
+ * every extra account connected before scopes existed (and every one an
229
+ * owner connects on a BYO/dedicated host). `org:<orgId>` is a workspace's
230
+ * account on a shared host: resolved ONLY for that workspace's tasks, and
231
+ * never joined by the default slot — a tenant sees exactly what it connected.
232
+ */
233
+ export { HOST_ACCOUNT_SCOPE, isEngineAccountScope };
234
+
235
+ /** A row's scope (legacy rows read as `host`) matches the requested one. */
236
+ export function accountInScope(
237
+ rowScope: string | null | undefined,
238
+ scope: string,
239
+ ): boolean {
240
+ return (rowScope ?? HOST_ACCOUNT_SCOPE) === scope;
241
+ }
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // Resolution.
245
+ // ---------------------------------------------------------------------------
246
+
247
+ /** Whether the kind's DEFAULT (legacy) credential is present on this host. */
248
+ function defaultAvailable(kind: string, s: AccountSeams): boolean {
249
+ const d = KINDS[kind];
250
+ if (!d) return false;
251
+ if (kind === "claude") {
252
+ return Boolean(
253
+ s.procEnv.CLAUDE_CODE_OAUTH_TOKEN ||
254
+ s.procEnv.ANTHROPIC_API_KEY ||
255
+ s.procEnv.ANTHROPIC_AUTH_TOKEN,
256
+ );
257
+ }
258
+ if (d.authKind === "config-dir" && d.defaultHostDir) {
259
+ return s.fileExists(join(d.defaultHostDir(s.ownerHome()), "auth.json"));
260
+ }
261
+ return false;
262
+ }
263
+
264
+ function synthesizeDefault(
265
+ kind: string,
266
+ s: AccountSeams,
267
+ ): ResolvedEngineAccount | null {
268
+ const d = KINDS[kind];
269
+ if (!d || !defaultAvailable(kind, s)) return null;
270
+ if (d.authKind === "env") {
271
+ // Ambient: the token in .env.local/process.env is forwarded via passEnv,
272
+ // so the default account injects nothing.
273
+ return {
274
+ id: `default:${kind}`,
275
+ kind,
276
+ label: "Default",
277
+ isDefault: true,
278
+ authKind: "env",
279
+ scope: HOST_ACCOUNT_SCOPE,
280
+ execEnv: {},
281
+ };
282
+ }
283
+ const containerDir = d.defaultContainerDir as string;
284
+ return {
285
+ id: `default:${kind}`,
286
+ kind,
287
+ label: "Default",
288
+ isDefault: true,
289
+ authKind: "config-dir",
290
+ scope: HOST_ACCOUNT_SCOPE,
291
+ execEnv: d.selectVar ? { [d.selectVar]: containerDir } : {},
292
+ configDir: {
293
+ hostDir: (d.defaultHostDir as (o: string) => string)(s.ownerHome()),
294
+ containerDir,
295
+ },
296
+ };
297
+ }
298
+
299
+ /** DB extras for a kind, resolved into injectable form. */
300
+ function resolveExtras(
301
+ kind: string,
302
+ s: AccountSeams,
303
+ scope: string,
304
+ ): ResolvedEngineAccount[] {
305
+ const d = KINDS[kind];
306
+ if (!d) return [];
307
+ const rows = getDb()
308
+ .select()
309
+ .from(schema.engineAccounts)
310
+ .all()
311
+ .filter(
312
+ (r) =>
313
+ r.kind === kind &&
314
+ accountInScope((r as { scope?: string | null }).scope, scope),
315
+ )
316
+ .sort(compareAccountRows);
317
+ const out: ResolvedEngineAccount[] = [];
318
+ for (const row of rows) {
319
+ if (row.authKind === "env") {
320
+ let execEnv: Record<string, string> = {};
321
+ try {
322
+ execEnv = row.secretEnc
323
+ ? (JSON.parse(unpackSecret(row.secretEnc)) as Record<string, string>)
324
+ : {};
325
+ } catch (err) {
326
+ console.warn(
327
+ `[engine-accounts] skipping ${kind}/${row.id}: decrypt failed (${
328
+ err instanceof Error ? err.message : String(err)
329
+ })`,
330
+ );
331
+ continue;
332
+ }
333
+ out.push({
334
+ id: row.id,
335
+ kind,
336
+ label: row.label,
337
+ isDefault: false,
338
+ authKind: "env",
339
+ scope,
340
+ execEnv,
341
+ });
342
+ } else if (row.authKind === "config-dir" && row.configDir && d.selectVar) {
343
+ const containerDir = `${d.containerDirPrefix}${row.id}`;
344
+ out.push({
345
+ id: row.id,
346
+ kind,
347
+ label: row.label,
348
+ isDefault: false,
349
+ authKind: "config-dir",
350
+ scope,
351
+ execEnv: { [d.selectVar]: containerDir },
352
+ configDir: { hostDir: row.configDir, containerDir },
353
+ });
354
+ } else if (row.authKind === "config-dir" && isStoreHeldRow(row)) {
355
+ // ADR-136 sign-in box: the material lives in the store, not here. The
356
+ // row is listed (label, scope — what the capabilities frame carries)
357
+ // and never provisioned: the box runs no task.
358
+ out.push({
359
+ id: row.id,
360
+ kind,
361
+ label: row.label,
362
+ isDefault: false,
363
+ authKind: "config-dir",
364
+ scope,
365
+ execEnv: {},
366
+ });
367
+ }
368
+ }
369
+ return out;
370
+ }
371
+
372
+ /** All accounts for a kind: [default if present] + [DB extras]. */
373
+ export function resolveEngineAccounts(
374
+ kind: string,
375
+ seams: Partial<AccountSeams> = {},
376
+ scope: string = HOST_ACCOUNT_SCOPE,
377
+ ): ResolvedEngineAccount[] {
378
+ const s = withDefaults(seams);
379
+ const accounts: ResolvedEngineAccount[] = [];
380
+ // The default slot is the OWNER's credential: it joins the host scope only.
381
+ if (scope === HOST_ACCOUNT_SCOPE) {
382
+ const def = synthesizeDefault(kind, s);
383
+ if (def) accounts.push(def);
384
+ }
385
+ accounts.push(...resolveExtras(kind, s, scope));
386
+ return accounts;
387
+ }
388
+
389
+ /** Public account list for the host UI (no secrets). */
390
+ export function listEngineAccounts(
391
+ kind: string,
392
+ seams: Partial<AccountSeams> = {},
393
+ scope: string = HOST_ACCOUNT_SCOPE,
394
+ ): EngineAccountInfo[] {
395
+ return resolveEngineAccounts(kind, seams, scope).map((a) => ({
396
+ id: a.id,
397
+ kind: a.kind,
398
+ label: a.label,
399
+ authKind: a.authKind,
400
+ isDefault: a.isDefault,
401
+ scope: a.scope,
402
+ }));
403
+ }
404
+
405
+ /** Every scope holding at least one stored account for the kind, plus
406
+ * `host` (which may consist of the default slot alone). */
407
+ export function engineAccountScopes(kind: string): string[] {
408
+ const scopes = new Set<string>([HOST_ACCOUNT_SCOPE]);
409
+ for (const row of getDb().select().from(schema.engineAccounts).all()) {
410
+ if (row.kind !== kind) continue;
411
+ scopes.add((row as { scope?: string | null }).scope ?? HOST_ACCOUNT_SCOPE);
412
+ }
413
+ return [...scopes];
414
+ }
415
+
416
+ /** Account list across ALL scopes — the capabilities advertisement. The
417
+ * cloud filters per caller; labels only, never credentials. */
418
+ export function listEngineAccountsAllScopes(
419
+ kind: string,
420
+ seams: Partial<AccountSeams> = {},
421
+ ): EngineAccountInfo[] {
422
+ return engineAccountScopes(kind).flatMap((scope) =>
423
+ listEngineAccounts(kind, seams, scope),
424
+ );
425
+ }
426
+
427
+ /** How many accounts a kind has right now (default + extras). */
428
+ export function engineAccountCount(
429
+ kind: string,
430
+ seams: Partial<AccountSeams> = {},
431
+ scope: string = HOST_ACCOUNT_SCOPE,
432
+ ): number {
433
+ return resolveEngineAccounts(kind, seams, scope).length;
434
+ }
435
+
436
+ // ---------------------------------------------------------------------------
437
+ // Rotation state (in-memory) + selection.
438
+ // ---------------------------------------------------------------------------
439
+
440
+ interface RotationState {
441
+ /** One account may be capped by several independent provider windows. Null
442
+ * is a rate-limit error that named no window and needs a full snapshot to
443
+ * disprove. Each cause keeps its own deadline so clearing one cannot clear
444
+ * or extend another. */
445
+ cooldowns: Map<string | null, number>;
446
+ lastUsedAt: number;
447
+ }
448
+
449
+ const rotationState = new Map<string, RotationState>();
450
+
451
+ /** Default cooldown when the engine gives no reset hint. */
452
+ export const DEFAULT_COOLDOWN_MS = 15 * 60_000;
453
+ /** Provider resets too close to the current instant still need a short bench
454
+ * so repeated terminal events cannot hot-loop the same account. */
455
+ export const MIN_ENGINE_ACCOUNT_COOLDOWN_MS = 60_000;
456
+ /** Refuse a corrupt/far-future provider timestamp from benching an account
457
+ * indefinitely. */
458
+ export const MAX_ENGINE_ACCOUNT_COOLDOWN_MS = 7 * 24 * 60 * 60_000;
459
+
460
+ function state(id: string): RotationState {
461
+ let st = rotationState.get(id);
462
+ if (!st) {
463
+ st = { cooldowns: new Map(), lastUsedAt: 0 };
464
+ rotationState.set(id, st);
465
+ }
466
+ return st;
467
+ }
468
+
469
+ function cooldown(
470
+ id: string,
471
+ window: string | null,
472
+ ms: number,
473
+ now: number,
474
+ ): void {
475
+ const account = state(id);
476
+ const previous = account.cooldowns.get(window) ?? 0;
477
+ account.cooldowns.set(window, Math.max(previous, now + ms));
478
+ }
479
+
480
+ function cooldownDuration(resetsAt: number, now: number): number {
481
+ return Number.isFinite(resetsAt)
482
+ ? Math.min(
483
+ MAX_ENGINE_ACCOUNT_COOLDOWN_MS,
484
+ Math.max(MIN_ENGINE_ACCOUNT_COOLDOWN_MS, resetsAt - now),
485
+ )
486
+ : DEFAULT_COOLDOWN_MS;
487
+ }
488
+
489
+ function accountCooldownUntil(id: string): number {
490
+ return Math.max(0, ...state(id).cooldowns.values());
491
+ }
492
+
493
+ /** Put an account on cooldown (rate-limited). */
494
+ export function cooldownEngineAccount(
495
+ id: string,
496
+ ms: number = DEFAULT_COOLDOWN_MS,
497
+ now: number = Date.now(),
498
+ ): void {
499
+ cooldown(id, null, ms, now);
500
+ }
501
+
502
+ /** Put an account on cooldown through the engine's real reset, bounded against
503
+ * stale or corrupt timestamps. */
504
+ export function cooldownEngineAccountUntil(
505
+ id: string,
506
+ resetsAt: number,
507
+ now: number = Date.now(),
508
+ ): void {
509
+ cooldown(id, null, cooldownDuration(resetsAt, now), now);
510
+ }
511
+
512
+ /** Bench one named provider window without conflating it with another. */
513
+ export function cooldownEngineAccountWindow(
514
+ id: string,
515
+ window: string,
516
+ ms: number = DEFAULT_COOLDOWN_MS,
517
+ now: number = Date.now(),
518
+ ): void {
519
+ cooldown(id, window, ms, now);
520
+ }
521
+
522
+ /** Bench one named provider window through its bounded real reset. */
523
+ export function cooldownEngineAccountWindowUntil(
524
+ id: string,
525
+ window: string,
526
+ resetsAt: number,
527
+ now: number = Date.now(),
528
+ ): void {
529
+ cooldown(id, window, cooldownDuration(resetsAt, now), now);
530
+ }
531
+
532
+ /** A per-window reading proves only that same provider window usable. */
533
+ export function clearEngineAccountCooldownWindow(
534
+ id: string,
535
+ window: string,
536
+ ): void {
537
+ rotationState.get(id)?.cooldowns.delete(window);
538
+ }
539
+
540
+ /** A complete provider snapshot with no limited window disproves every bench,
541
+ * including an error that could not name which window was exhausted. */
542
+ export function clearEngineAccountCooldowns(id: string): void {
543
+ rotationState.get(id)?.cooldowns.clear();
544
+ }
545
+
546
+ /** Record that an account was just used (least-recently-used ordering). */
547
+ export function noteEngineAccountUsed(id: string, now: number = Date.now()): void {
548
+ state(id).lastUsedAt = now;
549
+ }
550
+
551
+ /**
552
+ * Pure selection over a resolved list: least-recently-used account not in
553
+ * `exclude` and not cooling. Falls back to the least-recently-used cooling
554
+ * account if ALL are cooling (better to try a capped account than stall).
555
+ * Returns null only when the list (minus exclusions) is empty.
556
+ */
557
+ export function selectAccount(
558
+ accounts: ResolvedEngineAccount[],
559
+ opts: { exclude?: Set<string>; now?: number; preferredId?: string } = {},
560
+ ): ResolvedEngineAccount | null {
561
+ const now = opts.now ?? Date.now();
562
+ const exclude = opts.exclude ?? new Set<string>();
563
+ const candidates = accounts.filter((a) => !exclude.has(a.id));
564
+ if (candidates.length === 0) return null;
565
+ const lru = (a: ResolvedEngineAccount): number => state(a.id).lastUsedAt;
566
+ const healthy = candidates.filter((a) => accountCooldownUntil(a.id) <= now);
567
+ const preferred = opts.preferredId
568
+ ? healthy.find((account) => account.id === opts.preferredId)
569
+ : undefined;
570
+ if (preferred) return preferred;
571
+ // Manage-v1: the list arrives in PRIORITY order (default slot, then extras
572
+ // by position) — the first healthy account wins, so the operator's
573
+ // ordering decides which account a task uses and which are fallbacks.
574
+ // Only when every account is cooling do we fall back to the least recently
575
+ // used one (the stalest cooldown is the likeliest to have lifted).
576
+ if (healthy.length > 0) return healthy[0]!;
577
+ return candidates.reduce(
578
+ (best, a) => (lru(a) < lru(best) ? a : best),
579
+ candidates[0]!,
580
+ );
581
+ }
582
+
583
+ /** Pick an account for a kind (default + extras), applying rotation state. */
584
+ export function pickEngineAccount(
585
+ kind: string,
586
+ opts: {
587
+ exclude?: Set<string>;
588
+ seams?: Partial<AccountSeams>;
589
+ now?: number;
590
+ /** One agent's durable preference; cooling or absent falls through. */
591
+ preferredId?: string;
592
+ /** ADR-131 D3: the task's scope; default = the host's own accounts. */
593
+ scope?: string;
594
+ } = {},
595
+ ): ResolvedEngineAccount | null {
596
+ const accounts = resolveEngineAccounts(
597
+ kind,
598
+ opts.seams ?? {},
599
+ opts.scope ?? HOST_ACCOUNT_SCOPE,
600
+ );
601
+ return selectAccount(accounts, {
602
+ exclude: opts.exclude,
603
+ now: opts.now,
604
+ preferredId: opts.preferredId,
605
+ });
606
+ }
607
+
608
+ /**
609
+ * ADR-076 + codex-cli 0.152.0: two codex agents sharing one account share one
610
+ * CODEX_HOME, and codex's sqlite state runtime cannot be opened by two
611
+ * concurrent processes. These pure helpers give each codex agent its own
612
+ * home (a copy of the account's) — the orchestrator wraps them with the
613
+ * container exec. Only codex config-dir homes under /home/node/.codex are
614
+ * isolated; ambient/env accounts and other kinds are left untouched.
615
+ */
616
+ export function codexHomeIsolatable(
617
+ kind: string,
618
+ codexHome: string | undefined,
619
+ ): codexHome is string {
620
+ return (
621
+ kind === "codex" &&
622
+ typeof codexHome === "string" &&
623
+ codexHome.startsWith("/home/node/.codex")
624
+ );
625
+ }
626
+
627
+ /** The per-agent home for a shared account home. Agent id is sanitized so it
628
+ * is always a safe path segment. */
629
+ export function codexPerAgentHome(codexHome: string, agentId: string): string {
630
+ const safe = agentId.replace(/[^A-Za-z0-9_-]/g, "_");
631
+ return `${codexHome}-agent-${safe}`;
632
+ }
633
+
634
+ /** The idempotent seed command: copy the account home into the per-agent home
635
+ * ONCE (only when the per-agent home is still empty), node-owned. */
636
+ export function codexHomeSeedCommand(
637
+ codexHome: string,
638
+ perAgentHome: string,
639
+ ): string {
640
+ return (
641
+ `set -e; mkdir -p "${perAgentHome}"; ` +
642
+ `if [ -z "$(ls -A "${perAgentHome}" 2>/dev/null)" ]; then ` +
643
+ `cp -a "${codexHome}/." "${perAgentHome}/" 2>/dev/null || true; fi; ` +
644
+ `chown -R node:node "${perAgentHome}"`
645
+ );
646
+ }
647
+
648
+ /** Test hook: clear in-memory rotation state. */
649
+ export function resetRotationState(): void {
650
+ rotationState.clear();
651
+ }
652
+
653
+ // ---------------------------------------------------------------------------
654
+ // Provisioning (extra config-dir accounts) — copied into per-account container
655
+ // dirs at session start, since task-up.sh (bash) can't read the sealed DB.
656
+ // ---------------------------------------------------------------------------
657
+
658
+ /**
659
+ * ADR-135: what a task receives for a config-dir account. The host dir is
660
+ * copied verbatim EXCEPT that a lane-owned `auth.json` gets its rotating
661
+ * refresh token replaced by the broker placeholder, and the broker's own
662
+ * sidecar never travels. Returns a private temp dir (0700) the caller copies
663
+ * from and must remove; null means "copy the host dir as it is".
664
+ */
665
+ export function stageBrokeredAccountDir(
666
+ kind: string,
667
+ hostDir: string,
668
+ ): { dir: string; cleanup: () => void } | null {
669
+ let entries: string[];
670
+ try {
671
+ entries = readdirSync(hostDir);
672
+ } catch {
673
+ return null;
674
+ }
675
+ const files: Record<string, string> = {};
676
+ const binaries = new Map<string, Buffer>();
677
+ for (const name of entries) {
678
+ if (name === BROKER_SIDECAR_FILE) continue;
679
+ const path = join(hostDir, name);
680
+ let content: Buffer;
681
+ try {
682
+ if (!statSync(path).isFile()) continue;
683
+ content = readFileSync(path);
684
+ } catch {
685
+ continue;
686
+ }
687
+ if (name === "auth.json") files[name] = content.toString("utf8");
688
+ else binaries.set(name, content);
689
+ }
690
+ if (!("auth.json" in files)) return null;
691
+ const brokered = brokeredFilesFor(
692
+ { kind, authKind: "config-dir", id: "staging", scope: HOST_ACCOUNT_SCOPE },
693
+ files,
694
+ );
695
+ if (brokered === files) return null;
696
+ const dir = mkdtempSync(join(tmpdir(), "uai-brokered-"));
697
+ try {
698
+ chmodSync(dir, 0o700);
699
+ for (const [name, content] of Object.entries(brokered)) {
700
+ writeFileSync(join(dir, name), content, { mode: 0o600 });
701
+ }
702
+ for (const [name, content] of binaries) {
703
+ writeFileSync(join(dir, name), content, { mode: 0o600 });
704
+ }
705
+ } catch (err) {
706
+ rmSync(dir, { recursive: true, force: true });
707
+ throw err;
708
+ }
709
+ return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
710
+ }
711
+
712
+ async function copyDirInto(
713
+ hostDir: string,
714
+ containerDir: string,
715
+ container: string,
716
+ environment?: Pick<TaskEnvironmentHandle, "exec" | "copy">,
717
+ kind?: string,
718
+ ): Promise<void> {
719
+ // ADR-135: never hand a task the rotating refresh token.
720
+ const staged = kind ? stageBrokeredAccountDir(kind, hostDir) : null;
721
+ if (staged) {
722
+ try {
723
+ await copyDirIntoRaw(staged.dir, containerDir, container, environment);
724
+ } finally {
725
+ staged.cleanup();
726
+ }
727
+ return;
728
+ }
729
+ await copyDirIntoRaw(hostDir, containerDir, container, environment);
730
+ }
731
+
732
+ /** The broker's per-dir metadata (docs/token-broker.md); never copied. */
733
+ export const BROKER_SIDECAR_FILE = "uai-broker.json";
734
+
735
+ async function copyDirIntoRaw(
736
+ hostDir: string,
737
+ containerDir: string,
738
+ container: string,
739
+ environment?: Pick<TaskEnvironmentHandle, "exec" | "copy">,
740
+ ): Promise<void> {
741
+ if (environment) {
742
+ const run = async (argv: [string, ...string[]], step: string) => {
743
+ const result = await environment.exec({
744
+ projectEnvProfile: "maintenance",
745
+ argv,
746
+ user: "root",
747
+ env: RUNTIME_AUTHORITY_ENV,
748
+ timeoutMs: EXEC_TIMEOUT_MS,
749
+ maxOutputBytes: 64 * 1024,
750
+ });
751
+ if (result.exitCode !== 0) {
752
+ const detail = Buffer.from(result.stderr).toString("utf8").trim().slice(0, 200);
753
+ throw new Error(
754
+ `${step} failed (${result.exitCode === null ? "environment timeout" : `exit ${result.exitCode}`})` +
755
+ (detail ? `: ${detail}` : ""),
756
+ );
757
+ }
758
+ };
759
+ await run(["/bin/mkdir", "-p", containerDir], `mkdir ${containerDir}`);
760
+ // The mkdir above runs as root, but a MACHINE copy extracts as the ssh
761
+ // user (node) — into a root-owned directory it dies with "Cannot open:
762
+ // Permission denied" and the account never authenticates (live
763
+ // 2026-09-07, first scoped Codex account on the shared host). Hand the
764
+ // directory to node BEFORE the copy; this also repairs a directory an
765
+ // earlier failed attempt left root-owned on a still-running machine.
766
+ await run(
767
+ ["/bin/chown", "-R", "node:node", containerDir],
768
+ `chown -R node:node ${containerDir}`,
769
+ );
770
+ await environment.copy({
771
+ direction: "into",
772
+ source: hostDir,
773
+ destination: containerDir,
774
+ });
775
+ await run(
776
+ ["/bin/chown", "-R", "node:node", containerDir],
777
+ `chown -R node:node ${containerDir}`,
778
+ );
779
+ return;
780
+ }
781
+ // Backend-aware fallback (2026-08-18 sweep + review round 4): the apple
782
+ // path normally arrives with an environment handle above; when it does
783
+ // not, the bundled CLI speaks these exec/cp verbs — but the CALLER's name
784
+ // is the Docker replica shape, which does not exist on apple.
785
+ const target = taskContainerBackend().apple
786
+ ? container.replace(/-app-1$/, "-app")
787
+ : container;
788
+ const must = async (args: string[], step: string): Promise<void> => {
789
+ const res = await taskContainerCli(args, { timeoutMs: EXEC_TIMEOUT_MS });
790
+ if (res.status !== 0) {
791
+ const detail = res.stderr.trim().slice(0, 200);
792
+ throw new Error(
793
+ `${step} failed (${res.status === null ? "docker timeout" : `exit ${res.status}`})` +
794
+ (detail ? `: ${detail}` : ""),
795
+ );
796
+ }
797
+ };
798
+ await must(
799
+ [
800
+ "exec",
801
+ "-u",
802
+ "root",
803
+ ...runtimeAuthorityDockerExecArgs(),
804
+ target,
805
+ "/bin/mkdir",
806
+ "-p",
807
+ containerDir,
808
+ ],
809
+ `mkdir ${containerDir}`,
810
+ );
811
+ // Copy the dir CONTENTS (trailing /.) into the target dir.
812
+ await must(["cp", `${hostDir}/.`, `${target}:${containerDir}`], "docker cp account dir");
813
+ await must(
814
+ [
815
+ "exec",
816
+ "-u",
817
+ "root",
818
+ ...runtimeAuthorityDockerExecArgs(),
819
+ target,
820
+ "/bin/chown",
821
+ "-R",
822
+ "node:node",
823
+ containerDir,
824
+ ],
825
+ `chown -R node:node ${containerDir}`,
826
+ );
827
+ }
828
+
829
+ /**
830
+ * Copy every EXTRA config-dir account (for the given kinds) into its per-account
831
+ * container dir + chown. The DEFAULT account is copied by task-up.sh, so it is
832
+ * skipped here. Best-effort per account — failures are logged (no secrets) and
833
+ * reported in aggregate so the orchestrator can retry without hot-looping.
834
+ */
835
+ export interface ProvisionEngineAccountsResult {
836
+ copied: number;
837
+ failed: number;
838
+ mayHaveMutated: boolean;
839
+ }
840
+
841
+ export async function provisionEngineAccounts(
842
+ container: string,
843
+ kinds: Iterable<string>,
844
+ seams: Partial<AccountSeams> = {},
845
+ environment?: Pick<TaskEnvironmentHandle, "exec" | "copy">,
846
+ scope: string = HOST_ACCOUNT_SCOPE,
847
+ ): Promise<ProvisionEngineAccountsResult> {
848
+ const s = withDefaults(seams);
849
+ const seen = new Set<string>();
850
+ let copied = 0;
851
+ let failed = 0;
852
+ let mayHaveMutated = false;
853
+ for (const kind of kinds) {
854
+ if (seen.has(kind)) continue;
855
+ seen.add(kind);
856
+ for (const account of resolveEngineAccounts(kind, s, scope)) {
857
+ if (account.isDefault || !account.configDir) continue;
858
+ if (!s.fileExists(account.configDir.hostDir)) {
859
+ failed += 1;
860
+ continue;
861
+ }
862
+ try {
863
+ await copyDirInto(
864
+ account.configDir.hostDir,
865
+ account.configDir.containerDir,
866
+ container,
867
+ environment,
868
+ kind,
869
+ );
870
+ copied += 1;
871
+ } catch (err) {
872
+ failed += 1;
873
+ // copyDirInto performs more than one external operation. A non-zero
874
+ // result can therefore mean the destination changed before the
875
+ // failure surfaced; callers must fence/reassert managed config.
876
+ mayHaveMutated = true;
877
+ console.error(
878
+ `[engine-accounts] provision ${kind}/${account.id} into ${container}: ` +
879
+ `${err instanceof Error ? err.message : err} — this account won't authenticate in the task`,
880
+ );
881
+ }
882
+ }
883
+ }
884
+ return { copied, failed, mayHaveMutated };
885
+ }
886
+
887
+ /** Priority order within (scope, kind): position, then age. Rows from before
888
+ * manage-v1 all carry 0 and therefore keep their creation order. */
889
+ function compareAccountRows(
890
+ a: { position?: number | null; createdAt: number; id: string },
891
+ b: { position?: number | null; createdAt: number; id: string },
892
+ ): number {
893
+ return (
894
+ (a.position ?? 0) - (b.position ?? 0) ||
895
+ a.createdAt - b.createdAt ||
896
+ a.id.localeCompare(b.id)
897
+ );
898
+ }
899
+
900
+ /** New accounts join the END of their (scope, kind) order. */
901
+ function nextPosition(kind: string, scope: string): number {
902
+ let max = -1;
903
+ for (const row of getDb().select().from(schema.engineAccounts).all()) {
904
+ if (row.kind !== kind) continue;
905
+ if (((row as { scope?: string | null }).scope ?? HOST_ACCOUNT_SCOPE) !== scope) continue;
906
+ max = Math.max(max, (row as { position?: number | null }).position ?? 0);
907
+ }
908
+ return max + 1;
909
+ }
910
+
911
+ // ---------------------------------------------------------------------------
912
+ // Mutations (host UI).
913
+ // ---------------------------------------------------------------------------
914
+
915
+ export interface AddAccountResult {
916
+ ok: boolean;
917
+ message: string;
918
+ id?: string;
919
+ }
920
+
921
+ /**
922
+ * Register an EXTRA account for a kind by pasting a token / API key (ADR-076).
923
+ * Claude accepts a `setup-token` token or an `sk-ant-…` key; Codex accepts an
924
+ * OpenAI API key (written into an isolated `~/.codex-acct-<id>/auth.json`).
925
+ */
926
+ /** What a labeled account will hold once persisted — the validated shape
927
+ * shared by the local writer (owned and shared hosts) and the store-only
928
+ * writer (the ADR-136 sign-in box), so both refuse the same inputs with the
929
+ * same words. Never logged. */
930
+ type LabeledAccountPlan =
931
+ | { authKind: "env"; label: string; env: Record<string, string>; message: string }
932
+ | { authKind: "config-dir"; label: string; files: Record<string, string>; message: string };
933
+
934
+ type PlannedAccount = { ok: false; message: string } | { ok: true; plan: LabeledAccountPlan };
935
+
936
+ /**
937
+ * What a pasted token or API key becomes for an engine: env vars, or (Codex)
938
+ * an `auth.json` holding the key. Shared by local accounts and ADR-154
939
+ * capture flows, which carry no local scope.
940
+ */
941
+ export function pastedSecretMaterial(
942
+ kind: string,
943
+ rawSecret: string,
944
+ ): { ok: true; material: Exclude<EngineCaptureMaterial, { authKind: "custom-connection" }> } | { ok: false; message: string } {
945
+ const secret = rawSecret.trim();
946
+ if (!secret) return { ok: false, message: "Paste a token or API key." };
947
+ if (/\s/.test(secret) || secret.length < 12) {
948
+ return { ok: false, message: "That doesn't look like a token. Paste just the value." };
949
+ }
950
+ if (kind === "claude") {
951
+ const envVar = secret.startsWith("sk-ant-api")
952
+ ? "ANTHROPIC_API_KEY"
953
+ : "CLAUDE_CODE_OAUTH_TOKEN";
954
+ return { ok: true, material: { authKind: "env", env: { [envVar]: secret } } };
955
+ }
956
+ const envKeyVar = API_KEY_ENV_VARS[kind];
957
+ if (envKeyVar) {
958
+ return { ok: true, material: { authKind: "env", env: { [envKeyVar]: secret } } };
959
+ }
960
+ if (kind === "codex") {
961
+ return {
962
+ ok: true,
963
+ material: {
964
+ authKind: "config-dir",
965
+ files: { "auth.json": `${JSON.stringify({ OPENAI_API_KEY: secret }, null, 2)}\n` },
966
+ },
967
+ };
968
+ }
969
+ return {
970
+ ok: false,
971
+ message: `Adding a second ${kind} account isn't supported yet — connect the primary account from the Engines panel.`,
972
+ };
973
+ }
974
+
975
+ function planLabeledAccount(
976
+ kind: string,
977
+ label: string,
978
+ creds: { apiKey?: string; token?: string },
979
+ scope: string,
980
+ ): PlannedAccount {
981
+ if (!isEngineAccountScope(scope)) {
982
+ return { ok: false, message: "The account scope is invalid." };
983
+ }
984
+ const trimmedLabel = label.trim();
985
+ if (!trimmedLabel) return { ok: false, message: "Give the account a label." };
986
+ const pasted = pastedSecretMaterial(kind, creds.token ?? creds.apiKey ?? "");
987
+ if (!pasted.ok) return pasted;
988
+ const engineName =
989
+ kind === "claude"
990
+ ? "Claude"
991
+ : kind === "codex"
992
+ ? "Codex"
993
+ : (API_KEY_ENGINE_LABELS[kind] ?? kind);
994
+ const message = `Added ${engineName} account "${trimmedLabel}".`;
995
+ return {
996
+ ok: true,
997
+ plan:
998
+ pasted.material.authKind === "env"
999
+ ? { authKind: "env", label: trimmedLabel, env: pasted.material.env, message }
1000
+ : { authKind: "config-dir", label: trimmedLabel, files: pasted.material.files, message },
1001
+ };
1002
+ }
1003
+
1004
+ function planCapturedConfigDirAccount(
1005
+ kind: "codex" | "grok",
1006
+ label: string,
1007
+ authJson: string,
1008
+ scope: string,
1009
+ ): PlannedAccount {
1010
+ const engineLabel = kind === "codex" ? "Codex" : "Grok";
1011
+ if (!isEngineAccountScope(scope)) {
1012
+ return { ok: false, message: "The account scope is invalid." };
1013
+ }
1014
+ const trimmedLabel = label.trim();
1015
+ if (!trimmedLabel) return { ok: false, message: "Give the account a label." };
1016
+ try {
1017
+ const parsed: unknown = JSON.parse(authJson);
1018
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1019
+ throw new Error("not an object");
1020
+ }
1021
+ } catch {
1022
+ return { ok: false, message: `The captured ${engineLabel} credential is not valid.` };
1023
+ }
1024
+ return {
1025
+ ok: true,
1026
+ plan: {
1027
+ authKind: "config-dir",
1028
+ label: trimmedLabel,
1029
+ files: { "auth.json": authJson.endsWith("\n") ? authJson : `${authJson}\n` },
1030
+ message: `Added ${engineLabel} account "${trimmedLabel}".`,
1031
+ },
1032
+ };
1033
+ }
1034
+
1035
+ /** Persist a plan on THIS host — a sealed env row or an isolated config dir
1036
+ * under the owner's home — and mirror it to the store when one is configured. */
1037
+ function persistPlannedAccountLocally(
1038
+ kind: string,
1039
+ scope: string,
1040
+ plan: LabeledAccountPlan,
1041
+ s: AccountSeams,
1042
+ ): AddAccountResult {
1043
+ const id = newId();
1044
+ let configDir: string | null = null;
1045
+ if (plan.authKind === "config-dir") {
1046
+ const d = KINDS[kind];
1047
+ if (!d?.extraHostDir) {
1048
+ return { ok: false, message: `${kind} has no config-dir layout.` };
1049
+ }
1050
+ const hostDir = d.extraHostDir(s.ownerHome(), id);
1051
+ try {
1052
+ mkdirSync(hostDir, { recursive: true });
1053
+ for (const [basename, content] of Object.entries(plan.files)) {
1054
+ const file = join(hostDir, basename);
1055
+ writeFileSync(file, content, { mode: 0o600 });
1056
+ try {
1057
+ chmodSync(file, 0o600);
1058
+ } catch {
1059
+ /* best effort */
1060
+ }
1061
+ }
1062
+ } catch (err) {
1063
+ return {
1064
+ ok: false,
1065
+ message: `Could not write the account config: ${
1066
+ err instanceof Error ? err.message : String(err)
1067
+ }`,
1068
+ };
1069
+ }
1070
+ configDir = hostDir;
1071
+ }
1072
+ getDb()
1073
+ .insert(schema.engineAccounts)
1074
+ .values({
1075
+ id,
1076
+ kind,
1077
+ label: plan.label,
1078
+ authKind: plan.authKind,
1079
+ secretEnc: plan.authKind === "env" ? packSecret(JSON.stringify(plan.env)) : null,
1080
+ configDir,
1081
+ createdAt: Date.now(),
1082
+ scope,
1083
+ position: nextPosition(kind, scope),
1084
+ })
1085
+ .run();
1086
+ mirrorEngineAccount(id);
1087
+ return { ok: true, message: plan.message, id };
1088
+ }
1089
+
1090
+ export function addEngineAccount(
1091
+ kind: string,
1092
+ label: string,
1093
+ creds: { apiKey?: string; token?: string },
1094
+ seams: Partial<AccountSeams> = {},
1095
+ scope: string = HOST_ACCOUNT_SCOPE,
1096
+ ): AddAccountResult {
1097
+ const planned = planLabeledAccount(kind, label, creds, scope);
1098
+ if (!planned.ok) return planned;
1099
+ return persistPlannedAccountLocally(kind, scope, planned.plan, withDefaults(seams));
1100
+ }
1101
+
1102
+ /**
1103
+ * ADR-116: register an EXTRA Codex account from a browser-login capture — the
1104
+ * verbatim `auth.json` a `codex login` flow produced (OAuth tokens, not an
1105
+ * API key), written into the same isolated `~/.codex-acct-<id>` layout the
1106
+ * pasted-key path uses. Callers pass the file CONTENTS; nothing is logged.
1107
+ */
1108
+ export function addCodexAccountFromAuthJson(
1109
+ label: string,
1110
+ authJson: string,
1111
+ seams: Partial<AccountSeams> = {},
1112
+ scope: string = HOST_ACCOUNT_SCOPE,
1113
+ ): AddAccountResult {
1114
+ return addConfigDirAccountFromAuthJson("codex", label, authJson, seams, scope);
1115
+ }
1116
+
1117
+ /** Grok (host 0.9.105): the `auth.json` a `grok login --device-auth` capture
1118
+ * produced, stored as an isolated `~/.grok-acct-<id>` selected by GROK_HOME. */
1119
+ export function addGrokAccountFromAuthJson(
1120
+ label: string,
1121
+ authJson: string,
1122
+ seams: Partial<AccountSeams> = {},
1123
+ scope: string = HOST_ACCOUNT_SCOPE,
1124
+ ): AddAccountResult {
1125
+ return addConfigDirAccountFromAuthJson("grok", label, authJson, seams, scope);
1126
+ }
1127
+
1128
+ function addConfigDirAccountFromAuthJson(
1129
+ kind: "codex" | "grok",
1130
+ label: string,
1131
+ authJson: string,
1132
+ seams: Partial<AccountSeams>,
1133
+ scope: string,
1134
+ ): AddAccountResult {
1135
+ const planned = planCapturedConfigDirAccount(kind, label, authJson, scope);
1136
+ if (!planned.ok) return planned;
1137
+ return persistPlannedAccountLocally(kind, scope, planned.plan, withDefaults(seams));
1138
+ }
1139
+
1140
+ /** Remove an EXTRA account (idempotent). The synthesized default can't be
1141
+ * removed here (use the engine disconnect flow for the legacy slot). The
1142
+ * isolated host config dir of a config-dir account is deleted too. */
1143
+ /** Whether a caller acting in `scope` may remove `row`. `undefined` is the
1144
+ * host's own local UI, which may remove anything. */
1145
+ export function accountRemovableInScope(
1146
+ row: { scope?: string | null },
1147
+ scope: string | undefined,
1148
+ ): boolean {
1149
+ return scope === undefined || accountInScope(row.scope, scope);
1150
+ }
1151
+
1152
+ export function removeEngineAccount(
1153
+ id: string,
1154
+ scope?: string,
1155
+ ): { ok: boolean; message: string } {
1156
+ if (id.startsWith("default:")) {
1157
+ return {
1158
+ ok: false,
1159
+ message: "The default account is managed from the engine's Connect/Disconnect.",
1160
+ };
1161
+ }
1162
+ const row = getDb()
1163
+ .select()
1164
+ .from(schema.engineAccounts)
1165
+ .where(eq(schema.engineAccounts.id, id))
1166
+ .get();
1167
+ if (!row) return { ok: true, message: "Account already removed." };
1168
+ // ADR-131 D3: a workspace removes only its own accounts — never the host's
1169
+ // and never another workspace's, whatever id it names.
1170
+ if (!accountRemovableInScope(row, scope)) {
1171
+ return { ok: false, message: "That account belongs to a different scope." };
1172
+ }
1173
+ removeLocalAccountRow(row);
1174
+ // ADR-134: the parameter goes too, best effort; the row is already gone.
1175
+ queueStoreWrite(`delete ${id}`, async (store) => {
1176
+ const name = row.storeRef ?? engineAccountParameterName(row);
1177
+ if (name) await store.delete(name);
1178
+ });
1179
+ return { ok: true, message: `Removed "${row.label}".` };
1180
+ }
1181
+
1182
+ /** Manage-v1: relabel an extra account. Same scope rule as removal. */
1183
+ export function renameEngineAccount(
1184
+ id: string,
1185
+ label: string,
1186
+ scope?: string,
1187
+ ): { ok: boolean; message: string } {
1188
+ if (id.startsWith("default:")) {
1189
+ return { ok: false, message: "The default account cannot be renamed." };
1190
+ }
1191
+ const trimmed = label.trim();
1192
+ if (!isEngineAccountLabel(trimmed)) {
1193
+ return { ok: false, message: "Give the account a label (up to 64 characters)." };
1194
+ }
1195
+ const row = getDb()
1196
+ .select()
1197
+ .from(schema.engineAccounts)
1198
+ .where(eq(schema.engineAccounts.id, id))
1199
+ .get();
1200
+ if (!row) return { ok: false, message: "That account no longer exists." };
1201
+ if (!accountRemovableInScope(row, scope)) {
1202
+ return { ok: false, message: "That account belongs to a different scope." };
1203
+ }
1204
+ getDb()
1205
+ .update(schema.engineAccounts)
1206
+ .set({ label: trimmed })
1207
+ .where(eq(schema.engineAccounts.id, id))
1208
+ .run();
1209
+ mirrorEngineAccount(id);
1210
+ return { ok: true, message: `Renamed to "${trimmed}".` };
1211
+ }
1212
+
1213
+ /**
1214
+ * Manage-v1: set the priority order of `kind`'s extra accounts in `scope`.
1215
+ * `ids` must be exactly the set of that kind's rows in the scope — a stale
1216
+ * list (an account added or removed meanwhile) is refused rather than
1217
+ * silently partially applied.
1218
+ */
1219
+ export function reorderEngineAccounts(
1220
+ kind: string,
1221
+ ids: readonly string[],
1222
+ scope: string = HOST_ACCOUNT_SCOPE,
1223
+ ): { ok: boolean; message: string } {
1224
+ if (!isEngineAccountScope(scope)) {
1225
+ return { ok: false, message: "The account scope is invalid." };
1226
+ }
1227
+ const rows = getDb()
1228
+ .select()
1229
+ .from(schema.engineAccounts)
1230
+ .all()
1231
+ .filter(
1232
+ (r) =>
1233
+ r.kind === kind &&
1234
+ ((r as { scope?: string | null }).scope ?? HOST_ACCOUNT_SCOPE) === scope,
1235
+ );
1236
+ const have = new Set(rows.map((r) => r.id));
1237
+ const want = new Set(ids);
1238
+ if (
1239
+ have.size !== want.size ||
1240
+ ids.length !== want.size ||
1241
+ [...have].some((id) => !want.has(id))
1242
+ ) {
1243
+ return {
1244
+ ok: false,
1245
+ message: "The account list changed — refresh and try again.",
1246
+ };
1247
+ }
1248
+ ids.forEach((id, index) => {
1249
+ getDb()
1250
+ .update(schema.engineAccounts)
1251
+ .set({ position: index })
1252
+ .where(eq(schema.engineAccounts.id, id))
1253
+ .run();
1254
+ });
1255
+ for (const id of ids) mirrorEngineAccount(id);
1256
+ return { ok: true, message: "Order saved." };
1257
+ }
1258
+
1259
+ /** Delete a row and its isolated config dir (no store call). */
1260
+ function removeLocalAccountRow(row: {
1261
+ id: string;
1262
+ authKind: string;
1263
+ configDir: string | null;
1264
+ }): void {
1265
+ getDb().delete(schema.engineAccounts).where(eq(schema.engineAccounts.id, row.id)).run();
1266
+ if (row.authKind === "config-dir" && row.configDir) {
1267
+ try {
1268
+ rmSync(row.configDir, { recursive: true, force: true });
1269
+ } catch {
1270
+ /* best effort — the DB row is gone, which is what matters */
1271
+ }
1272
+ }
1273
+ }
1274
+
1275
+ // ---------------------------------------------------------------------------
1276
+ // Credential store mirror (ADR-134, docs/credential-store.md).
1277
+ //
1278
+ // On a Uai-operated host the rows above are a CACHE of AWS Parameter Store:
1279
+ // every mutation mirrors its row (metadata + secret material) to one
1280
+ // parameter, and `reconcileEngineAccountsFromStore` at host start imports
1281
+ // what this host lacks, uploads rows that were never mirrored, and drops rows
1282
+ // whose parameter is gone. Task-up resolution never waits on the store.
1283
+ // On an owned host `configuredCredentialStore()` is null and none of this
1284
+ // runs — credentials never leave the machine.
1285
+ // ---------------------------------------------------------------------------
1286
+
1287
+ /** The versioned JSON a parameter holds. Never logged. */
1288
+ export interface StoredEngineAccount {
1289
+ v: 1;
1290
+ id: string;
1291
+ kind: string;
1292
+ label: string;
1293
+ authKind: AccountAuthKind;
1294
+ scope: string;
1295
+ position: number;
1296
+ createdAt: number;
1297
+ /** `env` accounts: the exec env the row injects. */
1298
+ env?: Record<string, string>;
1299
+ /** `config-dir` accounts: the dir's files by basename (today: auth.json). */
1300
+ files?: Record<string, string>;
1301
+ }
1302
+
1303
+ /** Largest single file mirrored from a config dir (Advanced tier is 8 KB total). */
1304
+ const MAX_STORED_FILE_BYTES = 8 * 1024;
1305
+
1306
+ type StoreRow = {
1307
+ id: string;
1308
+ kind: string;
1309
+ label: string;
1310
+ authKind: string;
1311
+ secretEnc: string | null;
1312
+ configDir: string | null;
1313
+ scope?: string | null;
1314
+ position?: number | null;
1315
+ storeRef?: string | null;
1316
+ createdAt: number;
1317
+ };
1318
+
1319
+ let storeHostId: string | null = null;
1320
+ let warnedNoHostId = false;
1321
+
1322
+ /** main.ts: the host id that names `host`-scope parameters. */
1323
+ export function configureEngineAccountStore(opts: { hostId: string | null }): void {
1324
+ storeHostId = opts.hostId;
1325
+ }
1326
+
1327
+ export function engineAccountHostId(): string | null {
1328
+ return storeHostId;
1329
+ }
1330
+
1331
+ /** Tests: wait for every queued mirror/delete to settle. */
1332
+ export async function settleEngineAccountStoreWrites(): Promise<void> {
1333
+ await settleStoreWrites();
1334
+ }
1335
+
1336
+ /**
1337
+ * The parameter a row mirrors to: `<prefix>/orgs/<orgId>/engines/<kind>/<id>`
1338
+ * for a workspace's account, `<prefix>/hosts/<hostId>/engines/<kind>/<id>` for
1339
+ * the host's own. Null when a host-scope row has no host id to file under.
1340
+ * Throws when an id would not be a legal path segment.
1341
+ */
1342
+ export function engineAccountParameterName(
1343
+ row: { id: string; kind: string; scope?: string | null },
1344
+ hostId: string | null = storeHostId,
1345
+ ): string | null {
1346
+ const scope = row.scope ?? HOST_ACCOUNT_SCOPE;
1347
+ const prefix = credentialStorePrefix();
1348
+ if (scope === HOST_ACCOUNT_SCOPE) {
1349
+ if (!hostId) return null;
1350
+ return parameterName(prefix, "hosts", hostId, "engines", row.kind, row.id);
1351
+ }
1352
+ if (!isEngineAccountScope(scope)) throw new Error(`invalid account scope: ${scope}`);
1353
+ return parameterName(prefix, "orgs", scope.slice("org:".length), "engines", row.kind, row.id);
1354
+ }
1355
+
1356
+ /** Inverse of `engineAccountParameterName`; null for parameters that are not
1357
+ * engine accounts (future slices file MCP/GitHub credentials nearby). */
1358
+ export function parseEngineAccountParameterName(
1359
+ name: string,
1360
+ prefix: string = credentialStorePrefix(),
1361
+ ): { scope: string; hostId?: string; kind: string; id: string } | null {
1362
+ if (!name.startsWith(`${prefix}/`)) return null;
1363
+ const parts = name.slice(prefix.length + 1).split("/");
1364
+ if (parts.length !== 5 || parts[2] !== "engines") return null;
1365
+ if (!parts.every((p) => isParameterPathSegment(p))) return null;
1366
+ const [kindOfOwner, owner, , kind, id] = parts as [string, string, string, string, string];
1367
+ if (kindOfOwner === "orgs") return { scope: `org:${owner}`, kind, id };
1368
+ if (kindOfOwner === "hosts") return { scope: HOST_ACCOUNT_SCOPE, hostId: owner, kind, id };
1369
+ return null;
1370
+ }
1371
+
1372
+ function storedAccountFromRow(row: StoreRow): StoredEngineAccount {
1373
+ const base = {
1374
+ v: 1 as const,
1375
+ id: row.id,
1376
+ kind: row.kind,
1377
+ label: row.label,
1378
+ scope: row.scope ?? HOST_ACCOUNT_SCOPE,
1379
+ position: row.position ?? 0,
1380
+ createdAt: row.createdAt,
1381
+ };
1382
+ if (row.authKind === "env") {
1383
+ const env = row.secretEnc
1384
+ ? (JSON.parse(unpackSecret(row.secretEnc)) as Record<string, string>)
1385
+ : {};
1386
+ return { ...base, authKind: "env", env };
1387
+ }
1388
+ if (row.authKind !== "config-dir" || !row.configDir) {
1389
+ throw new Error(`account ${row.id} has no mirrorable material`);
1390
+ }
1391
+ const files: Record<string, string> = {};
1392
+ for (const entry of readdirSync(row.configDir, { withFileTypes: true })) {
1393
+ if (!entry.isFile() || entry.name === BROKER_SIDECAR_FILE) continue;
1394
+ const content = readFileSync(join(row.configDir, entry.name));
1395
+ if (content.byteLength > MAX_STORED_FILE_BYTES) continue;
1396
+ files[entry.name] = content.toString("utf8");
1397
+ }
1398
+ return { ...base, authKind: "config-dir", files };
1399
+ }
1400
+
1401
+ function parseStoredAccount(value: string): StoredEngineAccount {
1402
+ const parsed: unknown = JSON.parse(value);
1403
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1404
+ throw new Error("not an object");
1405
+ }
1406
+ const o = parsed as Record<string, unknown>;
1407
+ if (o.v !== 1) throw new Error(`unsupported version ${String(o.v)}`);
1408
+ if (typeof o.id !== "string" || !isParameterPathSegment(o.id)) throw new Error("bad id");
1409
+ if (typeof o.kind !== "string" || !isParameterPathSegment(o.kind)) throw new Error("bad kind");
1410
+ if (typeof o.label !== "string" || !isEngineAccountLabel(o.label)) throw new Error("bad label");
1411
+ if (typeof o.scope !== "string" || !isEngineAccountScope(o.scope)) throw new Error("bad scope");
1412
+ if (o.authKind !== "env" && o.authKind !== "config-dir") throw new Error("bad authKind");
1413
+ const stringMap = (v: unknown): Record<string, string> | undefined => {
1414
+ if (v === undefined) return undefined;
1415
+ if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("bad map");
1416
+ const out: Record<string, string> = {};
1417
+ for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
1418
+ if (typeof val !== "string") throw new Error("bad map value");
1419
+ out[k] = val;
1420
+ }
1421
+ return out;
1422
+ };
1423
+ return {
1424
+ v: 1,
1425
+ id: o.id,
1426
+ kind: o.kind,
1427
+ label: o.label,
1428
+ authKind: o.authKind,
1429
+ scope: o.scope,
1430
+ position: typeof o.position === "number" ? o.position : 0,
1431
+ createdAt: typeof o.createdAt === "number" ? o.createdAt : Date.now(),
1432
+ env: stringMap(o.env),
1433
+ files: stringMap(o.files),
1434
+ };
1435
+ }
1436
+
1437
+
1438
+ function selectRow(id: string): StoreRow | undefined {
1439
+ return getDb()
1440
+ .select()
1441
+ .from(schema.engineAccounts)
1442
+ .where(eq(schema.engineAccounts.id, id))
1443
+ .get() as StoreRow | undefined;
1444
+ }
1445
+
1446
+ function setStoreRef(id: string, storeRef: string): void {
1447
+ getDb()
1448
+ .update(schema.engineAccounts)
1449
+ .set({ storeRef })
1450
+ .where(eq(schema.engineAccounts.id, id))
1451
+ .run();
1452
+ }
1453
+
1454
+ async function putRow(store: CredentialStore, row: StoreRow, hostId: string | null): Promise<boolean> {
1455
+ if (isStoreHeldRow(row)) {
1456
+ // ADR-136: nothing here to mirror — an empty env put would overwrite the
1457
+ // only copy of the credential.
1458
+ throw new Error(`account ${row.id} is store-held; this station holds no material to mirror`);
1459
+ }
1460
+ const name = engineAccountParameterName(row, hostId);
1461
+ if (!name) {
1462
+ if (!warnedNoHostId) {
1463
+ warnedNoHostId = true;
1464
+ console.warn(
1465
+ "[engine-accounts] host-scope accounts are not mirrored until the host id is known",
1466
+ );
1467
+ }
1468
+ return false;
1469
+ }
1470
+ await store.put(name, JSON.stringify(storedAccountFromRow(row)));
1471
+ if (row.storeRef !== name) setStoreRef(row.id, name);
1472
+ return true;
1473
+ }
1474
+
1475
+ /** Mirror one row to its parameter, asynchronously and best effort. A no-op
1476
+ * on owned hosts. Rows read the CURRENT state when the write runs, so a
1477
+ * burst of edits collapses to the latest content. */
1478
+ export function mirrorEngineAccount(id: string): void {
1479
+ queueStoreWrite(`mirror ${id}`, async (store) => {
1480
+ const row = selectRow(id);
1481
+ if (!row) return;
1482
+ if (isStoreHeldRow(row)) {
1483
+ // ADR-136 sign-in box: a rename or reorder rides as metadata tags —
1484
+ // the secret is not here to re-put, and tags are what a replacement
1485
+ // box reads back.
1486
+ await store.tag(row.storeRef!, engineAccountMetadataTags(row));
1487
+ return;
1488
+ }
1489
+ await putRow(store, row, storeHostId);
1490
+ });
1491
+ }
1492
+
1493
+ function importStoredAccount(
1494
+ stored: StoredEngineAccount,
1495
+ storeRef: string,
1496
+ s: AccountSeams,
1497
+ ): void {
1498
+ const d = KINDS[stored.kind];
1499
+ if (!d) throw new Error(`unknown engine kind ${stored.kind}`);
1500
+ let secretEnc: string | null = null;
1501
+ let configDir: string | null = null;
1502
+ if (stored.authKind === "env") {
1503
+ secretEnc = packSecret(JSON.stringify(stored.env ?? {}));
1504
+ } else {
1505
+ if (!d.extraHostDir) throw new Error(`${stored.kind} has no config-dir layout`);
1506
+ const hostDir = d.extraHostDir(s.ownerHome(), stored.id);
1507
+ mkdirSync(hostDir, { recursive: true, mode: 0o700 });
1508
+ for (const [basename, content] of Object.entries(stored.files ?? {})) {
1509
+ if (basename.includes("/") || basename.includes("\\") || basename === "..") {
1510
+ throw new Error("bad file name");
1511
+ }
1512
+ const file = join(hostDir, basename);
1513
+ writeFileSync(file, content, { mode: 0o600 });
1514
+ try {
1515
+ chmodSync(file, 0o600);
1516
+ } catch {
1517
+ /* best effort */
1518
+ }
1519
+ }
1520
+ configDir = hostDir;
1521
+ }
1522
+ getDb()
1523
+ .insert(schema.engineAccounts)
1524
+ .values({
1525
+ id: stored.id,
1526
+ kind: stored.kind,
1527
+ label: stored.label,
1528
+ authKind: stored.authKind,
1529
+ secretEnc,
1530
+ configDir,
1531
+ scope: stored.scope,
1532
+ position: stored.position,
1533
+ storeRef,
1534
+ createdAt: stored.createdAt,
1535
+ })
1536
+ .run();
1537
+ }
1538
+
1539
+ export interface ReconcileEngineAccountsResult {
1540
+ /** No store is configured (an owned host); nothing was touched. */
1541
+ skipped: boolean;
1542
+ imported: number;
1543
+ uploaded: number;
1544
+ removed: number;
1545
+ errors: number;
1546
+ }
1547
+
1548
+ /**
1549
+ * Host start: make the local cache agree with the store.
1550
+ * - a parameter with no local row is imported (a replacement host inherits
1551
+ * every workspace's accounts);
1552
+ * - a local row with no `store_ref` is uploaded (first start with the flag);
1553
+ * - a local row whose `store_ref` is under a successfully listed path but
1554
+ * absent from it was removed elsewhere — it goes locally too.
1555
+ * Listing failures leave the rows under that path untouched.
1556
+ */
1557
+ export async function reconcileEngineAccountsFromStore(
1558
+ opts: { hostId?: string | null; seams?: Partial<AccountSeams> } = {},
1559
+ ): Promise<ReconcileEngineAccountsResult> {
1560
+ const result: ReconcileEngineAccountsResult = {
1561
+ skipped: false,
1562
+ imported: 0,
1563
+ uploaded: 0,
1564
+ removed: 0,
1565
+ errors: 0,
1566
+ };
1567
+ const store = configuredCredentialStore();
1568
+ if (!store) return { ...result, skipped: true };
1569
+ const s = withDefaults(opts.seams ?? {});
1570
+ const hostId = opts.hostId === undefined ? storeHostId : opts.hostId;
1571
+ const prefix = credentialStorePrefix();
1572
+ const warn = (line: string): void => console.warn(`[engine-accounts] credential store: ${line}`);
1573
+
1574
+ const paths = engineAccountListPaths(prefix, hostId);
1575
+ if (paths === null) return { ...result, skipped: true };
1576
+ const listed: Array<{ name: string; value: string }> = [];
1577
+ const listedPrefixes: string[] = [];
1578
+ for (const path of paths) {
1579
+ try {
1580
+ listed.push(...(await store.list(path)));
1581
+ listedPrefixes.push(`${path}/`);
1582
+ } catch (err) {
1583
+ result.errors++;
1584
+ warn(`listing ${path} failed: ${err instanceof Error ? err.message : String(err)}`);
1585
+ }
1586
+ }
1587
+
1588
+ const rows = getDb().select().from(schema.engineAccounts).all() as StoreRow[];
1589
+ const byId = new Map(rows.map((r) => [r.id, r]));
1590
+ const seen = new Set<string>();
1591
+ for (const param of listed) {
1592
+ const at = parseEngineAccountParameterName(param.name, prefix);
1593
+ if (!at) continue; // not an engine account
1594
+ if (at.scope === HOST_ACCOUNT_SCOPE && at.hostId !== hostId) continue;
1595
+ seen.add(param.name);
1596
+ let stored: StoredEngineAccount;
1597
+ try {
1598
+ stored = parseStoredAccount(param.value);
1599
+ } catch (err) {
1600
+ result.errors++;
1601
+ warn(`${param.name} is unreadable: ${err instanceof Error ? err.message : String(err)}`);
1602
+ continue;
1603
+ }
1604
+ if (stored.id !== at.id || stored.kind !== at.kind || stored.scope !== at.scope) {
1605
+ result.errors++;
1606
+ warn(`${param.name} does not describe the account its path names; ignored`);
1607
+ continue;
1608
+ }
1609
+ const local = byId.get(stored.id);
1610
+ if (local) {
1611
+ if (local.storeRef !== param.name) setStoreRef(local.id, param.name);
1612
+ continue;
1613
+ }
1614
+ try {
1615
+ importStoredAccount(stored, param.name, s);
1616
+ result.imported++;
1617
+ } catch (err) {
1618
+ result.errors++;
1619
+ warn(`importing ${param.name} failed: ${err instanceof Error ? err.message : String(err)}`);
1620
+ }
1621
+ }
1622
+
1623
+ for (const row of rows) {
1624
+ if (row.storeRef) {
1625
+ const under = listedPrefixes.some((p) => row.storeRef!.startsWith(p));
1626
+ if (under && !seen.has(row.storeRef)) {
1627
+ removeLocalAccountRow(row);
1628
+ result.removed++;
1629
+ }
1630
+ continue;
1631
+ }
1632
+ try {
1633
+ if (await putRow(store, row, hostId)) result.uploaded++;
1634
+ } catch (err) {
1635
+ result.errors++;
1636
+ warn(`uploading ${row.kind}/${row.id} failed: ${err instanceof Error ? err.message : String(err)}`);
1637
+ }
1638
+ }
1639
+ return result;
1640
+ }
1641
+
1642
+ /**
1643
+ * ADR-135 pickup: a parameter changed in the store (the broker refreshed it,
1644
+ * or a sibling host re-mirrored it). Update the local row's material in
1645
+ * place — config-dir files rewritten atomically, env secret re-sealed — or
1646
+ * import the row when this host lacks it. Returns the kind touched, or null
1647
+ * when the value is not an engine account this host should hold.
1648
+ */
1649
+ export function applyStoredEngineAccount(
1650
+ name: string,
1651
+ value: string,
1652
+ seams: Partial<AccountSeams> = {},
1653
+ ): { kind: string; id: string; imported: boolean } | null {
1654
+ const at = parseEngineAccountParameterName(name);
1655
+ if (!at) return null;
1656
+ if (at.scope === HOST_ACCOUNT_SCOPE && at.hostId !== storeHostId) return null;
1657
+ const stored = parseStoredAccount(value);
1658
+ if (stored.id !== at.id || stored.kind !== at.kind || stored.scope !== at.scope) {
1659
+ throw new Error("payload does not describe the account its path names");
1660
+ }
1661
+ const row = selectRow(stored.id);
1662
+ if (!row) {
1663
+ importStoredAccount(stored, name, withDefaults(seams));
1664
+ return { kind: stored.kind, id: stored.id, imported: true };
1665
+ }
1666
+ if (row.authKind === "env" && stored.authKind === "env") {
1667
+ getDb()
1668
+ .update(schema.engineAccounts)
1669
+ .set({ secretEnc: packSecret(JSON.stringify(stored.env ?? {})), storeRef: name })
1670
+ .where(eq(schema.engineAccounts.id, row.id))
1671
+ .run();
1672
+ } else if (row.authKind === "config-dir" && stored.authKind === "config-dir" && row.configDir) {
1673
+ mkdirSync(row.configDir, { recursive: true, mode: 0o700 });
1674
+ for (const [basename, content] of Object.entries(stored.files ?? {})) {
1675
+ if (basename.includes("/") || basename.includes("\\") || basename === "..") continue;
1676
+ const file = join(row.configDir, basename);
1677
+ const tmp = `${file}.${process.pid}.tmp`;
1678
+ writeFileSync(tmp, content, { mode: 0o600 });
1679
+ renameSync(tmp, file);
1680
+ }
1681
+ if (row.storeRef !== name) setStoreRef(row.id, name);
1682
+ } else {
1683
+ throw new Error("stored auth kind does not match the local row");
1684
+ }
1685
+ return { kind: stored.kind, id: stored.id, imported: false };
1686
+ }
1687
+
1688
+ // ---------------------------------------------------------------------------
1689
+ // Store-only accounts (ADR-136 sign-in box, docs/shared-host-retirement.md).
1690
+ //
1691
+ // The small always-on machine behind the "Uai Cloud" row writes every capture
1692
+ // STRAIGHT into the credential store and keeps only a metadata row — id, kind,
1693
+ // label, scope, position, store_ref — with no sealed secret and no config dir.
1694
+ // Its store identity is write-only (put, tag, delete, describe names; never
1695
+ // get, never decrypt), so a compromised box can break sign-ins but cannot read
1696
+ // one. Labels therefore also ride as parameter TAGS, which
1697
+ // `ssm:ListTagsForResource` returns without a KMS grant: a replacement box
1698
+ // rebuilds its list from names + tags alone (`reconcileEngineAccountMetadataFromStore`).
1699
+ // ---------------------------------------------------------------------------
1700
+
1701
+ export const ACCOUNT_LABEL_TAG = "uai_label_b64";
1702
+ export const ACCOUNT_POSITION_TAG = "uai_position";
1703
+ export const ACCOUNT_CREATED_AT_TAG = "uai_created_at";
1704
+ export const ACCOUNT_AUTH_KIND_TAG = "uai_auth_kind";
1705
+ /** SSM tag values: 256 chars of `[A-Za-z0-9_.:/=+@ -]`; base64url fits. */
1706
+ const MAX_TAG_VALUE_CHARS = 256;
1707
+
1708
+ /** A row whose material lives ONLY in the store: a store_ref and no secret. */
1709
+ export function isStoreHeldRow(row: {
1710
+ authKind: string;
1711
+ secretEnc: string | null;
1712
+ configDir: string | null;
1713
+ storeRef?: string | null;
1714
+ }): boolean {
1715
+ if (!row.storeRef) return false;
1716
+ return row.authKind === "env" ? row.secretEnc === null : row.configDir === null;
1717
+ }
1718
+
1719
+ /** The secret-free metadata a parameter carries as tags. The label is
1720
+ * base64url so any label character survives SSM's tag charset; a label too
1721
+ * long to fit is shortened (the store value keeps the full one). */
1722
+ export function engineAccountMetadataTags(row: {
1723
+ label: string;
1724
+ authKind: string;
1725
+ position?: number | null;
1726
+ createdAt: number;
1727
+ }): Record<string, string> {
1728
+ let label = row.label;
1729
+ let encoded = Buffer.from(label, "utf8").toString("base64url");
1730
+ while (encoded.length > MAX_TAG_VALUE_CHARS && label.length > 1) {
1731
+ label = label.slice(0, -1);
1732
+ encoded = Buffer.from(label, "utf8").toString("base64url");
1733
+ }
1734
+ return {
1735
+ [ACCOUNT_LABEL_TAG]: encoded,
1736
+ [ACCOUNT_POSITION_TAG]: String(row.position ?? 0),
1737
+ [ACCOUNT_CREATED_AT_TAG]: String(row.createdAt),
1738
+ [ACCOUNT_AUTH_KIND_TAG]: row.authKind,
1739
+ };
1740
+ }
1741
+
1742
+ function derivedAccountLabel(kind: string, id: string): string {
1743
+ const engine = kind.charAt(0).toUpperCase() + kind.slice(1);
1744
+ return `${engine} ${id.slice(-6)}`;
1745
+ }
1746
+
1747
+ function labelFromTags(tags: Record<string, string>, kind: string, id: string): string {
1748
+ const encoded = tags[ACCOUNT_LABEL_TAG];
1749
+ if (encoded) {
1750
+ try {
1751
+ const label = Buffer.from(encoded, "base64url").toString("utf8").trim();
1752
+ if (isEngineAccountLabel(label)) return label;
1753
+ } catch {
1754
+ /* fall through */
1755
+ }
1756
+ }
1757
+ return derivedAccountLabel(kind, id);
1758
+ }
1759
+
1760
+ async function persistPlannedAccountToStore(
1761
+ kind: string,
1762
+ scope: string,
1763
+ plan: LabeledAccountPlan,
1764
+ ): Promise<AddAccountResult> {
1765
+ const store = configuredCredentialStore();
1766
+ if (!store) {
1767
+ return { ok: false, message: "This station has no credential store to hold the account." };
1768
+ }
1769
+ if (scope === HOST_ACCOUNT_SCOPE) {
1770
+ return { ok: false, message: "Store-only accounts belong to a workspace, not the station." };
1771
+ }
1772
+ const id = newId();
1773
+ const now = Date.now();
1774
+ const position = nextPosition(kind, scope);
1775
+ const row = { id, kind, label: plan.label, authKind: plan.authKind, scope, position, createdAt: now };
1776
+ const name = engineAccountParameterName(row);
1777
+ if (!name) {
1778
+ return { ok: false, message: "Store-only accounts belong to a workspace, not the station." };
1779
+ }
1780
+ const stored: StoredEngineAccount = {
1781
+ v: 1,
1782
+ ...row,
1783
+ ...(plan.authKind === "env" ? { env: plan.env } : { files: plan.files }),
1784
+ };
1785
+ try {
1786
+ await store.put(name, JSON.stringify(stored));
1787
+ } catch (err) {
1788
+ console.warn(
1789
+ `[engine-accounts] store-only ${kind}/${id}: put failed (${err instanceof Error ? err.message : String(err)})`,
1790
+ );
1791
+ return {
1792
+ ok: false,
1793
+ message: "The credential store refused the write; nothing was kept on this station.",
1794
+ };
1795
+ }
1796
+ try {
1797
+ await store.tag(name, engineAccountMetadataTags(row));
1798
+ } catch (err) {
1799
+ console.warn(
1800
+ `[engine-accounts] store-only ${kind}/${id}: tagging failed (${err instanceof Error ? err.message : String(err)}) — a replacement sign-in box lists it as "${derivedAccountLabel(kind, id)}"`,
1801
+ );
1802
+ }
1803
+ getDb()
1804
+ .insert(schema.engineAccounts)
1805
+ .values({
1806
+ id,
1807
+ kind,
1808
+ label: plan.label,
1809
+ authKind: plan.authKind,
1810
+ secretEnc: null,
1811
+ configDir: null,
1812
+ createdAt: now,
1813
+ scope,
1814
+ position,
1815
+ storeRef: name,
1816
+ })
1817
+ .run();
1818
+ return { ok: true, message: plan.message, id };
1819
+ }
1820
+
1821
+ /** `addEngineAccount` for the sign-in box: the same validation, the store
1822
+ * written FIRST and awaited, then a metadata-only row. A store failure is the
1823
+ * caller's failure and leaves nothing behind. */
1824
+ export async function addEngineAccountToStore(
1825
+ kind: string,
1826
+ label: string,
1827
+ creds: { apiKey?: string; token?: string },
1828
+ scope: string,
1829
+ ): Promise<AddAccountResult> {
1830
+ const planned = planLabeledAccount(kind, label, creds, scope);
1831
+ if (!planned.ok) return planned;
1832
+ return persistPlannedAccountToStore(kind, scope, planned.plan);
1833
+ }
1834
+
1835
+ /** `addCodexAccountFromAuthJson` / `addGrokAccountFromAuthJson` for the
1836
+ * sign-in box: the captured auth.json goes to the store, never to a dir. */
1837
+ export async function addCapturedConfigDirAccountToStore(
1838
+ kind: "codex" | "grok",
1839
+ label: string,
1840
+ authJson: string,
1841
+ scope: string,
1842
+ ): Promise<AddAccountResult> {
1843
+ const planned = planCapturedConfigDirAccount(kind, label, authJson, scope);
1844
+ if (!planned.ok) return planned;
1845
+ return persistPlannedAccountToStore(kind, scope, planned.plan);
1846
+ }
1847
+
1848
+ export interface PurgeLocalEngineAccountSecretsResult {
1849
+ /** Workspace rows whose sealed secret or config dir was removed. */
1850
+ stripped: number;
1851
+ /** Rows deleted outright: host-scope accounts and rows never mirrored. */
1852
+ removed: number;
1853
+ errors: number;
1854
+ }
1855
+
1856
+ /**
1857
+ * Sign-in box start: nothing the previous release (or an operator) left on
1858
+ * this machine may hold a credential. Workspace rows keep their metadata and
1859
+ * lose their material; host-scope rows and rows the store never received are
1860
+ * deleted — the box holds workspace accounts only, and a secret with no copy
1861
+ * in the store still may not live here.
1862
+ */
1863
+ export function purgeLocalEngineAccountSecrets(): PurgeLocalEngineAccountSecretsResult {
1864
+ const result: PurgeLocalEngineAccountSecretsResult = { stripped: 0, removed: 0, errors: 0 };
1865
+ const rows = getDb().select().from(schema.engineAccounts).all() as StoreRow[];
1866
+ for (const row of rows) {
1867
+ const scope = row.scope ?? HOST_ACCOUNT_SCOPE;
1868
+ if (scope === HOST_ACCOUNT_SCOPE || !row.storeRef) {
1869
+ removeLocalAccountRow(row);
1870
+ result.removed++;
1871
+ continue;
1872
+ }
1873
+ if (isStoreHeldRow(row)) continue;
1874
+ if (row.configDir) {
1875
+ try {
1876
+ rmSync(row.configDir, { recursive: true, force: true });
1877
+ } catch {
1878
+ result.errors++;
1879
+ }
1880
+ }
1881
+ getDb()
1882
+ .update(schema.engineAccounts)
1883
+ .set({ secretEnc: null, configDir: null })
1884
+ .where(eq(schema.engineAccounts.id, row.id))
1885
+ .run();
1886
+ result.stripped++;
1887
+ }
1888
+ return result;
1889
+ }
1890
+
1891
+ export interface ReconcileEngineAccountMetadataResult {
1892
+ skipped: boolean;
1893
+ imported: number;
1894
+ removed: number;
1895
+ errors: number;
1896
+ }
1897
+
1898
+ /**
1899
+ * Sign-in box start: make the metadata rows agree with the store WITHOUT
1900
+ * reading a value. Names come from `describe-parameters` (allowed on `*`),
1901
+ * labels and order from each new parameter's tags. A workspace parameter this
1902
+ * box lacks becomes a metadata row; a row whose parameter is gone is dropped;
1903
+ * host-scope rows are dropped (the box has none). A failed listing touches
1904
+ * nothing.
1905
+ */
1906
+ export async function reconcileEngineAccountMetadataFromStore(): Promise<ReconcileEngineAccountMetadataResult> {
1907
+ const result: ReconcileEngineAccountMetadataResult = { skipped: false, imported: 0, removed: 0, errors: 0 };
1908
+ const store = configuredCredentialStore();
1909
+ if (!store) return { ...result, skipped: true };
1910
+ const prefix = credentialStorePrefix();
1911
+ const warn = (line: string): void => console.warn(`[engine-accounts] metadata reconcile: ${line}`);
1912
+ let names: string[];
1913
+ try {
1914
+ names = await store.describeNames(`${prefix}/orgs`);
1915
+ } catch (err) {
1916
+ warn(`listing ${prefix}/orgs failed: ${err instanceof Error ? err.message : String(err)}`);
1917
+ return { ...result, errors: 1 };
1918
+ }
1919
+ const rows = getDb().select().from(schema.engineAccounts).all() as StoreRow[];
1920
+ const byRef = new Set(rows.map((r) => r.storeRef).filter((ref): ref is string => Boolean(ref)));
1921
+ const seen = new Set<string>();
1922
+ for (const name of names) {
1923
+ const at = parseEngineAccountParameterName(name, prefix);
1924
+ if (!at || at.scope === HOST_ACCOUNT_SCOPE) continue;
1925
+ seen.add(name);
1926
+ if (byRef.has(name)) continue;
1927
+ let tags: Record<string, string> = {};
1928
+ try {
1929
+ tags = await store.listTags(name);
1930
+ } catch (err) {
1931
+ result.errors++;
1932
+ warn(`tags of ${name} unreadable: ${err instanceof Error ? err.message : String(err)}`);
1933
+ }
1934
+ const taggedAuthKind = tags[ACCOUNT_AUTH_KIND_TAG];
1935
+ const authKind: AccountAuthKind =
1936
+ taggedAuthKind === "env" || taggedAuthKind === "config-dir"
1937
+ ? taggedAuthKind
1938
+ : (KINDS[at.kind]?.authKind ?? "env");
1939
+ const position = Number.parseInt(tags[ACCOUNT_POSITION_TAG] ?? "", 10);
1940
+ const createdAt = Number.parseInt(tags[ACCOUNT_CREATED_AT_TAG] ?? "", 10);
1941
+ try {
1942
+ getDb()
1943
+ .insert(schema.engineAccounts)
1944
+ .values({
1945
+ id: at.id,
1946
+ kind: at.kind,
1947
+ label: labelFromTags(tags, at.kind, at.id),
1948
+ authKind,
1949
+ secretEnc: null,
1950
+ configDir: null,
1951
+ scope: at.scope,
1952
+ position: Number.isFinite(position) ? position : 0,
1953
+ storeRef: name,
1954
+ createdAt: Number.isFinite(createdAt) ? createdAt : Date.now(),
1955
+ })
1956
+ .run();
1957
+ result.imported++;
1958
+ } catch (err) {
1959
+ result.errors++;
1960
+ warn(`importing ${name} failed: ${err instanceof Error ? err.message : String(err)}`);
1961
+ }
1962
+ }
1963
+ for (const row of rows) {
1964
+ const scope = row.scope ?? HOST_ACCOUNT_SCOPE;
1965
+ if (scope !== HOST_ACCOUNT_SCOPE && row.storeRef && seen.has(row.storeRef)) continue;
1966
+ removeLocalAccountRow(row);
1967
+ result.removed++;
1968
+ }
1969
+ return result;
1970
+ }