@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,923 @@
1
+ /**
2
+ * ADR-121: the `aws` MachineProvider — task machines as EC2 instances.
3
+ *
4
+ * Shell-out to the `aws` CLI with strict JSON parsing, matching every other
5
+ * backend integration (docker, container, ssh): no SDK dependency rides the
6
+ * OTA channel, and tests inject the runner.
7
+ *
8
+ * Identity model: EC2 invents instance ids, but the environment layer's
9
+ * locator-first invariant needs a DERIVED id. The machine id is therefore
10
+ * the logical `uai-machine-<taskId>`; the provider resolves it internally
11
+ * through the `com.uai.machine` tag on every call, and `--client-token`
12
+ * makes launch idempotent so a crashed provision retried cannot mint a
13
+ * second instance. Two live instances carrying one machine's tag is a
14
+ * confusing answer and maps to `unknown` — never to either instance.
15
+ *
16
+ * Absence: a SUCCESSFUL tag query with zero non-terminated matches proves
17
+ * absence (the filter asked AWS directly and AWS answered "none");
18
+ * `terminated` counts as absent — the id is unreachable and its disk is
19
+ * gone. A failed query proves nothing.
20
+ */
21
+
22
+ import { spawn } from "node:child_process";
23
+
24
+ import type { DockerResult } from "./docker-exec";
25
+ import { createHash } from "node:crypto";
26
+
27
+ import type {
28
+ MachineInfo,
29
+ MachineProvider,
30
+ MachineSpec,
31
+ MachineState,
32
+ } from "./machine-provider";
33
+
34
+ export const AWS_MACHINE_TAG = "com.uai.machine";
35
+
36
+ /** ADR-125 BYO-AWS: the Uai account that publishes the public payload AMIs,
37
+ * and the name prefix build-ami.sh stamps on them. A host with no
38
+ * UAI_MACHINE_IMAGE resolves the newest published payload in its region. */
39
+ export const PAYLOAD_AMI_OWNER = "277057415208";
40
+ export const PAYLOAD_AMI_NAME_PREFIX = "uai-machine-payload";
41
+ /** Regions the payload AMI is published to (build-ami.sh bakes in the first
42
+ * and copies to the rest). A BYO-AWS runner elsewhere would find no image
43
+ * and fail every launch, so setup refuses other regions up front (owner,
44
+ * 2026-09-10: "only show the available ones for now"). */
45
+ export const PAYLOAD_AMI_REGIONS: readonly string[] = ["us-west-2", "us-east-1"];
46
+ /** Name of the security group auto-created in the default VPC when the host
47
+ * config names none. Inbound: all TCP from the HOST's egress /32 only —
48
+ * code-server runs auth-none and preview ports are plain HTTP, so exposure
49
+ * wider than the orchestrator would hand the editor to the internet. */
50
+ const MANAGED_SECURITY_GROUP = "uai-machines";
51
+
52
+ export interface AwsMachineConfig {
53
+ region: string;
54
+ /** AMI id used when the spec's image is not already an ami-*. */
55
+ subnetId?: string;
56
+ securityGroupId?: string;
57
+ iamInstanceProfileArn?: string;
58
+ /** Override the size ladder; the default picks the smallest Graviton
59
+ * type satisfying both cpu and memory. */
60
+ instanceType?: (spec: MachineSpec) => string;
61
+ /** Root EBS volume size. The AMI snapshot default (8 GiB Debian) starves
62
+ * real work — a monorepo install plus a browser left ~36 MB free live
63
+ * (2026-08-28). cloud-init's growpart expands the filesystem on boot. */
64
+ diskGiB?: number;
65
+ }
66
+
67
+ const DEFAULT_DISK_GIB = 50;
68
+
69
+ type Runner = (args: string[]) => Promise<DockerResult>;
70
+
71
+ const DEFAULT_TIMEOUT_MS = 60_000;
72
+
73
+ function defaultRunner(region: string): Runner {
74
+ return (args) =>
75
+ new Promise<DockerResult>((resolve) => {
76
+ let stdout = "";
77
+ let stderr = "";
78
+ let settled = false;
79
+ const child = spawn(
80
+ "aws",
81
+ ["--region", region, "--output", "json", ...args],
82
+ { stdio: ["ignore", "pipe", "pipe"] },
83
+ );
84
+ const settle = (status: number | null): void => {
85
+ if (settled) return;
86
+ settled = true;
87
+ clearTimeout(timer);
88
+ resolve({ status, stdout, stderr });
89
+ };
90
+ const timer = setTimeout(() => {
91
+ child.kill("SIGKILL");
92
+ const grace = setTimeout(() => settle(null), 2_000);
93
+ grace.unref?.();
94
+ }, DEFAULT_TIMEOUT_MS);
95
+ timer.unref?.();
96
+ child.stdout.setEncoding("utf8");
97
+ child.stderr.setEncoding("utf8");
98
+ child.stdout.on("data", (chunk: string) => (stdout += chunk));
99
+ child.stderr.on("data", (chunk: string) => (stderr += chunk));
100
+ child.once("error", () => settle(null));
101
+ child.once("close", (code) => settle(code));
102
+ });
103
+ }
104
+
105
+ export function awsMachineName(taskId: string): string {
106
+ return `uai-machine-${taskId.toLowerCase()}`;
107
+ }
108
+
109
+ /** Smallest Graviton type satisfying both dimensions.
110
+ *
111
+ * FLOORS AT 16GiB (owner decision 2026-08-31, priced at $0.25/machine-up
112
+ * hour): an 8GiB machine thrashed to death twice in one day under the
113
+ * ordinary heavy shape — a 5-agent crew + Turbopack dev server + editor +
114
+ * VNC browser — and a memory wall reads as "the product is broken", which
115
+ * costs more than the larger instance. m7g (sustained) over t4g (burstable)
116
+ * deliberately: compile bursts on a drained CPU-credit balance are the same
117
+ * wall wearing a different mask. The floor is AWS-only — local VM sizing
118
+ * has its own constraints and keeps the spec-driven path. */
119
+ export function defaultInstanceType(spec: MachineSpec): string {
120
+ const ladder: Array<[string, number, number]> = [
121
+ ["m7g.xlarge", 4, 16384],
122
+ ["m7g.2xlarge", 8, 32768],
123
+ ["m7g.4xlarge", 16, 65536],
124
+ ];
125
+ for (const [type, cpus, memoryMiB] of ladder) {
126
+ if (spec.cpus <= cpus && spec.memoryMiB <= memoryMiB) return type;
127
+ }
128
+ return "m7g.4xlarge";
129
+ }
130
+
131
+ /** cloud-init: install the orchestrator key for node (and root for the
132
+ * provisioning path), creating the node user when the AMI lacks it. */
133
+ export function awsUserData(authorizedPublicKey: string): string {
134
+ const script = [
135
+ "#!/bin/sh",
136
+ "set -e",
137
+ // uid 1000 is often TAKEN on stock cloud images (Debian's `admin`) —
138
+ // insisting on it aborted the whole script under set -e and left no key
139
+ // installed for anyone (live 2026-08-27, first EC2 smoke). Prefer 1000,
140
+ // fall back to auto: on a machine the task owns the whole filesystem,
141
+ // so the exact uid is convention, not correctness.
142
+ "id node >/dev/null 2>&1 || useradd -m -u 1000 -s /bin/bash node 2>/dev/null || useradd -m -s /bin/bash node",
143
+ "for account in node root; do",
144
+ ' home=$(getent passwd "$account" | cut -d: -f6)',
145
+ ' mkdir -p "$home/.ssh"',
146
+ ` printf '%s\\n' '${authorizedPublicKey.replace(/'/g, "")}' > "$home/.ssh/authorized_keys"`,
147
+ ' chown -R "$account" "$home/.ssh"',
148
+ ' chmod 700 "$home/.ssh"',
149
+ ' chmod 600 "$home/.ssh/authorized_keys"',
150
+ "done",
151
+ // This user-data is emitted only by the task-machine provider. Owned
152
+ // runners use the host installer and never execute it, so their OS swap
153
+ // remains untouched.
154
+ //
155
+ // Swap BEFORE any workload: an Opus agent + a bundler + Chromium on a
156
+ // swapless machine hits the memory wall. The backing file is encrypted
157
+ // by a plain dm-crypt mapping whose key is read from /dev/urandom every
158
+ // time the mapping is created. /run's marker makes this idempotent within
159
+ // one boot; crypttab recreates the mapping (and therefore the key) after
160
+ // every boot. The persistent marker only says the backing file has been
161
+ // scrubbed once and has never subsequently held plaintext swap.
162
+ "if ! command -v cryptsetup >/dev/null 2>&1 || [ ! -x /lib/systemd/system-generators/systemd-cryptsetup-generator ]; then apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends cryptsetup; fi",
163
+ "mkdir -p /run/uai /var/lib/uai",
164
+ "chmod 700 /run/uai /var/lib/uai",
165
+ "touch /etc/crypttab /etc/fstab",
166
+ "crypttab_tmp=$(mktemp /etc/crypttab.uai.XXXXXX)",
167
+ "awk 'BEGIN { managed=0 } $0 == \"# BEGIN UAI TASK MACHINE SWAP\" { managed=1; next } $0 == \"# END UAI TASK MACHINE SWAP\" { managed=0; next } managed { next } /^[[:space:]]*#/ { print; next } $1 == \"uai-swap\" || $4 ~ /(^|,)swap(,|$)/ { print \"# uai-replaced-swap \" $0; next } { print }' /etc/crypttab > \"$crypttab_tmp\"",
168
+ "printf '%s\\n' '# BEGIN UAI TASK MACHINE SWAP' 'uai-swap /swapfile /dev/urandom swap,cipher=aes-xts-plain64,size=512' '# END UAI TASK MACHINE SWAP' >> \"$crypttab_tmp\"",
169
+ "chown root:root \"$crypttab_tmp\" && chmod 600 \"$crypttab_tmp\" && mv -f -- \"$crypttab_tmp\" /etc/crypttab",
170
+ "fstab_tmp=$(mktemp /etc/fstab.uai.XXXXXX)",
171
+ "awk 'BEGIN { managed=0 } $0 == \"# BEGIN UAI TASK MACHINE SWAP\" { managed=1; next } $0 == \"# END UAI TASK MACHINE SWAP\" { managed=0; next } managed { next } /^[[:space:]]*#/ { print; next } NF >= 3 && $3 == \"swap\" { print \"# uai-disabled-raw-swap \" $0; next } { print }' /etc/fstab > \"$fstab_tmp\"",
172
+ "printf '%s\\n' '# BEGIN UAI TASK MACHINE SWAP' '/dev/mapper/uai-swap none swap sw 0 0' '# END UAI TASK MACHINE SWAP' >> \"$fstab_tmp\"",
173
+ "chown root:root \"$fstab_tmp\" && chmod 644 \"$fstab_tmp\" && mv -f -- \"$fstab_tmp\" /etc/fstab",
174
+ "active_swap=$(awk 'NR > 1 { print $1 }' /proc/swaps)",
175
+ "if [ ! -f /run/uai/swap-ready-v1 ] || [ \"$active_swap\" != /dev/mapper/uai-swap ]; then",
176
+ // Failing to page every old raw-swap page back into RAM is fatal: the
177
+ // sidecar must not start beside durable plaintext merely to keep booting.
178
+ " swapoff -a",
179
+ " cryptsetup close uai-swap 2>/dev/null || true",
180
+ " if [ ! -f /var/lib/uai/swap-backing-initialized-v1 ]; then",
181
+ " if [ -L /swapfile ] || { [ -e /swapfile ] && [ ! -f /swapfile ]; }; then echo 'unsafe /swapfile type' >&2; exit 1; fi",
182
+ // An older task-machine release used this file as raw swap. One complete
183
+ // zero pass removes any pages it left before the file is reused solely as
184
+ // ciphertext backing. Crashes are safe: without the marker the next run
185
+ // repeats the scrub.
186
+ " if [ -f /swapfile ]; then shred --iterations=0 --zero -- /swapfile; fi",
187
+ " rm -f -- /swapfile",
188
+ " fi",
189
+ // 16G (owner decision 2026-08-31): the graceful-degradation floor has to
190
+ // absorb the whole heavy shape. Every command is mandatory; failure keeps
191
+ // cloud-init and therefore the task machine unavailable.
192
+ " fallocate -l 16G /swapfile",
193
+ " chown root:root /swapfile && chmod 600 /swapfile",
194
+ " cryptsetup open --batch-mode --type plain --cipher aes-xts-plain64 --key-size 512 --key-file /dev/urandom /swapfile uai-swap",
195
+ " mkswap /dev/mapper/uai-swap >/dev/null",
196
+ " swapon /dev/mapper/uai-swap",
197
+ " touch /var/lib/uai/swap-backing-initialized-v1 /run/uai/swap-ready-v1",
198
+ " chmod 600 /var/lib/uai/swap-backing-initialized-v1 /run/uai/swap-ready-v1",
199
+ "fi",
200
+ // The task OWNS its machine (ADR-121 — per-customer kernel isolation is
201
+ // the whole point); denying sudo just breaks project setups that
202
+ // container tasks handle fine (live 2026-08-31: a Hightop machine task
203
+ // could not install the Infisical CLI its repo's config expects).
204
+ "echo 'node ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/node && chmod 440 /etc/sudoers.d/node",
205
+ // Infisical CLI: baseline parity with the standard container image —
206
+ // pnpm-secrets-style project setups expect it on PATH. Best-effort.
207
+ "(V=0.43.110; case \$(uname -m) in aarch64|arm64) T=arm64;; *) T=amd64;; esac; curl -fsSL -o /tmp/inf.tgz https://github.com/Infisical/cli/releases/download/v\${V}/cli_\${V}_linux_\${T}.tar.gz && tar -xzf /tmp/inf.tgz -C /tmp infisical && install -m 0755 /tmp/infisical /usr/local/bin/infisical) || true",
208
+ "",
209
+ ].join("\n");
210
+ // RAW script, not base64: AWS CLI v2 base64-encodes --user-data ITSELF.
211
+ // Pre-encoding double-encoded it, cloud-init saw garbage and silently ran
212
+ // nothing — no key installed for anyone (live 2026-08-27, second EC2
213
+ // smoke failure; the uid-1000 collision fixed just before was real too,
214
+ // but masked by this).
215
+ return script;
216
+ }
217
+
218
+ type LiveInstance = {
219
+ instanceId: string;
220
+ state: string;
221
+ privateIp: string | null;
222
+ taskLabel: string | null;
223
+ };
224
+
225
+ /** Strict reservations parse. Any malformed instance voids the answer. */
226
+ function parseReservations(stdout: string): LiveInstance[] | null {
227
+ let parsed: unknown;
228
+ try {
229
+ parsed = JSON.parse(stdout);
230
+ } catch {
231
+ return null;
232
+ }
233
+ const reservations = (parsed as { Reservations?: unknown }).Reservations;
234
+ if (!Array.isArray(reservations)) return null;
235
+ const instances: LiveInstance[] = [];
236
+ for (const reservation of reservations) {
237
+ const list = (reservation as { Instances?: unknown }).Instances;
238
+ if (!Array.isArray(list)) return null;
239
+ for (const instance of list) {
240
+ if (typeof instance !== "object" || instance === null) return null;
241
+ const record = instance as {
242
+ InstanceId?: unknown;
243
+ State?: { Name?: unknown };
244
+ PrivateIpAddress?: unknown;
245
+ PublicIpAddress?: unknown;
246
+ Tags?: Array<{ Key?: unknown; Value?: unknown }>;
247
+ };
248
+ if (typeof record.InstanceId !== "string") return null;
249
+ const state = record.State?.Name;
250
+ if (typeof state !== "string") return null;
251
+ let taskLabel: string | null = null;
252
+ if (record.Tags !== undefined) {
253
+ if (!Array.isArray(record.Tags)) return null;
254
+ for (const tag of record.Tags) {
255
+ if (tag?.Key === AWS_MACHINE_TAG) {
256
+ if (typeof tag.Value !== "string") return null;
257
+ taskLabel = tag.Value;
258
+ }
259
+ }
260
+ }
261
+ instances.push({
262
+ instanceId: record.InstanceId,
263
+ state,
264
+ // Prefer the public address when the subnet assigns one: an
265
+ // orchestrator OUTSIDE the VPC (the local smoke, a dev box) can only
266
+ // dial that, while the production in-VPC orchestrator sees no public
267
+ // IP on its machines and keeps dialing private, unchanged.
268
+ privateIp:
269
+ typeof record.PublicIpAddress === "string"
270
+ ? record.PublicIpAddress
271
+ : typeof record.PrivateIpAddress === "string"
272
+ ? record.PrivateIpAddress
273
+ : null,
274
+ taskLabel,
275
+ });
276
+ }
277
+ }
278
+ return instances;
279
+ }
280
+
281
+ function stateFromAws(state: string): MachineState {
282
+ switch (state) {
283
+ case "running":
284
+ return "running";
285
+ case "pending":
286
+ return "pending";
287
+ case "stopping":
288
+ case "stopped":
289
+ return "stopped";
290
+ case "shutting-down":
291
+ case "terminated":
292
+ return "absent";
293
+ default:
294
+ return "unknown";
295
+ }
296
+ }
297
+
298
+ function unknown(id: string, detail: string): MachineInfo {
299
+ return { id, taskLabel: null, state: "unknown", address: null, detail };
300
+ }
301
+
302
+ /** The host's public egress address, for scoping the auto-created security
303
+ * group. Only consulted when the group must be CREATED — an existing group
304
+ * (explicit config or a prior run) never re-resolves. Overridable for hosts
305
+ * whose egress the checker cannot see (e.g. VPN'd). */
306
+ async function hostEgressIp(): Promise<string> {
307
+ const configured = process.env.UAI_HOST_EGRESS_IP;
308
+ if (configured && /^\d{1,3}(\.\d{1,3}){3}$/.test(configured)) {
309
+ return configured;
310
+ }
311
+ try {
312
+ const res = await fetch("https://checkip.amazonaws.com", {
313
+ signal: AbortSignal.timeout(5_000),
314
+ });
315
+ const ip = (await res.text()).trim();
316
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) return ip;
317
+ } catch {
318
+ // fall through to the instruction below
319
+ }
320
+ throw new Error(
321
+ "could not determine this host's public address to scope the machine " +
322
+ "security group — set UAI_HOST_EGRESS_IP or UAI_AWS_SECURITY_GROUP_ID",
323
+ );
324
+ }
325
+
326
+ /** How long a resolved payload AMI is trusted before the newest published
327
+ * image is looked up again. Long enough that a burst of launches shares one
328
+ * describe-images; short enough that a freshly published image reaches every
329
+ * RUNNING host — shared and BYO — on its next task without a host restart
330
+ * (before 0.9.106 the answer was kept for the process lifetime). */
331
+ export const PAYLOAD_AMI_TTL_MS = 5 * 60_000;
332
+
333
+ export function createAwsMachineProvider(
334
+ config: AwsMachineConfig,
335
+ runner: Runner = defaultRunner(config.region),
336
+ deps: { now?: () => number } = {},
337
+ ): MachineProvider {
338
+ const now = deps.now ?? (() => Date.now());
339
+ const taskIdOf = (machineId: string): string => {
340
+ if (!machineId.startsWith("uai-machine-")) {
341
+ throw new Error(`not a uai machine id: ${machineId}`);
342
+ }
343
+ return machineId.slice("uai-machine-".length);
344
+ };
345
+
346
+ /** Root device name per AMI, resolved once per process. A failure throws:
347
+ * guessing the device would silently attach a blank second volume while
348
+ * the root stays at its 8 GiB snapshot size. */
349
+ const rootDeviceByImage = new Map<string, string>();
350
+ async function rootDeviceName(image: string): Promise<string> {
351
+ const cached = rootDeviceByImage.get(image);
352
+ if (cached) return cached;
353
+ const res = await runner([
354
+ "ec2",
355
+ "describe-images",
356
+ "--image-ids",
357
+ image,
358
+ "--query",
359
+ "Images[0].RootDeviceName",
360
+ ]);
361
+ if (res.status !== 0) {
362
+ throw new Error(
363
+ `could not resolve the root device of ${image}: ${
364
+ res.stderr.trim() || `exit ${res.status ?? "killed"}`
365
+ }`,
366
+ );
367
+ }
368
+ let device: unknown;
369
+ try {
370
+ device = JSON.parse(res.stdout);
371
+ } catch {
372
+ device = null;
373
+ }
374
+ if (typeof device !== "string" || !device.startsWith("/dev/")) {
375
+ throw new Error(`image ${image} reports no root device name`);
376
+ }
377
+ rootDeviceByImage.set(image, device);
378
+ return device;
379
+ }
380
+
381
+ // -------------------------------------------------------------------------
382
+ // ADR-125 BYO-AWS zero-config discovery. Every resolver prefers explicit
383
+ // host config, resolves once per process, and fails with an instruction
384
+ // rather than a guess — a wrong subnet or security group is a machine the
385
+ // orchestrator can never reach.
386
+ // -------------------------------------------------------------------------
387
+
388
+ let resolvedImage: { id: string; at: number } | null = null;
389
+ async function resolveImage(specImage: string): Promise<string> {
390
+ if (specImage.startsWith("ami-")) return specImage;
391
+ if (resolvedImage && now() - resolvedImage.at < PAYLOAD_AMI_TTL_MS) {
392
+ return resolvedImage.id;
393
+ }
394
+ const res = await runner([
395
+ "ec2",
396
+ "describe-images",
397
+ "--owners",
398
+ PAYLOAD_AMI_OWNER,
399
+ "--filters",
400
+ `Name=name,Values=${PAYLOAD_AMI_NAME_PREFIX}-*`,
401
+ "Name=state,Values=available",
402
+ "--query",
403
+ "sort_by(Images, &CreationDate)[-1].ImageId",
404
+ ]);
405
+ let image: unknown = null;
406
+ if (res.status === 0) {
407
+ try {
408
+ image = JSON.parse(res.stdout);
409
+ } catch {
410
+ image = null;
411
+ }
412
+ }
413
+ if (typeof image !== "string" || !image.startsWith("ami-")) {
414
+ // A stale-but-known image beats a failed launch when the lookup itself
415
+ // hiccups; only a host that never resolved one has nothing to offer.
416
+ if (resolvedImage) return resolvedImage.id;
417
+ throw new Error(
418
+ `no uai machine payload AMI is published in ${config.region} — set ` +
419
+ "UAI_MACHINE_IMAGE to an AMI id, or ask Moondeck to publish this region",
420
+ );
421
+ }
422
+ if (resolvedImage?.id !== image) {
423
+ // The journal line that tells an operator which payload a task got —
424
+ // an exit 127 for a missing CLI is otherwise the only symptom of a
425
+ // region that never received the latest publish.
426
+ console.log(
427
+ `[machine-aws] payload image for ${config.region}: ${image}` +
428
+ (resolvedImage ? ` (was ${resolvedImage.id})` : ""),
429
+ );
430
+ }
431
+ resolvedImage = { id: image, at: now() };
432
+ return image;
433
+ }
434
+
435
+ let resolvedSubnet: string | null = null;
436
+ async function resolveSubnet(): Promise<string> {
437
+ if (config.subnetId) return config.subnetId;
438
+ if (resolvedSubnet) return resolvedSubnet;
439
+ const res = await runner([
440
+ "ec2",
441
+ "describe-subnets",
442
+ "--filters",
443
+ "Name=default-for-az,Values=true",
444
+ "--query",
445
+ "sort_by(Subnets, &AvailabilityZone)[].{id: SubnetId, public: MapPublicIpOnLaunch, vpc: VpcId}",
446
+ ]);
447
+ let subnets: Array<{ id?: unknown; public?: unknown }> = [];
448
+ if (res.status === 0) {
449
+ try {
450
+ const parsed = JSON.parse(res.stdout);
451
+ if (Array.isArray(parsed)) subnets = parsed;
452
+ } catch {
453
+ subnets = [];
454
+ }
455
+ }
456
+ const chosen =
457
+ subnets.find((s) => s.public === true && typeof s.id === "string") ??
458
+ subnets.find((s) => typeof s.id === "string");
459
+ if (!chosen || typeof chosen.id !== "string") {
460
+ throw new Error(
461
+ `no default-VPC subnet found in ${config.region} — set ` +
462
+ "UAI_AWS_SUBNET_ID to a public subnet",
463
+ );
464
+ }
465
+ resolvedSubnet = chosen.id;
466
+ return chosen.id;
467
+ }
468
+
469
+ let resolvedSecurityGroup: string | null = null;
470
+ async function resolveSecurityGroup(subnetId: string): Promise<string> {
471
+ if (config.securityGroupId) return config.securityGroupId;
472
+ if (resolvedSecurityGroup) return resolvedSecurityGroup;
473
+ const vpcRes = await runner([
474
+ "ec2",
475
+ "describe-subnets",
476
+ "--subnet-ids",
477
+ subnetId,
478
+ "--query",
479
+ "Subnets[0].VpcId",
480
+ ]);
481
+ const vpcId = vpcRes.status === 0 ? JSON.parse(vpcRes.stdout) : null;
482
+ if (typeof vpcId !== "string") {
483
+ throw new Error(`could not resolve the VPC of subnet ${subnetId}`);
484
+ }
485
+ const found = await runner([
486
+ "ec2",
487
+ "describe-security-groups",
488
+ "--filters",
489
+ `Name=group-name,Values=${MANAGED_SECURITY_GROUP}`,
490
+ `Name=vpc-id,Values=${vpcId}`,
491
+ "--query",
492
+ "SecurityGroups[0].GroupId",
493
+ ]);
494
+ if (found.status === 0) {
495
+ try {
496
+ const existing = JSON.parse(found.stdout);
497
+ if (typeof existing === "string" && existing.startsWith("sg-")) {
498
+ // The group admits only host addresses — and it may predate THIS
499
+ // host (a second managed host in the fleet account, or this host
500
+ // rebooting onto a new public IP). Ensure our egress /32 is
501
+ // present; a duplicate rule is AWS's problem to dedupe, not ours
502
+ // to pre-check. Without this, every machine of a newer host sat
503
+ // unreachable behind the first host's rule ("machine never became
504
+ // reachable over ssh", live 2026-08-31).
505
+ const ensureEgress = await hostEgressIp();
506
+ const ensured = await runner([
507
+ "ec2",
508
+ "authorize-security-group-ingress",
509
+ "--group-id",
510
+ existing,
511
+ "--ip-permissions",
512
+ JSON.stringify([
513
+ {
514
+ IpProtocol: "tcp",
515
+ FromPort: 0,
516
+ ToPort: 65535,
517
+ IpRanges: [
518
+ {
519
+ CidrIp: `${ensureEgress}/32`,
520
+ Description: "uai orchestrating host",
521
+ },
522
+ ],
523
+ },
524
+ ]),
525
+ ]);
526
+ if (
527
+ ensured.status !== 0 &&
528
+ !/InvalidPermission\.Duplicate/.test(ensured.stderr)
529
+ ) {
530
+ throw new Error(
531
+ `could not admit this host into ${MANAGED_SECURITY_GROUP}: ${
532
+ ensured.stderr.trim() || `exit ${ensured.status ?? "killed"}`
533
+ }`,
534
+ );
535
+ }
536
+ resolvedSecurityGroup = existing;
537
+ return existing;
538
+ }
539
+ } catch (err) {
540
+ if (err instanceof Error && /could not admit this host/.test(err.message)) {
541
+ throw err;
542
+ }
543
+ // fall through to creation
544
+ }
545
+ }
546
+ const egress = await hostEgressIp();
547
+ const created = await runner([
548
+ "ec2",
549
+ "create-security-group",
550
+ "--group-name",
551
+ MANAGED_SECURITY_GROUP,
552
+ "--description",
553
+ "uai task machines: reachable only from the orchestrating host",
554
+ "--vpc-id",
555
+ vpcId,
556
+ "--query",
557
+ "GroupId",
558
+ ]);
559
+ const groupId = created.status === 0 ? JSON.parse(created.stdout) : null;
560
+ if (typeof groupId !== "string") {
561
+ throw new Error(
562
+ `could not create the ${MANAGED_SECURITY_GROUP} security group: ${
563
+ created.stderr.trim() || `exit ${created.status ?? "killed"}`
564
+ }`,
565
+ );
566
+ }
567
+ const authorized = await runner([
568
+ "ec2",
569
+ "authorize-security-group-ingress",
570
+ "--group-id",
571
+ groupId,
572
+ "--ip-permissions",
573
+ JSON.stringify([
574
+ {
575
+ IpProtocol: "tcp",
576
+ FromPort: 0,
577
+ ToPort: 65535,
578
+ IpRanges: [
579
+ {
580
+ CidrIp: `${egress}/32`,
581
+ Description: "uai orchestrating host",
582
+ },
583
+ ],
584
+ },
585
+ ]),
586
+ ]);
587
+ if (authorized.status !== 0) {
588
+ throw new Error(
589
+ `created ${groupId} but could not authorize the host's address: ${
590
+ authorized.stderr.trim() || `exit ${authorized.status ?? "killed"}`
591
+ }`,
592
+ );
593
+ }
594
+ console.log(
595
+ `[machine-aws] created security group ${groupId} (${MANAGED_SECURITY_GROUP}) allowing ${egress}/32`,
596
+ );
597
+ resolvedSecurityGroup = groupId;
598
+ return groupId;
599
+ }
600
+
601
+ /** Resolve the logical machine id to its live instances via the tag. */
602
+ async function resolveLive(
603
+ machineId: string,
604
+ ): Promise<{ instances: LiveInstance[] } | { failure: string }> {
605
+ const res = await runner([
606
+ "ec2",
607
+ "describe-instances",
608
+ "--filters",
609
+ `Name=tag:${AWS_MACHINE_TAG},Values=${taskIdOf(machineId)}`,
610
+ "Name=instance-state-name,Values=pending,running,stopping,stopped",
611
+ ]);
612
+ if (res.status !== 0) {
613
+ return {
614
+ failure: res.stderr.trim() || `describe exited ${res.status ?? "killed"}`,
615
+ };
616
+ }
617
+ const instances = parseReservations(res.stdout);
618
+ if (instances === null) {
619
+ return { failure: "describe answered with a confusing shape" };
620
+ }
621
+ return { instances };
622
+ }
623
+
624
+ async function describe(machineId: string): Promise<MachineInfo> {
625
+ let resolved: Awaited<ReturnType<typeof resolveLive>>;
626
+ try {
627
+ resolved = await resolveLive(machineId);
628
+ } catch (error) {
629
+ return unknown(
630
+ machineId,
631
+ error instanceof Error ? error.message : String(error),
632
+ );
633
+ }
634
+ if ("failure" in resolved) return unknown(machineId, resolved.failure);
635
+ if (resolved.instances.length === 0) {
636
+ // A successful tag query answering "none live" IS the absence proof.
637
+ return { id: machineId, taskLabel: null, state: "absent", address: null };
638
+ }
639
+ if (resolved.instances.length > 1) {
640
+ return unknown(
641
+ machineId,
642
+ `multiple live instances carry this machine's tag`,
643
+ );
644
+ }
645
+ const instance = resolved.instances[0]!;
646
+ const state = stateFromAws(instance.state);
647
+ return {
648
+ id: machineId,
649
+ taskLabel: instance.taskLabel,
650
+ state,
651
+ // Still stopping: its processes run until the instance is stopped.
652
+ ...(instance.state === "stopping" ? { frozen: true as const } : {}),
653
+ address: state === "running" ? instance.privateIp : null,
654
+ };
655
+ }
656
+
657
+ return {
658
+ kind: "aws",
659
+
660
+ async launch(spec: MachineSpec): Promise<MachineInfo> {
661
+ const machineId = awsMachineName(spec.taskId);
662
+ const type = (config.instanceType ?? defaultInstanceType)(spec);
663
+ // The idempotency token binds the machine id TO these exact launch
664
+ // arguments: EC2 remembers a client token's arguments for ~24h (even
665
+ // past termination) and rejects reuse with different ones
666
+ // (IdempotentParameterMismatch — live 2026-08-27: a Resume after a
667
+ // provider fix changed the user-data and the retry was refused). An
668
+ // identical retry still cannot mint a second instance; a CHANGED
669
+ // launch legitimately gets a fresh token, and the resume-aware
670
+ // provision's describe-first step remains the guard against doubling
671
+ // The AMI snapshot's root volume (8 GiB on the Debian payload) starves
672
+ // real work; request a task-sized gp3 root instead. The mapping must
673
+ // name the AMI's actual root device — a wrong name would ADD a blank
674
+ // volume while the root stays small — so resolve it per image.
675
+ const diskGiB = config.diskGiB ?? DEFAULT_DISK_GIB;
676
+ // ADR-125 BYO-AWS: explicit config wins; otherwise resolve the newest
677
+ // published payload AMI and the default-VPC network per process.
678
+ const image = await resolveImage(spec.image);
679
+ const subnetId = await resolveSubnet();
680
+ const securityGroupId = await resolveSecurityGroup(subnetId);
681
+ const rootDevice = await rootDeviceName(image);
682
+ // a live machine.
683
+ const launchFingerprint = createHash("sha256")
684
+ .update(
685
+ JSON.stringify([
686
+ image,
687
+ type,
688
+ spec.authorizedPublicKey ?? null,
689
+ subnetId,
690
+ securityGroupId,
691
+ config.iamInstanceProfileArn ?? null,
692
+ diskGiB,
693
+ ]),
694
+ )
695
+ .digest("hex")
696
+ .slice(0, 16);
697
+ const args = [
698
+ "ec2",
699
+ "run-instances",
700
+ "--image-id",
701
+ image,
702
+ "--instance-type",
703
+ type,
704
+ "--count",
705
+ "1",
706
+ // Idempotency across crashed/retried provisions: the same token can
707
+ // never mint a second instance.
708
+ "--client-token",
709
+ `${machineId}-${launchFingerprint}`.slice(0, 64),
710
+ "--tag-specifications",
711
+ `ResourceType=instance,Tags=[{Key=${AWS_MACHINE_TAG},Value=${spec.taskId}},{Key=Name,Value=${machineId}}]`,
712
+ "--block-device-mappings",
713
+ JSON.stringify([
714
+ {
715
+ DeviceName: rootDevice,
716
+ Ebs: {
717
+ VolumeSize: diskGiB,
718
+ VolumeType: "gp3",
719
+ DeleteOnTermination: true,
720
+ },
721
+ },
722
+ ]),
723
+ ...(spec.authorizedPublicKey
724
+ ? ["--user-data", awsUserData(spec.authorizedPublicKey)]
725
+ : []),
726
+ "--subnet-id",
727
+ subnetId,
728
+ "--security-group-ids",
729
+ securityGroupId,
730
+ ...(config.iamInstanceProfileArn
731
+ ? ["--iam-instance-profile", `Arn=${config.iamInstanceProfileArn}`]
732
+ : []),
733
+ ];
734
+ const res = await runner(args);
735
+ if (res.status !== 0) {
736
+ throw new Error(
737
+ `machine launch failed for ${machineId}: ${
738
+ res.stderr.trim() || `exit ${res.status ?? "killed"}`
739
+ }`,
740
+ );
741
+ }
742
+ // DescribeInstances TAG FILTERS are eventually consistent: the query
743
+ // can miss an instance RunInstances just created (live 2026-08-28 —
744
+ // the machine was running the whole time while launch reported
745
+ // "absent"). Absence immediately after a successful launch is
746
+ // therefore not evidence yet; give the filter a bounded window.
747
+ let info = await describe(machineId);
748
+ for (
749
+ let attempt = 0;
750
+ (info.state === "absent" || info.state === "unknown") && attempt < 5;
751
+ attempt += 1
752
+ ) {
753
+ await new Promise<void>((resolve) => setTimeout(resolve, 3_000));
754
+ info = await describe(machineId);
755
+ }
756
+ if (info.state === "absent" || info.state === "unknown") {
757
+ throw new Error(
758
+ `machine ${machineId} launched but could not be described (${info.state}${
759
+ info.detail ? `: ${info.detail}` : ""
760
+ })`,
761
+ );
762
+ }
763
+ return info;
764
+ },
765
+
766
+ async stop(machineId: string): Promise<void> {
767
+ const instance = await requireSingleLive(machineId);
768
+ const res = await runner([
769
+ "ec2",
770
+ "stop-instances",
771
+ "--instance-ids",
772
+ instance.instanceId,
773
+ ]);
774
+ if (res.status !== 0) {
775
+ throw new Error(
776
+ `machine stop failed for ${machineId}: ${
777
+ res.stderr.trim() || `exit ${res.status ?? "killed"}`
778
+ }`,
779
+ );
780
+ }
781
+ // EC2 sits in `stopping` for tens of seconds, and StartInstances
782
+ // refuses it with IncorrectInstanceState — an immediate stop→resume
783
+ // (a user pressing Resume right after Stop; the live 2026-08-27
784
+ // smoke) failed exactly there because our state map folds `stopping`
785
+ // into "stopped". Stop resolves only once the instance can actually
786
+ // be started again, mirroring terminate's poll-to-absent.
787
+ const deadline = Date.now() + 180_000;
788
+ for (;;) {
789
+ const raw = await runner([
790
+ "ec2",
791
+ "describe-instances",
792
+ "--instance-ids",
793
+ instance.instanceId,
794
+ "--query",
795
+ "Reservations[].Instances[].State.Name",
796
+ "--output",
797
+ "text",
798
+ ]);
799
+ const state = raw.status === 0 ? raw.stdout.trim() : "";
800
+ if (state === "stopped") return;
801
+ if (state === "terminated" || state === "shutting-down") {
802
+ throw new Error(
803
+ `machine ${machineId} terminated while stopping (${state})`,
804
+ );
805
+ }
806
+ if (Date.now() >= deadline) {
807
+ throw new Error(
808
+ `machine ${machineId} did not reach stopped within the stop deadline (${state || "unknown"})`,
809
+ );
810
+ }
811
+ await new Promise<void>((resolve) => setTimeout(resolve, 5_000));
812
+ }
813
+ },
814
+
815
+ async start(machineId: string): Promise<MachineInfo> {
816
+ const instance = await requireSingleLive(machineId);
817
+ const res = await runner([
818
+ "ec2",
819
+ "start-instances",
820
+ "--instance-ids",
821
+ instance.instanceId,
822
+ ]);
823
+ if (res.status !== 0) {
824
+ throw new Error(
825
+ `machine start failed for ${machineId}: ${
826
+ res.stderr.trim() || `exit ${res.status ?? "killed"}`
827
+ }`,
828
+ );
829
+ }
830
+ return describe(machineId);
831
+ },
832
+
833
+ async terminate(machineId: string): Promise<void> {
834
+ const resolved = await resolveLive(machineId);
835
+ if ("failure" in resolved) {
836
+ throw new Error(
837
+ `machine ${machineId} could not be resolved for terminate: ${resolved.failure}`,
838
+ );
839
+ }
840
+ // Terminate EVERY live instance carrying the tag: a duplicate from a
841
+ // pathological double-launch must not survive its sibling's teardown.
842
+ for (const instance of resolved.instances) {
843
+ const res = await runner([
844
+ "ec2",
845
+ "terminate-instances",
846
+ "--instance-ids",
847
+ instance.instanceId,
848
+ ]);
849
+ if (res.status !== 0) {
850
+ throw new Error(
851
+ `machine terminate failed for ${machineId} (${instance.instanceId}): ${
852
+ res.stderr.trim() || `exit ${res.status ?? "killed"}`
853
+ }`,
854
+ );
855
+ }
856
+ }
857
+ // Absence is not best-effort: poll until the tag query answers "none
858
+ // live" (shutting-down/terminated fall out of the live filter).
859
+ const deadline = Date.now() + 120_000;
860
+ for (;;) {
861
+ const after = await describe(machineId);
862
+ if (after.state === "absent") return;
863
+ if (Date.now() >= deadline) {
864
+ throw new Error(
865
+ `machine ${machineId} could not be proven absent after terminate (${after.state}${
866
+ after.detail ? `: ${after.detail}` : ""
867
+ })`,
868
+ );
869
+ }
870
+ await new Promise<void>((resolve) => setTimeout(resolve, 5_000));
871
+ }
872
+ },
873
+
874
+ describe,
875
+
876
+ async list(): Promise<MachineInfo[]> {
877
+ const res = await runner([
878
+ "ec2",
879
+ "describe-instances",
880
+ "--filters",
881
+ `Name=tag-key,Values=${AWS_MACHINE_TAG}`,
882
+ "Name=instance-state-name,Values=pending,running,stopping,stopped",
883
+ ]);
884
+ if (res.status !== 0) {
885
+ throw new Error(
886
+ `machine listing failed: ${
887
+ res.stderr.trim() || `exit ${res.status ?? "killed"}`
888
+ }`,
889
+ );
890
+ }
891
+ const instances = parseReservations(res.stdout);
892
+ if (instances === null) {
893
+ throw new Error("machine inventory was unparseable");
894
+ }
895
+ return instances.map((instance) => {
896
+ const state = stateFromAws(instance.state);
897
+ return {
898
+ id:
899
+ instance.taskLabel !== null
900
+ ? awsMachineName(instance.taskLabel)
901
+ : instance.instanceId,
902
+ taskLabel: instance.taskLabel,
903
+ state,
904
+ ...(instance.state === "stopping" ? { frozen: true as const } : {}),
905
+ address: state === "running" ? instance.privateIp : null,
906
+ };
907
+ });
908
+ },
909
+ };
910
+
911
+ async function requireSingleLive(machineId: string): Promise<LiveInstance> {
912
+ const resolved = await resolveLive(machineId);
913
+ if ("failure" in resolved) {
914
+ throw new Error(`machine ${machineId} could not be resolved: ${resolved.failure}`);
915
+ }
916
+ if (resolved.instances.length !== 1) {
917
+ throw new Error(
918
+ `machine ${machineId} resolution expected one live instance, found ${resolved.instances.length}`,
919
+ );
920
+ }
921
+ return resolved.instances[0]!;
922
+ }
923
+ }