@runuai/host 0.9.148 → 0.9.150

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 (93) hide show
  1. package/db/migrations/0024_task_env_mode.sql +9 -0
  2. package/db/migrations/0025_project_env_authority.sql +147 -0
  3. package/db/migrations/0026_project_env_replica.sql +150 -0
  4. package/db/migrations/meta/_journal.json +21 -0
  5. package/db/schema.ts +312 -2
  6. package/images/standard/Dockerfile +44 -6
  7. package/images/standard/container/uai-init +85 -2
  8. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  9. package/lib/agent-cli.ts +18 -1
  10. package/lib/agent.ts +99 -34
  11. package/lib/agents/codex.ts +9 -1
  12. package/lib/agents/cursor.ts +15 -1
  13. package/lib/agents/grok.ts +8 -0
  14. package/lib/agents/kimi.ts +1 -0
  15. package/lib/agents/machine-durable.ts +2 -0
  16. package/lib/agents/opencode.ts +1 -0
  17. package/lib/agents/proc.ts +6 -1
  18. package/lib/agents/transport.ts +9 -5
  19. package/lib/browser-testing.ts +6 -0
  20. package/lib/codex-auth.ts +2 -0
  21. package/lib/credential-store.ts +141 -6
  22. package/lib/engine-accounts.ts +1 -0
  23. package/lib/env-file.ts +7 -0
  24. package/lib/env-schema.ts +841 -51
  25. package/lib/git-diff.ts +1 -0
  26. package/lib/git-identity.ts +2 -0
  27. package/lib/github-tokens.ts +1 -0
  28. package/lib/host-env.ts +98 -18
  29. package/lib/lease.ts +11 -3
  30. package/lib/machine-gateway-tunnel.ts +195 -17
  31. package/lib/machine-project-env-loader.ts +152 -0
  32. package/lib/managed-runtime.ts +270 -51
  33. package/lib/mcp-gateway.ts +777 -26
  34. package/lib/orchestrator.ts +408 -81
  35. package/lib/project-env-authority.ts +1319 -0
  36. package/lib/project-env-feature.ts +74 -0
  37. package/lib/project-env-loader.ts +237 -0
  38. package/lib/project-env-replica.ts +2094 -0
  39. package/lib/project-env-schema-capture.ts +342 -0
  40. package/lib/project-env-staging.ts +148 -0
  41. package/lib/project-env-task-policy.ts +176 -0
  42. package/lib/runtime-archive.ts +79 -16
  43. package/lib/runtime-authority.ts +44 -0
  44. package/lib/runtime-state.ts +175 -10
  45. package/lib/secrets-proxy/address.ts +72 -0
  46. package/lib/secrets-proxy/audit.ts +30 -0
  47. package/lib/secrets-proxy/ca-store.ts +200 -0
  48. package/lib/secrets-proxy/ca.ts +470 -0
  49. package/lib/secrets-proxy/container-steps.ts +138 -0
  50. package/lib/secrets-proxy/der.ts +273 -0
  51. package/lib/secrets-proxy/engine.ts +1633 -0
  52. package/lib/secrets-proxy/host.ts +93 -0
  53. package/lib/secrets-proxy/hostname.ts +60 -0
  54. package/lib/secrets-proxy/placeholders.ts +196 -0
  55. package/lib/secrets-proxy/policy.ts +141 -0
  56. package/lib/secrets-proxy/readiness.ts +148 -0
  57. package/lib/secrets-proxy/runtime.ts +582 -0
  58. package/lib/secrets-proxy/scrub.ts +559 -0
  59. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  60. package/lib/secrets-proxy/task-scripts.ts +262 -0
  61. package/lib/secrets-proxy/task-trust.ts +574 -0
  62. package/lib/secrets-proxy/trust-watch.ts +80 -0
  63. package/lib/shared-files-s3.ts +3 -0
  64. package/lib/sidecar-guard.ts +58 -0
  65. package/lib/sidecar-resume.ts +51 -3
  66. package/lib/standard-image.ts +50 -12
  67. package/lib/task-env-forwarder.ts +228 -0
  68. package/lib/task-env-source.ts +193 -0
  69. package/lib/task-environment/apple-container.ts +45 -11
  70. package/lib/task-environment/docker.ts +51 -12
  71. package/lib/task-environment/index.ts +6 -2
  72. package/lib/task-environment/machine-task-up.ts +258 -25
  73. package/lib/task-environment/machine.ts +80 -22
  74. package/lib/task-environment/registry.ts +3 -2
  75. package/lib/task-environment/self-identity.ts +20 -0
  76. package/lib/task-environment/self.ts +92 -16
  77. package/lib/task-environment/types.ts +63 -0
  78. package/lib/task-gateway-port.ts +22 -0
  79. package/lib/task-gateway-protocol.ts +190 -0
  80. package/lib/task-gateway-recovery.ts +44 -0
  81. package/lib/task-gateway-state.ts +497 -0
  82. package/lib/transcript.ts +1 -0
  83. package/package.json +13 -1
  84. package/scripts/agent/env-hook.sh +626 -0
  85. package/scripts/agent/task-up.sh +476 -27
  86. package/scripts/agent/uai-env-exec.sh +26 -0
  87. package/src/cli.ts +26 -3
  88. package/src/index.ts +273 -19
  89. package/src/main.ts +939 -48
  90. package/src/project-env-latest.ts +130 -0
  91. package/src/project-env-policy.ts +281 -0
  92. package/src/project-env-wording.ts +47 -0
  93. package/src/protocol.ts +2219 -15
@@ -28,13 +28,38 @@ import type {
28
28
  TaskUpResult,
29
29
  } from "../agent";
30
30
  import { injectCodexIntoMachine } from "../codex-auth";
31
+ import {
32
+ ensureMachineGatewayTunnelReady,
33
+ MACHINE_GATEWAY_TUNNEL_PORT,
34
+ stopMachineGatewayTunnel,
35
+ } from "../machine-gateway-tunnel";
36
+ import {
37
+ installMachineProjectEnvLoader,
38
+ installMachineTaskForwarderAsset,
39
+ } from "../machine-project-env-loader";
40
+ import {
41
+ MACHINE_RUNTIME_AUTHORITY_ENV,
42
+ PROJECT_ENV_HOOK_PATH,
43
+ } from "../runtime-authority";
31
44
  import { sharedRoot } from "../shared-files";
45
+ import { launchTaskEnvForwarder } from "../task-env-forwarder";
46
+ import { MAX_ENV_SCHEMA_BYTES } from "../env-schema";
47
+ import {
48
+ captureTaskEnvSchemaFromClone,
49
+ captureTaskEnvSchemas,
50
+ taskEnvSchemaObservation,
51
+ type RemoteGit,
52
+ } from "../project-env-schema-capture";
53
+ import { activeSecretsProxy } from "../secrets-proxy/host";
54
+ import { reconcileSidecarTaskProxy } from "../secrets-proxy/task-trust";
55
+ import { TASK_FORWARDER_SECRET_BYTES } from "../task-gateway-protocol";
32
56
  import {
33
57
  MACHINE_TASK_ENVIRONMENT_PROVIDER,
34
58
  parseMachineTaskEnvironmentLocator,
35
59
  type MachineTaskInput,
36
60
  } from "./machine";
37
61
  import type {
62
+ ProjectEnvMode,
38
63
  TaskEnvironmentDescriptor,
39
64
  TaskEnvironmentHandle,
40
65
  TaskEnvironmentProvider,
@@ -75,6 +100,51 @@ export interface MachineHostTaskUpDeps {
75
100
  workspacePath: string,
76
101
  task: { ownerOrgId?: string; ownerUserId: string; sharedFiles?: string },
77
102
  ) => Promise<string | null>;
103
+ /** Test seam for the host-side persistent reverse-tunnel supervisor. */
104
+ ensureGatewayTunnel?: typeof ensureMachineGatewayTunnelReady;
105
+ }
106
+
107
+ async function startMachineTaskForwarder(
108
+ taskId: string,
109
+ handle: TaskEnvironmentHandle<unknown>,
110
+ credentials: TaskUpCredentials,
111
+ ensureGatewayTunnel: typeof ensureMachineGatewayTunnelReady,
112
+ ): Promise<void> {
113
+ const gatewayTunnelSshArgs = handle.gatewayTunnelSshArgs?.bind(handle);
114
+ if (!gatewayTunnelSshArgs) {
115
+ // The sidecar gateway is in this root process and assigns loopback
116
+ // traffic to its one fixed task. Binding another listener would collide.
117
+ return;
118
+ }
119
+ const identity = credentials.taskGateway;
120
+ if (
121
+ !identity ||
122
+ !/^[A-Za-z0-9_-]{16,64}$/.test(identity.generation) ||
123
+ identity.secret.byteLength !== TASK_FORWARDER_SECRET_BYTES
124
+ ) {
125
+ throw new Error("machine task env gateway identity is unavailable");
126
+ }
127
+ await installMachineTaskForwarderAsset(handle);
128
+
129
+ await launchTaskEnvForwarder(
130
+ handle,
131
+ { taskId, generation: identity.generation },
132
+ Uint8Array.from(identity.secret),
133
+ {
134
+ gatewayHost: "127.0.0.1",
135
+ gatewayPort: MACHINE_GATEWAY_TUNNEL_PORT,
136
+ nodePath: "/usr/bin/node",
137
+ },
138
+ );
139
+
140
+ // Start this during task-up rather than waiting for the first agent. The
141
+ // editor can open a terminal before any roster process exists, and its
142
+ // first command must already be able to refresh through the forwarder.
143
+ await ensureGatewayTunnel(
144
+ taskId,
145
+ { gatewayTunnelSshArgs },
146
+ MACHINE_GATEWAY_TUNNEL_PORT,
147
+ );
78
148
  }
79
149
 
80
150
  /** Mirrors src/index.ts isGithubRepo — host-side predicate only. */
@@ -129,6 +199,7 @@ async function execWithStdin(
129
199
  ): Promise<StepResult> {
130
200
  const proc = await handle.spawn({
131
201
  argv,
202
+ projectEnvProfile: "maintenance",
132
203
  inheritEnv: [],
133
204
  env: { GIT_TERMINAL_PROMPT: "0" },
134
205
  timeoutMs,
@@ -159,6 +230,7 @@ async function execStep(
159
230
  ): Promise<StepResult> {
160
231
  const result = await handle.exec({
161
232
  argv,
233
+ projectEnvProfile: "maintenance",
162
234
  env: { GIT_TERMINAL_PROMPT: "0" },
163
235
  timeoutMs,
164
236
  maxOutputBytes: STEP_OUTPUT_BYTES,
@@ -175,15 +247,23 @@ async function execStep(
175
247
  const CREDENTIALED_CLONE_SCRIPT =
176
248
  '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"';
177
249
 
250
+ type ProjectEnvSchemaObservation = NonNullable<TaskUpResult["envSchemas"]>[number];
251
+
252
+ /** What a project's clone step did: cloned it now, found the task's own
253
+ * clone already there (a resume), or could not clone it; and what to say. */
254
+ interface CloneOutcome {
255
+ clone: "fresh" | "existing" | "failed";
256
+ warning: string | null;
257
+ }
258
+
178
259
  async function cloneProject(
179
260
  handle: TaskEnvironmentHandle<unknown>,
180
- workspacePath: string,
261
+ destination: string,
181
262
  project: { slug: string; repoUrl: string },
182
263
  taskBranch: string,
183
264
  owner: { name?: string; email?: string },
184
265
  token: string | null,
185
- ): Promise<string | null> {
186
- const destination = `${workspacePath}/${projectDirectoryName(project.slug)}`;
266
+ ): Promise<CloneOutcome> {
187
267
  const url = machineCloneUrl(project.repoUrl);
188
268
  const useToken = token !== null && isGithubRepoUrl(project.repoUrl);
189
269
 
@@ -194,7 +274,7 @@ async function cloneProject(
194
274
  ["/bin/sh", "-c", 'test -e "$1/.git"', "check", destination],
195
275
  GIT_STEP_TIMEOUT_MS,
196
276
  );
197
- if (already.exitCode === 0) return null;
277
+ if (already.exitCode === 0) return { clone: "existing", warning: null };
198
278
 
199
279
  const clone = useToken
200
280
  ? await execWithStdin(
@@ -214,13 +294,15 @@ async function cloneProject(
214
294
  // git's own message ("could not read Username … terminal prompts
215
295
  // disabled") explains none of that. Say the actionable thing.
216
296
  if (token === null && isGithubRepoUrl(project.repoUrl)) {
217
- return (
218
- `could not clone ${project.repoUrl} for ${project.slug}: GitHub is ` +
219
- "not connected on this host. Connect GitHub on the host's page " +
220
- "(Connections → GitHub), then Resume."
221
- );
297
+ return {
298
+ clone: "failed",
299
+ warning:
300
+ `could not clone ${project.repoUrl} for ${project.slug}: GitHub is ` +
301
+ "not connected on this host. Connect GitHub on the host's page " +
302
+ "(Connections → GitHub), then Resume.",
303
+ };
222
304
  }
223
- return `could not clone ${project.repoUrl} for ${project.slug}: ${clone.stderr || "clone failed"}`;
305
+ return { clone: "failed", warning: `could not clone ${project.repoUrl} for ${project.slug}: ${clone.stderr || "clone failed"}` };
224
306
  }
225
307
 
226
308
  // Task branch off the clone's default HEAD (matches the compose path's
@@ -232,7 +314,7 @@ async function cloneProject(
232
314
  GIT_STEP_TIMEOUT_MS,
233
315
  );
234
316
  if (branch.exitCode !== 0) {
235
- return `cloned ${project.slug} but could not create branch ${taskBranch}: ${branch.stderr}`;
317
+ return { clone: "fresh", warning: `cloned ${project.slug} but could not create branch ${taskBranch}: ${branch.stderr}` };
236
318
  }
237
319
 
238
320
  // Repo-local commit identity so agent commits work out of the box.
@@ -250,7 +332,85 @@ async function cloneProject(
250
332
  GIT_STEP_TIMEOUT_MS,
251
333
  );
252
334
  }
253
- return null;
335
+ return { clone: "fresh", warning: null };
336
+ }
337
+
338
+ /** Git, read-only, in a fresh clone on the machine, as the task's user (the
339
+ * way the clone ran) but with nothing of that user's environment: no
340
+ * profile variable, PATH entry or global config can point it elsewhere. */
341
+ function gitInClone(handle: TaskEnvironmentHandle<unknown>, destination: string): RemoteGit {
342
+ return async (args) => {
343
+ for (let attempt = 1; ; attempt += 1) {
344
+ const result = await handle.exec({
345
+ argv: [
346
+ "/usr/bin/env",
347
+ "-i",
348
+ "PATH=/usr/bin:/bin",
349
+ "HOME=/nonexistent",
350
+ "LC_ALL=C",
351
+ "GIT_CONFIG_NOSYSTEM=1",
352
+ "GIT_CONFIG_GLOBAL=/dev/null",
353
+ "GIT_TERMINAL_PROMPT=0",
354
+ "/usr/bin/git",
355
+ "--no-replace-objects",
356
+ "-C",
357
+ destination,
358
+ "-c",
359
+ "core.hooksPath=/dev/null",
360
+ "-c",
361
+ "core.fsmonitor=false",
362
+ ...args,
363
+ ],
364
+ projectEnvProfile: "maintenance",
365
+ timeoutMs: GIT_STEP_TIMEOUT_MS,
366
+ maxOutputBytes: MAX_ENV_SCHEMA_BYTES + 64 * 1024,
367
+ });
368
+ // A process that did not run to its end says nothing about the clone.
369
+ if (result.exitCode !== null) {
370
+ return result.exitCode === 0 && !result.stdoutTruncated ? Buffer.from(result.stdout) : null;
371
+ }
372
+ if (attempt >= 3) return null;
373
+ }
374
+ };
375
+ }
376
+
377
+ /**
378
+ * ADR-153 §4, "Machine and sidecar": each project's schema observation,
379
+ * stored where the task cannot write before anything in it starts, and the
380
+ * same one reported by every task-up of the task (the cloud keeps the first).
381
+ * Only a clone made now, on a machine where nothing of the task has run, is
382
+ * read. A resumed clone is the task's live work, and a clone that failed is
383
+ * retried on a resume after the task's processes have run, so neither is
384
+ * ever read: with nothing stored, the base is missing, for the task's life.
385
+ * A failure here withholds that project's unbound keys, never fails the
386
+ * start.
387
+ */
388
+ async function captureMachineEnvSchemas(
389
+ taskId: string,
390
+ handle: TaskEnvironmentHandle<unknown>,
391
+ clones: ReadonlyArray<{ projectId: string; destination: string; clone: CloneOutcome["clone"] }>,
392
+ ): Promise<ProjectEnvSchemaObservation[]> {
393
+ const observations: ProjectEnvSchemaObservation[] = [];
394
+ for (const { projectId, destination, clone } of clones) {
395
+ try {
396
+ const stored = taskEnvSchemaObservation(taskId, projectId);
397
+ observations.push(
398
+ stored ??
399
+ (clone === "fresh"
400
+ ? await captureTaskEnvSchemaFromClone(taskId, projectId, gitInClone(handle, destination))
401
+ : // No base is ever recorded on a machine: this stores it missing.
402
+ captureTaskEnvSchemas(taskId, [projectId])[0]!),
403
+ );
404
+ } catch (error) {
405
+ console.warn(
406
+ `[machine] task ${taskId}: .env.schema capture failed for ${projectId}: ${
407
+ error instanceof Error ? error.message : String(error)
408
+ }`,
409
+ );
410
+ observations.push(captureTaskEnvSchemas(taskId, [projectId])[0]!);
411
+ }
412
+ }
413
+ return observations;
254
414
  }
255
415
 
256
416
  /** The curated code-server defaults (slim chrome, telemetry off, trust
@@ -293,6 +453,7 @@ async function seedEditorSettings(
293
453
  "seed",
294
454
  destination,
295
455
  ],
456
+ projectEnvProfile: "maintenance",
296
457
  stdin: Buffer.from(editorSettingsSeed(editorTheme)),
297
458
  timeoutMs: GIT_STEP_TIMEOUT_MS,
298
459
  maxOutputBytes: STEP_OUTPUT_BYTES,
@@ -351,6 +512,7 @@ async function copySharedFiles(
351
512
  "shared",
352
513
  scope.destination,
353
514
  ],
515
+ projectEnvProfile: "maintenance",
354
516
  stdin: tarball.stdout,
355
517
  timeoutMs: 5 * 60_000,
356
518
  maxOutputBytes: STEP_OUTPUT_BYTES,
@@ -392,6 +554,7 @@ export async function ensureCodeServer(
392
554
  handle: TaskEnvironmentHandle<unknown>,
393
555
  workspacePath: string,
394
556
  editorTheme: string | undefined,
557
+ envMode: ProjectEnvMode = handle.descriptor.envMode ?? "baked",
395
558
  ): Promise<void> {
396
559
  // Settings before process: code-server reads them at startup. A failed
397
560
  // seed still launches — a themed editor is worth less than one that starts.
@@ -413,21 +576,49 @@ export async function ensureCodeServer(
413
576
  "-c",
414
577
  'pgrep -f "[c]ode-server.*--bind-addr" >/dev/null 2>&1',
415
578
  ],
579
+ projectEnvProfile: "maintenance",
416
580
  timeoutMs: GIT_STEP_TIMEOUT_MS,
417
581
  maxOutputBytes: STEP_OUTPUT_BYTES,
418
582
  });
419
583
  if (running.exitCode === 0) return;
420
- const script =
421
- "mkdir -p /home/node/.local/share/code-server && " +
422
- "exec /home/node/.local/bin/code-server " +
423
- "--user-data-dir /home/node/.local/share/code-server " +
424
- "--disable-workspace-trust --auth none --disable-telemetry " +
425
- `--disable-update-check --bind-addr 0.0.0.0:${MACHINE_CODE_SERVER_PORT} ` +
426
- '"$1" >/tmp/code-server.log 2>&1';
584
+ // Editor processes are task processes, but code-server must not inherit a
585
+ // real project value that a fixed-task sidecar's outer login shell loaded.
586
+ // Start it from an explicit empty environment and give its descendants only
587
+ // the host-owned runtime baseline plus the loader controls editor terminals
588
+ // need. The maintenance profile also prevents the provider-added outer
589
+ // shell from fetching project env before `env -i` gets control.
590
+ const editorEnvironment = {
591
+ ...MACHINE_RUNTIME_AUTHORITY_ENV,
592
+ ...(envMode === "loader"
593
+ ? { BASH_ENV: PROJECT_ENV_HOOK_PATH, UAI_ENV_HOOK: "1" }
594
+ : {}),
595
+ LOGNAME: "node",
596
+ PLAYWRIGHT_BROWSERS_PATH: "/opt/pw-browsers",
597
+ SHELL: "/bin/bash",
598
+ USER: "node",
599
+ } as const;
427
600
  const result = await handle.launchDetachedSession({
428
- argv: ["/bin/sh", "-c", script, "code-server", workspacePath],
601
+ argv: [
602
+ "/usr/bin/env",
603
+ "-i",
604
+ ...Object.entries(editorEnvironment)
605
+ .sort(([left], [right]) => left.localeCompare(right))
606
+ .map(([name, value]) => `${name}=${value}`),
607
+ "/home/node/.local/bin/code-server",
608
+ "--user-data-dir",
609
+ "/home/node/.local/share/code-server",
610
+ "--disable-workspace-trust",
611
+ "--auth",
612
+ "none",
613
+ "--disable-telemetry",
614
+ "--disable-update-check",
615
+ "--bind-addr",
616
+ `0.0.0.0:${MACHINE_CODE_SERVER_PORT}`,
617
+ workspacePath,
618
+ ],
429
619
  inheritEnv: [],
430
620
  env: {},
621
+ projectEnvProfile: "maintenance",
431
622
  launchTimeoutMs: 30_000,
432
623
  maxOutputBytes: 64 * 1024,
433
624
  });
@@ -502,6 +693,7 @@ export function createMachineHostTaskEnvironmentProvider(
502
693
  request: TaskEnvironmentProvisionRequest<TaskLaunchInput, TaskUpCredentials>,
503
694
  ): Promise<TaskEnvironmentProvisioned<TaskUpResult, TaskDownResult>> {
504
695
  const { input } = request;
696
+ const envMode = request.envMode ?? "loader";
505
697
  const provisioned = await deps.environment.provision({
506
698
  taskId: request.taskId,
507
699
  input: {
@@ -511,13 +703,27 @@ export function createMachineHostTaskEnvironmentProvider(
511
703
  ...(deps.publishSsh() ? { publishSsh: true } : {}),
512
704
  },
513
705
  credentials: undefined,
706
+ envMode,
514
707
  onPrepared: request.onPrepared,
515
708
  });
516
709
  const handle = provisioned.handle;
517
710
  const workspacePath = handle.descriptor.workspacePath;
518
711
  const warnings: string[] = [];
712
+ const clones: Array<{ projectId: string; destination: string; clone: CloneOutcome["clone"] }> = [];
519
713
 
520
714
  try {
715
+ if (envMode === "loader") {
716
+ await installMachineProjectEnvLoader(handle);
717
+ // ADR-153, on a machine's own task (the sidecar's): the proxy's CA
718
+ // and pins before anything in the task loads its env. Never fails
719
+ // the start; its proxied keys stay withheld until they are in.
720
+ const proxyWarning = await reconcileSidecarTaskProxy(
721
+ handle,
722
+ activeSecretsProxy()?.runtime ?? null,
723
+ { start: true },
724
+ );
725
+ if (proxyWarning !== null) console.warn(`[machine] task ${request.taskId}: ${proxyWarning}`);
726
+ }
521
727
  const projects = [...input.projects].sort(
522
728
  (a, b) => a.position - b.position,
523
729
  );
@@ -528,15 +734,17 @@ export function createMachineHostTaskEnvironmentProvider(
528
734
  ? await deps.githubToken(input.task.ownerUserId)
529
735
  : null;
530
736
  for (const project of projects) {
531
- const warning = await cloneProject(
737
+ const destination = `${workspacePath}/${projectDirectoryName(project.slug)}`;
738
+ const outcome = await cloneProject(
532
739
  handle,
533
- workspacePath,
740
+ destination,
534
741
  project,
535
742
  input.task.branch,
536
743
  { name: input.ownerName, email: input.ownerEmail },
537
744
  token?.accessToken ?? null,
538
745
  );
539
- if (warning) warnings.push(warning);
746
+ clones.push({ projectId: project.id, destination, clone: outcome.clone });
747
+ if (outcome.warning) warnings.push(outcome.warning);
540
748
  }
541
749
  } catch (error) {
542
750
  // Provisioning-infrastructure failures (ssh died, unsafe slug) tear
@@ -545,6 +753,9 @@ export function createMachineHostTaskEnvironmentProvider(
545
753
  await handle.teardown().catch(() => {});
546
754
  throw error;
547
755
  }
756
+ // ADR-153 §4: right after the clones, before shared files, the
757
+ // forwarder, the editor or any agent.
758
+ const envSchemas = await captureMachineEnvSchemas(request.taskId, handle, clones);
548
759
 
549
760
  // ADR-062 shared files: point-in-time copy (see copySharedFiles).
550
761
  const sharedWarning = await (deps.sharedFiles ?? copySharedFiles)(handle, workspacePath, {
@@ -574,6 +785,24 @@ export function createMachineHostTaskEnvironmentProvider(
574
785
  });
575
786
  }
576
787
 
788
+ if (envMode === "loader") {
789
+ try {
790
+ await startMachineTaskForwarder(
791
+ request.taskId,
792
+ handle,
793
+ request.credentials,
794
+ deps.ensureGatewayTunnel ?? ensureMachineGatewayTunnelReady,
795
+ );
796
+ } catch (error) {
797
+ // Unlike editor/auth conveniences, the loader is the only project
798
+ // env delivery path. A task without it must not start and silently
799
+ // hand consumers an incomplete or legacy environment.
800
+ stopMachineGatewayTunnel(request.taskId);
801
+ await handle.teardown().catch(() => {});
802
+ throw error;
803
+ }
804
+ }
805
+
577
806
  // Editor pane: code-server on the machine (same flags as uai-init's
578
807
  // container launch), idempotent across provision re-runs and resume.
579
808
  // Best-effort — a missing binary degrades the Editor tab, never the task.
@@ -581,6 +810,7 @@ export function createMachineHostTaskEnvironmentProvider(
581
810
  handle,
582
811
  workspacePath,
583
812
  input.task.previewEnv?.UAI_EDITOR_THEME,
813
+ envMode,
584
814
  ).catch((error: unknown) => {
585
815
  console.warn(
586
816
  `[machine] task ${request.taskId}: code-server launch failed: ${
@@ -597,6 +827,7 @@ export function createMachineHostTaskEnvironmentProvider(
597
827
  composeProject: environmentRuntimeIdentity(handle),
598
828
  worktreePath: deps.taskControlDir(request.taskId),
599
829
  codeServerPort: MACHINE_CODE_SERVER_PORT,
830
+ ...(envSchemas.length > 0 ? { envSchemas } : {}),
600
831
  ...(warnings.length > 0
601
832
  ? { initWarning: warnings.join("\n") }
602
833
  : {}),
@@ -604,8 +835,10 @@ export function createMachineHostTaskEnvironmentProvider(
604
835
  };
605
836
  },
606
837
 
607
- async reconstruct(locator) {
608
- return withTaskDownResult(await deps.environment.reconstruct(locator));
838
+ async reconstruct(locator, envMode = "baked") {
839
+ return withTaskDownResult(
840
+ await deps.environment.reconstruct(locator, envMode),
841
+ );
609
842
  },
610
843
  };
611
844
  }