@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.
@@ -0,0 +1,934 @@
1
+ /**
2
+ * ADR-154 Files v1 on task machines.
3
+ *
4
+ * A Files session is deliberately unlike the other leased credentials: the
5
+ * Mountpoint process needs a renewable credential_process file for the whole
6
+ * life of the mount. The cloud still remains the only authority. A grant is
7
+ * kept in memory until its exact gate opens; only then may task-up put the
8
+ * session in the task machine's root-only tmpfs and mount the prefixes named
9
+ * by that grant.
10
+ */
11
+ import {
12
+ chmodSync,
13
+ closeSync,
14
+ constants,
15
+ existsSync,
16
+ fsyncSync,
17
+ lstatSync,
18
+ mkdirSync,
19
+ openSync,
20
+ renameSync,
21
+ rmSync,
22
+ statfsSync,
23
+ statSync,
24
+ writeFileSync,
25
+ } from "node:fs";
26
+ import { dirname } from "node:path";
27
+
28
+ import type {
29
+ CredentialInstallRequest,
30
+ TaskAssignment,
31
+ TaskCredentialView,
32
+ TaskLeaseFilesAccess,
33
+ } from "../cloud-credential-leases";
34
+ import {
35
+ AuthoritativeTaskFilesFailure,
36
+ type TaskFilesConsumer,
37
+ } from "../cloud-credential-runtime";
38
+ import {
39
+ listCredentialCleanupPaths,
40
+ rememberCredentialCleanupPath,
41
+ } from "../credential-cleanup-inventory";
42
+ import {
43
+ LEASE_AWS_CONFIG_FILE,
44
+ LEASE_CREDENTIALS_FILE,
45
+ leaseAwsConfig,
46
+ } from "../lease";
47
+ import {
48
+ hideSharedFilesV1Sync,
49
+ mountSharedFilesFromAccess,
50
+ publishSharedFilesV1Sync,
51
+ type SharedFilesMode,
52
+ unmountSharedFilesStrict,
53
+ } from "../shared-files-s3";
54
+ import { reconstructTaskEnvironment } from "../task-environment";
55
+ import {
56
+ parseTaskEnvironmentLocator,
57
+ serializeTaskEnvironmentLocator,
58
+ type TaskEnvironmentHandle,
59
+ type TaskEnvironmentLocator,
60
+ } from "../task-environment/types";
61
+ import type { TaskCredentialCleanupRequest } from "../task-credential-cleanup";
62
+ import type {
63
+ TaskFilesMachineRetirementReason,
64
+ TaskFilesMachineRetirementRequest,
65
+ } from "../task-files-machine-retirement";
66
+ import { TMPFS_MAGIC, taskFilesV1Readiness } from "../task-files-readiness";
67
+
68
+ export const FILES_SESSION_CREDENTIAL_ID = "files-session";
69
+ export const FILES_SESSION_STAGED_SUFFIX = ".uai-tmp";
70
+ export const FILES_SESSION_MANAGED_PATHS = [
71
+ LEASE_CREDENTIALS_FILE,
72
+ `${LEASE_CREDENTIALS_FILE}${FILES_SESSION_STAGED_SUFFIX}`,
73
+ LEASE_AWS_CONFIG_FILE,
74
+ `${LEASE_AWS_CONFIG_FILE}${FILES_SESSION_STAGED_SUFFIX}`,
75
+ ] as const;
76
+
77
+ type RuntimeEnvironment = Pick<
78
+ TaskEnvironmentHandle<unknown>,
79
+ "descriptor" | "exec" | "writeWorkspaceFile"
80
+ >;
81
+
82
+ interface FilesCandidate {
83
+ view: TaskCredentialView;
84
+ readonly access: TaskLeaseFilesAccess;
85
+ /** Valid only for the install transaction which created this candidate.
86
+ * After open, the consumer's exact served candidate + abort fence is the
87
+ * authority; the registry deliberately makes this callback false. */
88
+ installCurrent: () => boolean;
89
+ readonly controller: AbortController;
90
+ }
91
+
92
+ interface FilesBinding {
93
+ readonly locator: TaskEnvironmentLocator;
94
+ readonly workspacePath: string;
95
+ readonly mode: SharedFilesMode;
96
+ /** Present in the process which established/recovered the runtime. */
97
+ environment: RuntimeEnvironment | null;
98
+ /** The exact prefixes are mounted below the root-only fixed directory. */
99
+ mounted: boolean;
100
+ /** The workspace link has been atomically published for this run. A
101
+ * replacement grant can stage a remount while this remains false; the
102
+ * registry's synchronous open is the only operation which flips it. */
103
+ published: boolean;
104
+ /** Non-secret identity of the prefixes which are physically mounted. */
105
+ mountIdentity: string | null;
106
+ }
107
+
108
+ interface FilesState {
109
+ readonly assignment: TaskAssignment;
110
+ candidate: FilesCandidate | null;
111
+ served: FilesCandidate | null;
112
+ binding: FilesBinding | null;
113
+ gateOpen: boolean;
114
+ tail: Promise<void>;
115
+ }
116
+
117
+ export interface TaskFilesV1Consumer extends TaskFilesConsumer {
118
+ /** Put the exact open grant into this runtime and mount its Files roots. */
119
+ runtimeReady(
120
+ assignment: Readonly<TaskAssignment>,
121
+ environment: RuntimeEnvironment,
122
+ mode: SharedFilesMode,
123
+ signal: AbortSignal,
124
+ ): Promise<void>;
125
+ /** Forget values and refuse new work at process shutdown; leave mounts and
126
+ * files for the next process's recovery cleanup. */
127
+ shutdown(): void;
128
+ }
129
+
130
+ export interface TaskFilesV1ConsumerDeps {
131
+ rememberPath?: typeof rememberCredentialCleanupPath;
132
+ listPaths?: typeof listCredentialCleanupPaths;
133
+ writeSession?: (access: TaskLeaseFilesAccess) => void | Promise<void>;
134
+ mount?: typeof mountSharedFilesFromAccess;
135
+ publish?: typeof publishSharedFilesV1Sync;
136
+ hide?: typeof hideSharedFilesV1Sync;
137
+ unmount?: typeof unmountSharedFilesStrict;
138
+ reconstruct?: (
139
+ locator: TaskEnvironmentLocator,
140
+ ) => Promise<TaskEnvironmentHandle<unknown>>;
141
+ /** A runtime which survived this process is bound before its first
142
+ * recovered grant installs. This lets install stage the mount while the
143
+ * gate is still closed, rather than exposing a credential-less interval
144
+ * to processes which survived the restart. */
145
+ recoveredBinding?: (
146
+ assignment: Readonly<TaskAssignment>,
147
+ ) =>
148
+ | {
149
+ locator: TaskEnvironmentLocator;
150
+ workspacePath: string;
151
+ mode: SharedFilesMode;
152
+ }
153
+ | null;
154
+ ready?: () => boolean;
155
+ /** A task machine whose Files boundary cannot be hidden or retired must
156
+ * leave service. The cutover installs a handler which quarantines the
157
+ * local task and durably asks the cloud to retire this exact run's machine;
158
+ * tests may observe the request and let the original error throw. */
159
+ retireMachine?: (request: TaskFilesMachineRetirementRequest) => void;
160
+ }
161
+
162
+ function sameAssignment(
163
+ left: Readonly<TaskAssignment>,
164
+ right: Readonly<TaskAssignment>,
165
+ ): boolean {
166
+ return left.taskId === right.taskId && left.runEpoch === right.runEpoch;
167
+ }
168
+
169
+ function sameFilesAccess(
170
+ left: Readonly<TaskLeaseFilesAccess>,
171
+ right: Readonly<TaskLeaseFilesAccess>,
172
+ ): boolean {
173
+ return (
174
+ sameAssignment(left.assignment, right.assignment) &&
175
+ left.region === right.region &&
176
+ left.files.bucket === right.files.bucket &&
177
+ left.files.orgPrefix === right.files.orgPrefix &&
178
+ left.files.userPrefix === right.files.userPrefix &&
179
+ left.credentials.accessKeyId === right.credentials.accessKeyId &&
180
+ left.credentials.secretAccessKey === right.credentials.secretAccessKey &&
181
+ left.credentials.sessionToken === right.credentials.sessionToken &&
182
+ left.credentials.expiresAt === right.credentials.expiresAt
183
+ );
184
+ }
185
+
186
+ function filesScopes(access: TaskLeaseFilesAccess): Array<"org" | "me"> {
187
+ return access.files.orgPrefix === null ? ["me"] : ["org", "me"];
188
+ }
189
+
190
+ function filesMountIdentity(
191
+ mode: SharedFilesMode,
192
+ access: TaskLeaseFilesAccess,
193
+ ): string {
194
+ return JSON.stringify({
195
+ mode,
196
+ region: access.region,
197
+ bucket: access.files.bucket,
198
+ orgPrefix: access.files.orgPrefix,
199
+ userPrefix: access.files.userPrefix,
200
+ });
201
+ }
202
+
203
+ function assertCanonicalTaskMachineRuntime(
204
+ assignment: Readonly<TaskAssignment>,
205
+ environment: RuntimeEnvironment,
206
+ ): void {
207
+ const { descriptor } = environment;
208
+ const locator = descriptor.locator;
209
+ const value = locator.value;
210
+ if (
211
+ descriptor.taskId !== assignment.taskId ||
212
+ descriptor.workspacePath !== "/workspace" ||
213
+ locator.provider !== "self" ||
214
+ typeof value !== "object" ||
215
+ value === null ||
216
+ Array.isArray(value) ||
217
+ (value as Record<string, unknown>).taskId !== assignment.taskId ||
218
+ (value as Record<string, unknown>).workspacePath !== "/workspace"
219
+ ) {
220
+ throw new Error("the Files session requires the exact canonical task-machine runtime");
221
+ }
222
+ }
223
+
224
+ function filesCredentialProcessOutput(access: TaskLeaseFilesAccess): string {
225
+ return JSON.stringify({
226
+ Version: 1,
227
+ AccessKeyId: access.credentials.accessKeyId,
228
+ SecretAccessKey: access.credentials.secretAccessKey,
229
+ SessionToken: access.credentials.sessionToken,
230
+ Expiration: new Date(access.credentials.expiresAt).toISOString(),
231
+ });
232
+ }
233
+
234
+ function ensurePrivateRunDirectory(): void {
235
+ const root = dirname(LEASE_CREDENTIALS_FILE);
236
+ if (!existsSync(root)) mkdirSync(root, { mode: 0o700 });
237
+ const info = lstatSync(root);
238
+ if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== 0) {
239
+ throw new Error("the Files session directory is not a root-owned directory");
240
+ }
241
+ chmodSync(root, 0o700);
242
+ // `/run` being tmpfs is not enough: a pre-existing `/run/uai` could be a
243
+ // bind mount onto persistent storage. Prove the directory which will hold
244
+ // the bytes immediately before every atomic write pass.
245
+ if (Number(statfsSync(root).type) !== TMPFS_MAGIC) {
246
+ throw new Error("the Files session directory is not memory-backed");
247
+ }
248
+ }
249
+
250
+ function atomicRootFile(path: string, contents: string): void {
251
+ const staged = `${path}${FILES_SESSION_STAGED_SUFFIX}`;
252
+ // An interrupted older write is itself inventoried. Removing its directory
253
+ // entry cannot follow it if it was somehow replaced by a symlink.
254
+ rmSync(staged, { force: true });
255
+ const noFollow = constants.O_NOFOLLOW ?? 0;
256
+ const descriptor = openSync(
257
+ staged,
258
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow,
259
+ 0o600,
260
+ );
261
+ try {
262
+ writeFileSync(descriptor, contents, { encoding: "utf8" });
263
+ fsyncSync(descriptor);
264
+ } finally {
265
+ closeSync(descriptor);
266
+ }
267
+ renameSync(staged, path);
268
+ chmodSync(path, 0o600);
269
+ const written = statSync(path);
270
+ if (!written.isFile() || written.uid !== 0 || (written.mode & 0o777) !== 0o600) {
271
+ throw new Error("a Files session file is not a root-owned regular file");
272
+ }
273
+ }
274
+
275
+ /** Atomic root-only write. The caller has already proved `/run` is tmpfs
276
+ * and inventoried both final and staged names. */
277
+ export function writeTaskFilesSession(access: TaskLeaseFilesAccess): void {
278
+ if (!taskFilesV1Readiness(true).ready) {
279
+ throw new Error("the host cannot safely persist a Files session");
280
+ }
281
+ ensurePrivateRunDirectory();
282
+ atomicRootFile(LEASE_CREDENTIALS_FILE, filesCredentialProcessOutput(access));
283
+ atomicRootFile(LEASE_AWS_CONFIG_FILE, leaseAwsConfig({ region: access.region }));
284
+ }
285
+
286
+ function abortError(signal?: AbortSignal): Error {
287
+ return signal?.reason instanceof Error
288
+ ? signal.reason
289
+ : new Error("the task's Files session was withdrawn");
290
+ }
291
+
292
+ /** One serialized Files consumer per task-machine process. */
293
+ export function createTaskFilesV1Consumer(
294
+ deps: TaskFilesV1ConsumerDeps = {},
295
+ ): TaskFilesV1Consumer {
296
+ const states = new Map<string, FilesState>();
297
+ let stopped = false;
298
+ const remember = deps.rememberPath ?? rememberCredentialCleanupPath;
299
+ const list = deps.listPaths ?? listCredentialCleanupPaths;
300
+ const writeSession = deps.writeSession ?? writeTaskFilesSession;
301
+ const mount = deps.mount ?? mountSharedFilesFromAccess;
302
+ const publish = deps.publish ?? publishSharedFilesV1Sync;
303
+ const hide = deps.hide ?? hideSharedFilesV1Sync;
304
+ const unmount = deps.unmount ?? unmountSharedFilesStrict;
305
+ const reconstruct =
306
+ deps.reconstruct ??
307
+ ((locator: TaskEnvironmentLocator) => reconstructTaskEnvironment(locator, "loader"));
308
+ const recoveredBinding = deps.recoveredBinding ?? (() => null);
309
+ const ready = deps.ready ?? (() => taskFilesV1Readiness(true).ready);
310
+ let retirementRequested = false;
311
+ const retireMachine =
312
+ deps.retireMachine ??
313
+ ((request: TaskFilesMachineRetirementRequest): void => {
314
+ throw new Error("task-machine Files retirement is not installed", {
315
+ cause: request.error,
316
+ });
317
+ });
318
+
319
+ const terminalFailure = (
320
+ assignment: Readonly<TaskAssignment>,
321
+ reason: TaskFilesMachineRetirementReason,
322
+ error: unknown,
323
+ message: string,
324
+ ): Error => {
325
+ const failure = error instanceof Error ? error : new Error(message, { cause: error });
326
+ // Terminal is one-way for this process. No retry or later grant may
327
+ // republish Files while the cutover-owned retirement is pending.
328
+ stopped = true;
329
+ for (const state of states.values()) {
330
+ state.gateOpen = false;
331
+ state.candidate?.controller.abort(failure);
332
+ if (state.served !== state.candidate) state.served?.controller.abort(failure);
333
+ }
334
+ // Re-attempt the local visibility fence before handing off. The handler
335
+ // owns the stronger task-process quarantine and durable cloud request.
336
+ for (const workspacePath of new Set(
337
+ [...states.values()].flatMap((state) =>
338
+ state.binding ? [state.binding.workspacePath] : [],
339
+ ),
340
+ )) {
341
+ try {
342
+ hide(workspacePath);
343
+ } catch {
344
+ // The terminal handler receives the original proof failure and must
345
+ // contain the whole task even when HIDE remains unprovable.
346
+ }
347
+ }
348
+ if (!retirementRequested) {
349
+ retireMachine({ assignment: { ...assignment }, reason, error: failure });
350
+ retirementRequested = true;
351
+ }
352
+ return failure;
353
+ };
354
+
355
+ const throwAuthoritativeFailure = (
356
+ assignment: Readonly<TaskAssignment>,
357
+ reason: TaskFilesMachineRetirementReason,
358
+ error: unknown,
359
+ message: string,
360
+ ): never => {
361
+ try {
362
+ terminalFailure(assignment, reason, error, message);
363
+ } catch (retirementError) {
364
+ throw new AuthoritativeTaskFilesFailure(retirementError);
365
+ }
366
+ throw new AuthoritativeTaskFilesFailure(error);
367
+ };
368
+
369
+ const publishBoundary = (
370
+ assignment: Readonly<TaskAssignment>,
371
+ binding: FilesBinding,
372
+ access: TaskLeaseFilesAccess,
373
+ afterPublish?: () => void,
374
+ ): void => {
375
+ try {
376
+ publish(binding.workspacePath, binding.mode, filesScopes(access));
377
+ binding.published = true;
378
+ afterPublish?.();
379
+ } catch (error) {
380
+ // A lost spawnSync result can mean LINK made the root traversable
381
+ // before its caller saw an error. Hide without relying on the success
382
+ // bit. This also compensates a fence which fails just after LINK.
383
+ try {
384
+ hide(binding.workspacePath);
385
+ binding.published = false;
386
+ } catch (hideError) {
387
+ const failure = new AggregateError(
388
+ [error, hideError],
389
+ "the task's Files boundary could not be hidden after publication failed",
390
+ );
391
+ throwAuthoritativeFailure(
392
+ assignment,
393
+ "visibility-unproven",
394
+ failure,
395
+ "the task's Files boundary could not be hidden after publication failed",
396
+ );
397
+ }
398
+ throw error;
399
+ }
400
+ };
401
+
402
+ const assertCandidate = (
403
+ state: FilesState,
404
+ candidate: FilesCandidate,
405
+ signal?: AbortSignal,
406
+ requireOpen = false,
407
+ ): void => {
408
+ if (
409
+ stopped ||
410
+ states.get(state.assignment.taskId) !== state ||
411
+ (requireOpen ? state.served !== candidate : state.candidate !== candidate) ||
412
+ candidate.controller.signal.aborted ||
413
+ signal?.aborted ||
414
+ (!requireOpen && !candidate.installCurrent()) ||
415
+ (requireOpen && !state.gateOpen)
416
+ ) {
417
+ throw abortError(signal ?? candidate.controller.signal);
418
+ }
419
+ };
420
+
421
+ const queue = (state: FilesState, work: () => Promise<void>): Promise<void> => {
422
+ const next = state.tail.catch(() => undefined).then(work);
423
+ state.tail = next;
424
+ return next;
425
+ };
426
+
427
+ const inventory = (state: FilesState, binding: FilesBinding): void => {
428
+ for (const path of FILES_SESSION_MANAGED_PATHS) {
429
+ remember({
430
+ taskId: state.assignment.taskId,
431
+ runEpoch: state.assignment.runEpoch,
432
+ runtime: binding.locator,
433
+ lane: "files-session",
434
+ path,
435
+ credentialId: FILES_SESSION_CREDENTIAL_ID,
436
+ });
437
+ }
438
+ };
439
+
440
+ const materialize = async (
441
+ state: FilesState,
442
+ candidate: FilesCandidate,
443
+ binding: FilesBinding,
444
+ signal: AbortSignal | undefined,
445
+ requireOpen: boolean,
446
+ ): Promise<void> => {
447
+ let environment = binding.environment;
448
+ if (!environment) {
449
+ const recovered = await reconstruct(binding.locator);
450
+ if (
451
+ recovered.descriptor.taskId !== state.assignment.taskId ||
452
+ recovered.descriptor.workspacePath !== binding.workspacePath
453
+ ) {
454
+ throw new Error("the recovered Files runtime does not match its assignment");
455
+ }
456
+ binding.environment = recovered;
457
+ environment = recovered;
458
+ }
459
+ assertCandidate(state, candidate, signal, requireOpen);
460
+ // Never accept a healthy-looking mount inherited from v0 or from a
461
+ // crashed process as this grant's. The first pass in this process
462
+ // removes and proves both endpoints before it puts any session on disk.
463
+ const establish = !binding.mounted;
464
+ const mountIdentity = filesMountIdentity(binding.mode, candidate.access);
465
+ if (!establish && binding.mountIdentity !== mountIdentity) {
466
+ throw new Error("the Files mount authority changed and requires full cleanup");
467
+ }
468
+ if (establish) {
469
+ await unmount(environment, binding.workspacePath, {
470
+ admit: () => assertCandidate(state, candidate, signal, requireOpen),
471
+ });
472
+ assertCandidate(state, candidate, signal, requireOpen);
473
+ }
474
+ if (binding.mode === "off") {
475
+ binding.mounted = true;
476
+ binding.published = true;
477
+ binding.mountIdentity = mountIdentity;
478
+ return;
479
+ }
480
+ // Durable witnesses precede the first byte of either final or staged
481
+ // file. They remain if anything after this point fails.
482
+ inventory(state, binding);
483
+ assertCandidate(state, candidate, signal, requireOpen);
484
+ await writeSession(candidate.access);
485
+ assertCandidate(state, candidate, signal, requireOpen);
486
+ if (establish) {
487
+ await mount(
488
+ environment as TaskEnvironmentHandle<unknown>,
489
+ binding.workspacePath,
490
+ binding.mode,
491
+ candidate.access,
492
+ {
493
+ admit: () => assertCandidate(state, candidate, signal, requireOpen),
494
+ publish: false,
495
+ },
496
+ );
497
+ binding.mounted = true;
498
+ binding.published = false;
499
+ binding.mountIdentity = mountIdentity;
500
+ }
501
+ assertCandidate(state, candidate, signal, requireOpen);
502
+ if (requireOpen && !binding.published) {
503
+ publishBoundary(state.assignment, binding, candidate.access, () =>
504
+ assertCandidate(state, candidate, signal, requireOpen),
505
+ );
506
+ }
507
+ };
508
+
509
+ const compensateRuntimeReadyFailure = async (
510
+ state: FilesState,
511
+ candidate: FilesCandidate,
512
+ binding: FilesBinding,
513
+ environment: RuntimeEnvironment,
514
+ signal: AbortSignal,
515
+ error: unknown,
516
+ ): Promise<never> => {
517
+ // A helper which already failed its visibility proof has performed the
518
+ // one-way retirement transition. Preserve that authoritative cause.
519
+ if (error instanceof AuthoritativeTaskFilesFailure) throw error;
520
+ // A stale setup must never hide or unmount a winner which took ownership
521
+ // while this one was suspended.
522
+ assertCandidate(state, candidate, signal, true);
523
+
524
+ // LINK or mount(2) may have succeeded before a provider result was lost.
525
+ // Ignore both success bits: hide first, then prove the fixed mounts gone.
526
+ try {
527
+ hide(binding.workspacePath);
528
+ binding.published = false;
529
+ } catch (hideError) {
530
+ throwAuthoritativeFailure(
531
+ state.assignment,
532
+ "visibility-unproven",
533
+ new AggregateError(
534
+ [error, hideError],
535
+ "the task's Files boundary could not be hidden after setup failed",
536
+ ),
537
+ "the task's Files boundary could not be hidden after setup failed",
538
+ );
539
+ }
540
+
541
+ try {
542
+ await unmount(environment, binding.workspacePath, {
543
+ admit: () => assertCandidate(state, candidate, signal, true),
544
+ });
545
+ } catch (unmountError) {
546
+ // A close or replacement which won while compensation was awaiting the
547
+ // provider owns the remaining cleanup. Never retire it for this stale
548
+ // setup attempt.
549
+ assertCandidate(state, candidate, signal, true);
550
+ throwAuthoritativeFailure(
551
+ state.assignment,
552
+ "mount-retirement-unproven",
553
+ new AggregateError(
554
+ [error, unmountError],
555
+ "the task's Files mounts could not be proven absent after setup failed",
556
+ ),
557
+ "the task's Files mounts could not be proven absent after setup failed",
558
+ );
559
+ }
560
+
561
+ binding.mounted = false;
562
+ binding.published = false;
563
+ binding.mountIdentity = null;
564
+ assertCandidate(state, candidate, signal, true);
565
+ throw error;
566
+ };
567
+
568
+ return {
569
+ async install(request): Promise<void> {
570
+ if (stopped) throw new Error("the Files consumer is shut down");
571
+ if (!ready()) throw new Error("task-machine Files v1 is unavailable on this host");
572
+ if (!request.files || !sameAssignment(request.assignment, request.files.assignment)) {
573
+ throw new Error("a task-machine grant has no Files session for its assignment");
574
+ }
575
+ const existing = states.get(request.assignment.taskId);
576
+ if (existing && existing.assignment.runEpoch > request.assignment.runEpoch) {
577
+ throw new Error("an older run cannot install a Files session");
578
+ }
579
+ // A credential.changed frame changes an engine or MCP record inside the
580
+ // same lease. It does not replace the Files session, remount anything,
581
+ // or abort runtimeReady already under way. Retarget only the view used
582
+ // by a gate which the registry may reopen after a refused credential.
583
+ if (request.reason === "credential-change") {
584
+ const prior = [existing?.candidate, existing?.served].find(
585
+ (candidate): candidate is FilesCandidate => candidate?.view === request.previous,
586
+ );
587
+ if (
588
+ !existing ||
589
+ !sameAssignment(existing.assignment, request.assignment) ||
590
+ !prior ||
591
+ !sameFilesAccess(prior.access, request.files)
592
+ ) {
593
+ throw new Error("a Files credential change does not match its grant");
594
+ }
595
+ if (existing.gateOpen && !prior.controller.signal.aborted) {
596
+ // The normal case: preserve the exact candidate so a Files setup
597
+ // already in flight remains authoritative.
598
+ assertCandidate(existing, prior, undefined, prior === existing.served);
599
+ prior.view = request.next;
600
+ prior.installCurrent = request.isCurrent;
601
+ return;
602
+ }
603
+ // A refused credential closes the gate before its selective cleanup.
604
+ // Its candidate was aborted by that close, so give the same Files
605
+ // session a fresh authority which the registry can open afterwards.
606
+ const replacement: FilesCandidate = {
607
+ view: request.next,
608
+ access: request.files,
609
+ installCurrent: request.isCurrent,
610
+ controller: new AbortController(),
611
+ };
612
+ existing.candidate = replacement;
613
+ assertCandidate(existing, replacement);
614
+ return;
615
+ }
616
+ const controller = new AbortController();
617
+ const candidate: FilesCandidate = {
618
+ view: request.next,
619
+ access: request.files,
620
+ installCurrent: request.isCurrent,
621
+ controller,
622
+ };
623
+ let state: FilesState;
624
+ if (!existing || existing.assignment.runEpoch < request.assignment.runEpoch) {
625
+ if (existing?.candidate && existing.candidate !== existing.served) {
626
+ existing.candidate.controller.abort(abortError());
627
+ }
628
+ let recovered: ReturnType<typeof recoveredBinding>;
629
+ try {
630
+ recovered = recoveredBinding(request.assignment);
631
+ } catch (error) {
632
+ if (request.isCurrent()) {
633
+ terminalFailure(
634
+ request.assignment,
635
+ "setup-unproven",
636
+ error,
637
+ "the recovered task's Files boundary could not be reconstructed",
638
+ );
639
+ }
640
+ throw error;
641
+ }
642
+ state = {
643
+ assignment: { ...request.assignment },
644
+ candidate,
645
+ served: null,
646
+ binding: recovered
647
+ ? {
648
+ ...recovered,
649
+ environment: null,
650
+ mounted: false,
651
+ published: false,
652
+ mountIdentity: null,
653
+ }
654
+ : null,
655
+ gateOpen: false,
656
+ tail: Promise.resolve(),
657
+ };
658
+ states.set(request.assignment.taskId, state);
659
+ } else {
660
+ if (existing.candidate && existing.candidate !== existing.served) {
661
+ existing.candidate.controller.abort(abortError());
662
+ }
663
+ existing.candidate = candidate;
664
+ state = existing;
665
+ }
666
+ // A running/recovered runtime refreshes its credential_process session
667
+ // before the replacement grant can open. A first grant has no binding
668
+ // yet and remains memory-only until runtimeReady.
669
+ if (state.binding) {
670
+ const binding = state.binding;
671
+ await queue(state, () => materialize(state, candidate, binding, undefined, false));
672
+ }
673
+ assertCandidate(state, candidate);
674
+ },
675
+
676
+ open(assignment, view): void {
677
+ const state = states.get(assignment.taskId);
678
+ if (
679
+ stopped ||
680
+ !state ||
681
+ !sameAssignment(state.assignment, assignment) ||
682
+ !state.candidate ||
683
+ state.candidate.view !== view ||
684
+ state.candidate.controller.signal.aborted
685
+ ) {
686
+ throw new Error("the Files gate cannot open on this grant");
687
+ }
688
+ const next = state.candidate;
689
+ const previous = state.served;
690
+ const binding = state.binding;
691
+ if (binding?.mounted && !binding.published) {
692
+ publishBoundary(state.assignment, binding, next.access);
693
+ }
694
+ state.served = next;
695
+ state.gateOpen = true;
696
+ if (previous && previous !== next) previous.controller.abort(abortError());
697
+ },
698
+
699
+ close(assignment): void {
700
+ const state = states.get(assignment.taskId);
701
+ if (!state || state.assignment.runEpoch > assignment.runEpoch) return;
702
+ state.gateOpen = false;
703
+ const served = state.served;
704
+ served?.controller.abort(abortError());
705
+ if (state.candidate !== served) state.candidate?.controller.abort(abortError());
706
+ if (state.binding) {
707
+ try {
708
+ // Do not trust `published`: a LINK helper may have crossed its
709
+ // final chmod before its caller lost the result. HIDE is
710
+ // idempotent and only removes the exact fixed-root link.
711
+ hide(state.binding.workspacePath);
712
+ state.binding.published = false;
713
+ } catch (error) {
714
+ throw terminalFailure(
715
+ state.assignment,
716
+ "visibility-unproven",
717
+ error,
718
+ "the task's Files boundary could not be hidden while its gate closed",
719
+ );
720
+ }
721
+ }
722
+ },
723
+
724
+ async runtimeReady(assignment, environment, mode, signal): Promise<void> {
725
+ const state = states.get(assignment.taskId);
726
+ if (!state || !sameAssignment(state.assignment, assignment)) {
727
+ throw new Error("the task has no Files session for this run");
728
+ }
729
+ const candidate = state.served;
730
+ if (!candidate || !state.gateOpen) {
731
+ throw new Error("the task's Files session is not open");
732
+ }
733
+ let binding: FilesBinding;
734
+ try {
735
+ assertCanonicalTaskMachineRuntime(assignment, environment);
736
+ const existing = state.binding;
737
+ const locator = serializeTaskEnvironmentLocator(environment.descriptor.locator);
738
+ if (existing) {
739
+ if (
740
+ serializeTaskEnvironmentLocator(existing.locator) !== locator ||
741
+ existing.workspacePath !== environment.descriptor.workspacePath ||
742
+ existing.mode !== mode
743
+ ) {
744
+ throw new Error("the run's Files runtime changed without cleanup");
745
+ }
746
+ // A lost-response task-up retry may hand over a newly fenced wrapper
747
+ // for the same runtime. Keep the established mount; only refresh the
748
+ // exact session and replace the callable handle.
749
+ existing.environment = environment;
750
+ binding = existing;
751
+ } else {
752
+ binding = {
753
+ locator: environment.descriptor.locator,
754
+ workspacePath: environment.descriptor.workspacePath,
755
+ mode,
756
+ environment,
757
+ mounted: false,
758
+ published: false,
759
+ mountIdentity: null,
760
+ };
761
+ state.binding = binding;
762
+ }
763
+ } catch (error) {
764
+ // This check and callback are deliberately synchronous. A lost-response
765
+ // retry may abort an older start for this same run, or a renewal may
766
+ // replace the candidate while setup is suspended; neither stale
767
+ // rejection is allowed to close the winner's grant.
768
+ let authoritative = false;
769
+ try {
770
+ assertCandidate(state, candidate, signal, true);
771
+ authoritative = true;
772
+ } catch {
773
+ // Stale/withdrawn setup: its owner performs no broader cleanup.
774
+ }
775
+ if (!authoritative) throw error;
776
+ // A runtime which did not pass the canonical identity checks cannot
777
+ // safely be used to prove the fixed mounts absent. This is an
778
+ // invariant failure rather than an ordinary provider/setup error.
779
+ throwAuthoritativeFailure(
780
+ assignment,
781
+ "setup-unproven",
782
+ error,
783
+ "the task's Files boundary could not be established",
784
+ );
785
+ }
786
+ await queue(state, async () => {
787
+ try {
788
+ await materialize(state, candidate, binding, signal, true);
789
+ } catch (error) {
790
+ // Compensation belongs to the serialized operation too. A retry
791
+ // queued behind this failure must not begin its mount while the
792
+ // failed attempt is still hiding or unmounting.
793
+ await compensateRuntimeReadyFailure(
794
+ state,
795
+ candidate,
796
+ binding,
797
+ environment,
798
+ signal,
799
+ error,
800
+ );
801
+ }
802
+ });
803
+ },
804
+
805
+ retire(assignment, error): void {
806
+ terminalFailure(
807
+ assignment,
808
+ "setup-unproven",
809
+ error,
810
+ "the task's Files boundary could not be established",
811
+ );
812
+ },
813
+
814
+ async closeConsumers(request: TaskCredentialCleanupRequest): Promise<void> {
815
+ const state = states.get(request.taskId);
816
+ // A selective credential cleanup never owns the Files session, but it
817
+ // still joins a write admitted before its gate closed.
818
+ if (request.credentialIds !== undefined) {
819
+ if (state && state.assignment.runEpoch <= request.runEpoch) {
820
+ await state.tail.catch(() => undefined);
821
+ }
822
+ return;
823
+ }
824
+ if (state && state.assignment.runEpoch <= request.runEpoch) {
825
+ state.gateOpen = false;
826
+ const served = state.served;
827
+ served?.controller.abort(abortError());
828
+ if (state.candidate !== served) state.candidate?.controller.abort(abortError());
829
+ await state.tail.catch(() => undefined);
830
+ }
831
+
832
+ const rows = list(request.taskId).filter(
833
+ (row) => row.lane === "files-session" && row.runEpoch <= request.runEpoch,
834
+ );
835
+ const newer = list(request.taskId).filter(
836
+ (row) => row.lane === "files-session" && row.runEpoch > request.runEpoch,
837
+ );
838
+ const newerLocators = new Set(newer.map((row) => row.runtimeLocator));
839
+ const locators = new Map<string, TaskEnvironmentLocator>();
840
+ for (const row of rows) {
841
+ if (!newerLocators.has(row.runtimeLocator)) {
842
+ locators.set(row.runtimeLocator, parseTaskEnvironmentLocator(row.runtimeLocator));
843
+ }
844
+ }
845
+ if (state?.binding && state.assignment.runEpoch <= request.runEpoch) {
846
+ const serialized = rows.find(
847
+ (row) => row.runtimeLocator === serializeTaskEnvironmentLocator(state.binding!.locator),
848
+ );
849
+ // The inventory normally supplies it. Keep the live binding as a
850
+ // fallback for injected test stores that intentionally omit rows.
851
+ if (!serialized && newerLocators.size === 0) {
852
+ locators.set(serializeTaskEnvironmentLocator(state.binding.locator), state.binding.locator);
853
+ }
854
+ }
855
+ const failures: unknown[] = [];
856
+ for (const [serialized, locator] of locators) {
857
+ const stillOwnedByCleanup = (): boolean => {
858
+ const current = states.get(request.taskId);
859
+ if (current && current.assignment.runEpoch > request.runEpoch) return false;
860
+ return !list(request.taskId).some(
861
+ (row) =>
862
+ row.lane === "files-session" &&
863
+ row.runEpoch > request.runEpoch &&
864
+ row.runtimeLocator === serialized,
865
+ );
866
+ };
867
+ if (!stillOwnedByCleanup()) continue;
868
+ try {
869
+ // The runtime-ready handle is fenced by the task-up hold and is
870
+ // deliberately unusable once this cleanup closes the gate. Rebuild
871
+ // an unfenced cleanup handle from the durable locator instead.
872
+ const environment = await reconstruct(locator);
873
+ if (environment.descriptor.taskId !== request.taskId) {
874
+ throw new Error("the Files cleanup runtime belongs to another task");
875
+ }
876
+ if (!stillOwnedByCleanup()) continue;
877
+ await unmount(environment, environment.descriptor.workspacePath, {
878
+ admit: () => {
879
+ if (!stillOwnedByCleanup()) {
880
+ throw new Error("a newer run owns the task's Files mounts");
881
+ }
882
+ },
883
+ });
884
+ } catch (error) {
885
+ // Ownership may transfer while the provider resolves the runtime.
886
+ // In that case the newer run's own setup/cleanup owns both fixed
887
+ // mountpoints; the obsolete cleanup must neither touch nor block it.
888
+ if (stillOwnedByCleanup()) failures.push(error);
889
+ }
890
+ }
891
+ if (failures.length > 0) {
892
+ throw terminalFailure(
893
+ { taskId: request.taskId, runEpoch: request.runEpoch },
894
+ "mount-retirement-unproven",
895
+ new AggregateError(failures, "the task's Files mounts could not be proven absent"),
896
+ "the task's Files mounts could not be proven absent",
897
+ );
898
+ }
899
+ if (state && state.assignment.runEpoch <= request.runEpoch) {
900
+ const continuingReason = request.lease?.reason;
901
+ const continues =
902
+ state.assignment.runEpoch === request.runEpoch &&
903
+ (continuingReason === "denied" ||
904
+ continuingReason === "expired" ||
905
+ continuingReason === "minimum-revision" ||
906
+ continuingReason === "manifest-narrowed" ||
907
+ continuingReason === "cleanup-failed" ||
908
+ continuingReason === "fence-failed");
909
+ if (continues && state.binding) {
910
+ // The registry asks for another grant after these cleanups. Keep
911
+ // only non-secret runtime identity, and force its next install to
912
+ // reconstruct and establish the fixed mounts before the gate opens.
913
+ state.binding.environment = null;
914
+ state.binding.mounted = false;
915
+ state.binding.published = false;
916
+ state.binding.mountIdentity = null;
917
+ state.candidate = null;
918
+ state.served = null;
919
+ } else {
920
+ states.delete(request.taskId);
921
+ }
922
+ }
923
+ },
924
+
925
+ shutdown(): void {
926
+ stopped = true;
927
+ for (const state of states.values()) {
928
+ state.candidate?.controller.abort(abortError());
929
+ if (state.served !== state.candidate) state.served?.controller.abort(abortError());
930
+ }
931
+ states.clear();
932
+ },
933
+ };
934
+ }