@moondeck/station 0.0.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -2
  3. package/bin/uai-host.mjs +130 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/0005_host_env_vars.sql +5 -0
  10. package/db/migrations/0006_host_project_env.sql +9 -0
  11. package/db/migrations/0007_host_github_token_kind.sql +7 -0
  12. package/db/migrations/0008_host_mcp_connections.sql +18 -0
  13. package/db/migrations/0009_host_agent_sessions.sql +15 -0
  14. package/db/migrations/0010_host_engine_accounts.sql +13 -0
  15. package/db/migrations/0011_codex_thread_id.sql +5 -0
  16. package/db/migrations/0012_agent_session_attach_key.sql +4 -0
  17. package/db/migrations/0013_github_credential_generations.sql +11 -0
  18. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  19. package/db/migrations/0015_host_settings.sql +9 -0
  20. package/db/migrations/0016_task_environment.sql +2 -0
  21. package/db/migrations/0017_engine_account_scope.sql +7 -0
  22. package/db/migrations/0018_engine_account_position.sql +3 -0
  23. package/db/migrations/0019_engine_account_store_ref.sql +3 -0
  24. package/db/migrations/0020_user_credential_store_ref.sql +6 -0
  25. package/db/migrations/0021_project_env_store_ref.sql +3 -0
  26. package/db/migrations/0022_task_project_ids.sql +5 -0
  27. package/db/migrations/0023_project_env_org.sql +4 -0
  28. package/db/migrations/0024_task_env_mode.sql +9 -0
  29. package/db/migrations/0025_project_env_authority.sql +147 -0
  30. package/db/migrations/0026_project_env_replica.sql +150 -0
  31. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  32. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  33. package/db/migrations/0029_task_shared_files_mode.sql +5 -0
  34. package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
  35. package/db/migrations/0031_first_turn_receipts.sql +9 -0
  36. package/db/migrations/meta/_journal.json +230 -0
  37. package/db/schema.ts +871 -0
  38. package/images/standard/Dockerfile +347 -0
  39. package/images/standard/README.md +167 -0
  40. package/images/standard/container/code-server-settings.json +63 -0
  41. package/images/standard/container/corepack-version +1 -0
  42. package/images/standard/container/uai-init +704 -0
  43. package/images/standard/container/uai-materialize-runtimes +1527 -0
  44. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  45. package/images/standard/tool-versions +2 -0
  46. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  47. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  48. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  49. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  50. package/lib/agent-cli.ts +1507 -0
  51. package/lib/agent-errors.ts +22 -0
  52. package/lib/agent.ts +672 -0
  53. package/lib/agents/attach-compatibility.ts +51 -0
  54. package/lib/agents/claude.ts +918 -0
  55. package/lib/agents/codex-token-usage.fixture.json +130 -0
  56. package/lib/agents/codex.ts +1392 -0
  57. package/lib/agents/cursor-period-usage.ts +253 -0
  58. package/lib/agents/cursor.ts +562 -0
  59. package/lib/agents/dispatch.ts +78 -0
  60. package/lib/agents/durable-proc.ts +334 -0
  61. package/lib/agents/factory.ts +57 -0
  62. package/lib/agents/grok.ts +381 -0
  63. package/lib/agents/kimi.ts +329 -0
  64. package/lib/agents/machine-durable.ts +604 -0
  65. package/lib/agents/mock.ts +133 -0
  66. package/lib/agents/mode.ts +64 -0
  67. package/lib/agents/opencode.ts +508 -0
  68. package/lib/agents/output-source.ts +3 -0
  69. package/lib/agents/proc.ts +221 -0
  70. package/lib/agents/rate-limit.ts +337 -0
  71. package/lib/agents/registry.ts +182 -0
  72. package/lib/agents/session-state.ts +197 -0
  73. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  74. package/lib/agents/transport.ts +741 -0
  75. package/lib/agents/types.ts +254 -0
  76. package/lib/agents/uai.ts +106 -0
  77. package/lib/agents/usage.ts +44 -0
  78. package/lib/apple-one-off.ts +277 -0
  79. package/lib/apple-runtime-recycle.ts +237 -0
  80. package/lib/apple-uninstall-teardown.ts +224 -0
  81. package/lib/assignment-evidence.ts +94 -0
  82. package/lib/attachments.ts +48 -0
  83. package/lib/browser-testing.ts +1537 -0
  84. package/lib/capture-flow.ts +332 -0
  85. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  86. package/lib/cloud-credential-host.ts +512 -0
  87. package/lib/cloud-credential-leases.ts +2587 -0
  88. package/lib/cloud-credential-runtime.ts +1357 -0
  89. package/lib/cloud-state.ts +56 -0
  90. package/lib/codex-auth.ts +456 -0
  91. package/lib/command-db.ts +310 -0
  92. package/lib/compose-network-recovery.ts +283 -0
  93. package/lib/connection-secret-redaction.ts +25 -0
  94. package/lib/container-runtime.ts +1470 -0
  95. package/lib/credential-cleanup-inventory.ts +490 -0
  96. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  97. package/lib/credential-consumers/engines.ts +797 -0
  98. package/lib/credential-consumers/files.ts +934 -0
  99. package/lib/credential-consumers/github.ts +702 -0
  100. package/lib/credential-consumers/mcp.ts +389 -0
  101. package/lib/credential-consumers/task-files.ts +127 -0
  102. package/lib/credential-consumers/task-secrets.ts +10 -0
  103. package/lib/credential-store-pickup.ts +149 -0
  104. package/lib/credential-store-scope.ts +112 -0
  105. package/lib/credential-store.ts +932 -0
  106. package/lib/credentials-activation-authority.ts +709 -0
  107. package/lib/credentials-purge.ts +1006 -0
  108. package/lib/custom-connection-capture.ts +395 -0
  109. package/lib/custom-connection-launch.ts +384 -0
  110. package/lib/custom-connection-runtime.ts +141 -0
  111. package/lib/db.ts +80 -0
  112. package/lib/disk-pressure.ts +42 -0
  113. package/lib/docker-exec.ts +156 -0
  114. package/lib/engine-accounts.ts +1970 -0
  115. package/lib/engine-connect.ts +321 -0
  116. package/lib/engine-credential-env.ts +52 -0
  117. package/lib/engine-login.ts +2933 -0
  118. package/lib/engines.ts +1342 -0
  119. package/lib/enrollment-state.ts +251 -0
  120. package/lib/env-file.ts +246 -0
  121. package/lib/env-schema.ts +937 -0
  122. package/lib/env.ts +195 -0
  123. package/lib/first-turn-receipts.ts +62 -0
  124. package/lib/fs-aliases.ts +49 -0
  125. package/lib/git-clone-credentials.ts +334 -0
  126. package/lib/git-diff.ts +530 -0
  127. package/lib/git-identity.ts +715 -0
  128. package/lib/github-git-auth.ts +207 -0
  129. package/lib/github-origin.ts +28 -0
  130. package/lib/github-tokens.ts +2439 -0
  131. package/lib/host-cloud-url.ts +62 -0
  132. package/lib/host-config.ts +279 -0
  133. package/lib/host-env.ts +424 -0
  134. package/lib/host-logs.ts +965 -0
  135. package/lib/internal-connections.ts +5 -0
  136. package/lib/keyed-promise-tail.ts +23 -0
  137. package/lib/launch-mode.ts +25 -0
  138. package/lib/lease.ts +239 -0
  139. package/lib/legacy-host.ts +769 -0
  140. package/lib/legacy-runtime-retirement.ts +424 -0
  141. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  142. package/lib/machine-exec.ts +238 -0
  143. package/lib/machine-gateway-tunnel.ts +344 -0
  144. package/lib/machine-keys.ts +65 -0
  145. package/lib/machine-project-env-loader.ts +152 -0
  146. package/lib/machine-provider-aws.ts +923 -0
  147. package/lib/machine-provider-local.ts +338 -0
  148. package/lib/machine-provider.ts +90 -0
  149. package/lib/managed-activation-watcher.ts +72 -0
  150. package/lib/managed-install-owner-watcher.ts +55 -0
  151. package/lib/managed-operation-drain.ts +57 -0
  152. package/lib/managed-runtime.ts +4296 -0
  153. package/lib/managed-update-scheduler.ts +125 -0
  154. package/lib/mcp-capture.ts +865 -0
  155. package/lib/mcp-config-lock.ts +2 -0
  156. package/lib/mcp-connections.ts +959 -0
  157. package/lib/mcp-gateway.ts +2594 -0
  158. package/lib/obs.ts +512 -0
  159. package/lib/openrouter-budget.ts +247 -0
  160. package/lib/orchestrator.ts +11409 -0
  161. package/lib/person-capture.ts +327 -0
  162. package/lib/preview-ports.ts +85 -0
  163. package/lib/preview-sidecar.ts +331 -0
  164. package/lib/project-env-authority.ts +1319 -0
  165. package/lib/project-env-feature.ts +74 -0
  166. package/lib/project-env-legacy-tags.ts +130 -0
  167. package/lib/project-env-loader.ts +242 -0
  168. package/lib/project-env-replica.ts +2094 -0
  169. package/lib/project-env-schema-capture.ts +342 -0
  170. package/lib/project-env-staging.ts +148 -0
  171. package/lib/project-env-task-policy.ts +176 -0
  172. package/lib/project-env-task-replica.ts +1752 -0
  173. package/lib/release-feed.ts +61 -0
  174. package/lib/release-manifest.ts +710 -0
  175. package/lib/release-trust.ts +46 -0
  176. package/lib/repo-clone.ts +38 -0
  177. package/lib/routing/shadow.ts +261 -0
  178. package/lib/runner-boot.ts +10 -0
  179. package/lib/runtime-activation-tail.ts +279 -0
  180. package/lib/runtime-archive.ts +1216 -0
  181. package/lib/runtime-authority.ts +166 -0
  182. package/lib/runtime-guard.ts +36 -0
  183. package/lib/runtime-provider-state.ts +169 -0
  184. package/lib/runtime-reconnect.ts +86 -0
  185. package/lib/runtime-state.ts +1033 -0
  186. package/lib/safe-http.ts +364 -0
  187. package/lib/secrets-proxy/address.ts +95 -0
  188. package/lib/secrets-proxy/audit.ts +30 -0
  189. package/lib/secrets-proxy/ca-store.ts +200 -0
  190. package/lib/secrets-proxy/ca.ts +470 -0
  191. package/lib/secrets-proxy/container-steps.ts +138 -0
  192. package/lib/secrets-proxy/der.ts +273 -0
  193. package/lib/secrets-proxy/engine.ts +1633 -0
  194. package/lib/secrets-proxy/host.ts +93 -0
  195. package/lib/secrets-proxy/hostname.ts +57 -0
  196. package/lib/secrets-proxy/placeholders.ts +196 -0
  197. package/lib/secrets-proxy/policy.ts +150 -0
  198. package/lib/secrets-proxy/readiness.ts +148 -0
  199. package/lib/secrets-proxy/runtime.ts +582 -0
  200. package/lib/secrets-proxy/scrub.ts +559 -0
  201. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  202. package/lib/secrets-proxy/task-scripts.ts +262 -0
  203. package/lib/secrets-proxy/task-trust.ts +605 -0
  204. package/lib/secrets-proxy/trust-watch.ts +80 -0
  205. package/lib/secrets.ts +71 -0
  206. package/lib/shared-files-s3.ts +976 -0
  207. package/lib/shared-files.ts +156 -0
  208. package/lib/sidecar-assign.ts +49 -0
  209. package/lib/sidecar-guard.ts +58 -0
  210. package/lib/sidecar-resume.ts +178 -0
  211. package/lib/sidecar.ts +69 -0
  212. package/lib/signin-box-credentials.ts +465 -0
  213. package/lib/signin-box.ts +114 -0
  214. package/lib/skill-index-example.fixture.ts +56 -0
  215. package/lib/skill-layout.ts +417 -0
  216. package/lib/skills.ts +489 -0
  217. package/lib/ssh.ts +401 -0
  218. package/lib/standard-image-cleanup.ts +117 -0
  219. package/lib/standard-image-pin.ts +207 -0
  220. package/lib/standard-image.ts +1697 -0
  221. package/lib/stopped-task-status-queue.ts +120 -0
  222. package/lib/task-container-cli.ts +269 -0
  223. package/lib/task-credential-cleanup.ts +752 -0
  224. package/lib/task-diff-sandbox.ts +464 -0
  225. package/lib/task-diff.ts +91 -0
  226. package/lib/task-env-forwarder.ts +228 -0
  227. package/lib/task-env-source.ts +249 -0
  228. package/lib/task-environment/anchor.ts +31 -0
  229. package/lib/task-environment/apple-container.ts +860 -0
  230. package/lib/task-environment/docker.ts +1344 -0
  231. package/lib/task-environment/index.ts +466 -0
  232. package/lib/task-environment/legacy-adoption.ts +467 -0
  233. package/lib/task-environment/machine-environment.ts +61 -0
  234. package/lib/task-environment/machine-task-up.ts +1227 -0
  235. package/lib/task-environment/machine.ts +974 -0
  236. package/lib/task-environment/registry.ts +59 -0
  237. package/lib/task-environment/self-identity.ts +20 -0
  238. package/lib/task-environment/self.ts +566 -0
  239. package/lib/task-environment/types.ts +642 -0
  240. package/lib/task-environment/workspace-files.ts +685 -0
  241. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  242. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  243. package/lib/task-files-machine-retirement.ts +116 -0
  244. package/lib/task-files-readiness.ts +127 -0
  245. package/lib/task-gateway-port.ts +22 -0
  246. package/lib/task-gateway-protocol.ts +190 -0
  247. package/lib/task-gateway-recovery.ts +47 -0
  248. package/lib/task-gateway-state.ts +512 -0
  249. package/lib/task-identity.ts +19 -0
  250. package/lib/task-inventory.ts +682 -0
  251. package/lib/task-machine-credentials-activation.ts +95 -0
  252. package/lib/task-machine-swap-safety.ts +729 -0
  253. package/lib/task-person-credentials.ts +76 -0
  254. package/lib/task-project-ops-host.ts +147 -0
  255. package/lib/task-project-ops.ts +1062 -0
  256. package/lib/task-secret-staging.ts +16 -0
  257. package/lib/task-status.ts +46 -0
  258. package/lib/task-token.ts +46 -0
  259. package/lib/token-broker-adopt.ts +344 -0
  260. package/lib/token-broker-lambda.ts +105 -0
  261. package/lib/token-broker-local.ts +392 -0
  262. package/lib/token-broker-revision.ts +310 -0
  263. package/lib/token-broker-ssm.ts +525 -0
  264. package/lib/token-broker.ts +218 -0
  265. package/lib/token-lanes/openai-oauth.ts +123 -0
  266. package/lib/token-lanes/registry.ts +45 -0
  267. package/lib/token-lanes/types.ts +163 -0
  268. package/lib/token-lanes/xai-oauth.ts +165 -0
  269. package/lib/transcript.ts +128 -0
  270. package/lib/tunnel-registry.ts +220 -0
  271. package/lib/tunnel-runtime.ts +235 -0
  272. package/lib/ulid.ts +16 -0
  273. package/lib/user-bus-env.ts +116 -0
  274. package/lib/user-credentials-store.ts +435 -0
  275. package/lib/vnode-pressure.ts +142 -0
  276. package/package.json +115 -8
  277. package/runner/runner.mjs +442 -0
  278. package/scripts/agent/_common.sh +633 -0
  279. package/scripts/agent/env-hook.sh +626 -0
  280. package/scripts/agent/moondeck-cli.sh +5 -0
  281. package/scripts/agent/task-down.sh +291 -0
  282. package/scripts/agent/task-status.sh +70 -0
  283. package/scripts/agent/task-up.sh +3761 -0
  284. package/scripts/agent/uai-env-exec.sh +26 -0
  285. package/scripts/install/darwin.ts +1048 -0
  286. package/scripts/install/linux.ts +958 -0
  287. package/scripts/install/types.ts +78 -0
  288. package/scripts/install/util.ts +254 -0
  289. package/scripts/install/win.ts +143 -0
  290. package/src/activation-respawn.ts +155 -0
  291. package/src/apple-tunnel-route.ts +104 -0
  292. package/src/bridge-diagnostics.ts +39 -0
  293. package/src/capture-wire.ts +102 -0
  294. package/src/cli.ts +2585 -0
  295. package/src/cloud-credentials.ts +2144 -0
  296. package/src/cloud-origins.ts +23 -0
  297. package/src/credential-location.ts +248 -0
  298. package/src/credential-wire-validation.ts +25 -0
  299. package/src/custom-connections.ts +237 -0
  300. package/src/engine-account-paths.ts +11 -0
  301. package/src/engine-models.ts +39 -0
  302. package/src/engine-usage.ts +104 -0
  303. package/src/enrollment-next-steps.ts +24 -0
  304. package/src/event-outbox.ts +185 -0
  305. package/src/index.ts +2907 -0
  306. package/src/load-env.ts +97 -0
  307. package/src/machine-tunnel-route.ts +156 -0
  308. package/src/main.ts +6363 -0
  309. package/src/never-proxied-domains.ts +31 -0
  310. package/src/outbound-frame.ts +31 -0
  311. package/src/paths.ts +81 -0
  312. package/src/project-env-latest.ts +130 -0
  313. package/src/project-env-policy.ts +396 -0
  314. package/src/project-env-wording.ts +53 -0
  315. package/src/protocol.ts +5550 -0
  316. package/src/runtime-bootstrap.ts +165 -0
  317. package/src/station-identity.ts +96 -0
  318. package/src/task-env-names.ts +28 -0
  319. package/src/task-secrets.ts +75 -0
  320. package/src/tunnel-dial.ts +102 -0
  321. package/src/ui/server.ts +852 -0
  322. package/src/ui/types.ts +173 -0
  323. package/src/workspace-path-policy.ts +41 -0
  324. package/ui/app.js +1102 -0
  325. package/ui/index.html +90 -0
  326. package/ui/style.css +685 -0
  327. package/ui/uai-favicon.svg +21 -0
  328. package/ui/uai-logo-black.svg +9 -0
  329. package/ui/uai-wheel.svg +9 -0
  330. package/bin/moondeck-station.mjs +0 -3
@@ -0,0 +1,1227 @@
1
+ /**
2
+ * ADR-121: the machine-backed task-up adapter.
3
+ *
4
+ * Bridges the host's task contract (TaskLaunchInput → TaskUpResult) onto the
5
+ * pure machine environment provider (MachineTaskInput → descriptor). This is
6
+ * where task semantics live for machines: project clones happen IN the
7
+ * machine's world with a one-shot credential over stdin, the task branch is
8
+ * created off the clone's default HEAD, and repo problems degrade into an
9
+ * initWarning system note instead of failing the task (a machine with an
10
+ * empty workspace and a clear message beats a respawn loop).
11
+ *
12
+ * Compose-era result fields are satisfied honestly rather than faked:
13
+ * `composeProject` carries the machine id (the one durable runtime name this
14
+ * task owns) and `worktreePath` carries the host-side task control dir (the
15
+ * directory that really does hold this task's host-side state — its machine
16
+ * key). Consumers that need the workspace go through the environment handle.
17
+ */
18
+ import { execFile } from "node:child_process";
19
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
20
+ import { dirname, resolve } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import { promisify } from "node:util";
23
+
24
+ import {
25
+ AgentError,
26
+ assertLeasedTaskUp,
27
+ type TaskDownResult,
28
+ type TaskLaunchInput,
29
+ type TaskUpCredentials,
30
+ type TaskUpResult,
31
+ } from "../agent";
32
+ import { injectCodexIntoMachine } from "../codex-auth";
33
+ import {
34
+ ensureMachineGatewayTunnelReady,
35
+ MACHINE_GATEWAY_TUNNEL_PORT,
36
+ stopMachineGatewayTunnel,
37
+ } from "../machine-gateway-tunnel";
38
+ import {
39
+ installMachineProjectEnvLoader,
40
+ installMachineTaskForwarderAsset,
41
+ } from "../machine-project-env-loader";
42
+ import {
43
+ MACHINE_RUNTIME_AUTHORITY_ENV,
44
+ PROJECT_ENV_HOOK_PATH,
45
+ } from "../runtime-authority";
46
+ import { sharedRoot } from "../shared-files";
47
+ import { launchTaskEnvForwarder } from "../task-env-forwarder";
48
+ import { MAX_ENV_SCHEMA_BYTES } from "../env-schema";
49
+ import {
50
+ captureTaskEnvSchemaFromClone,
51
+ captureTaskEnvSchemas,
52
+ taskEnvSchemaObservation,
53
+ type RemoteGit,
54
+ } from "../project-env-schema-capture";
55
+ import { activeSecretsProxy } from "../secrets-proxy/host";
56
+ import { reconcileSidecarTaskProxy } from "../secrets-proxy/task-trust";
57
+ import { TASK_FORWARDER_SECRET_BYTES } from "../task-gateway-protocol";
58
+ import {
59
+ MACHINE_TASK_ENVIRONMENT_PROVIDER,
60
+ parseMachineTaskEnvironmentLocator,
61
+ type MachineTaskInput,
62
+ } from "./machine";
63
+ import type {
64
+ TaskEnvironmentCallOptions,
65
+ ProjectEnvMode,
66
+ TaskEnvironmentDescriptor,
67
+ TaskEnvironmentHandle,
68
+ TaskEnvironmentProcess,
69
+ TaskEnvironmentProvider,
70
+ TaskEnvironmentProvisionRequest,
71
+ TaskEnvironmentProvisioned,
72
+ } from "./types";
73
+ import { TASK_SSH_KEY_PATH } from "../credential-consumers/github";
74
+
75
+ /** In-machine clone budget per project. */
76
+ const CLONE_TIMEOUT_MS = 10 * 60_000;
77
+ /** The machine port code-server listens on (dialed directly by the editor
78
+ * tunnel via inspectRoute; never published). */
79
+ export const MACHINE_CODE_SERVER_PORT = 8080;
80
+ const GIT_STEP_TIMEOUT_MS = 60_000;
81
+ const STEP_OUTPUT_BYTES = 256 * 1024;
82
+
83
+ export interface MachineHostTaskUpDeps {
84
+ /** The pure machine environment provider (createMachineTaskEnvironmentProvider). */
85
+ environment: TaskEnvironmentProvider<
86
+ MachineTaskInput,
87
+ void,
88
+ TaskEnvironmentDescriptor
89
+ >;
90
+ /** Host-side control dir for the task (holds the machine key). */
91
+ taskControlDir(taskId: string): string;
92
+ machineImage(): string;
93
+ machineCpus(): number;
94
+ machineMemoryMiB(): number;
95
+ /** macOS-local machines need loopback-published ssh; cloud machines don't. */
96
+ publishSsh(): boolean;
97
+ /** The task owner's GitHub access token, or null when GitHub is not
98
+ * connected (public/anonymous clone fallback). */
99
+ githubToken(userId: string): Promise<{ accessToken: string } | null>;
100
+ /** ADR-133: how this environment gets its shared files. Default = the
101
+ * point-in-time copy from the host's shared directory (machines over
102
+ * ssh); the sidecar mounts them from S3 instead. */
103
+ sharedFiles?: (
104
+ handle: TaskEnvironmentHandle<unknown>,
105
+ workspacePath: string,
106
+ task: { ownerOrgId?: string; ownerUserId: string; sharedFiles?: string },
107
+ ) => Promise<string | null>;
108
+ /** Test seam for the host-side persistent reverse-tunnel supervisor. */
109
+ ensureGatewayTunnel?: typeof ensureMachineGatewayTunnelReady;
110
+ /** How long a start that is not one waits for the stages it left running
111
+ * (tests). */
112
+ abandonJoinMs?: number;
113
+ }
114
+
115
+ async function startMachineTaskForwarder(
116
+ taskId: string,
117
+ handle: TaskEnvironmentHandle<unknown>,
118
+ credentials: TaskUpCredentials,
119
+ ensureGatewayTunnel: typeof ensureMachineGatewayTunnelReady,
120
+ ): Promise<void> {
121
+ const gatewayTunnelSshArgs = handle.gatewayTunnelSshArgs?.bind(handle);
122
+ if (!gatewayTunnelSshArgs) {
123
+ // The sidecar gateway is in this root process and assigns loopback
124
+ // traffic to its one fixed task. Binding another listener would collide.
125
+ return;
126
+ }
127
+ const identity = credentials.taskGateway;
128
+ if (
129
+ !identity ||
130
+ !/^[A-Za-z0-9_-]{16,64}$/.test(identity.generation) ||
131
+ identity.secret.byteLength !== TASK_FORWARDER_SECRET_BYTES
132
+ ) {
133
+ throw new Error("machine task env gateway identity is unavailable");
134
+ }
135
+ await installMachineTaskForwarderAsset(handle);
136
+
137
+ await launchTaskEnvForwarder(
138
+ handle,
139
+ { taskId, generation: identity.generation },
140
+ Uint8Array.from(identity.secret),
141
+ {
142
+ gatewayHost: "127.0.0.1",
143
+ gatewayPort: MACHINE_GATEWAY_TUNNEL_PORT,
144
+ nodePath: "/usr/bin/node",
145
+ },
146
+ );
147
+
148
+ // Start this during task-up rather than waiting for the first agent. The
149
+ // editor can open a terminal before any roster process exists, and its
150
+ // first command must already be able to refresh through the forwarder.
151
+ await ensureGatewayTunnel(
152
+ taskId,
153
+ { gatewayTunnelSshArgs },
154
+ MACHINE_GATEWAY_TUNNEL_PORT,
155
+ );
156
+ }
157
+
158
+ /** Mirrors src/index.ts isGithubRepo — host-side predicate only. */
159
+ export function isGithubRepoUrl(input: string): boolean {
160
+ const value = input.trim();
161
+ if (/^(?:[^@/]+@)?(?:www\.)?github\.com:/i.test(value)) return true;
162
+ if (/^[\w.-]+\/[\w.-]+(?:\.git)?\/?$/i.test(value)) return true;
163
+ try {
164
+ const url = new URL(value);
165
+ return /^(?:www\.)?github\.com$/i.test(url.hostname);
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ /** Canonical HTTPS form for GitHub repos (shorthand and ssh forms included);
172
+ * non-GitHub URLs pass through untouched. */
173
+ export function machineCloneUrl(repoUrl: string): string {
174
+ const value = repoUrl.trim();
175
+ const ssh = /^(?:[^@/]+@)?(?:www\.)?github\.com:(.+)$/i.exec(value);
176
+ if (ssh?.[1]) {
177
+ return `https://github.com/${ssh[1].replace(/\.git$/i, "")}.git`;
178
+ }
179
+ if (/^[\w.-]+\/[\w.-]+(?:\.git)?\/?$/i.test(value)) {
180
+ return `https://github.com/${value.replace(/\/+$/, "").replace(/\.git$/i, "")}.git`;
181
+ }
182
+ return value;
183
+ }
184
+
185
+ /** Workspace-safe project directory name. The slug is cloud-normalized, but
186
+ * the machine path re-checks: a path-capable slug must never reach argv. */
187
+ function projectDirectoryName(slug: string): string {
188
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(slug) || slug.includes("..")) {
189
+ throw new Error(`unsafe project slug for machine clone: ${slug}`);
190
+ }
191
+ // Files v1 publishes the task's leased S3 roots at this fixed workspace
192
+ // entry. Refuse a project with the same name before clone can follow the
193
+ // published link and write repository data into the Files bucket.
194
+ if (slug === "files") {
195
+ throw new Error("the project slug files is reserved for shared files");
196
+ }
197
+ return slug;
198
+ }
199
+
200
+ interface StepResult {
201
+ exitCode: number | null;
202
+ stderr: string;
203
+ }
204
+
205
+ /** One-shot exec with stdin: spawn, deliver the secret, drain, await exit.
206
+ * The secret rides the ssh stream only — never argv, never the persisted
207
+ * env, never machine disk. */
208
+ async function execWithStdin(
209
+ handle: TaskEnvironmentHandle<unknown>,
210
+ argv: readonly [string, ...string[]],
211
+ stdin: string,
212
+ timeoutMs: number,
213
+ /** Aborted: the process is ended at once (ADR-154 §12, a withdrawn start). */
214
+ signal?: AbortSignal,
215
+ user?: string,
216
+ ): Promise<StepResult> {
217
+ if (signal?.aborted) throw new Error(WITHDRAWN);
218
+ const proc = await handle.spawn({
219
+ argv,
220
+ ...(user === undefined ? {} : { user }),
221
+ projectEnvProfile: "maintenance",
222
+ inheritEnv: [],
223
+ env: { GIT_TERMINAL_PROMPT: "0" },
224
+ timeoutMs,
225
+ maxOutputBytes: STEP_OUTPUT_BYTES,
226
+ });
227
+ const onAbort = (): void => void proc.terminate("SIGKILL").catch(() => {});
228
+ if (signal?.aborted) onAbort();
229
+ else signal?.addEventListener("abort", onAbort, { once: true });
230
+ try {
231
+ return await drainStep(proc, stdin);
232
+ } finally {
233
+ signal?.removeEventListener("abort", onAbort);
234
+ }
235
+ }
236
+
237
+ async function drainStep(proc: TaskEnvironmentProcess, stdin: string): Promise<StepResult> {
238
+ let stderr = "";
239
+ const drainOut = (async () => {
240
+ for await (const chunk of proc.stdout) {
241
+ void chunk; // drained; clone progress is not retained
242
+ }
243
+ })();
244
+ const drainErr = (async () => {
245
+ for await (const chunk of proc.stderr) {
246
+ stderr = (stderr + Buffer.from(chunk).toString("utf8")).slice(-8192);
247
+ }
248
+ })();
249
+ await proc.write(Buffer.from(stdin));
250
+ await proc.closeInput();
251
+ const exit = await proc.completion;
252
+ await Promise.allSettled([drainOut, drainErr]);
253
+ return { exitCode: exit.exitCode, stderr: stderr.trim() };
254
+ }
255
+
256
+ async function execStep(
257
+ handle: TaskEnvironmentHandle<unknown>,
258
+ argv: readonly [string, ...string[]],
259
+ timeoutMs: number,
260
+ ): Promise<StepResult> {
261
+ const result = await handle.exec({
262
+ argv,
263
+ projectEnvProfile: "maintenance",
264
+ env: { GIT_TERMINAL_PROMPT: "0" },
265
+ timeoutMs,
266
+ maxOutputBytes: STEP_OUTPUT_BYTES,
267
+ });
268
+ return {
269
+ exitCode: result.exitCode,
270
+ stderr: Buffer.from(result.stderr).toString("utf8").trim().slice(-8192),
271
+ };
272
+ }
273
+
274
+ /** The remote clone script for the credentialed path. The token is read from
275
+ * stdin into the clone process env; /usr/local/bin/uai-git-askpass (baked in
276
+ * the machine image) answers git's prompts from it. */
277
+ const CREDENTIALED_CLONE_SCRIPT =
278
+ 'IFS= read -r UAI_GIT_TOKEN && export UAI_GIT_TOKEN GIT_ASKPASS=/usr/local/bin/uai-git-askpass GIT_TERMINAL_PROMPT=0 && exec git clone -- "$1" "$2"';
279
+
280
+ const WITHDRAWN = "the task's credentials were withdrawn while it was starting; start the task again";
281
+
282
+ /** The task's user on a machine, and in sidecar mode. */
283
+ const MACHINE_TASK_USER = "node";
284
+
285
+ /**
286
+ * ADR-154 §12: a task on cloud credentials clones by exactly the route its
287
+ * lease names, with nothing of the machine's own and nothing the task could
288
+ * have put in its own login:
289
+ * - the clone starts as root, whose login is not the task's: a profile the
290
+ * task wrote could otherwise read the token off stdin before anything
291
+ * else, or put its own `env`, `git`, `GIT_EXEC_PATH` or `LD_PRELOAD` in
292
+ * the clone's way;
293
+ * - in an environment rebuilt from nothing (`env -i`): a fixed PATH and no
294
+ * home (so no .netrc, ~/.gitconfig or ~/.ssh/config), no global or system
295
+ * git configuration;
296
+ * - then as the task's user (`setpriv`, when root), in a shell that reads no
297
+ * profile. It alone reads the token from stdin, and runs git and ssh by
298
+ * absolute path, with no credential helper and no agent.
299
+ */
300
+ const LEASED_CLONE_ENV = [
301
+ "PATH=/usr/bin:/bin",
302
+ "HOME=/nonexistent",
303
+ "XDG_CONFIG_HOME=/nonexistent",
304
+ "LC_ALL=C",
305
+ "GIT_CONFIG_NOSYSTEM=1",
306
+ "GIT_CONFIG_GLOBAL=/dev/null",
307
+ "GIT_TERMINAL_PROMPT=0",
308
+ ] as const;
309
+ /** Root drops to the task's user; anyone else (a non-root dev sidecar, whose
310
+ * user is the task's) stays. The route's script arrives as $1, never on
311
+ * stdin, which only it reads. */
312
+ const LEASED_CLONE_DROP =
313
+ 'if [ "$(/usr/bin/id -u)" = 0 ]; then ' +
314
+ `exec /usr/bin/setpriv --reuid=${MACHINE_TASK_USER} --regid=${MACHINE_TASK_USER} --init-groups -- /bin/sh -c "$1" clone "$2" "$3"; ` +
315
+ 'fi; exec /bin/sh -c "$1" clone "$2" "$3"';
316
+ const LEASED_GIT = "exec /usr/bin/git -c credential.helper= -c core.hooksPath=/dev/null";
317
+ const LEASED_TOKEN_CLONE_SCRIPT =
318
+ 'IFS= read -r UAI_GIT_TOKEN && export UAI_GIT_TOKEN GIT_ASKPASS=/usr/local/bin/uai-git-askpass && ' +
319
+ `${LEASED_GIT} clone -- "$1" "$2"`;
320
+ const LEASED_SSH_CLONE_SCRIPT =
321
+ `export GIT_SSH_COMMAND="/usr/bin/ssh -F /dev/null -i ${TASK_SSH_KEY_PATH} -o IdentitiesOnly=yes -o IdentityAgent=none -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/home/node/.ssh/known_hosts" && ` +
322
+ `${LEASED_GIT} -c url.git@github.com:.insteadOf=https://github.com/ clone -- "$1" "$2"`;
323
+ const LEASED_ANONYMOUS_CLONE_SCRIPT = `${LEASED_GIT} clone -- "$1" "$2"`;
324
+
325
+ /** The leased clone's command, run as root (see above). */
326
+ export function leasedCloneArgv(script: string, url: string, destination: string): [string, ...string[]] {
327
+ return ["/usr/bin/env", "-i", ...LEASED_CLONE_ENV, "/bin/sh", "-c", LEASED_CLONE_DROP, "drop", script, url, destination];
328
+ }
329
+
330
+ /** How a task on cloud credentials clones: its lease's token, its SSH key,
331
+ * or neither (it names neither). */
332
+ interface LeasedCloneRoute {
333
+ ssh: boolean;
334
+ signal: AbortSignal;
335
+ }
336
+
337
+ const DENIED =
338
+ /repository not found|permission denied|access rights|could not read username|authentication failed|invalid username or password|returned error: 40[134]|HTTP 40[134]/i;
339
+
340
+ /**
341
+ * A fresh clone by a leased route that failed: the start fails and the
342
+ * machine goes (unlike the legacy route's warning, ADR-121), saying which of
343
+ * the owner's credentials GitHub refused and what to do about it.
344
+ */
345
+ function leasedCloneFailure(repoUrl: string, route: "token" | "ssh" | "none", stderr: string): AgentError {
346
+ const step = "clone";
347
+ if (isGithubRepoUrl(repoUrl) && DENIED.test(stderr)) {
348
+ if (route === "token") {
349
+ return new AgentError(
350
+ "GITHUB_TOKEN_ACCESS_DENIED",
351
+ `GitHub denied the task owner's GitHub sign-in access to ${repoUrl}. Confirm that the Moondeck GitHub App is installed on the owning account or organization, this repository is included in its grant, and the owner can read it. Then start the task again.`,
352
+ { step },
353
+ );
354
+ }
355
+ if (route === "ssh") {
356
+ return new AgentError(
357
+ "GITHUB_SSH_ACCESS_DENIED",
358
+ `GitHub denied the task owner's SSH key access to ${repoUrl}. Register that key with an account that can read the repository (and authorize it for SAML SSO when required), or connect GitHub in Settings. Then start the task again.`,
359
+ { step },
360
+ );
361
+ }
362
+ return new AgentError(
363
+ "GITHUB_CONNECTION_REQUIRED",
364
+ `The task owner has neither a GitHub sign-in nor an SSH key in Moondeck that can read ${repoUrl}. Connect GitHub in Settings, then start the task again.`,
365
+ { step },
366
+ );
367
+ }
368
+ return new AgentError("CLONE_FAILED", `could not clone ${repoUrl}: ${stderr || "clone failed"}`, { step });
369
+ }
370
+
371
+ type ProjectEnvSchemaObservation = NonNullable<TaskUpResult["envSchemas"]>[number];
372
+
373
+ /** What a project's clone step did: cloned it now, found the task's own
374
+ * clone already there (a resume), or could not clone it; and what to say. */
375
+ interface CloneOutcome {
376
+ clone: "fresh" | "existing" | "failed";
377
+ warning: string | null;
378
+ }
379
+
380
+ async function cloneProject(
381
+ handle: TaskEnvironmentHandle<unknown>,
382
+ destination: string,
383
+ project: { slug: string; repoUrl: string },
384
+ taskBranch: string,
385
+ owner: { name?: string; email?: string },
386
+ token: string | null,
387
+ /** A task on cloud credentials: its exact route, and its withdrawal. */
388
+ leased?: LeasedCloneRoute,
389
+ ): Promise<CloneOutcome> {
390
+ const url = machineCloneUrl(project.repoUrl);
391
+ const useToken = token !== null && isGithubRepoUrl(project.repoUrl);
392
+
393
+ // Resume/retry idempotency: a repository that already exists is the task's
394
+ // live work — never re-clone over it, and leave its branch state alone.
395
+ const already = await execStep(
396
+ handle,
397
+ ["/bin/sh", "-c", 'test -e "$1/.git"', "check", destination],
398
+ GIT_STEP_TIMEOUT_MS,
399
+ );
400
+ if (already.exitCode === 0) return { clone: "existing", warning: null };
401
+ // Withdrawn while the probe ran: the clone never starts.
402
+ if (leased?.signal.aborted) throw new Error(WITHDRAWN);
403
+
404
+ const route = useToken ? "token" : leased?.ssh && isGithubRepoUrl(project.repoUrl) ? "ssh" : "none";
405
+ const clone = leased
406
+ ? await execWithStdin(
407
+ handle,
408
+ leasedCloneArgv(
409
+ route === "token" ? LEASED_TOKEN_CLONE_SCRIPT : route === "ssh" ? LEASED_SSH_CLONE_SCRIPT : LEASED_ANONYMOUS_CLONE_SCRIPT,
410
+ url,
411
+ destination,
412
+ ),
413
+ useToken ? `${token}\n` : "",
414
+ CLONE_TIMEOUT_MS,
415
+ leased.signal,
416
+ "root",
417
+ )
418
+ : useToken
419
+ ? await execWithStdin(
420
+ handle,
421
+ ["/bin/sh", "-c", CREDENTIALED_CLONE_SCRIPT, "clone", url, destination],
422
+ `${token}\n`,
423
+ CLONE_TIMEOUT_MS,
424
+ )
425
+ : await execStep(
426
+ handle,
427
+ ["git", "clone", "--", url, destination],
428
+ CLONE_TIMEOUT_MS,
429
+ );
430
+ if (leased?.signal.aborted) throw new Error(WITHDRAWN);
431
+ if (clone.exitCode !== 0 && leased) throw leasedCloneFailure(project.repoUrl, route, clone.stderr);
432
+ if (clone.exitCode !== 0) {
433
+ // The commonest failure by far is an ANONYMOUS clone of a private GitHub
434
+ // repo because this host has no GitHub credential for the task owner —
435
+ // git's own message ("could not read Username … terminal prompts
436
+ // disabled") explains none of that. Say the actionable thing.
437
+ if (token === null && isGithubRepoUrl(project.repoUrl)) {
438
+ return {
439
+ clone: "failed",
440
+ warning:
441
+ `could not clone ${project.repoUrl} for ${project.slug}: GitHub is ` +
442
+ "not connected on this host. Connect GitHub on the host's page " +
443
+ "(Connections → GitHub), then Resume.",
444
+ };
445
+ }
446
+ return { clone: "failed", warning: `could not clone ${project.repoUrl} for ${project.slug}: ${clone.stderr || "clone failed"}` };
447
+ }
448
+
449
+ // Task branch off the clone's default HEAD (matches the compose path's
450
+ // `-b <branch> origin/<default>`). -B keeps an empty-repo clone (unborn
451
+ // HEAD) and a re-run both working; failure degrades, never fails the task.
452
+ const branch = await execStep(
453
+ handle,
454
+ ["git", "-C", destination, "checkout", "-B", taskBranch],
455
+ GIT_STEP_TIMEOUT_MS,
456
+ );
457
+ if (branch.exitCode !== 0) {
458
+ return { clone: "fresh", warning: `cloned ${project.slug} but could not create branch ${taskBranch}: ${branch.stderr}` };
459
+ }
460
+
461
+ // Repo-local commit identity so agent commits work out of the box.
462
+ if (owner.name) {
463
+ await execStep(
464
+ handle,
465
+ ["git", "-C", destination, "config", "user.name", owner.name],
466
+ GIT_STEP_TIMEOUT_MS,
467
+ );
468
+ }
469
+ if (owner.email) {
470
+ await execStep(
471
+ handle,
472
+ ["git", "-C", destination, "config", "user.email", owner.email],
473
+ GIT_STEP_TIMEOUT_MS,
474
+ );
475
+ }
476
+ return { clone: "fresh", warning: null };
477
+ }
478
+
479
+ /** Git, read-only, in a fresh clone on the machine, as the task's user (the
480
+ * way the clone ran) but with nothing of that user's environment: no
481
+ * profile variable, PATH entry or global config can point it elsewhere. */
482
+ function gitInClone(handle: TaskEnvironmentHandle<unknown>, destination: string): RemoteGit {
483
+ return async (args) => {
484
+ for (let attempt = 1; ; attempt += 1) {
485
+ const result = await handle.exec({
486
+ argv: [
487
+ "/usr/bin/env",
488
+ "-i",
489
+ "PATH=/usr/bin:/bin",
490
+ "HOME=/nonexistent",
491
+ "LC_ALL=C",
492
+ "GIT_CONFIG_NOSYSTEM=1",
493
+ "GIT_CONFIG_GLOBAL=/dev/null",
494
+ "GIT_TERMINAL_PROMPT=0",
495
+ "/usr/bin/git",
496
+ "--no-replace-objects",
497
+ "-C",
498
+ destination,
499
+ "-c",
500
+ "core.hooksPath=/dev/null",
501
+ "-c",
502
+ "core.fsmonitor=false",
503
+ ...args,
504
+ ],
505
+ projectEnvProfile: "maintenance",
506
+ timeoutMs: GIT_STEP_TIMEOUT_MS,
507
+ maxOutputBytes: MAX_ENV_SCHEMA_BYTES + 64 * 1024,
508
+ });
509
+ // A process that did not run to its end says nothing about the clone.
510
+ if (result.exitCode !== null) {
511
+ return result.exitCode === 0 && !result.stdoutTruncated ? Buffer.from(result.stdout) : null;
512
+ }
513
+ if (attempt >= 3) return null;
514
+ }
515
+ };
516
+ }
517
+
518
+ /**
519
+ * ADR-153 §4, "Machine and sidecar": each project's schema observation,
520
+ * stored where the task cannot write before anything in it starts, and the
521
+ * same one reported by every task-up of the task (the cloud keeps the first).
522
+ * Only a clone made now, on a machine where nothing of the task has run, is
523
+ * read. A resumed clone is the task's live work, and a clone that failed is
524
+ * retried on a resume after the task's processes have run, so neither is
525
+ * ever read: with nothing stored, the base is missing, for the task's life.
526
+ * A failure here withholds that project's unbound keys, never fails the
527
+ * start.
528
+ */
529
+ async function captureMachineEnvSchemas(
530
+ taskId: string,
531
+ handle: TaskEnvironmentHandle<unknown>,
532
+ clones: ReadonlyArray<{ projectId: string; destination: string; clone: CloneOutcome["clone"] }>,
533
+ ): Promise<ProjectEnvSchemaObservation[]> {
534
+ const observations: ProjectEnvSchemaObservation[] = [];
535
+ for (const { projectId, destination, clone } of clones) {
536
+ try {
537
+ const stored = taskEnvSchemaObservation(taskId, projectId);
538
+ observations.push(
539
+ stored ??
540
+ (clone === "fresh"
541
+ ? await captureTaskEnvSchemaFromClone(taskId, projectId, gitInClone(handle, destination))
542
+ : // No base is ever recorded on a machine: this stores it missing.
543
+ captureTaskEnvSchemas(taskId, [projectId])[0]!),
544
+ );
545
+ } catch (error) {
546
+ console.warn(
547
+ `[machine] task ${taskId}: .env.schema capture failed for ${projectId}: ${
548
+ error instanceof Error ? error.message : String(error)
549
+ }`,
550
+ );
551
+ observations.push(captureTaskEnvSchemas(taskId, [projectId])[0]!);
552
+ }
553
+ }
554
+ return observations;
555
+ }
556
+
557
+ /** The curated code-server defaults (slim chrome, telemetry off, trust
558
+ * off) — the SAME seed the standard image bakes; resolved from this package
559
+ * so machine and container editors can never diverge. Machines adjust two
560
+ * keys host-side: the task's ADR-091 theme, and bash for the terminal (the
561
+ * machine payload carries no zsh). */
562
+ function editorSettingsSeed(editorTheme: string | undefined): string {
563
+ const seedPath = resolve(
564
+ dirname(fileURLToPath(import.meta.url)),
565
+ "..",
566
+ "..",
567
+ "images",
568
+ "standard",
569
+ "container",
570
+ "code-server-settings.json",
571
+ );
572
+ const seed = JSON.parse(readFileSync(seedPath, "utf8")) as Record<
573
+ string,
574
+ unknown
575
+ >;
576
+ if (editorTheme) seed["workbench.colorTheme"] = editorTheme;
577
+ seed["terminal.integrated.defaultProfile.linux"] = "bash";
578
+ return `${JSON.stringify(seed, null, 2)}\n`;
579
+ }
580
+
581
+ /** Seed the editor settings exactly once (an existing settings.json is the
582
+ * user's own state and is never touched — same contract as uai-init). */
583
+ async function seedEditorSettings(
584
+ handle: TaskEnvironmentHandle<unknown>,
585
+ editorTheme: string | undefined,
586
+ ): Promise<void> {
587
+ const destination =
588
+ "/home/node/.local/share/code-server/User/settings.json";
589
+ await handle.exec({
590
+ argv: [
591
+ "/bin/sh",
592
+ "-c",
593
+ '[ -e "$1" ] && exit 0; mkdir -p "$(dirname "$1")" && cat > "$1"',
594
+ "seed",
595
+ destination,
596
+ ],
597
+ projectEnvProfile: "maintenance",
598
+ stdin: Buffer.from(editorSettingsSeed(editorTheme)),
599
+ timeoutMs: GIT_STEP_TIMEOUT_MS,
600
+ maxOutputBytes: STEP_OUTPUT_BYTES,
601
+ });
602
+ }
603
+
604
+ const execFileAsync = promisify(execFile);
605
+ /** Shared-files snapshots are reference docs, not repos; a tree past this
606
+ * is skipped with a warning rather than ballooning host memory. */
607
+ const SHARED_FILES_TAR_MAX_BYTES = 256 * 1024 * 1024;
608
+
609
+ /**
610
+ * ADR-062 shared files on machines: containers get live bind mounts at
611
+ * /workspace/files/{org,me}; a machine shares no filesystem, so it gets a
612
+ * POINT-IN-TIME COPY at provision (tar streamed over the transport). The
613
+ * managed-hosting answer for live semantics is EFS (ADR-121); until then a
614
+ * snapshot with an honest note beats silently absent files.
615
+ * Returns a warning string when rw mode was requested (writes cannot sync
616
+ * back) or a scope was skipped; null when clean.
617
+ */
618
+ async function copySharedFiles(
619
+ handle: TaskEnvironmentHandle<unknown>,
620
+ workspacePath: string,
621
+ task: { ownerOrgId?: string; ownerUserId: string; sharedFiles?: string },
622
+ ): Promise<string | null> {
623
+ const mode = task.sharedFiles ?? "ro";
624
+ if (mode === "off") return null;
625
+ const scopes: Array<{ host: string; destination: string }> = [];
626
+ if (task.ownerOrgId) {
627
+ scopes.push({
628
+ host: sharedRoot("org", task.ownerOrgId, task.ownerUserId),
629
+ destination: `${workspacePath}/files/org`,
630
+ });
631
+ }
632
+ scopes.push({
633
+ host: sharedRoot("me", task.ownerOrgId ?? "", task.ownerUserId),
634
+ destination: `${workspacePath}/files/me`,
635
+ });
636
+ const warnings: string[] = [];
637
+ let copiedAny = false;
638
+ for (const scope of scopes) {
639
+ if (!existsSync(scope.host) || readdirSync(scope.host).length === 0) {
640
+ continue;
641
+ }
642
+ try {
643
+ const tarball = await execFileAsync(
644
+ "tar",
645
+ ["-C", scope.host, "-cf", "-", "."],
646
+ { encoding: "buffer", maxBuffer: SHARED_FILES_TAR_MAX_BYTES },
647
+ );
648
+ const result = await handle.exec({
649
+ argv: [
650
+ "/bin/sh",
651
+ "-c",
652
+ 'mkdir -p "$1" && tar -C "$1" -xf -',
653
+ "shared",
654
+ scope.destination,
655
+ ],
656
+ projectEnvProfile: "maintenance",
657
+ stdin: tarball.stdout,
658
+ timeoutMs: 5 * 60_000,
659
+ maxOutputBytes: STEP_OUTPUT_BYTES,
660
+ });
661
+ if (result.exitCode !== 0) {
662
+ throw new Error(
663
+ `extract exited ${result.exitCode}: ${Buffer.from(result.stderr).toString("utf8").trim()}`,
664
+ );
665
+ }
666
+ copiedAny = true;
667
+ } catch (error) {
668
+ warnings.push(
669
+ `shared files: could not copy ${scope.destination}: ${
670
+ error instanceof Error ? error.message : String(error)
671
+ }`,
672
+ );
673
+ }
674
+ }
675
+ if (copiedAny || warnings.length === 0) {
676
+ // Marker gates the "## Shared files" preamble briefing, mirroring the
677
+ // container path's task-up marker.
678
+ await handle
679
+ .writeWorkspaceFile(`${workspacePath}/.moondeck/files-mounted`, Buffer.from(""))
680
+ .catch(() => {});
681
+ }
682
+ if (mode === "rw") {
683
+ warnings.push(
684
+ "shared files on this machine task are a point-in-time copy — " +
685
+ "changes under /workspace/files will NOT sync back to the host yet",
686
+ );
687
+ }
688
+ return warnings.length > 0 ? warnings.join("\n") : null;
689
+ }
690
+
691
+ /** Launch code-server for the Editor pane, exactly once. The pgrep guard
692
+ * makes re-provision and resume idempotent; the exec chain leaves only
693
+ * code-server holding the detached job. */
694
+ export async function ensureCodeServer(
695
+ handle: TaskEnvironmentHandle<unknown>,
696
+ workspacePath: string,
697
+ editorTheme: string | undefined,
698
+ envMode: ProjectEnvMode = handle.descriptor.envMode ?? "baked",
699
+ ): Promise<void> {
700
+ // Settings before process: code-server reads them at startup. A failed
701
+ // seed still launches — a themed editor is worth less than one that starts.
702
+ await seedEditorSettings(handle, editorTheme).catch((error: unknown) => {
703
+ console.warn(
704
+ `[machine] editor settings seed failed: ${
705
+ error instanceof Error ? error.message : String(error)
706
+ }`,
707
+ );
708
+ });
709
+ // The guard and the launch are SEPARATE execs on purpose: any guard that
710
+ // shares a cmdline with the launch text matches itself — the bracketed
711
+ // pattern alone did not save the first combined script, because the plain
712
+ // launch line later in the same argv still contained code-server +
713
+ // --bind-addr (live 2026-08-27, second silent skip).
714
+ const running = await handle.exec({
715
+ argv: [
716
+ "/bin/sh",
717
+ "-c",
718
+ 'pgrep -f "[c]ode-server.*--bind-addr" >/dev/null 2>&1',
719
+ ],
720
+ projectEnvProfile: "maintenance",
721
+ timeoutMs: GIT_STEP_TIMEOUT_MS,
722
+ maxOutputBytes: STEP_OUTPUT_BYTES,
723
+ });
724
+ if (running.exitCode === 0) return;
725
+ // Editor processes are task processes, but code-server must not inherit a
726
+ // real project value that a fixed-task sidecar's outer login shell loaded.
727
+ // Start it from an explicit empty environment and give its descendants only
728
+ // the host-owned runtime baseline plus the loader controls editor terminals
729
+ // need. The maintenance profile also prevents the provider-added outer
730
+ // shell from fetching project env before `env -i` gets control.
731
+ const editorEnvironment = {
732
+ ...MACHINE_RUNTIME_AUTHORITY_ENV,
733
+ ...(envMode === "loader"
734
+ ? { BASH_ENV: PROJECT_ENV_HOOK_PATH, UAI_ENV_HOOK: "1" }
735
+ : {}),
736
+ LOGNAME: "node",
737
+ PLAYWRIGHT_BROWSERS_PATH: "/opt/pw-browsers",
738
+ SHELL: "/bin/bash",
739
+ USER: "node",
740
+ } as const;
741
+ const result = await handle.launchDetachedSession({
742
+ argv: [
743
+ "/usr/bin/env",
744
+ "-i",
745
+ ...Object.entries(editorEnvironment)
746
+ .sort(([left], [right]) => left.localeCompare(right))
747
+ .map(([name, value]) => `${name}=${value}`),
748
+ "/home/node/.local/bin/code-server",
749
+ "--user-data-dir",
750
+ "/home/node/.local/share/code-server",
751
+ "--disable-workspace-trust",
752
+ "--auth",
753
+ "none",
754
+ "--disable-telemetry",
755
+ "--disable-update-check",
756
+ "--bind-addr",
757
+ `0.0.0.0:${MACHINE_CODE_SERVER_PORT}`,
758
+ workspacePath,
759
+ ],
760
+ inheritEnv: [],
761
+ env: {},
762
+ projectEnvProfile: "maintenance",
763
+ launchTimeoutMs: 30_000,
764
+ maxOutputBytes: 64 * 1024,
765
+ });
766
+ if (result.exitCode !== 0) {
767
+ throw new Error(
768
+ `code-server launch exited ${result.exitCode}: ${Buffer.from(result.stderr).toString("utf8").trim()}`,
769
+ );
770
+ }
771
+ }
772
+
773
+ /** How long a start that is not one waits for the stages it left running. */
774
+ const ABANDON_JOIN_MS = 15_000;
775
+
776
+ /** Until every one of `work` is settled, or `ms` has passed. */
777
+ async function joinBounded(work: readonly Promise<unknown>[], ms: number): Promise<void> {
778
+ let timer: ReturnType<typeof setTimeout> | undefined;
779
+ await Promise.race([
780
+ Promise.allSettled(work),
781
+ new Promise<void>((resolve) => {
782
+ timer = setTimeout(resolve, ms);
783
+ timer.unref?.();
784
+ }),
785
+ ]);
786
+ if (timer) clearTimeout(timer);
787
+ }
788
+
789
+ /**
790
+ * A start's own view of its machine: every call through it, and every write
791
+ * to a process started through it, is refused once `over()` holds. Work the
792
+ * start left behind therefore never reaches the machine again, nor a
793
+ * replacement that reuses its name.
794
+ */
795
+ function fencedHandle(handle: TaskEnvironmentHandle<unknown>, over: () => boolean): TaskEnvironmentHandle<unknown> {
796
+ const admit = (): void => {
797
+ if (over()) throw new Error(WITHDRAWN);
798
+ };
799
+ // Checked again by the provider at its last moment, after anything it
800
+ // awaits first (a machine looks up its address): a call admitted before
801
+ // the start ended never starts after it.
802
+ const lastly = (inner?: () => void) => (): void => {
803
+ admit();
804
+ inner?.();
805
+ };
806
+ const fenced = (proc: TaskEnvironmentProcess): TaskEnvironmentProcess => ({
807
+ stdout: proc.stdout,
808
+ stderr: proc.stderr,
809
+ completion: proc.completion,
810
+ write: async (chunk) => {
811
+ admit();
812
+ await proc.write(chunk);
813
+ },
814
+ closeInput: () => proc.closeInput(),
815
+ terminate: (signal) => proc.terminate(signal),
816
+ });
817
+ return {
818
+ descriptor: handle.descriptor,
819
+ durableIdentity: handle.durableIdentity,
820
+ ...(handle.anchors === undefined ? {} : { anchors: handle.anchors }),
821
+ ...(handle.credentialDelivery === undefined ? {} : { credentialDelivery: handle.credentialDelivery }),
822
+ readWorkspaceFile: async (path, options) => {
823
+ admit();
824
+ return handle.readWorkspaceFile(path, { ...options, admit: lastly(options?.admit) });
825
+ },
826
+ writeWorkspaceFile: async (path, bytes, options) => {
827
+ admit();
828
+ await handle.writeWorkspaceFile(path, bytes, { ...options, admit: lastly(options?.admit) });
829
+ },
830
+ exec: async (request) => {
831
+ admit();
832
+ return handle.exec({ ...request, admit: lastly(request.admit) });
833
+ },
834
+ spawn: async (request) => {
835
+ admit();
836
+ return fenced(await handle.spawn({ ...request, admit: lastly(request.admit) }));
837
+ },
838
+ spawnSession: async (request) => {
839
+ admit();
840
+ return fenced(await handle.spawnSession({ ...request, admit: lastly(request.admit) }));
841
+ },
842
+ launchDetachedSession: async (request) => {
843
+ admit();
844
+ return handle.launchDetachedSession({ ...request, admit: lastly(request.admit) });
845
+ },
846
+ copy: async (request) => {
847
+ admit();
848
+ await handle.copy({ ...request, admit: lastly(request.admit) });
849
+ },
850
+ ports: async () => {
851
+ admit();
852
+ return handle.ports();
853
+ },
854
+ status: async () => {
855
+ admit();
856
+ return handle.status();
857
+ },
858
+ recover: async (context) => {
859
+ admit();
860
+ return handle.recover(context);
861
+ },
862
+ stop: async () => {
863
+ admit();
864
+ await handle.stop();
865
+ },
866
+ teardown: async () => {
867
+ admit();
868
+ await handle.teardown();
869
+ },
870
+ ...(handle.inspectRoute
871
+ ? {
872
+ inspectRoute: async () => {
873
+ admit();
874
+ return handle.inspectRoute!();
875
+ },
876
+ }
877
+ : {}),
878
+ ...(handle.gatewayTunnelSshArgs
879
+ ? {
880
+ gatewayTunnelSshArgs: async (machinePort: number, hostPort: number, options?: TaskEnvironmentCallOptions) => {
881
+ admit();
882
+ return handle.gatewayTunnelSshArgs!(machinePort, hostPort, { ...options, admit: lastly(options?.admit) });
883
+ },
884
+ }
885
+ : {}),
886
+ };
887
+ }
888
+
889
+ /** The identity the host records for the task's runtime: the machine id for
890
+ * a machine over ssh (what recovery and orphan sweeps key on), the handle's
891
+ * durable identity for anything else (sidecar mode: `self:<task>`). */
892
+ function environmentRuntimeIdentity(handle: TaskEnvironmentHandle<unknown>): string {
893
+ return handle.descriptor.locator.provider === MACHINE_TASK_ENVIRONMENT_PROVIDER
894
+ ? parseMachineTaskEnvironmentLocator(handle.descriptor.locator).machineId
895
+ : handle.durableIdentity;
896
+ }
897
+
898
+ /** Wrap the void-teardown machine handle so registry consumers get the
899
+ * TaskDownResult contract the compose providers speak. */
900
+ function withTaskDownResult(
901
+ handle: TaskEnvironmentHandle<unknown>,
902
+ ): TaskEnvironmentHandle<TaskDownResult> {
903
+ // Explicit delegation, never Object.create: the machine handle is a class
904
+ // with private fields, and a prototype-borrowed method invoked on a foreign
905
+ // receiver throws on its first #member access.
906
+ return {
907
+ descriptor: handle.descriptor,
908
+ durableIdentity: handle.durableIdentity,
909
+ ...(handle.anchors === undefined ? {} : { anchors: handle.anchors }),
910
+ ...(handle.credentialDelivery === undefined ? {} : { credentialDelivery: handle.credentialDelivery }),
911
+ readWorkspaceFile: (path, options) => handle.readWorkspaceFile(path, options),
912
+ writeWorkspaceFile: (path, bytes, options) => handle.writeWorkspaceFile(path, bytes, options),
913
+ exec: (request) => handle.exec(request),
914
+ spawn: (request) => handle.spawn(request),
915
+ spawnSession: (request) => handle.spawnSession(request),
916
+ launchDetachedSession: (request) => handle.launchDetachedSession(request),
917
+ copy: (request) => handle.copy(request),
918
+ ports: () => handle.ports(),
919
+ status: () => handle.status(),
920
+ recover: (context) => handle.recover(context),
921
+ stop: () => handle.stop(),
922
+ teardown: async (): Promise<TaskDownResult> => {
923
+ await handle.teardown();
924
+ return { status: "terminated" };
925
+ },
926
+ ...(handle.inspectRoute
927
+ ? { inspectRoute: () => handle.inspectRoute!() }
928
+ : {}),
929
+ ...(handle.gatewayTunnelSshArgs
930
+ ? {
931
+ gatewayTunnelSshArgs: (machinePort: number, hostPort: number, options?: TaskEnvironmentCallOptions) =>
932
+ handle.gatewayTunnelSshArgs!(machinePort, hostPort, options),
933
+ }
934
+ : {}),
935
+ };
936
+ }
937
+
938
+ export function createMachineHostTaskEnvironmentProvider(
939
+ deps: MachineHostTaskUpDeps,
940
+ ): TaskEnvironmentProvider<
941
+ TaskLaunchInput,
942
+ TaskUpCredentials,
943
+ TaskUpResult,
944
+ TaskDownResult
945
+ > {
946
+ return {
947
+ // The provider kind is whichever environment sits underneath: a machine
948
+ // over ssh, or this machine in sidecar mode (ADR-133).
949
+ kind: deps.environment.kind,
950
+
951
+ async provision(
952
+ request: TaskEnvironmentProvisionRequest<TaskLaunchInput, TaskUpCredentials>,
953
+ ): Promise<TaskEnvironmentProvisioned<TaskUpResult, TaskDownResult>> {
954
+ const { input } = request;
955
+ const envMode = request.envMode ?? "loader";
956
+ // Validate every task-controlled workspace directory before the
957
+ // provider is allowed to create a machine or a leased runtime-ready
958
+ // writer can publish anything into it. Validating lazily in the clone
959
+ // loop is too late: an invalid later project would leave all of those
960
+ // earlier effects behind.
961
+ const projects = [...input.projects]
962
+ .sort((a, b) => a.position - b.position)
963
+ .map((project) => ({
964
+ project,
965
+ directoryName: projectDirectoryName(project.slug),
966
+ }));
967
+ // ADR-154 §12: a task on cloud credentials holds its grant for the
968
+ // whole start; withdrawn (its gate closed, a cleanup reached it), the
969
+ // start stops wherever it is, its clone included, and the machine goes.
970
+ const leased = request.credentials.leased;
971
+ assertLeasedTaskUp(leased, input.task);
972
+ const clone = leased?.clone;
973
+ const withdrawal = (): Error => new Error(WITHDRAWN);
974
+ if (clone?.signal.aborted) throw withdrawal();
975
+ const untilWithdrawn = <T>(work: Promise<T>): Promise<T> => {
976
+ if (!clone) return work;
977
+ return new Promise<T>((resolve, reject) => {
978
+ const onAbort = (): void => reject(withdrawal());
979
+ if (clone.signal.aborted) {
980
+ onAbort();
981
+ return;
982
+ }
983
+ clone.signal.addEventListener("abort", onAbort, { once: true });
984
+ work.then(
985
+ (value) => {
986
+ clone.signal.removeEventListener("abort", onAbort);
987
+ resolve(value);
988
+ },
989
+ (error: unknown) => {
990
+ clone.signal.removeEventListener("abort", onAbort);
991
+ reject(error);
992
+ },
993
+ );
994
+ });
995
+ };
996
+ const provisioning = deps.environment.provision({
997
+ taskId: request.taskId,
998
+ input: {
999
+ image: deps.machineImage(),
1000
+ cpus: deps.machineCpus(),
1001
+ memoryMiB: deps.machineMemoryMiB(),
1002
+ ...(deps.publishSsh() ? { publishSsh: true } : {}),
1003
+ },
1004
+ credentials: undefined,
1005
+ envMode,
1006
+ onPrepared: request.onPrepared,
1007
+ });
1008
+ let provisioned: Awaited<typeof provisioning>;
1009
+ try {
1010
+ provisioned = await untilWithdrawn(provisioning);
1011
+ } catch (error) {
1012
+ if (clone?.signal.aborted) {
1013
+ // Withdrawn while the machine came up: the start returns only once
1014
+ // that is over and the machine is gone again (the provider's own
1015
+ // timeouts bound the wait). A teardown left for later could take a
1016
+ // replacement, which reuses the task's machine name.
1017
+ const late = await provisioning.catch(() => null);
1018
+ // A reused machine holds the task's work: stopped, never destroyed.
1019
+ if (late) await (late.reused ? late.handle.stop() : late.handle.teardown()).catch(() => {});
1020
+ }
1021
+ throw error;
1022
+ }
1023
+ const handle = provisioned.handle;
1024
+ const workspacePath = handle.descriptor.workspacePath;
1025
+ const warnings: string[] = [];
1026
+ const clones: Array<{ projectId: string; destination: string; clone: CloneOutcome["clone"] }> = [];
1027
+ // The start's own view of the machine: once the start is withdrawn or
1028
+ // failed, every call through it is refused, so work it began and left
1029
+ // behind never reaches the machine again, nor the replacement that
1030
+ // reuses its name: no exec, write, secret or teardown.
1031
+ let over = false;
1032
+ const own = fencedHandle(handle, () => over || clone?.signal.aborted === true);
1033
+ // Every later stage races the withdrawal, through the start's last
1034
+ // step: withdrawn, the start stops wherever it is. What it began is
1035
+ // fenced off, the machine goes, and the stages still running get a
1036
+ // bounded chance to end before the start returns.
1037
+ const begun: Array<Promise<unknown>> = [];
1038
+ const stage = <T>(work: Promise<T>): Promise<T> => {
1039
+ begun.push(work);
1040
+ work.catch(() => {});
1041
+ return untilWithdrawn(work);
1042
+ };
1043
+ let abandoning: Promise<void> | null = null;
1044
+ const abandon = (): Promise<void> => {
1045
+ over = true;
1046
+ abandoning ??= (async () => {
1047
+ // Only a machine this start launched is destroyed. One it reused (a
1048
+ // Resume of a stopped machine) holds the task's work on its disk, so
1049
+ // a failed start stops it again and the next Resume tries afresh.
1050
+ await (provisioned.reused ? handle.stop() : handle.teardown()).catch(() => {});
1051
+ await joinBounded(begun, deps.abandonJoinMs ?? ABANDON_JOIN_MS);
1052
+ })();
1053
+ return abandoning;
1054
+ };
1055
+
1056
+ try {
1057
+ if (clone?.signal.aborted) throw withdrawal();
1058
+ // ADR-154 §12: the runtime exists and nothing in it has run yet: the
1059
+ // runner's writes (the owner's GitHub configuration and SSH key,
1060
+ // recorded for cleanup) come first, so the clones below authenticate
1061
+ // with them, over SSH too.
1062
+ if (leased) {
1063
+ const writes = leased.runtimeReady(own, leased.clone.signal);
1064
+ try {
1065
+ await untilWithdrawn(writes);
1066
+ } catch (error) {
1067
+ // Joined before anything else happens: nothing still writes into
1068
+ // the machine once the start has stopped.
1069
+ over = true;
1070
+ await writes.catch(() => {});
1071
+ throw error;
1072
+ }
1073
+ }
1074
+ if (envMode === "loader") {
1075
+ await stage(installMachineProjectEnvLoader(own));
1076
+ // ADR-153, on a machine's own task (the sidecar's): the proxy's CA
1077
+ // and pins before anything in the task loads its env. Never fails
1078
+ // the start; its proxied keys stay withheld until they are in.
1079
+ const proxyWarning = await stage(
1080
+ reconcileSidecarTaskProxy(own, activeSecretsProxy()?.runtime ?? null, { start: true }),
1081
+ );
1082
+ if (proxyWarning !== null) console.warn(`[machine] task ${request.taskId}: ${proxyWarning}`);
1083
+ }
1084
+ const wantsGithub = projects.some(({ project }) =>
1085
+ isGithubRepoUrl(project.repoUrl),
1086
+ );
1087
+ // ADR-154 §12: a task on cloud credentials clones with the token its
1088
+ // lease holds for this start, never the runner's own store.
1089
+ const token = !wantsGithub
1090
+ ? null
1091
+ : clone
1092
+ ? clone.github && { accessToken: clone.github.token }
1093
+ : await deps.githubToken(input.task.ownerUserId);
1094
+ for (const { project, directoryName } of projects) {
1095
+ if (clone?.signal.aborted) throw withdrawal();
1096
+ const destination = `${workspacePath}/${directoryName}`;
1097
+ const outcome = await untilWithdrawn(
1098
+ cloneProject(
1099
+ own,
1100
+ destination,
1101
+ project,
1102
+ input.task.branch,
1103
+ { name: input.ownerName, email: input.ownerEmail },
1104
+ token?.accessToken ?? null,
1105
+ clone ? { ssh: clone.ssh !== null, signal: clone.signal } : undefined,
1106
+ ),
1107
+ );
1108
+ clones.push({ projectId: project.id, destination, clone: outcome.clone });
1109
+ if (outcome.warning) warnings.push(outcome.warning);
1110
+ }
1111
+ if (clone?.signal.aborted) throw withdrawal();
1112
+ } catch (error) {
1113
+ // Provisioning-infrastructure failures (ssh died, unsafe slug) tear
1114
+ // the machine down rather than stranding an orphan, and so does a
1115
+ // leased clone that failed; a legacy route's per-repo problems were
1116
+ // already converted to warnings above and do not land here.
1117
+ await abandon();
1118
+ throw error;
1119
+ }
1120
+ let envSchemas: ProjectEnvSchemaObservation[];
1121
+ try {
1122
+ // ADR-153 §4: right after the clones, before shared files, the
1123
+ // forwarder, the editor or any agent.
1124
+ envSchemas = await stage(captureMachineEnvSchemas(request.taskId, own, clones));
1125
+
1126
+ // ADR-062 shared files: point-in-time copy (see copySharedFiles).
1127
+ const sharedWarning = leased?.filesV1
1128
+ ? null
1129
+ : await stage(
1130
+ (deps.sharedFiles ?? copySharedFiles)(own, workspacePath, {
1131
+ ...(input.task.ownerOrgId ? { ownerOrgId: input.task.ownerOrgId } : {}),
1132
+ ownerUserId: input.task.ownerUserId,
1133
+ ...(input.task.sharedFiles ? { sharedFiles: input.task.sharedFiles } : {}),
1134
+ }).catch((error: unknown) => {
1135
+ console.warn(
1136
+ `[machine] task ${request.taskId}: shared files copy failed: ${
1137
+ error instanceof Error ? error.message : String(error)
1138
+ }`,
1139
+ );
1140
+ return null;
1141
+ }),
1142
+ );
1143
+ if (sharedWarning) warnings.push(sharedWarning);
1144
+
1145
+ // Codex default-slot credentials: the legacy delivery paths are all
1146
+ // docker grammar; machines get theirs over the transport. Best-effort
1147
+ // — a failed inject degrades codex agents, never the task. A task on
1148
+ // cloud credentials gets its accounts from its lease, never the
1149
+ // runner's own (ADR-154 §6).
1150
+ if (!clone && input.task.agents.some((agent) => agent.kind === "codex")) {
1151
+ await injectCodexIntoMachine(own).catch((error: unknown) => {
1152
+ console.warn(
1153
+ `[machine] task ${request.taskId}: codex inject failed: ${
1154
+ error instanceof Error ? error.message : String(error)
1155
+ }`,
1156
+ );
1157
+ });
1158
+ }
1159
+
1160
+ if (envMode === "loader") {
1161
+ try {
1162
+ await stage(
1163
+ startMachineTaskForwarder(
1164
+ request.taskId,
1165
+ own,
1166
+ request.credentials,
1167
+ deps.ensureGatewayTunnel ?? ensureMachineGatewayTunnelReady,
1168
+ ),
1169
+ );
1170
+ } catch (error) {
1171
+ // Unlike editor/auth conveniences, the loader is the only project
1172
+ // env delivery path. A task without it must not start and silently
1173
+ // hand consumers an incomplete or legacy environment.
1174
+ stopMachineGatewayTunnel(request.taskId);
1175
+ await abandon();
1176
+ throw error;
1177
+ }
1178
+ }
1179
+
1180
+ // Editor pane: code-server on the machine (same flags as uai-init's
1181
+ // container launch), idempotent across provision re-runs and resume.
1182
+ // Best-effort — a missing binary degrades the Editor tab, never the task.
1183
+ await stage(
1184
+ ensureCodeServer(own, workspacePath, input.task.previewEnv?.UAI_EDITOR_THEME, envMode).catch(
1185
+ (error: unknown) => {
1186
+ console.warn(
1187
+ `[machine] task ${request.taskId}: code-server launch failed: ${
1188
+ error instanceof Error ? error.message : String(error)
1189
+ }`,
1190
+ );
1191
+ },
1192
+ ),
1193
+ );
1194
+
1195
+ // Withdrawn while the rest came up: the start is not one.
1196
+ if (clone?.signal.aborted) throw withdrawal();
1197
+ } catch (error) {
1198
+ if (clone?.signal.aborted) {
1199
+ stopMachineGatewayTunnel(request.taskId);
1200
+ await abandon();
1201
+ throw withdrawal();
1202
+ }
1203
+ throw error;
1204
+ }
1205
+ return {
1206
+ handle: withTaskDownResult(handle),
1207
+ result: {
1208
+ // Recorded as the task's runtime identity (the compose-project
1209
+ // column): the machine id over ssh, `self:<task>` in sidecar mode.
1210
+ composeProject: environmentRuntimeIdentity(handle),
1211
+ worktreePath: deps.taskControlDir(request.taskId),
1212
+ codeServerPort: MACHINE_CODE_SERVER_PORT,
1213
+ ...(envSchemas.length > 0 ? { envSchemas } : {}),
1214
+ ...(warnings.length > 0
1215
+ ? { initWarning: warnings.join("\n") }
1216
+ : {}),
1217
+ },
1218
+ };
1219
+ },
1220
+
1221
+ async reconstruct(locator, envMode = "baked") {
1222
+ return withTaskDownResult(
1223
+ await deps.environment.reconstruct(locator, envMode),
1224
+ );
1225
+ },
1226
+ };
1227
+ }