@moondeck/station 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (325) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -2
  3. package/bin/uai-host.mjs +130 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/0005_host_env_vars.sql +5 -0
  10. package/db/migrations/0006_host_project_env.sql +9 -0
  11. package/db/migrations/0007_host_github_token_kind.sql +7 -0
  12. package/db/migrations/0008_host_mcp_connections.sql +18 -0
  13. package/db/migrations/0009_host_agent_sessions.sql +15 -0
  14. package/db/migrations/0010_host_engine_accounts.sql +13 -0
  15. package/db/migrations/0011_codex_thread_id.sql +5 -0
  16. package/db/migrations/0012_agent_session_attach_key.sql +4 -0
  17. package/db/migrations/0013_github_credential_generations.sql +11 -0
  18. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  19. package/db/migrations/0015_host_settings.sql +9 -0
  20. package/db/migrations/0016_task_environment.sql +2 -0
  21. package/db/migrations/0017_engine_account_scope.sql +7 -0
  22. package/db/migrations/0018_engine_account_position.sql +3 -0
  23. package/db/migrations/0019_engine_account_store_ref.sql +3 -0
  24. package/db/migrations/0020_user_credential_store_ref.sql +6 -0
  25. package/db/migrations/0021_project_env_store_ref.sql +3 -0
  26. package/db/migrations/0022_task_project_ids.sql +5 -0
  27. package/db/migrations/0023_project_env_org.sql +4 -0
  28. package/db/migrations/0024_task_env_mode.sql +9 -0
  29. package/db/migrations/0025_project_env_authority.sql +147 -0
  30. package/db/migrations/0026_project_env_replica.sql +150 -0
  31. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  32. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  33. package/db/migrations/0029_task_shared_files_mode.sql +5 -0
  34. package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
  35. package/db/migrations/meta/_journal.json +223 -0
  36. package/db/schema.ts +850 -0
  37. package/images/standard/Dockerfile +347 -0
  38. package/images/standard/README.md +167 -0
  39. package/images/standard/container/code-server-settings.json +63 -0
  40. package/images/standard/container/corepack-version +1 -0
  41. package/images/standard/container/uai-init +704 -0
  42. package/images/standard/container/uai-materialize-runtimes +1527 -0
  43. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  44. package/images/standard/tool-versions +2 -0
  45. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  46. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  47. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  48. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  49. package/lib/agent-cli.ts +1507 -0
  50. package/lib/agent-errors.ts +22 -0
  51. package/lib/agent.ts +672 -0
  52. package/lib/agents/attach-compatibility.ts +51 -0
  53. package/lib/agents/claude.ts +918 -0
  54. package/lib/agents/codex-token-usage.fixture.json +130 -0
  55. package/lib/agents/codex.ts +1392 -0
  56. package/lib/agents/cursor-period-usage.ts +253 -0
  57. package/lib/agents/cursor.ts +562 -0
  58. package/lib/agents/dispatch.ts +78 -0
  59. package/lib/agents/durable-proc.ts +334 -0
  60. package/lib/agents/factory.ts +57 -0
  61. package/lib/agents/grok.ts +381 -0
  62. package/lib/agents/kimi.ts +329 -0
  63. package/lib/agents/machine-durable.ts +604 -0
  64. package/lib/agents/mock.ts +133 -0
  65. package/lib/agents/mode.ts +64 -0
  66. package/lib/agents/opencode.ts +508 -0
  67. package/lib/agents/output-source.ts +3 -0
  68. package/lib/agents/proc.ts +221 -0
  69. package/lib/agents/rate-limit.ts +337 -0
  70. package/lib/agents/registry.ts +182 -0
  71. package/lib/agents/session-state.ts +197 -0
  72. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  73. package/lib/agents/transport.ts +741 -0
  74. package/lib/agents/types.ts +254 -0
  75. package/lib/agents/uai.ts +106 -0
  76. package/lib/agents/usage.ts +44 -0
  77. package/lib/apple-one-off.ts +277 -0
  78. package/lib/apple-runtime-recycle.ts +237 -0
  79. package/lib/apple-uninstall-teardown.ts +224 -0
  80. package/lib/assignment-evidence.ts +94 -0
  81. package/lib/attachments.ts +48 -0
  82. package/lib/browser-testing.ts +1537 -0
  83. package/lib/capture-flow.ts +332 -0
  84. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  85. package/lib/cloud-credential-host.ts +512 -0
  86. package/lib/cloud-credential-leases.ts +2587 -0
  87. package/lib/cloud-credential-runtime.ts +1357 -0
  88. package/lib/cloud-state.ts +56 -0
  89. package/lib/codex-auth.ts +456 -0
  90. package/lib/command-db.ts +310 -0
  91. package/lib/compose-network-recovery.ts +283 -0
  92. package/lib/connection-secret-redaction.ts +25 -0
  93. package/lib/container-runtime.ts +1453 -0
  94. package/lib/credential-cleanup-inventory.ts +490 -0
  95. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  96. package/lib/credential-consumers/engines.ts +797 -0
  97. package/lib/credential-consumers/files.ts +934 -0
  98. package/lib/credential-consumers/github.ts +702 -0
  99. package/lib/credential-consumers/mcp.ts +389 -0
  100. package/lib/credential-consumers/task-files.ts +127 -0
  101. package/lib/credential-consumers/task-secrets.ts +10 -0
  102. package/lib/credential-store-pickup.ts +149 -0
  103. package/lib/credential-store-scope.ts +112 -0
  104. package/lib/credential-store.ts +932 -0
  105. package/lib/credentials-activation-authority.ts +709 -0
  106. package/lib/credentials-purge.ts +1006 -0
  107. package/lib/custom-connection-capture.ts +395 -0
  108. package/lib/custom-connection-launch.ts +384 -0
  109. package/lib/custom-connection-runtime.ts +141 -0
  110. package/lib/db.ts +80 -0
  111. package/lib/disk-pressure.ts +42 -0
  112. package/lib/docker-exec.ts +156 -0
  113. package/lib/engine-accounts.ts +1970 -0
  114. package/lib/engine-connect.ts +321 -0
  115. package/lib/engine-credential-env.ts +52 -0
  116. package/lib/engine-login.ts +2933 -0
  117. package/lib/engines.ts +1342 -0
  118. package/lib/enrollment-state.ts +251 -0
  119. package/lib/env-file.ts +246 -0
  120. package/lib/env-schema.ts +891 -0
  121. package/lib/env.ts +195 -0
  122. package/lib/fs-aliases.ts +49 -0
  123. package/lib/git-clone-credentials.ts +334 -0
  124. package/lib/git-diff.ts +530 -0
  125. package/lib/git-identity.ts +715 -0
  126. package/lib/github-git-auth.ts +207 -0
  127. package/lib/github-origin.ts +28 -0
  128. package/lib/github-tokens.ts +2439 -0
  129. package/lib/host-cloud-url.ts +62 -0
  130. package/lib/host-config.ts +279 -0
  131. package/lib/host-env.ts +424 -0
  132. package/lib/host-logs.ts +965 -0
  133. package/lib/internal-connections.ts +5 -0
  134. package/lib/keyed-promise-tail.ts +23 -0
  135. package/lib/launch-mode.ts +25 -0
  136. package/lib/lease.ts +239 -0
  137. package/lib/legacy-host.ts +769 -0
  138. package/lib/legacy-runtime-retirement.ts +424 -0
  139. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  140. package/lib/machine-exec.ts +238 -0
  141. package/lib/machine-gateway-tunnel.ts +344 -0
  142. package/lib/machine-keys.ts +65 -0
  143. package/lib/machine-project-env-loader.ts +152 -0
  144. package/lib/machine-provider-aws.ts +923 -0
  145. package/lib/machine-provider-local.ts +338 -0
  146. package/lib/machine-provider.ts +90 -0
  147. package/lib/managed-activation-watcher.ts +72 -0
  148. package/lib/managed-install-owner-watcher.ts +55 -0
  149. package/lib/managed-operation-drain.ts +57 -0
  150. package/lib/managed-runtime.ts +4296 -0
  151. package/lib/managed-update-scheduler.ts +125 -0
  152. package/lib/mcp-capture.ts +865 -0
  153. package/lib/mcp-config-lock.ts +2 -0
  154. package/lib/mcp-connections.ts +959 -0
  155. package/lib/mcp-gateway.ts +2594 -0
  156. package/lib/obs.ts +512 -0
  157. package/lib/openrouter-budget.ts +247 -0
  158. package/lib/orchestrator.ts +11409 -0
  159. package/lib/person-capture.ts +327 -0
  160. package/lib/preview-ports.ts +85 -0
  161. package/lib/preview-sidecar.ts +331 -0
  162. package/lib/project-env-authority.ts +1319 -0
  163. package/lib/project-env-feature.ts +74 -0
  164. package/lib/project-env-legacy-tags.ts +130 -0
  165. package/lib/project-env-loader.ts +242 -0
  166. package/lib/project-env-replica.ts +2094 -0
  167. package/lib/project-env-schema-capture.ts +342 -0
  168. package/lib/project-env-staging.ts +148 -0
  169. package/lib/project-env-task-policy.ts +176 -0
  170. package/lib/project-env-task-replica.ts +1752 -0
  171. package/lib/release-feed.ts +61 -0
  172. package/lib/release-manifest.ts +710 -0
  173. package/lib/release-trust.ts +46 -0
  174. package/lib/repo-clone.ts +38 -0
  175. package/lib/routing/shadow.ts +261 -0
  176. package/lib/runner-boot.ts +10 -0
  177. package/lib/runtime-activation-tail.ts +279 -0
  178. package/lib/runtime-archive.ts +1216 -0
  179. package/lib/runtime-authority.ts +166 -0
  180. package/lib/runtime-guard.ts +36 -0
  181. package/lib/runtime-provider-state.ts +169 -0
  182. package/lib/runtime-reconnect.ts +86 -0
  183. package/lib/runtime-state.ts +1033 -0
  184. package/lib/safe-http.ts +364 -0
  185. package/lib/secrets-proxy/address.ts +95 -0
  186. package/lib/secrets-proxy/audit.ts +30 -0
  187. package/lib/secrets-proxy/ca-store.ts +200 -0
  188. package/lib/secrets-proxy/ca.ts +470 -0
  189. package/lib/secrets-proxy/container-steps.ts +138 -0
  190. package/lib/secrets-proxy/der.ts +273 -0
  191. package/lib/secrets-proxy/engine.ts +1633 -0
  192. package/lib/secrets-proxy/host.ts +93 -0
  193. package/lib/secrets-proxy/hostname.ts +57 -0
  194. package/lib/secrets-proxy/placeholders.ts +196 -0
  195. package/lib/secrets-proxy/policy.ts +150 -0
  196. package/lib/secrets-proxy/readiness.ts +148 -0
  197. package/lib/secrets-proxy/runtime.ts +582 -0
  198. package/lib/secrets-proxy/scrub.ts +559 -0
  199. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  200. package/lib/secrets-proxy/task-scripts.ts +262 -0
  201. package/lib/secrets-proxy/task-trust.ts +605 -0
  202. package/lib/secrets-proxy/trust-watch.ts +80 -0
  203. package/lib/secrets.ts +71 -0
  204. package/lib/shared-files-s3.ts +976 -0
  205. package/lib/shared-files.ts +156 -0
  206. package/lib/sidecar-assign.ts +49 -0
  207. package/lib/sidecar-guard.ts +58 -0
  208. package/lib/sidecar-resume.ts +178 -0
  209. package/lib/sidecar.ts +69 -0
  210. package/lib/signin-box-credentials.ts +465 -0
  211. package/lib/signin-box.ts +114 -0
  212. package/lib/skill-index-example.fixture.ts +56 -0
  213. package/lib/skill-layout.ts +417 -0
  214. package/lib/skills.ts +489 -0
  215. package/lib/ssh.ts +401 -0
  216. package/lib/standard-image-cleanup.ts +117 -0
  217. package/lib/standard-image-pin.ts +207 -0
  218. package/lib/standard-image.ts +1697 -0
  219. package/lib/stopped-task-status-queue.ts +120 -0
  220. package/lib/task-container-cli.ts +269 -0
  221. package/lib/task-credential-cleanup.ts +752 -0
  222. package/lib/task-diff-sandbox.ts +464 -0
  223. package/lib/task-diff.ts +91 -0
  224. package/lib/task-env-forwarder.ts +228 -0
  225. package/lib/task-env-source.ts +249 -0
  226. package/lib/task-environment/anchor.ts +31 -0
  227. package/lib/task-environment/apple-container.ts +860 -0
  228. package/lib/task-environment/docker.ts +1344 -0
  229. package/lib/task-environment/index.ts +466 -0
  230. package/lib/task-environment/legacy-adoption.ts +467 -0
  231. package/lib/task-environment/machine-environment.ts +61 -0
  232. package/lib/task-environment/machine-task-up.ts +1227 -0
  233. package/lib/task-environment/machine.ts +974 -0
  234. package/lib/task-environment/registry.ts +59 -0
  235. package/lib/task-environment/self-identity.ts +20 -0
  236. package/lib/task-environment/self.ts +566 -0
  237. package/lib/task-environment/types.ts +642 -0
  238. package/lib/task-environment/workspace-files.ts +685 -0
  239. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  240. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  241. package/lib/task-files-machine-retirement.ts +116 -0
  242. package/lib/task-files-readiness.ts +127 -0
  243. package/lib/task-gateway-port.ts +22 -0
  244. package/lib/task-gateway-protocol.ts +190 -0
  245. package/lib/task-gateway-recovery.ts +47 -0
  246. package/lib/task-gateway-state.ts +512 -0
  247. package/lib/task-identity.ts +19 -0
  248. package/lib/task-inventory.ts +682 -0
  249. package/lib/task-machine-credentials-activation.ts +95 -0
  250. package/lib/task-machine-swap-safety.ts +729 -0
  251. package/lib/task-person-credentials.ts +76 -0
  252. package/lib/task-project-ops-host.ts +147 -0
  253. package/lib/task-project-ops.ts +1062 -0
  254. package/lib/task-secret-staging.ts +16 -0
  255. package/lib/task-status.ts +46 -0
  256. package/lib/task-token.ts +46 -0
  257. package/lib/token-broker-adopt.ts +344 -0
  258. package/lib/token-broker-lambda.ts +105 -0
  259. package/lib/token-broker-local.ts +392 -0
  260. package/lib/token-broker-revision.ts +310 -0
  261. package/lib/token-broker-ssm.ts +525 -0
  262. package/lib/token-broker.ts +218 -0
  263. package/lib/token-lanes/openai-oauth.ts +123 -0
  264. package/lib/token-lanes/registry.ts +45 -0
  265. package/lib/token-lanes/types.ts +163 -0
  266. package/lib/token-lanes/xai-oauth.ts +165 -0
  267. package/lib/transcript.ts +128 -0
  268. package/lib/tunnel-registry.ts +220 -0
  269. package/lib/tunnel-runtime.ts +235 -0
  270. package/lib/ulid.ts +16 -0
  271. package/lib/user-bus-env.ts +116 -0
  272. package/lib/user-credentials-store.ts +435 -0
  273. package/lib/vnode-pressure.ts +142 -0
  274. package/package.json +131 -4
  275. package/runner/runner.mjs +442 -0
  276. package/scripts/agent/_common.sh +633 -0
  277. package/scripts/agent/env-hook.sh +626 -0
  278. package/scripts/agent/moondeck-cli.sh +5 -0
  279. package/scripts/agent/task-down.sh +291 -0
  280. package/scripts/agent/task-status.sh +70 -0
  281. package/scripts/agent/task-up.sh +3761 -0
  282. package/scripts/agent/uai-env-exec.sh +26 -0
  283. package/scripts/install/darwin.ts +1048 -0
  284. package/scripts/install/linux.ts +958 -0
  285. package/scripts/install/types.ts +78 -0
  286. package/scripts/install/util.ts +254 -0
  287. package/scripts/install/win.ts +143 -0
  288. package/src/activation-respawn.ts +155 -0
  289. package/src/apple-tunnel-route.ts +104 -0
  290. package/src/capture-wire.ts +102 -0
  291. package/src/cli.ts +2583 -0
  292. package/src/cloud-credentials.ts +2144 -0
  293. package/src/cloud-origins.ts +23 -0
  294. package/src/credential-location.ts +248 -0
  295. package/src/credential-wire-validation.ts +25 -0
  296. package/src/custom-connections.ts +237 -0
  297. package/src/engine-account-paths.ts +11 -0
  298. package/src/engine-models.ts +39 -0
  299. package/src/engine-usage.ts +104 -0
  300. package/src/event-outbox.ts +185 -0
  301. package/src/index.ts +2899 -0
  302. package/src/load-env.ts +97 -0
  303. package/src/machine-tunnel-route.ts +156 -0
  304. package/src/main.ts +6349 -0
  305. package/src/never-proxied-domains.ts +31 -0
  306. package/src/outbound-frame.ts +31 -0
  307. package/src/paths.ts +81 -0
  308. package/src/project-env-latest.ts +130 -0
  309. package/src/project-env-policy.ts +396 -0
  310. package/src/project-env-wording.ts +53 -0
  311. package/src/protocol.ts +5545 -0
  312. package/src/runtime-bootstrap.ts +165 -0
  313. package/src/station-identity.ts +96 -0
  314. package/src/task-env-names.ts +28 -0
  315. package/src/task-secrets.ts +75 -0
  316. package/src/tunnel-dial.ts +102 -0
  317. package/src/ui/server.ts +852 -0
  318. package/src/ui/types.ts +173 -0
  319. package/src/workspace-path-policy.ts +41 -0
  320. package/ui/app.js +1102 -0
  321. package/ui/index.html +90 -0
  322. package/ui/style.css +685 -0
  323. package/ui/uai-favicon.svg +21 -0
  324. package/ui/uai-logo-black.svg +9 -0
  325. package/ui/uai-wheel.svg +9 -0
@@ -0,0 +1,974 @@
1
+ /**
2
+ * ADR-121 walking skeleton: the machine-backed TaskEnvironmentProvider.
3
+ *
4
+ * A task's environment is a whole machine (EC2/GCP instance, or a local
5
+ * SSH-reachable container on the guinea-pig daemon), reached exclusively
6
+ * over SSH via the shared machine-exec transport. The workspace lives ON
7
+ * the machine (ADR-121's core principle) — provisioning clones in-env; the
8
+ * host holds only the locator, the SSH identity, and proofs.
9
+ *
10
+ * NOT yet registered with the production task flow: the provider is
11
+ * exercised by tests and the guinea-pig smoke driver until the strangler
12
+ * ops land (attachments/diff/transcripts still assume a host-side
13
+ * workspace). Registration is one line when that day comes; the contract
14
+ * is complete now so nothing about the shape is provisional.
15
+ */
16
+
17
+ import { execFile, spawn as nodeSpawn } from "node:child_process";
18
+ import { readFileSync, statSync, writeFileSync } from "node:fs";
19
+ import { promisify } from "node:util";
20
+
21
+ import type {
22
+ MachineInfo,
23
+ MachineProvider,
24
+ } from "../machine-provider";
25
+ import { machineEnvironmentSshArgs } from "../machine-exec";
26
+ import { applyProjectEnvProfile } from "../runtime-authority";
27
+ import { rootAnchoredRequest } from "./anchor";
28
+ import {
29
+ BASELINE_PATH,
30
+ ETC_ENVIRONMENT_PATH,
31
+ parseEtcEnvironment,
32
+ withoutEngineCredentials,
33
+ withoutLaunchSecrets,
34
+ } from "./machine-environment";
35
+ import { NotARegularFileError } from "./workspace-files";
36
+ import { capturedCliExec, streamingCliExec } from "./docker";
37
+ import {
38
+ assertTaskEnvironmentExecRequest,
39
+ assertTaskEnvironmentProcessRequest,
40
+ refusePrivilegedExec,
41
+ assertTaskEnvironmentSessionRequest,
42
+ assertWorkspaceScopedPath,
43
+ type TaskEnvironmentCallOptions,
44
+ type TaskEnvironmentReadOptions,
45
+ WORKSPACE_READ_MAX_BYTES,
46
+ type TaskEnvironmentCopyRequest,
47
+ type TaskEnvironmentDescriptor,
48
+ type TaskEnvironmentDetachedSessionRequest,
49
+ type TaskEnvironmentExecRequest,
50
+ type TaskEnvironmentExecResult,
51
+ type TaskEnvironmentHandle,
52
+ type TaskEnvironmentLocator,
53
+ type TaskEnvironmentPort,
54
+ type TaskEnvironmentProcess,
55
+ type ProjectEnvMode,
56
+ type TaskEnvironmentProvider,
57
+ type TaskEnvironmentProvisionRequest,
58
+ type TaskEnvironmentProvisioned,
59
+ type TaskEnvironmentRecoveryContext,
60
+ type TaskEnvironmentRecoveryResult,
61
+ type TaskEnvironmentSessionRequest,
62
+ type TaskEnvironmentSpawnRequest,
63
+ type TaskEnvironmentStatus,
64
+ } from "./types";
65
+
66
+ export const MACHINE_TASK_ENVIRONMENT_PROVIDER = "machine";
67
+
68
+ const SAFE_TASK_ID = /^[a-z0-9][a-z0-9_-]{0,127}$/;
69
+
70
+ export interface MachineTaskEnvironmentLocatorValue {
71
+ taskId: string;
72
+ /** MachineProvider-native identifier (instance id / container name). */
73
+ machineId: string;
74
+ /** Which MachineProvider kind owns machineId — a locator must never be
75
+ * replayed against a different backend. */
76
+ machineKind: "local" | "aws" | "gcp";
77
+ /** Host path of the private key the machine authorizes. */
78
+ privateKeyPath: string;
79
+ /** Absolute workspace path on the machine. */
80
+ workspacePath: string;
81
+ sshUser: string;
82
+ }
83
+
84
+ /**
85
+ * A workspace read on a machine, as the task user: argv $1 the path, $2 the
86
+ * most bytes it takes, $3 "end" for the file's last bytes. Absence and
87
+ * error are different facts: 44 marks a MISSING file so a transport or
88
+ * permission failure can never masquerade as one; 45 a path that is not a
89
+ * regular file (a link in its place, a FIFO, a device), never read. A file
90
+ * swapped in after that check is bounded by the read's own timeout.
91
+ */
92
+ export const MACHINE_WORKSPACE_READ_SCRIPT =
93
+ `if [ ! -e "$1" ] && [ ! -L "$1" ]; then exit 44; fi; ` +
94
+ `if [ -L "$1" ] || [ ! -f "$1" ]; then exit 45; fi; ` +
95
+ `if [ "$3" = end ]; then exec tail -c "$2" "$1"; else exec head -c "$2" "$1"; fi`;
96
+
97
+ export interface MachineTaskInput {
98
+ image: string;
99
+ cpus: number;
100
+ memoryMiB: number;
101
+ workspacePath?: string;
102
+ /** Optional initial clone performed in-env (ADR-121: clone happens in the
103
+ * task's world; the URL may embed short-lived credentials that never
104
+ * touch machine disk beyond git's own storage). */
105
+ repo?: { url: string; branch?: string };
106
+ /** macOS-local development only; see MachineSpec.publishSsh. */
107
+ publishSsh?: boolean;
108
+ }
109
+
110
+ export interface MachineTaskEnvironmentDeps {
111
+ machines: MachineProvider;
112
+ /** Task control dir (host-side) for the SSH identity. */
113
+ taskControlDir(taskId: string): string;
114
+ mintKeyPair(taskControlDir: string): Promise<{
115
+ privateKeyPath: string;
116
+ publicKey: string;
117
+ }>;
118
+ spawn?: typeof nodeSpawn;
119
+ /** SSH-readiness budget after launch/start. */
120
+ readyTimeoutMs?: number;
121
+ }
122
+
123
+ export function machineTaskEnvironmentLocator(
124
+ value: MachineTaskEnvironmentLocatorValue,
125
+ ): TaskEnvironmentLocator {
126
+ validateMachineLocatorValue(value);
127
+ return {
128
+ schemaVersion: 1,
129
+ provider: MACHINE_TASK_ENVIRONMENT_PROVIDER,
130
+ value: { ...value },
131
+ };
132
+ }
133
+
134
+ export function parseMachineTaskEnvironmentLocator(
135
+ locator: TaskEnvironmentLocator,
136
+ ): MachineTaskEnvironmentLocatorValue {
137
+ if (
138
+ locator.schemaVersion !== 1 ||
139
+ locator.provider !== MACHINE_TASK_ENVIRONMENT_PROVIDER
140
+ ) {
141
+ throw new Error("task environment locator does not belong to machine");
142
+ }
143
+ return validateMachineLocatorValue(locator.value);
144
+ }
145
+
146
+ function validateMachineLocatorValue(
147
+ value: unknown,
148
+ ): MachineTaskEnvironmentLocatorValue {
149
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
150
+ throw new Error("machine locator value must be an object");
151
+ }
152
+ const record = value as Record<string, unknown>;
153
+ const {
154
+ taskId,
155
+ machineId,
156
+ machineKind,
157
+ privateKeyPath,
158
+ workspacePath,
159
+ sshUser,
160
+ } = record;
161
+ if (typeof taskId !== "string" || !SAFE_TASK_ID.test(taskId)) {
162
+ throw new Error("machine locator taskId is invalid");
163
+ }
164
+ if (
165
+ typeof machineId !== "string" ||
166
+ machineId.length === 0 ||
167
+ machineId.includes("\0")
168
+ ) {
169
+ throw new Error("machine locator machineId is invalid");
170
+ }
171
+ if (
172
+ machineKind !== "local" &&
173
+ machineKind !== "aws" &&
174
+ machineKind !== "gcp"
175
+ ) {
176
+ throw new Error("machine locator machineKind is invalid");
177
+ }
178
+ if (
179
+ typeof privateKeyPath !== "string" ||
180
+ !privateKeyPath.startsWith("/") ||
181
+ privateKeyPath.includes("\0")
182
+ ) {
183
+ throw new Error("machine locator privateKeyPath is invalid");
184
+ }
185
+ if (
186
+ typeof workspacePath !== "string" ||
187
+ !workspacePath.startsWith("/") ||
188
+ workspacePath.includes("\0")
189
+ ) {
190
+ throw new Error("machine locator workspacePath is invalid");
191
+ }
192
+ if (typeof sshUser !== "string" || sshUser.length === 0) {
193
+ throw new Error("machine locator sshUser is invalid");
194
+ }
195
+ return {
196
+ taskId,
197
+ machineId,
198
+ machineKind,
199
+ privateKeyPath,
200
+ workspacePath,
201
+ sshUser,
202
+ };
203
+ }
204
+
205
+ class MachineTaskEnvironmentHandle implements TaskEnvironmentHandle {
206
+ readonly descriptor: TaskEnvironmentDescriptor;
207
+ readonly durableIdentity: string;
208
+ /** ADR-154 §6: anchored requests start under the engine supervisor. */
209
+ readonly anchors = true;
210
+ /** ADR-154 §6: a request's environment rides the ssh command line, so a
211
+ * leased launch's engine credentials travel in its admission. */
212
+ readonly credentialDelivery = "admission";
213
+ readonly #value: MachineTaskEnvironmentLocatorValue;
214
+ readonly #deps: Required<
215
+ Pick<MachineTaskEnvironmentDeps, "machines" | "spawn" | "readyTimeoutMs">
216
+ > &
217
+ MachineTaskEnvironmentDeps;
218
+ /** The machine's own /etc/environment, read once (see #anchoredBaseline). */
219
+ #machineWide: Promise<Record<string, string>> | null = null;
220
+
221
+ constructor(
222
+ locator: TaskEnvironmentLocator,
223
+ deps: MachineTaskEnvironmentDeps,
224
+ envMode: ProjectEnvMode,
225
+ ) {
226
+ this.#value = parseMachineTaskEnvironmentLocator(locator);
227
+ this.#deps = {
228
+ ...deps,
229
+ machines: deps.machines,
230
+ spawn: deps.spawn ?? nodeSpawn,
231
+ readyTimeoutMs: deps.readyTimeoutMs ?? 120_000,
232
+ };
233
+ this.durableIdentity = this.#value.machineId;
234
+ this.descriptor = {
235
+ taskId: this.#value.taskId,
236
+ locator,
237
+ envMode,
238
+ workspacePath: this.#value.workspacePath,
239
+ };
240
+ }
241
+
242
+ /** Resolve the CURRENT dial target. Cloud machines change address across
243
+ * stop/start, so the locator never stores one — describe is the only
244
+ * source of truth, and a non-running machine has no target at all. */
245
+ async #target(): Promise<{
246
+ address: string;
247
+ keyPath: string;
248
+ port?: number;
249
+ }> {
250
+ const info = await this.#describeOwn();
251
+ if (info.state !== "running" || info.address === null) {
252
+ throw new Error(
253
+ `machine ${this.#value.machineId} is not reachable (${info.state})`,
254
+ );
255
+ }
256
+ return {
257
+ address: info.address,
258
+ keyPath: this.#value.privateKeyPath,
259
+ ...(info.sshPort !== undefined ? { port: info.sshPort } : {}),
260
+ };
261
+ }
262
+
263
+ /** Describe with the ownership check every mutation rides on: a machine
264
+ * that does not carry OUR task label is a foreign occupant of the id and
265
+ * is treated as unknown — never dialed, never stopped, never terminated. */
266
+ async #describeOwn(): Promise<MachineInfo> {
267
+ const info = await this.#deps.machines.describe(this.#value.machineId);
268
+ if (
269
+ (info.state === "running" || info.state === "stopped") &&
270
+ info.taskLabel !== this.#value.taskId
271
+ ) {
272
+ return {
273
+ id: info.id,
274
+ taskLabel: info.taskLabel,
275
+ state: "unknown",
276
+ address: null,
277
+ detail: "machine does not carry this task's ownership label",
278
+ };
279
+ }
280
+ return info;
281
+ }
282
+
283
+ /**
284
+ * ADR-154 §6: an anchored launch starts from nothing of its ssh session's
285
+ * environment (sshd's, PAM's, the login's: an older image may leave an
286
+ * owner's sign-in there), only from what it names: a PATH, the machine's
287
+ * /etc/environment as pam_env would apply it (never an engine's
288
+ * credential, nor an agent's task token), and its own env. Anything else
289
+ * is unchanged.
290
+ */
291
+ async #anchoredBaseline<
292
+ T extends { anchor?: unknown; env?: Readonly<Record<string, string>> },
293
+ >(request: T): Promise<T & { clean?: true }> {
294
+ if (!request.anchor) return request;
295
+ const machineWide = await this.#machineWideEnvironment();
296
+ return {
297
+ ...request,
298
+ // It rides the command line: no secret of the machine's on it.
299
+ env: { PATH: BASELINE_PATH, ...withoutLaunchSecrets(machineWide), ...request.env },
300
+ clean: true,
301
+ };
302
+ }
303
+
304
+ /** The machine's /etc/environment without any engine's credential, read
305
+ * once over ssh (absent: empty); read again after a failed read. */
306
+ #machineWideEnvironment(): Promise<Record<string, string>> {
307
+ if (this.#machineWide) return this.#machineWide;
308
+ const reading = (async () => {
309
+ const result = await this.exec({
310
+ argv: ["/bin/cat", ETC_ENVIRONMENT_PATH],
311
+ projectEnvProfile: "maintenance",
312
+ timeoutMs: 30_000,
313
+ maxOutputBytes: 64 * 1024,
314
+ });
315
+ if (result.exitCode === 1 && result.stdout.length === 0) return {};
316
+ if (result.exitCode !== 0 || result.stdoutTruncated) {
317
+ throw new Error(`machine ${this.#value.machineId}'s environment could not be read`);
318
+ }
319
+ return withoutEngineCredentials(parseEtcEnvironment(Buffer.from(result.stdout).toString("utf8")));
320
+ })();
321
+ this.#machineWide = reading;
322
+ reading.catch(() => {
323
+ if (this.#machineWide === reading) this.#machineWide = null;
324
+ });
325
+ return reading;
326
+ }
327
+
328
+ #maintenanceRequest<
329
+ T extends {
330
+ argv: readonly [string, ...string[]];
331
+ cwd?: string;
332
+ user?: string;
333
+ inheritEnv?: readonly string[];
334
+ env?: Readonly<Record<string, string>>;
335
+ },
336
+ >(
337
+ request: T,
338
+ ): T & { projectEnvProfile: "maintenance" } {
339
+ return applyProjectEnvProfile(
340
+ { ...request, projectEnvProfile: "maintenance" as const },
341
+ this.descriptor.envMode ?? "baked",
342
+ "/bin/bash",
343
+ );
344
+ }
345
+
346
+ async readWorkspaceFile(path: string, options?: TaskEnvironmentReadOptions): Promise<Uint8Array | null> {
347
+ assertWorkspaceScopedPath(this.descriptor, path);
348
+ const maxBytes = Math.max(0, Math.floor(options?.maxBytes ?? WORKSPACE_READ_MAX_BYTES));
349
+ const target = await this.#target();
350
+ options?.admit?.();
351
+ const result = await capturedCliExec(
352
+ this.#deps.spawn,
353
+ "ssh",
354
+ machineEnvironmentSshArgs(
355
+ target,
356
+ this.#maintenanceRequest({
357
+ argv: [
358
+ "/bin/sh",
359
+ "-c",
360
+ MACHINE_WORKSPACE_READ_SCRIPT,
361
+ "read",
362
+ path,
363
+ String(maxBytes),
364
+ options?.from === "end" ? "end" : "start",
365
+ ],
366
+ }),
367
+ "interactive",
368
+ ),
369
+ {
370
+ argv: ["/bin/cat"],
371
+ projectEnvProfile: "maintenance",
372
+ timeoutMs: 60_000,
373
+ maxOutputBytes: 64 * 1024 * 1024,
374
+ },
375
+ );
376
+ if (result.exitCode === 44) return null;
377
+ if (result.exitCode === 45) throw new NotARegularFileError();
378
+ if (result.exitCode !== 0) {
379
+ throw new Error(
380
+ `machine workspace read failed: ${Buffer.from(result.stderr).toString("utf8").trim()}`,
381
+ );
382
+ }
383
+ return result.stdout;
384
+ }
385
+
386
+ async writeWorkspaceFile(path: string, bytes: Uint8Array, options?: TaskEnvironmentCallOptions): Promise<void> {
387
+ assertWorkspaceScopedPath(this.descriptor, path);
388
+ const target = await this.#target();
389
+ options?.admit?.();
390
+ const result = await capturedCliExec(
391
+ this.#deps.spawn,
392
+ "ssh",
393
+ machineEnvironmentSshArgs(
394
+ target,
395
+ this.#maintenanceRequest({
396
+ argv: [
397
+ "/bin/sh",
398
+ "-c",
399
+ `mkdir -p "$(dirname "$1")" && cat > "$1"`,
400
+ "write",
401
+ path,
402
+ ],
403
+ }),
404
+ "interactive",
405
+ ),
406
+ {
407
+ argv: ["/bin/sh"],
408
+ projectEnvProfile: "maintenance",
409
+ stdin: bytes,
410
+ timeoutMs: 120_000,
411
+ maxOutputBytes: 1024 * 1024,
412
+ },
413
+ );
414
+ if (result.exitCode !== 0) {
415
+ throw new Error(
416
+ `machine workspace write failed: ${Buffer.from(result.stderr).toString("utf8").trim()}`,
417
+ );
418
+ }
419
+ }
420
+
421
+ async exec(
422
+ request: TaskEnvironmentExecRequest,
423
+ ): Promise<TaskEnvironmentExecResult> {
424
+ assertTaskEnvironmentExecRequest(request);
425
+ refusePrivilegedExec(request);
426
+ const profiled = applyProjectEnvProfile(
427
+ rootAnchoredRequest(await this.#anchoredBaseline(request)),
428
+ this.descriptor.envMode ?? "baked",
429
+ "/bin/bash",
430
+ );
431
+ const target = await this.#target();
432
+ request.admit?.();
433
+ return capturedCliExec(
434
+ this.#deps.spawn,
435
+ "ssh",
436
+ machineEnvironmentSshArgs(target, profiled, "interactive"),
437
+ profiled,
438
+ );
439
+ }
440
+
441
+ async spawn(
442
+ request: TaskEnvironmentSpawnRequest,
443
+ ): Promise<TaskEnvironmentProcess> {
444
+ assertTaskEnvironmentProcessRequest(request);
445
+ const profiled = applyProjectEnvProfile(
446
+ rootAnchoredRequest(await this.#anchoredBaseline(request)),
447
+ this.descriptor.envMode ?? "baked",
448
+ "/bin/bash",
449
+ );
450
+ const target = await this.#target();
451
+ request.admit?.();
452
+ return streamingCliExec(
453
+ this.#deps.spawn,
454
+ "ssh",
455
+ machineEnvironmentSshArgs(target, profiled, "interactive"),
456
+ profiled,
457
+ );
458
+ }
459
+
460
+ async spawnSession(
461
+ request: TaskEnvironmentSessionRequest,
462
+ ): Promise<TaskEnvironmentProcess> {
463
+ assertTaskEnvironmentSessionRequest(request);
464
+ // Sessions default to the workspace the way container images default to
465
+ // WORKDIR /workspace — an agent CLI must start in the task's world.
466
+ const sessionRequest = applyProjectEnvProfile(rootAnchoredRequest(await this.#anchoredBaseline({
467
+ ...request,
468
+ cwd: request.cwd ?? this.#value.workspacePath,
469
+ })), this.descriptor.envMode ?? "baked", "/bin/bash");
470
+ const target = await this.#target();
471
+ request.admit?.();
472
+ return streamingCliExec(
473
+ this.#deps.spawn,
474
+ "ssh",
475
+ machineEnvironmentSshArgs(target, sessionRequest, "interactive"),
476
+ sessionRequest,
477
+ );
478
+ }
479
+
480
+ async launchDetachedSession(
481
+ request: TaskEnvironmentDetachedSessionRequest,
482
+ ): Promise<TaskEnvironmentExecResult> {
483
+ assertTaskEnvironmentSessionRequest(request);
484
+ const sessionRequest = applyProjectEnvProfile(rootAnchoredRequest(await this.#anchoredBaseline({
485
+ ...request,
486
+ cwd: request.cwd ?? this.#value.workspacePath,
487
+ })), this.descriptor.envMode ?? "baked", "/bin/bash");
488
+ const target = await this.#target();
489
+ request.admit?.();
490
+ return capturedCliExec(
491
+ this.#deps.spawn,
492
+ "ssh",
493
+ machineEnvironmentSshArgs(target, sessionRequest, "detached"),
494
+ {
495
+ ...sessionRequest,
496
+ timeoutMs: sessionRequest.launchTimeoutMs,
497
+ },
498
+ );
499
+ }
500
+
501
+ async copy(request: TaskEnvironmentCopyRequest): Promise<void> {
502
+ if (
503
+ !request.source.startsWith("/") ||
504
+ !request.destination.startsWith("/") ||
505
+ request.source.includes("\0") ||
506
+ request.destination.includes("\0")
507
+ ) {
508
+ throw new Error("machine copy paths must be absolute and NUL-free");
509
+ }
510
+ const target = await this.#target();
511
+ if (request.direction === "into") {
512
+ // Directory sources tar-stream over the transport (engine-account
513
+ // homes — live 2026-08-27: codex's auth.json never reached the
514
+ // machine because readFileSync(dir) threw EISDIR and the account
515
+ // materialization died). Single files keep the direct cat path.
516
+ if (statSync(request.source).isDirectory()) {
517
+ const tarball = await promisify(execFile)(
518
+ "tar",
519
+ ["-C", request.source, "-cf", "-", "."],
520
+ { encoding: "buffer", maxBuffer: 256 * 1024 * 1024 },
521
+ );
522
+ request.admit?.();
523
+ const extract = await capturedCliExec(
524
+ this.#deps.spawn,
525
+ "ssh",
526
+ machineEnvironmentSshArgs(
527
+ target,
528
+ this.#maintenanceRequest({
529
+ argv: [
530
+ "/bin/sh",
531
+ "-c",
532
+ 'mkdir -p "$1" && tar -C "$1" -xf -',
533
+ "copy",
534
+ request.destination,
535
+ ],
536
+ }),
537
+ "interactive",
538
+ ),
539
+ {
540
+ argv: ["/bin/sh"],
541
+ projectEnvProfile: "maintenance",
542
+ stdin: tarball.stdout,
543
+ timeoutMs: 300_000,
544
+ maxOutputBytes: 1024 * 1024,
545
+ },
546
+ );
547
+ if (extract.exitCode !== 0) {
548
+ throw new Error(
549
+ `machine directory copy failed: ${Buffer.from(extract.stderr).toString("utf8").trim()}`,
550
+ );
551
+ }
552
+ return;
553
+ }
554
+ const bytes = readFileSync(request.source);
555
+ request.admit?.();
556
+ const result = await capturedCliExec(
557
+ this.#deps.spawn,
558
+ "ssh",
559
+ machineEnvironmentSshArgs(
560
+ target,
561
+ this.#maintenanceRequest({
562
+ argv: [
563
+ "/bin/sh",
564
+ "-c",
565
+ `mkdir -p "$(dirname "$1")" && cat > "$1"`,
566
+ "copy",
567
+ request.destination,
568
+ ],
569
+ }),
570
+ "interactive",
571
+ ),
572
+ {
573
+ argv: ["/bin/sh"],
574
+ projectEnvProfile: "maintenance",
575
+ stdin: bytes,
576
+ timeoutMs: 120_000,
577
+ maxOutputBytes: 1024 * 1024,
578
+ },
579
+ );
580
+ if (result.exitCode !== 0) {
581
+ throw new Error(
582
+ `machine copy into failed: ${Buffer.from(result.stderr).toString("utf8").trim()}`,
583
+ );
584
+ }
585
+ return;
586
+ }
587
+ request.admit?.();
588
+ const result = await capturedCliExec(
589
+ this.#deps.spawn,
590
+ "ssh",
591
+ machineEnvironmentSshArgs(
592
+ target,
593
+ this.#maintenanceRequest({ argv: ["/bin/cat", request.source] }),
594
+ "interactive",
595
+ ),
596
+ {
597
+ argv: ["/bin/cat"],
598
+ projectEnvProfile: "maintenance",
599
+ timeoutMs: 120_000,
600
+ maxOutputBytes: 64 * 1024 * 1024,
601
+ },
602
+ );
603
+ if (result.exitCode !== 0) {
604
+ throw new Error(
605
+ `machine copy out failed: ${Buffer.from(result.stderr).toString("utf8").trim()}`,
606
+ );
607
+ }
608
+ writeFileSync(request.destination, Buffer.from(result.stdout));
609
+ }
610
+
611
+ async ports(): Promise<TaskEnvironmentPort[]> {
612
+ // Direct-IP model, like the Apple provider: nothing is published on the
613
+ // host; routing resolves the machine address per connection below.
614
+ return [];
615
+ }
616
+
617
+ async inspectRoute(): Promise<
618
+ | { kind: "running"; ipv4Address: string }
619
+ | { kind: "unavailable" }
620
+ | { kind: "unknown"; detail: string }
621
+ > {
622
+ const info = await this.#describeOwn();
623
+ if (info.state === "running" && info.address !== null) {
624
+ return { kind: "running", ipv4Address: info.address };
625
+ }
626
+ if (info.state === "stopped" || info.state === "absent") {
627
+ return { kind: "unavailable" };
628
+ }
629
+ return {
630
+ kind: "unknown",
631
+ detail: info.detail ?? `machine state ${info.state}`,
632
+ };
633
+ }
634
+
635
+ /** Full ssh argv for the persistent REVERSE gateway tunnel: the host's
636
+ * loopback gateway appears at the machine's own 127.0.0.1:<machinePort>.
637
+ * Resolved per call — cloud machines change address across stop/start, so
638
+ * each (re)spawn of the tunnel re-resolves the dial target. */
639
+ async gatewayTunnelSshArgs(
640
+ machinePort: number,
641
+ hostPort: number,
642
+ options?: TaskEnvironmentCallOptions,
643
+ ): Promise<string[]> {
644
+ const target = await this.#target();
645
+ options?.admit?.();
646
+ return [
647
+ "-o", "BatchMode=yes",
648
+ "-o", "StrictHostKeyChecking=accept-new",
649
+ "-o", "ConnectTimeout=10",
650
+ "-o", "ExitOnForwardFailure=yes",
651
+ "-o", "ServerAliveInterval=15",
652
+ "-o", "ServerAliveCountMax=3",
653
+ "-i", target.keyPath,
654
+ "-p", String(target.port ?? 22),
655
+ "-l", this.#value.sshUser,
656
+ target.address,
657
+ "-N",
658
+ "-R", `127.0.0.1:${machinePort}:127.0.0.1:${hostPort}`,
659
+ ];
660
+ }
661
+
662
+ async status(): Promise<TaskEnvironmentStatus> {
663
+ const info = await this.#describeOwn();
664
+ switch (info.state) {
665
+ case "running":
666
+ return {
667
+ state: "running",
668
+ instances: [this.#value.machineId],
669
+ workspacePresent: await this.#workspacePresent(),
670
+ };
671
+ case "pending":
672
+ // Pending is running-shaped for status purposes: resources exist.
673
+ return {
674
+ state: "running",
675
+ instances: [this.#value.machineId],
676
+ workspacePresent: false,
677
+ };
678
+ case "stopped":
679
+ // The workspace lives on the machine's own disk, which stop keeps
680
+ // by design (EBS / container filesystem). Not observable while
681
+ // stopped; asserted from the architecture, not probed.
682
+ return {
683
+ state: "stopped",
684
+ instances: [this.#value.machineId],
685
+ workspacePresent: true,
686
+ ...(info.frozen ? { frozen: true as const } : {}),
687
+ };
688
+ case "absent":
689
+ return { state: "absent", instances: [], workspacePresent: false };
690
+ default:
691
+ return {
692
+ state: "unknown",
693
+ detail: info.detail ?? "machine state unknown",
694
+ };
695
+ }
696
+ }
697
+
698
+ async #workspacePresent(): Promise<boolean> {
699
+ try {
700
+ const target = await this.#target();
701
+ const result = await capturedCliExec(
702
+ this.#deps.spawn,
703
+ "ssh",
704
+ machineEnvironmentSshArgs(
705
+ target,
706
+ this.#maintenanceRequest({
707
+ argv: ["/usr/bin/test", "-d", this.#value.workspacePath],
708
+ }),
709
+ "interactive",
710
+ ),
711
+ {
712
+ argv: ["/usr/bin/test"],
713
+ projectEnvProfile: "maintenance",
714
+ timeoutMs: 15_000,
715
+ maxOutputBytes: 4 * 1024,
716
+ },
717
+ );
718
+ return result.exitCode === 0;
719
+ } catch {
720
+ return false;
721
+ }
722
+ }
723
+
724
+ async recover(
725
+ context: TaskEnvironmentRecoveryContext,
726
+ ): Promise<TaskEnvironmentRecoveryResult> {
727
+ await context.maintenanceReady;
728
+ if (!context.isCurrent()) {
729
+ return { outcome: "deferred", detail: "runtime generation superseded" };
730
+ }
731
+ const info = await this.#describeOwn();
732
+ if (info.state === "unknown") {
733
+ return {
734
+ outcome: "deferred",
735
+ detail: info.detail ?? "machine state unknown",
736
+ };
737
+ }
738
+ if (info.state === "absent" || info.state === "stopped") {
739
+ // Durable truths recovery may settle without touching anything.
740
+ return { outcome: "recovered", status: await this.status() };
741
+ }
742
+ // Running (or pending): the machine must actually answer before
743
+ // consumers are re-admitted — a wedged guest that describes as running
744
+ // must not be exposed (this week's OOM lesson, applied to machines).
745
+ const answered = await this.#sshAnswers();
746
+ if (!answered) {
747
+ return {
748
+ outcome: "deferred",
749
+ detail: "machine describes as running but does not answer ssh",
750
+ };
751
+ }
752
+ if (!context.isCurrent()) {
753
+ return { outcome: "deferred", detail: "runtime generation superseded" };
754
+ }
755
+ await context.prepareConsumers?.();
756
+ context.allowConsumers?.();
757
+ await context.ensureConsumersStarted?.();
758
+ if (!context.isCurrent()) {
759
+ await context.quarantineConsumers?.();
760
+ return { outcome: "deferred", detail: "runtime generation superseded" };
761
+ }
762
+ return { outcome: "recovered", status: await this.status() };
763
+ }
764
+
765
+ async #sshAnswers(): Promise<boolean> {
766
+ try {
767
+ const target = await this.#target();
768
+ const result = await capturedCliExec(
769
+ this.#deps.spawn,
770
+ "ssh",
771
+ machineEnvironmentSshArgs(
772
+ target,
773
+ this.#maintenanceRequest({ argv: ["/bin/true"] }),
774
+ "interactive",
775
+ ),
776
+ {
777
+ argv: ["/bin/true"],
778
+ projectEnvProfile: "maintenance",
779
+ timeoutMs: 15_000,
780
+ maxOutputBytes: 4 * 1024,
781
+ },
782
+ );
783
+ return result.exitCode === 0;
784
+ } catch {
785
+ return false;
786
+ }
787
+ }
788
+
789
+ async stop(): Promise<void> {
790
+ const info = await this.#describeOwn();
791
+ if (info.state === "stopped" || info.state === "absent") return;
792
+ if (info.state === "unknown") {
793
+ throw new Error(
794
+ `machine ${this.#value.machineId} cannot be stopped: ${
795
+ info.detail ?? "state unknown"
796
+ }`,
797
+ );
798
+ }
799
+ await this.#deps.machines.stop(this.#value.machineId);
800
+ }
801
+
802
+ async teardown(): Promise<void> {
803
+ const info = await this.#describeOwn();
804
+ if (info.state === "absent") return;
805
+ if (info.state === "unknown") {
806
+ // Terminating an id we cannot prove is ours would be destruction
807
+ // without authority — the same refusal task-down makes for containers.
808
+ throw new Error(
809
+ `machine ${this.#value.machineId} cannot be torn down: ${
810
+ info.detail ?? "ownership unprovable"
811
+ }`,
812
+ );
813
+ }
814
+ // terminate() resolves only after proven absence (provider contract).
815
+ await this.#deps.machines.terminate(this.#value.machineId);
816
+ }
817
+ }
818
+
819
+ export function createMachineTaskEnvironmentProvider(
820
+ deps: MachineTaskEnvironmentDeps,
821
+ ): TaskEnvironmentProvider<MachineTaskInput, void, TaskEnvironmentDescriptor> {
822
+ return {
823
+ kind: MACHINE_TASK_ENVIRONMENT_PROVIDER,
824
+
825
+ async provision(
826
+ request: TaskEnvironmentProvisionRequest<MachineTaskInput, void>,
827
+ ): Promise<TaskEnvironmentProvisioned<TaskEnvironmentDescriptor>> {
828
+ const { taskId, input } = request;
829
+ if (!SAFE_TASK_ID.test(taskId)) {
830
+ throw new Error("machine provisioning refused an unsafe task id");
831
+ }
832
+ const workspacePath = input.workspacePath ?? "/workspace";
833
+ const keys = await deps.mintKeyPair(deps.taskControlDir(taskId));
834
+ // The locator is durable BEFORE any external resource exists: a crash
835
+ // after onPrepared is recoverable from the locator alone, and the
836
+ // machine id is derived (never invented post-launch) so the locator
837
+ // written here names exactly what launch will create.
838
+ const machineId = `uai-machine-${taskId.toLowerCase()}`;
839
+ const locator = machineTaskEnvironmentLocator({
840
+ taskId,
841
+ machineId,
842
+ machineKind: deps.machines.kind,
843
+ privateKeyPath: keys.privateKeyPath,
844
+ workspacePath,
845
+ sshUser: "node",
846
+ });
847
+ await request.onPrepared?.(locator);
848
+
849
+ // Resume-aware launch: a machine may already exist for this task
850
+ // (host restart mid-provision, task resume after stop). Only a
851
+ // machine PROVEN to carry this task's ownership label may be reused;
852
+ // an unlabeled or foreign occupant of the name fails the provision —
853
+ // it is never adopted and never terminated from here.
854
+ const existing = await deps.machines.describe(machineId);
855
+ if (existing.state !== "absent" && existing.taskLabel !== taskId) {
856
+ throw new Error(
857
+ `machine name ${machineId} is occupied by a resource not labeled for task ${taskId}`,
858
+ );
859
+ }
860
+ if (existing.state === "unknown") {
861
+ throw new Error(
862
+ `machine ${machineId} state is unknown: ${existing.detail ?? "no detail"}`,
863
+ );
864
+ }
865
+ const reused = existing.state !== "absent";
866
+ if (existing.state === "absent") {
867
+ const launched = await deps.machines.launch({
868
+ taskId,
869
+ image: input.image,
870
+ cpus: input.cpus,
871
+ memoryMiB: input.memoryMiB,
872
+ authorizedPublicKey: keys.publicKey,
873
+ ...(input.publishSsh ? { publishSsh: true } : {}),
874
+ });
875
+ if (launched.id !== machineId) {
876
+ // The backend named the machine something other than what the
877
+ // durable locator promises — tear it down rather than strand an
878
+ // untracked machine, then fail the provision loudly.
879
+ await deps.machines.terminate(launched.id);
880
+ throw new Error(
881
+ `machine backend named ${launched.id}, locator promised ${machineId}`,
882
+ );
883
+ }
884
+ } else if (existing.state === "stopped") {
885
+ await deps.machines.start(machineId);
886
+ }
887
+ // running/pending: fall through to the ssh-readiness proof below.
888
+
889
+ const handle = new MachineTaskEnvironmentHandle(
890
+ locator,
891
+ deps,
892
+ request.envMode ?? "loader",
893
+ );
894
+ await waitForSsh(handle, deps.readyTimeoutMs ?? 120_000);
895
+
896
+ // Workspace + optional in-env clone (ADR-121: the clone happens in the
897
+ // task's world; the host never materializes task code).
898
+ // As root: the workspace root is typically directly under a
899
+ // root-owned / (cloud images and the local image alike), then handed
900
+ // to the task user — the same gesture cloud user-data makes.
901
+ const prepare = await handle.exec({
902
+ argv: [
903
+ "/bin/sh",
904
+ "-c",
905
+ `mkdir -p "$1" && chown node:node "$1"`,
906
+ "prepare",
907
+ workspacePath,
908
+ ],
909
+ user: "root",
910
+ projectEnvProfile: "maintenance",
911
+ timeoutMs: 30_000,
912
+ maxOutputBytes: 64 * 1024,
913
+ });
914
+ if (prepare.exitCode !== 0) {
915
+ throw new Error(
916
+ `machine workspace preparation failed: ${Buffer.from(prepare.stderr).toString("utf8").trim()}`,
917
+ );
918
+ }
919
+ if (input.repo) {
920
+ const cloneArgv: [string, ...string[]] = [
921
+ "/usr/bin/git",
922
+ "clone",
923
+ ...(input.repo.branch ? ["--branch", input.repo.branch] : []),
924
+ input.repo.url,
925
+ workspacePath,
926
+ ];
927
+ const clone = await handle.exec({
928
+ argv: cloneArgv,
929
+ projectEnvProfile: "maintenance",
930
+ timeoutMs: 10 * 60_000,
931
+ maxOutputBytes: 4 * 1024 * 1024,
932
+ });
933
+ if (clone.exitCode !== 0) {
934
+ throw new Error(
935
+ `in-env clone failed: ${Buffer.from(clone.stderr).toString("utf8").trim()}`,
936
+ );
937
+ }
938
+ }
939
+ return { handle, result: handle.descriptor, reused };
940
+ },
941
+
942
+ async reconstruct(
943
+ locator: TaskEnvironmentLocator,
944
+ envMode: ProjectEnvMode = "baked",
945
+ ): Promise<TaskEnvironmentHandle> {
946
+ return new MachineTaskEnvironmentHandle(locator, deps, envMode);
947
+ },
948
+ };
949
+ }
950
+
951
+ async function waitForSsh(
952
+ handle: MachineTaskEnvironmentHandle,
953
+ budgetMs: number,
954
+ ): Promise<void> {
955
+ const deadline = Date.now() + budgetMs;
956
+ let lastDetail = "ssh never answered";
957
+ while (Date.now() < deadline) {
958
+ try {
959
+ const probe = await handle.exec({
960
+ argv: ["/bin/true"],
961
+ projectEnvProfile: "maintenance",
962
+ timeoutMs: 10_000,
963
+ maxOutputBytes: 4 * 1024,
964
+ });
965
+ if (probe.exitCode === 0) return;
966
+ lastDetail = Buffer.from(probe.stderr).toString("utf8").trim() ||
967
+ `probe exited ${probe.exitCode ?? "killed"}`;
968
+ } catch (error) {
969
+ lastDetail = error instanceof Error ? error.message : String(error);
970
+ }
971
+ await new Promise<void>((resolve) => setTimeout(resolve, 2_000));
972
+ }
973
+ throw new Error(`machine never became reachable over ssh: ${lastDetail}`);
974
+ }