@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,251 @@
1
+ /**
2
+ * Crash-safe local state for first-time host enrollment (ADR-099).
3
+ *
4
+ * A host chooses its permanent id/secret before redeeming a one-time cloud
5
+ * token. Persist that proposal before the request so a lost response or a
6
+ * process crash can replay the exact same identity. The raw enrollment token
7
+ * is never written to disk; only its sha256 fingerprint is retained.
8
+ */
9
+
10
+ import { createHash, randomBytes } from "node:crypto";
11
+ import {
12
+ chmodSync,
13
+ closeSync,
14
+ existsSync,
15
+ fsyncSync,
16
+ linkSync,
17
+ lstatSync,
18
+ mkdirSync,
19
+ openSync,
20
+ readFileSync,
21
+ unlinkSync,
22
+ writeFileSync,
23
+ } from "node:fs";
24
+ import { basename, dirname, join } from "node:path";
25
+
26
+ import { writeEnvValuesAtomic } from "./env-file";
27
+ import { parseHostCloudEndpoint } from "./host-cloud-url";
28
+
29
+ const HOST_ID = /^[a-z0-9_-]{1,64}$/;
30
+ const SHA256 = /^[a-f0-9]{64}$/;
31
+
32
+ export { writeEnvValuesAtomic } from "./env-file";
33
+
34
+ export interface PendingEnrollment {
35
+ readonly schemaVersion: 1;
36
+ readonly enrollmentTokenSha256: string;
37
+ readonly hostId: string;
38
+ readonly hostSecret: string;
39
+ readonly cloudUrl: string;
40
+ readonly hostName: string;
41
+ readonly createdAt: number;
42
+ }
43
+
44
+ export function pendingEnrollmentPath(uaiHome: string): string {
45
+ return join(uaiHome, ".enrollment-pending.json");
46
+ }
47
+
48
+ export function enrollmentTokenSha256(token: string): string {
49
+ return createHash("sha256").update(token).digest("hex");
50
+ }
51
+
52
+ export function pendingEnrollmentMatches(
53
+ pending: PendingEnrollment,
54
+ enrollmentToken: string,
55
+ cloudUrl: string,
56
+ ): boolean {
57
+ return (
58
+ pending.enrollmentTokenSha256 === enrollmentTokenSha256(enrollmentToken) &&
59
+ pending.cloudUrl === cloudUrl
60
+ );
61
+ }
62
+
63
+ export function readPendingEnrollment(file: string): PendingEnrollment | null {
64
+ if (!existsSync(file)) return null;
65
+ const stat = lstatSync(file);
66
+ if (!stat.isFile() || stat.isSymbolicLink()) {
67
+ throw new Error("pending enrollment state must be a regular file");
68
+ }
69
+ let parsed: unknown;
70
+ try {
71
+ parsed = JSON.parse(readFileSync(file, "utf8"));
72
+ } catch {
73
+ throw new Error("pending enrollment state is malformed");
74
+ }
75
+ return validatePendingEnrollment(parsed);
76
+ }
77
+
78
+ /**
79
+ * Create the immutable pending proposal without an overwrite race. A complete
80
+ * 0600 temp file is fsync'd and hard-linked into place, so the durable path is
81
+ * never observable with partial JSON. If another process won, return its
82
+ * strictly validated proposal instead.
83
+ */
84
+ export function ensurePendingEnrollment(
85
+ file: string,
86
+ candidate: PendingEnrollment,
87
+ ): { pending: PendingEnrollment; created: boolean } {
88
+ const normalized = validatePendingEnrollment(candidate);
89
+ const parent = dirname(file);
90
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
91
+
92
+ const existing = readPendingEnrollment(file);
93
+ if (existing) return { pending: existing, created: false };
94
+
95
+ const temporary = join(
96
+ parent,
97
+ `.${basename(file)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`,
98
+ );
99
+ try {
100
+ writeDurableFile(temporary, `${JSON.stringify(normalized, null, 2)}\n`, true);
101
+ try {
102
+ linkSync(temporary, file);
103
+ fsyncDirectory(parent);
104
+ return { pending: normalized, created: true };
105
+ } catch (error) {
106
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
107
+ const winner = readPendingEnrollment(file);
108
+ if (!winner) throw new Error("pending enrollment winner disappeared");
109
+ return { pending: winner, created: false };
110
+ }
111
+ } finally {
112
+ try {
113
+ unlinkSync(temporary);
114
+ } catch (error) {
115
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
116
+ }
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Commit the exact pending identity to dotenv, then remove the journal. The
122
+ * ordering is intentional: a crash can leave both files, but can never leave
123
+ * neither. A later invocation can recognize and remove the harmless journal.
124
+ */
125
+ export function commitPendingEnrollment(
126
+ pendingFile: string,
127
+ envFile: string,
128
+ expected: PendingEnrollment,
129
+ ): void {
130
+ const current = readPendingEnrollment(pendingFile);
131
+ if (!current || JSON.stringify(current) !== JSON.stringify(expected)) {
132
+ throw new Error("pending enrollment state changed before commit");
133
+ }
134
+ writeEnvValuesAtomic(envFile, {
135
+ UAI_HOST_ID: expected.hostId,
136
+ UAI_HOST_TOKEN: expected.hostSecret,
137
+ UAI_CLOUD_URL: expected.cloudUrl,
138
+ });
139
+ removePendingEnrollment(pendingFile);
140
+ }
141
+
142
+ export function removeCommittedPendingEnrollment(
143
+ file: string,
144
+ identity: { hostId: string; hostSecret: string },
145
+ ): boolean {
146
+ const pending = readPendingEnrollment(file);
147
+ if (!pending) return false;
148
+ if (
149
+ pending.hostId !== identity.hostId ||
150
+ pending.hostSecret !== identity.hostSecret
151
+ ) {
152
+ return false;
153
+ }
154
+ removePendingEnrollment(file);
155
+ return true;
156
+ }
157
+
158
+ /** Deliberate operator reset used only by the existing `setup --force` path. */
159
+ export function discardPendingEnrollment(file: string): void {
160
+ removePendingEnrollment(file);
161
+ }
162
+
163
+ function removePendingEnrollment(file: string): void {
164
+ try {
165
+ unlinkSync(file);
166
+ fsyncDirectory(dirname(file));
167
+ } catch (error) {
168
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
169
+ }
170
+ }
171
+
172
+ function writeDurableFile(file: string, body: string, exclusive: boolean): void {
173
+ const descriptor = openSync(file, exclusive ? "wx" : "w", 0o600);
174
+ try {
175
+ writeFileSync(descriptor, body, "utf8");
176
+ fsyncSync(descriptor);
177
+ } finally {
178
+ closeSync(descriptor);
179
+ }
180
+ chmodSync(file, 0o600);
181
+ }
182
+
183
+ function fsyncDirectory(directory: string): void {
184
+ let descriptor: number | undefined;
185
+ try {
186
+ descriptor = openSync(directory, "r");
187
+ fsyncSync(descriptor);
188
+ } catch (error) {
189
+ // Some platforms/filesystems do not permit fsync on a directory. The file
190
+ // itself was still fsync'd before rename/link; do not make enrollment
191
+ // unusable solely because the directory durability primitive is absent.
192
+ const code = (error as NodeJS.ErrnoException).code;
193
+ if (code !== "EINVAL" && code !== "ENOTSUP" && code !== "EISDIR") throw error;
194
+ } finally {
195
+ if (descriptor !== undefined) closeSync(descriptor);
196
+ }
197
+ }
198
+
199
+ function validatePendingEnrollment(value: unknown): PendingEnrollment {
200
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
201
+ throw new Error("pending enrollment state must be an object");
202
+ }
203
+ const object = value as Record<string, unknown>;
204
+ const expected = [
205
+ "schemaVersion",
206
+ "enrollmentTokenSha256",
207
+ "hostId",
208
+ "hostSecret",
209
+ "cloudUrl",
210
+ "hostName",
211
+ "createdAt",
212
+ ];
213
+ if (
214
+ Object.keys(object).sort().join("\0") !== expected.sort().join("\0") ||
215
+ object.schemaVersion !== 1 ||
216
+ typeof object.enrollmentTokenSha256 !== "string" ||
217
+ !SHA256.test(object.enrollmentTokenSha256) ||
218
+ typeof object.hostId !== "string" ||
219
+ !HOST_ID.test(object.hostId) ||
220
+ typeof object.hostSecret !== "string" ||
221
+ !SHA256.test(object.hostSecret) ||
222
+ typeof object.cloudUrl !== "string" ||
223
+ object.cloudUrl.length < 1 ||
224
+ object.cloudUrl.length > 2_048 ||
225
+ typeof object.hostName !== "string" ||
226
+ object.hostName.length > 128 ||
227
+ typeof object.createdAt !== "number" ||
228
+ !Number.isSafeInteger(object.createdAt) ||
229
+ object.createdAt <= 0
230
+ ) {
231
+ throw new Error("pending enrollment state is invalid");
232
+ }
233
+ let canonicalCloudUrl: string;
234
+ try {
235
+ canonicalCloudUrl = parseHostCloudEndpoint(object.cloudUrl).bridgeUrl;
236
+ } catch {
237
+ throw new Error("pending enrollment state is invalid");
238
+ }
239
+ if (canonicalCloudUrl !== object.cloudUrl) {
240
+ throw new Error("pending enrollment state is invalid");
241
+ }
242
+ return {
243
+ schemaVersion: 1,
244
+ enrollmentTokenSha256: object.enrollmentTokenSha256,
245
+ hostId: object.hostId,
246
+ hostSecret: object.hostSecret,
247
+ cloudUrl: object.cloudUrl,
248
+ hostName: object.hostName,
249
+ createdAt: object.createdAt,
250
+ };
251
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Durable `.env.local` updates shared by every host-side settings writer.
3
+ *
4
+ * A mutation is a read/merge/fsync/rename operation, so concurrent writers
5
+ * must serialize the entire transition rather than only the final rename.
6
+ * `serializeEnvFileMutation` is the process-wide boundary used by remote host
7
+ * config today and interactive engine-login persistence later (ADR-100).
8
+ */
9
+
10
+ import { randomBytes } from "node:crypto";
11
+ import {
12
+ chmodSync,
13
+ closeSync,
14
+ existsSync,
15
+ fsyncSync,
16
+ mkdirSync,
17
+ openSync,
18
+ readFileSync,
19
+ renameSync,
20
+ unlinkSync,
21
+ writeFileSync,
22
+ } from "node:fs";
23
+ import { basename, dirname, resolve } from "node:path";
24
+
25
+ const ENV_KEY = /^[A-Z][A-Z0-9_]*$/;
26
+ const mutationTails = new Map<string, Promise<void>>();
27
+
28
+ export interface EnvFileSnapshot {
29
+ readonly exists: boolean;
30
+ readonly body: string;
31
+ }
32
+
33
+ /**
34
+ * Queue one complete mutation for an env file. A failed predecessor never
35
+ * poisons the queue, and the tail is removed once the last waiter settles.
36
+ */
37
+ export function serializeEnvFileMutation<T>(
38
+ file: string,
39
+ operation: () => T | Promise<T>,
40
+ ): Promise<T> {
41
+ const key = resolve(file);
42
+ const predecessor = mutationTails.get(key) ?? Promise.resolve();
43
+ const running = predecessor.catch(() => undefined).then(operation);
44
+ const settled = running.then(
45
+ () => undefined,
46
+ () => undefined,
47
+ );
48
+ mutationTails.set(key, settled);
49
+ void settled.finally(() => {
50
+ if (mutationTails.get(key) === settled) mutationTails.delete(key);
51
+ });
52
+ return running;
53
+ }
54
+
55
+ /** Atomically merge all supplied values into one durable 0600 dotenv file. */
56
+ export function writeEnvValuesAtomic(
57
+ file: string,
58
+ updates: Readonly<Record<string, string>>,
59
+ ): void {
60
+ const entries = Object.entries(updates);
61
+ if (entries.length === 0) return;
62
+ for (const [key, value] of entries) {
63
+ if (!ENV_KEY.test(key)) throw new Error(`invalid environment key: ${key}`);
64
+ if (value.includes("\0") || value.includes("\n") || value.includes("\r")) {
65
+ throw new Error(`environment value for ${key} contains a line break`);
66
+ }
67
+ }
68
+
69
+ const keys = new Set(entries.map(([key]) => key));
70
+ const current = existsSync(file) ? readFileSync(file, "utf8") : "";
71
+ const kept = current
72
+ .split(/\r?\n/)
73
+ .filter((line, index, lines) => {
74
+ if (index === lines.length - 1 && line === "") return false;
75
+ const equals = line.indexOf("=");
76
+ return equals <= 0 || !keys.has(line.slice(0, equals));
77
+ });
78
+ for (const [key, value] of entries) kept.push(`${key}=${value}`);
79
+ replaceEnvFileAtomic(file, `${kept.join("\n")}\n`);
80
+ }
81
+
82
+ /**
83
+ * Atomically drop `keys` from a dotenv file (a missing file or key is fine).
84
+ * The sign-in box (ADR-136) strips engine credentials from `.env.local` at
85
+ * start so no default-slot secret survives on that machine.
86
+ */
87
+ export function removeEnvValuesAtomic(
88
+ file: string,
89
+ keys: readonly string[],
90
+ ): string[] {
91
+ if (keys.length === 0 || !existsSync(file)) return [];
92
+ assertEnvKeys(keys);
93
+ const { body, removed } = withoutEnvKeys(readFileSync(file, "utf8"), keys);
94
+ if (removed.length > 0) replaceEnvFileAtomic(file, body);
95
+ return removed;
96
+ }
97
+
98
+ /**
99
+ * `body` without the lines that set any of `keys`, and the keys it dropped.
100
+ * A line sets a key as every writer here writes one: `KEY=value`, the key at
101
+ * the start of its own line. Pure, so that a caller that reads the file its
102
+ * own way (the cutover purge, which must not trust what it opens) drops
103
+ * exactly the lines `removeEnvValuesAtomic` would. With nothing dropped,
104
+ * `body` comes back as it was.
105
+ */
106
+ export function withoutEnvKeys(
107
+ body: string,
108
+ keys: readonly string[],
109
+ ): { body: string; removed: string[] } {
110
+ assertEnvKeys(keys);
111
+ const drop = new Set(keys);
112
+ const removed: string[] = [];
113
+ const kept = body.split(/\r?\n/).filter((line, index, lines) => {
114
+ if (index === lines.length - 1 && line === "") return false;
115
+ const equals = line.indexOf("=");
116
+ if (equals <= 0) return true;
117
+ const key = line.slice(0, equals);
118
+ if (!drop.has(key)) return true;
119
+ removed.push(key);
120
+ return false;
121
+ });
122
+ if (removed.length === 0) return { body, removed: [] };
123
+ return {
124
+ body: kept.length === 0 ? "" : `${kept.join("\n")}\n`,
125
+ removed: [...new Set(removed)],
126
+ };
127
+ }
128
+
129
+ /**
130
+ * The keys among `keys` whose line, as withoutEnvKeys finds it, opens a
131
+ * quoted value that it does not close: a value that runs on past its line,
132
+ * of which dropping that line would leave the rest. Read by this rule
133
+ * alone, not by any parser's notion of what a quoted value may hold, so it
134
+ * says the same under every Node: the value, its leading spaces aside,
135
+ * starts with `"`, `'` or a backtick, and no unescaped same quote follows on
136
+ * its line (a backslash takes the character after it).
137
+ */
138
+ export function keysQuotedPastTheirLine(body: string, keys: readonly string[]): string[] {
139
+ assertEnvKeys(keys);
140
+ const wanted = new Set(keys);
141
+ const found = new Set<string>();
142
+ for (const line of body.split(/\r?\n/)) {
143
+ const equals = line.indexOf("=");
144
+ if (equals <= 0) continue;
145
+ const key = line.slice(0, equals);
146
+ if (!wanted.has(key)) continue;
147
+ const value = line.slice(equals + 1).trimStart();
148
+ const quote = value[0];
149
+ if (quote !== '"' && quote !== "'" && quote !== "`") continue;
150
+ let closed = false;
151
+ for (let index = 1; index < value.length && !closed; index += 1) {
152
+ if (value[index] === "\\") index += 1;
153
+ else closed = value[index] === quote;
154
+ }
155
+ if (!closed) found.add(key);
156
+ }
157
+ return [...found];
158
+ }
159
+
160
+ function assertEnvKeys(keys: readonly string[]): void {
161
+ for (const key of keys) {
162
+ if (!ENV_KEY.test(key)) throw new Error(`invalid environment key: ${key}`);
163
+ }
164
+ }
165
+
166
+ /** Capture the exact bytes needed to roll back a multi-store config write. */
167
+ export function readEnvFileSnapshot(file: string): EnvFileSnapshot {
168
+ return existsSync(file)
169
+ ? { exists: true, body: readFileSync(file, "utf8") }
170
+ : { exists: false, body: "" };
171
+ }
172
+
173
+ /** Restore a prior snapshot durably. Successful replacements remain 0600. */
174
+ export function restoreEnvFileSnapshotAtomic(
175
+ file: string,
176
+ snapshot: EnvFileSnapshot,
177
+ ): void {
178
+ if (snapshot.exists) {
179
+ replaceEnvFileAtomic(file, snapshot.body);
180
+ return;
181
+ }
182
+ try {
183
+ unlinkSync(file);
184
+ fsyncDirectory(dirname(file));
185
+ } catch (error) {
186
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
187
+ }
188
+ }
189
+
190
+ /** Write `body` to `file` so a crash or power loss leaves either the old
191
+ * file or the new one, never a torn or empty one: fsync the data, rename,
192
+ * then fsync the directory. 0600, parent created 0700. */
193
+ export function writeDurableFileAtomic(file: string, body: string): void {
194
+ replaceEnvFileAtomic(file, body);
195
+ }
196
+
197
+ function replaceEnvFileAtomic(file: string, body: string): void {
198
+ const parent = dirname(file);
199
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
200
+ const temporary = joinTemporary(parent, basename(file));
201
+ try {
202
+ writeDurableFile(temporary, body);
203
+ renameSync(temporary, file);
204
+ chmodSync(file, 0o600);
205
+ fsyncDirectory(parent);
206
+ } finally {
207
+ try {
208
+ unlinkSync(temporary);
209
+ } catch (error) {
210
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
211
+ }
212
+ }
213
+ }
214
+
215
+ function joinTemporary(parent: string, name: string): string {
216
+ return resolve(
217
+ parent,
218
+ `.${name}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`,
219
+ );
220
+ }
221
+
222
+ function writeDurableFile(file: string, body: string): void {
223
+ const descriptor = openSync(file, "wx", 0o600);
224
+ try {
225
+ writeFileSync(descriptor, body, "utf8");
226
+ fsyncSync(descriptor);
227
+ } finally {
228
+ closeSync(descriptor);
229
+ }
230
+ chmodSync(file, 0o600);
231
+ }
232
+
233
+ function fsyncDirectory(directory: string): void {
234
+ let descriptor: number | undefined;
235
+ try {
236
+ descriptor = openSync(directory, "r");
237
+ fsyncSync(descriptor);
238
+ } catch (error) {
239
+ const code = (error as NodeJS.ErrnoException).code;
240
+ if (code !== "EINVAL" && code !== "ENOTSUP" && code !== "EISDIR") {
241
+ throw error;
242
+ }
243
+ } finally {
244
+ if (descriptor !== undefined) closeSync(descriptor);
245
+ }
246
+ }