@runuai/host 0.9.152 → 0.9.153

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.
@@ -37,6 +37,10 @@ export interface SidecarResumeDeps {
37
37
  workspacePath: string,
38
38
  task: { sharedFiles?: string },
39
39
  ) => Promise<string | null>;
40
+ /** ADR-154 Files v1: install and mount the exact recovered run's session
41
+ * from trusted host state. Unlike the legacy marker path, failure is
42
+ * terminal for this recovery setup and is not converted to a warning. */
43
+ prepareFiles?: (handle: TaskEnvironmentHandle<unknown>) => Promise<void>;
40
44
  loader?: (handle: TaskEnvironmentHandle<unknown>) => Promise<void>;
41
45
  editor?: (handle: TaskEnvironmentHandle<unknown>, workspacePath: string) => Promise<void>;
42
46
  proxy?: typeof reconcileSidecarTaskProxy;
@@ -100,6 +104,11 @@ export async function resumeSidecarTask(
100
104
  const ended = (): boolean => deps.signal?.aborted === true;
101
105
  const interrupted = (): SidecarResumeResult => ({ outcome: "interrupted", warnings });
102
106
 
107
+ if (deps.prepareFiles) {
108
+ if (ended()) return interrupted();
109
+ await deps.prepareFiles(handle);
110
+ }
111
+
103
112
  if (task.envMode === "loader") {
104
113
  if (ended()) return interrupted();
105
114
  try {
@@ -50,8 +50,13 @@ export interface TaskCredentialCleanupHooks {
50
50
  refuseNewWork(request: TaskCredentialCleanupRequest): void;
51
51
  closeGate(request: TaskCredentialCleanupRequest): void;
52
52
  drainWriters(request: TaskCredentialCleanupRequest): Promise<void>;
53
- closeConsumers(request: TaskCredentialCleanupRequest): Promise<void>;
54
53
  quiesceProcesses(request: TaskCredentialCleanupRequest): Promise<void>;
54
+ /** Retire process-backed consumers after leased engine agents have
55
+ * quiesced. Other task processes may still hold Files; a strict unmount
56
+ * which cannot prove them gone is terminal machine retirement. Their
57
+ * synchronous gate-close hook has already made Files unavailable to new
58
+ * work. */
59
+ closeConsumers(request: TaskCredentialCleanupRequest): Promise<void>;
55
60
  dropMemory(request: TaskCredentialCleanupRequest): void;
56
61
  }
57
62
 
@@ -403,8 +408,20 @@ export class TaskCredentialCleanupCoordinator {
403
408
 
404
409
  async #run(request: TaskCredentialCleanupRequest): Promise<void> {
405
410
  await this.#deps.hooks.drainWriters(request);
406
- await this.#deps.hooks.closeConsumers(request);
407
411
  await this.#deps.hooks.quiesceProcesses(request);
412
+ let consumersFailed = false;
413
+ let consumerError: unknown;
414
+ try {
415
+ await this.#deps.hooks.closeConsumers(request);
416
+ } catch (error) {
417
+ // A strict Files unmount can fail (for example, EBUSY) after every gate
418
+ // and writer has closed. The root-only session material must still be
419
+ // removed below. Keep its inventory rows and the pending-cleanup row as
420
+ // durable mount witnesses, then return this original proof failure so a
421
+ // later process retries the unmount from the recorded runtime locator.
422
+ consumersFailed = true;
423
+ consumerError = error;
424
+ }
408
425
 
409
426
  const allRows = listCredentialCleanupPaths(request.taskId, this.#deps.db);
410
427
  const selectedRows = selectRows(allRows, request);
@@ -428,10 +445,19 @@ export class TaskCredentialCleanupCoordinator {
428
445
  forgetRow(row, this.#deps.db);
429
446
  }
430
447
  let failed: HostCredentialCleanupPath[] = [];
431
- for (const row of rows) {
448
+ // A consumer failure means some non-Files process may still hold its
449
+ // credential. Preserve every such path and its witness for the retry.
450
+ // Files is the exception: its root-only session must be revoked even
451
+ // when the already-hidden mount cannot yet be proven absent.
452
+ const removableRows = consumersFailed
453
+ ? rows.filter((row) => row.lane === "files-session")
454
+ : rows;
455
+ for (const row of removableRows) {
432
456
  try {
433
457
  await (this.#deps.removePath ?? removeCredentialCleanupPath)(row);
434
- forgetRow(row, this.#deps.db);
458
+ if (!consumersFailed || row.lane !== "files-session") {
459
+ forgetRow(row, this.#deps.db);
460
+ }
435
461
  } catch {
436
462
  failed.push(row);
437
463
  }
@@ -439,7 +465,11 @@ export class TaskCredentialCleanupCoordinator {
439
465
 
440
466
  // A whole-task cleanup may prove absence by destroying the exact writable
441
467
  // runtime instead. Selective removal must never take this broader action.
442
- if (request.credentialIds === undefined && failed.length > 0) {
468
+ if (
469
+ !consumersFailed &&
470
+ request.credentialIds === undefined &&
471
+ failed.length > 0
472
+ ) {
443
473
  const byRuntime = new Map<string, HostCredentialCleanupPath[]>();
444
474
  for (const row of failed) {
445
475
  if (row.lane === "files-session") continue;
@@ -481,6 +511,7 @@ export class TaskCredentialCleanupCoordinator {
481
511
  }
482
512
  failed = failed.filter((row) => !proven.has(row));
483
513
  }
514
+ if (consumersFailed) throw consumerError;
484
515
  if (failed.length > 0) {
485
516
  throw new TaskCredentialCleanupError(
486
517
  request.taskId,
@@ -188,6 +188,12 @@ function projectDirectoryName(slug: string): string {
188
188
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(slug) || slug.includes("..")) {
189
189
  throw new Error(`unsafe project slug for machine clone: ${slug}`);
190
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
+ }
191
197
  return slug;
192
198
  }
193
199
 
@@ -947,6 +953,17 @@ export function createMachineHostTaskEnvironmentProvider(
947
953
  ): Promise<TaskEnvironmentProvisioned<TaskUpResult, TaskDownResult>> {
948
954
  const { input } = request;
949
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
+ }));
950
967
  // ADR-154 §12: a task on cloud credentials holds its grant for the
951
968
  // whole start; withdrawn (its gate closed, a cleanup reached it), the
952
969
  // start stops wherever it is, its clone included, and the machine goes.
@@ -1060,10 +1077,7 @@ export function createMachineHostTaskEnvironmentProvider(
1060
1077
  );
1061
1078
  if (proxyWarning !== null) console.warn(`[machine] task ${request.taskId}: ${proxyWarning}`);
1062
1079
  }
1063
- const projects = [...input.projects].sort(
1064
- (a, b) => a.position - b.position,
1065
- );
1066
- const wantsGithub = projects.some((project) =>
1080
+ const wantsGithub = projects.some(({ project }) =>
1067
1081
  isGithubRepoUrl(project.repoUrl),
1068
1082
  );
1069
1083
  // ADR-154 §12: a task on cloud credentials clones with the token its
@@ -1073,9 +1087,9 @@ export function createMachineHostTaskEnvironmentProvider(
1073
1087
  : clone
1074
1088
  ? clone.github && { accessToken: clone.github.token }
1075
1089
  : await deps.githubToken(input.task.ownerUserId);
1076
- for (const project of projects) {
1090
+ for (const { project, directoryName } of projects) {
1077
1091
  if (clone?.signal.aborted) throw withdrawal();
1078
- const destination = `${workspacePath}/${projectDirectoryName(project.slug)}`;
1092
+ const destination = `${workspacePath}/${directoryName}`;
1079
1093
  const outcome = await untilWithdrawn(
1080
1094
  cloneProject(
1081
1095
  own,
@@ -1106,20 +1120,22 @@ export function createMachineHostTaskEnvironmentProvider(
1106
1120
  envSchemas = await stage(captureMachineEnvSchemas(request.taskId, own, clones));
1107
1121
 
1108
1122
  // ADR-062 shared files: point-in-time copy (see copySharedFiles).
1109
- const sharedWarning = await stage(
1110
- (deps.sharedFiles ?? copySharedFiles)(own, workspacePath, {
1111
- ...(input.task.ownerOrgId ? { ownerOrgId: input.task.ownerOrgId } : {}),
1112
- ownerUserId: input.task.ownerUserId,
1113
- ...(input.task.sharedFiles ? { sharedFiles: input.task.sharedFiles } : {}),
1114
- }).catch((error: unknown) => {
1115
- console.warn(
1116
- `[machine] task ${request.taskId}: shared files copy failed: ${
1117
- error instanceof Error ? error.message : String(error)
1118
- }`,
1123
+ const sharedWarning = leased?.filesV1
1124
+ ? null
1125
+ : await stage(
1126
+ (deps.sharedFiles ?? copySharedFiles)(own, workspacePath, {
1127
+ ...(input.task.ownerOrgId ? { ownerOrgId: input.task.ownerOrgId } : {}),
1128
+ ownerUserId: input.task.ownerUserId,
1129
+ ...(input.task.sharedFiles ? { sharedFiles: input.task.sharedFiles } : {}),
1130
+ }).catch((error: unknown) => {
1131
+ console.warn(
1132
+ `[machine] task ${request.taskId}: shared files copy failed: ${
1133
+ error instanceof Error ? error.message : String(error)
1134
+ }`,
1135
+ );
1136
+ return null;
1137
+ }),
1119
1138
  );
1120
- return null;
1121
- }),
1122
- );
1123
1139
  if (sharedWarning) warnings.push(sharedWarning);
1124
1140
 
1125
1141
  // Codex default-slot credentials: the legacy delivery paths are all
@@ -0,0 +1,88 @@
1
+ import type { TaskAssignment } from "./cloud-credential-leases";
2
+
3
+ export type TaskFilesMachineRetirementReason =
4
+ | "setup-unproven"
5
+ | "visibility-unproven"
6
+ | "mount-retirement-unproven";
7
+
8
+ export interface TaskFilesMachineRetirementRequest {
9
+ assignment: Readonly<TaskAssignment>;
10
+ reason: TaskFilesMachineRetirementReason;
11
+ error: Error;
12
+ }
13
+
14
+ /**
15
+ * Cutover-owned terminal path for a one-task machine whose Files boundary
16
+ * cannot be proved closed. The handler must synchronously quarantine local
17
+ * task work, then durably retry an authenticated exact-run machine-retirement
18
+ * request until the cloud acknowledges it or the machine dies. Exiting only
19
+ * the sidecar is explicitly insufficient: its systemd unit uses
20
+ * KillMode=process and leaves the task processes alive.
21
+ */
22
+ export type TaskFilesMachineRetirementHandler = (
23
+ request: TaskFilesMachineRetirementRequest,
24
+ ) => undefined;
25
+
26
+ let handler: TaskFilesMachineRetirementHandler | null = null;
27
+ let retiring: TaskFilesMachineRetirementRequest | null = null;
28
+ let handedOff = false;
29
+
30
+ export function setTaskFilesMachineRetirementHandler(
31
+ next: TaskFilesMachineRetirementHandler | null,
32
+ ): void {
33
+ handler = next;
34
+ }
35
+
36
+ /** Files v1 must not be advertised, or activation entered, without this. */
37
+ export function taskFilesMachineRetirementReady(): boolean {
38
+ return handler !== null && retiring === null;
39
+ }
40
+
41
+ export function requestTaskFilesMachineRetirement(
42
+ request: TaskFilesMachineRetirementRequest,
43
+ ): void {
44
+ if (!handler) {
45
+ throw new Error("task-machine Files retirement is not installed", {
46
+ cause: request.error,
47
+ });
48
+ }
49
+ if (retiring === null) {
50
+ // First exact failure wins before any callback: a reconnect/capability
51
+ // pass in the callback's first await can no longer advertise this machine.
52
+ retiring = request;
53
+ } else if (
54
+ retiring.assignment.taskId !== request.assignment.taskId ||
55
+ retiring.assignment.runEpoch !== request.assignment.runEpoch
56
+ ) {
57
+ return;
58
+ }
59
+ if (handedOff) return;
60
+ // A synchronous handoff failure must be retryable without reopening the
61
+ // capability or letting a later assignment replace the first failure.
62
+ // Once this returns, the handler owns durable retries itself.
63
+ const result: unknown = handler(retiring);
64
+ if (result !== undefined) {
65
+ // A JavaScript caller can evade the type even though `undefined` (rather
66
+ // than `void`) rejects async handlers in TypeScript. Observe a returned
67
+ // promise so its later rejection is not unhandled, but never count an
68
+ // asynchronous callback as the required synchronous handoff.
69
+ if (
70
+ typeof result === "object" &&
71
+ result !== null &&
72
+ "then" in result &&
73
+ typeof (result as { then?: unknown }).then === "function"
74
+ ) {
75
+ void Promise.resolve(result).catch(() => {});
76
+ }
77
+ throw new Error("task-machine Files retirement must be handed off synchronously");
78
+ }
79
+ handedOff = true;
80
+ }
81
+
82
+ /** Tests model a fresh machine process without weakening the production
83
+ * one-way latch. */
84
+ export function resetTaskFilesMachineRetirementForTests(): void {
85
+ handler = null;
86
+ retiring = null;
87
+ handedOff = false;
88
+ }
@@ -0,0 +1,127 @@
1
+ import {
2
+ accessSync,
3
+ chmodSync,
4
+ constants,
5
+ lstatSync,
6
+ mkdirSync,
7
+ statfsSync,
8
+ } from "node:fs";
9
+
10
+ export const TASK_FILES_MOUNT_S3_PATH = "/usr/bin/mount-s3";
11
+ export const TASK_FILES_FUSE_PATH = "/dev/fuse";
12
+ export const TASK_FILES_HELPER_PATHS = [
13
+ "/usr/bin/python3",
14
+ "/usr/bin/env",
15
+ "/usr/bin/mountpoint",
16
+ "/usr/bin/umount",
17
+ "/bin/sh",
18
+ ] as const;
19
+ export const TASK_FILES_RUNTIME_ROOT = "/run";
20
+ export const TASK_FILES_SESSION_ROOT = "/run/uai";
21
+
22
+ // linux/magic.h: credentials may be written only to a memory-backed runtime.
23
+ export const TMPFS_MAGIC = 0x01021994;
24
+
25
+ export type TaskFilesV1Readiness =
26
+ | { ready: true }
27
+ | {
28
+ ready: false;
29
+ reason:
30
+ | "not-task-machine"
31
+ | "not-root"
32
+ | "mount-s3-unavailable"
33
+ | "fuse-unavailable"
34
+ | "helper-unavailable"
35
+ | "run-not-tmpfs"
36
+ | "session-root-unsafe";
37
+ };
38
+
39
+ export interface TaskFilesV1ReadinessDeps {
40
+ getuid(): number;
41
+ access(path: string, mode: number): void;
42
+ prepareSessionRoot(): void;
43
+ statfs(path: string): { type: number | bigint };
44
+ }
45
+
46
+ function prepareSessionRoot(): void {
47
+ try {
48
+ mkdirSync(TASK_FILES_SESSION_ROOT, { mode: 0o700 });
49
+ } catch (error) {
50
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
51
+ }
52
+ const info = lstatSync(TASK_FILES_SESSION_ROOT);
53
+ if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== 0) {
54
+ throw new Error("the Files session root is not a root-owned directory");
55
+ }
56
+ chmodSync(TASK_FILES_SESSION_ROOT, 0o700);
57
+ }
58
+
59
+ const DEFAULT_DEPS: TaskFilesV1ReadinessDeps = {
60
+ getuid: () => process.getuid?.() ?? -1,
61
+ access: accessSync,
62
+ prepareSessionRoot,
63
+ statfs: (path) => statfsSync(path),
64
+ };
65
+
66
+ /**
67
+ * Local half of the Files-v1 capability check. Cloud mode is irrelevant: a
68
+ * machine advertises what this process can safely do before activation. Keep
69
+ * this synchronous so the connect capability frame cannot race a later
70
+ * prerequisite probe.
71
+ */
72
+ export function taskFilesV1Readiness(
73
+ taskMachine: boolean,
74
+ deps: TaskFilesV1ReadinessDeps = DEFAULT_DEPS,
75
+ ): TaskFilesV1Readiness {
76
+ if (!taskMachine) return { ready: false, reason: "not-task-machine" };
77
+
78
+ let uid: number;
79
+ try {
80
+ uid = deps.getuid();
81
+ } catch {
82
+ return { ready: false, reason: "not-root" };
83
+ }
84
+ if (uid !== 0) return { ready: false, reason: "not-root" };
85
+
86
+ try {
87
+ deps.access(TASK_FILES_MOUNT_S3_PATH, constants.X_OK);
88
+ } catch {
89
+ return { ready: false, reason: "mount-s3-unavailable" };
90
+ }
91
+
92
+ try {
93
+ deps.access(TASK_FILES_FUSE_PATH, constants.R_OK | constants.W_OK);
94
+ } catch {
95
+ return { ready: false, reason: "fuse-unavailable" };
96
+ }
97
+
98
+ try {
99
+ for (const path of TASK_FILES_HELPER_PATHS) {
100
+ deps.access(path, constants.X_OK);
101
+ }
102
+ } catch {
103
+ return { ready: false, reason: "helper-unavailable" };
104
+ }
105
+
106
+ try {
107
+ if (Number(deps.statfs(TASK_FILES_RUNTIME_ROOT).type) !== TMPFS_MAGIC) {
108
+ return { ready: false, reason: "run-not-tmpfs" };
109
+ }
110
+ } catch {
111
+ return { ready: false, reason: "run-not-tmpfs" };
112
+ }
113
+
114
+ try {
115
+ deps.prepareSessionRoot();
116
+ // `/run` being tmpfs is insufficient: an existing `/run/uai` may be a
117
+ // bind mount onto persistent storage. Capability advertisement proves
118
+ // the exact directory in which the session will be written.
119
+ if (Number(deps.statfs(TASK_FILES_SESSION_ROOT).type) !== TMPFS_MAGIC) {
120
+ return { ready: false, reason: "session-root-unsafe" };
121
+ }
122
+ } catch {
123
+ return { ready: false, reason: "session-root-unsafe" };
124
+ }
125
+
126
+ return { ready: true };
127
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.152",
3
+ "version": "0.9.153",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
@@ -21,6 +21,16 @@
21
21
  /** Advertised by a runner that reads credentials only through v1 leases. */
22
22
  export const HOST_CLOUD_CREDENTIALS_FEATURE = "host-cloud-credentials-v1";
23
23
 
24
+ /**
25
+ * Advertised by a task machine only when it can install a task-scoped Files
26
+ * session and mount it from the v1 grant. This is deliberately separate from
27
+ * the general credentials feature: owned runners do not need Files-on-machine
28
+ * support to leave the legacy credential mode. The cloud must require both
29
+ * features for a task machine's activation, taskUp dispatch and v1 lease;
30
+ * this advertisement alone is not an admission check.
31
+ */
32
+ export const HOST_TASK_FILES_LEASE_FEATURE = "host-task-files-lease-v1";
33
+
24
34
  // ---------------------------------------------------------------------------
25
35
  // Parameter layout
26
36
  // ---------------------------------------------------------------------------
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ import { writeTaskGithub, writeTaskSshKey } from "../lib/credential-consumers/gi
26
26
  import { agentClisReady, ensureStandardImage } from "../lib/standard-image";
27
27
  import { cloneRepo } from "../lib/repo-clone";
28
28
  import { handleFilesOp } from "../lib/shared-files";
29
+ import type { SharedFilesMode } from "../lib/shared-files-s3";
29
30
  import {
30
31
  getOrchestrator,
31
32
  inspectTaskRuntimeContainer,
@@ -79,6 +80,7 @@ import {
79
80
  recordTaskOwner,
80
81
  recordTaskProjectEnvSpec,
81
82
  recordTaskRuntimeEstablished,
83
+ recordTaskSharedFilesMode,
82
84
  recordTaskStarting,
83
85
  recordTaskUpResult,
84
86
  taskProjectEnvSpec,
@@ -293,17 +295,32 @@ interface CloudCredentialTaskStart {
293
295
  /** The start's lease for task-up: its whole grant's hold, and what the
294
296
  * runner writes into the runtime once it exists (the owner's GitHub sign-in
295
297
  * and SSH key, recorded for cleanup) before anything in it runs. */
296
- function leasedTaskUp(start: TaskCredentialStart, hold: TaskUpHold): LeasedTaskUp {
298
+ function leasedTaskUp(
299
+ runtime: CloudCredentialRuntime,
300
+ start: TaskCredentialStart,
301
+ hold: TaskUpHold,
302
+ filesMode: SharedFilesMode,
303
+ ): LeasedTaskUp {
304
+ const filesV1 = runtime.taskFilesV1();
297
305
  return {
298
306
  // The whole grant's signal: its gate closing, a cleanup reaching it or
299
307
  // the start being interrupted all end task-up.
300
308
  clone: { ...hold.git, signal: hold.signal },
301
309
  runtimeReady: async (environment, signal) => {
310
+ if (filesV1) {
311
+ await runtime.prepareTaskFiles(
312
+ start.assignment,
313
+ environment as TaskEnvironmentHandle<unknown>,
314
+ filesMode,
315
+ signal,
316
+ );
317
+ }
302
318
  for (const write of [writeTaskGithub, writeTaskSshKey]) {
303
319
  if (signal.aborted) throw new CredentialLeaseUnavailableError("the task's credentials were withdrawn");
304
320
  await write(environment, start.assignment);
305
321
  }
306
322
  },
323
+ ...(filesV1 ? { filesV1: true as const } : {}),
307
324
  };
308
325
  }
309
326
 
@@ -1138,6 +1155,7 @@ async function taskUpOn(
1138
1155
  environmentLocator: existingTask.environmentLocator,
1139
1156
  envMode: existingTask.envMode,
1140
1157
  projectEnvSpec: existingTask.projectEnvSpec,
1158
+ sharedFilesMode: existingTask.sharedFilesMode,
1141
1159
  runEpoch: existingTask.runEpoch,
1142
1160
  }
1143
1161
  : {
@@ -1149,6 +1167,7 @@ async function taskUpOn(
1149
1167
  environmentLocator: existingTask.environmentLocator,
1150
1168
  envMode: existingTask.envMode,
1151
1169
  projectEnvSpec: existingTask.projectEnvSpec,
1170
+ sharedFilesMode: existingTask.sharedFilesMode,
1152
1171
  runEpoch: existingTask.runEpoch,
1153
1172
  }
1154
1173
  : {
@@ -1173,6 +1192,12 @@ async function taskUpOn(
1173
1192
  } else {
1174
1193
  recordTaskStarting(input.task.id, input.task.runEpoch);
1175
1194
  }
1195
+ // Files-v1 recovery gets its mode only from a launch already admitted on
1196
+ // cloud credentials, never from the task-writable marker. A legacy start
1197
+ // must leave the column null so cutover can distinguish and retire it.
1198
+ const sharedFilesMode = cloud
1199
+ ? recordTaskSharedFilesMode(input.task.id, input.task)
1200
+ : null;
1176
1201
  // Cross-process managed maintenance and task admission use a two-sided
1177
1202
  // handshake: update/uninstall writes its durable marker before checking
1178
1203
  // this DB row; task-up writes `starting` before checking admission again.
@@ -1318,7 +1343,18 @@ async function taskUpOn(
1318
1343
  input,
1319
1344
  {
1320
1345
  taskGateway,
1321
- ...(cloud?.start && cloud.hold ? { leased: leasedTaskUp(cloud.start, cloud.hold) } : {}),
1346
+ ...(cloud?.start && cloud.hold
1347
+ ? {
1348
+ leased: leasedTaskUp(
1349
+ cloud.runtime,
1350
+ cloud.start,
1351
+ cloud.hold,
1352
+ sharedFilesMode ?? (() => {
1353
+ throw new Error("a cloud-credential start has no trusted Files mode");
1354
+ })(),
1355
+ ),
1356
+ }
1357
+ : {}),
1322
1358
  },
1323
1359
  recordPrepared,
1324
1360
  );
package/src/main.ts CHANGED
@@ -28,7 +28,9 @@ import {
28
28
  closeTaskRunAssignment,
29
29
  getHostTask,
30
30
  hasInFlightHostTaskLifecycle,
31
+ hostTaskSharedFilesMode,
31
32
  readTaskRunEpochState,
33
+ taskFilesRecoveryBinding,
32
34
  upsertHostTask,
33
35
  } from "../lib/runtime-state";
34
36
  import {
@@ -112,6 +114,7 @@ import {
112
114
  parseLeaseGrantV1Frame,
113
115
  parseLeaseStaleFrame,
114
116
  parseLeaseUnavailableFrame,
117
+ HOST_TASK_FILES_LEASE_FEATURE,
115
118
  } from "./cloud-credentials";
116
119
  import { captureEngineAccount } from "../lib/capture-flow";
117
120
  import { registerTaskGatewayTlsHandler, setMcpGatewayLeaseOnly, startMcpGateway } from "../lib/mcp-gateway";
@@ -408,6 +411,12 @@ import {
408
411
  } from "../lib/legacy-runtime-retirement";
409
412
  import { taskContainerBackend, taskContainerCli } from "../lib/task-container-cli";
410
413
  import { CloudCredentialRuntime } from "../lib/cloud-credential-runtime";
414
+ import { createTaskFilesV1Consumer } from "../lib/credential-consumers/files";
415
+ import { taskFilesV1Readiness } from "../lib/task-files-readiness";
416
+ import {
417
+ requestTaskFilesMachineRetirement,
418
+ taskFilesMachineRetirementReady,
419
+ } from "../lib/task-files-machine-retirement";
411
420
  import { ProjectEnvTaskReplicaManager } from "../lib/project-env-task-replica";
412
421
  import {
413
422
  purgeDefaultEngineSlots,
@@ -908,15 +917,29 @@ if (signInBoxMode()) {
908
917
  // ADR-154: the composer for tasks on cloud credentials. Nothing runs on it
909
918
  // until the runner's activation authority makes cloud credentials its
910
919
  // tasks' only ones (lib/cloud-credential-host.ts).
920
+ const taskFilesReadiness = taskFilesV1Readiness(sidecarMode());
921
+ const taskFiles = sidecarMode()
922
+ ? createTaskFilesV1Consumer({
923
+ recoveredBinding: (assignment) => {
924
+ return taskFilesRecoveryBinding(getHostTask(assignment.taskId), assignment);
925
+ },
926
+ ready: () =>
927
+ taskFilesV1Readiness(true).ready &&
928
+ taskFilesMachineRetirementReady(),
929
+ retireMachine: requestTaskFilesMachineRetirement,
930
+ })
931
+ : null;
911
932
  const cloudCredentials = new CloudCredentialRuntime({
912
933
  runnerKind: sidecarMode() ? "task-machine" : "owned",
913
934
  send: sendCredentialFrame,
914
935
  quiesceProcesses: (request) => getOrchestrator().quiesceLeasedEngineAgents(request),
936
+ mcpServedChanged: (taskId) => getOrchestrator().mcpServedChanged(taskId),
915
937
  // The task-keyed env replica the cloud streams for each run. A change to
916
938
  // it, or to what the composer serves from it, makes the proxy's trust
917
939
  // delivery look again; /env reads it afresh on every command.
918
940
  envReplica: new ProjectEnvTaskReplicaManager({ onChange: () => noteProjectEnvViewChange() }),
919
941
  envServedChanged: () => noteProjectEnvViewChange(),
942
+ files: taskFiles,
920
943
  });
921
944
  // Leaving its own credentials ends every task's env from the runner's
922
945
  // stores at once, and makes the proxy's trust delivery look again.
@@ -926,8 +949,25 @@ followLeavingLegacy();
926
949
  // from its own run's Files session, never the runner's v0 lease); and the
927
950
  // proxy's upkeep from activation on, with no v0 first grant to start it.
928
951
  if (sidecarMode()) {
929
- setRecoveredRuntimeSetup(async (_run, signal) => {
930
- const resumed = await resumeSidecarTask({ remountFiles: false, signal });
952
+ setRecoveredRuntimeSetup(async (run, signal) => {
953
+ const task = getHostTask(run.taskId);
954
+ let mode: ReturnType<typeof hostTaskSharedFilesMode>;
955
+ try {
956
+ mode = task ? hostTaskSharedFilesMode(task) : null;
957
+ if (mode === null) {
958
+ throw new Error("the recovered task has no trusted Files-v1 mode");
959
+ }
960
+ } catch (error) {
961
+ if (signal.aborted) throw signal.reason;
962
+ cloudCredentials.failTaskFilesSetup(run, error);
963
+ throw error;
964
+ }
965
+ const resumed = await resumeSidecarTask({
966
+ remountFiles: false,
967
+ signal,
968
+ prepareFiles: (handle) =>
969
+ cloudCredentials.prepareTaskFiles(run, handle, mode, signal),
970
+ });
931
971
  if (resumed.outcome === "resumed") {
932
972
  console.log(
933
973
  `[sidecar] task ${sidecarTaskId()} resumed on cloud credentials: loader, proxy and editor re-asserted` +
@@ -1365,6 +1405,11 @@ function buildCapabilities(): HostCapabilities {
1365
1405
  ...(projectEnvBindings === null ? [] : [projectEnvBindings.feature]),
1366
1406
  // ADR-154 §13: the runner reads cloud credentials, its stores purged.
1367
1407
  ...(credentialsMode() === "active" ? [HOST_CLOUD_CREDENTIALS_FEATURE] : []),
1408
+ ...(taskFiles !== null &&
1409
+ taskFilesReadiness.ready &&
1410
+ taskFilesMachineRetirementReady()
1411
+ ? [HOST_TASK_FILES_LEASE_FEATURE]
1412
+ : []),
1368
1413
  // ADR-154: only the sign-in box writes cloud credentials.
1369
1414
  ...(signInBoxMode()
1370
1415
  ? [