@runuai/host 0.9.149 → 0.9.151

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 (107) hide show
  1. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  2. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  3. package/db/migrations/meta/_journal.json +14 -0
  4. package/db/schema.ts +188 -0
  5. package/lib/agent-cli.ts +11 -13
  6. package/lib/agent.ts +177 -7
  7. package/lib/agents/durable-proc.ts +56 -44
  8. package/lib/agents/registry.ts +19 -3
  9. package/lib/agents/session-state.ts +23 -10
  10. package/lib/agents/transport.ts +101 -69
  11. package/lib/apple-one-off.ts +277 -0
  12. package/lib/apple-runtime-recycle.ts +4 -3
  13. package/lib/attachments.ts +19 -17
  14. package/lib/capture-flow.ts +319 -0
  15. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  16. package/lib/cloud-credential-host.ts +512 -0
  17. package/lib/cloud-credential-leases.ts +2544 -0
  18. package/lib/cloud-credential-runtime.ts +1035 -0
  19. package/lib/codex-auth.ts +21 -1
  20. package/lib/container-runtime.ts +76 -0
  21. package/lib/credential-cleanup-inventory.ts +405 -0
  22. package/lib/credential-consumers/engine-anchor.ts +1876 -0
  23. package/lib/credential-consumers/engines.ts +773 -0
  24. package/lib/credential-consumers/github.ts +594 -0
  25. package/lib/credential-consumers/mcp.ts +384 -0
  26. package/lib/credential-consumers/task-files.ts +127 -0
  27. package/lib/credential-store-pickup.ts +15 -2
  28. package/lib/credential-store.ts +361 -33
  29. package/lib/credentials-activation-authority.ts +546 -0
  30. package/lib/credentials-purge.ts +1006 -0
  31. package/lib/db.ts +11 -0
  32. package/lib/docker-exec.ts +3 -1
  33. package/lib/engine-accounts.ts +42 -32
  34. package/lib/engine-connect.ts +47 -2
  35. package/lib/engine-credential-env.ts +50 -0
  36. package/lib/engine-login.ts +83 -1
  37. package/lib/env-file.ts +62 -8
  38. package/lib/env.ts +5 -0
  39. package/lib/fs-aliases.ts +49 -0
  40. package/lib/git-clone-credentials.ts +334 -0
  41. package/lib/git-diff.ts +219 -177
  42. package/lib/git-identity.ts +59 -14
  43. package/lib/github-origin.ts +28 -0
  44. package/lib/github-tokens.ts +56 -21
  45. package/lib/host-env.ts +17 -4
  46. package/lib/launch-mode.ts +25 -0
  47. package/lib/legacy-runtime-retirement.ts +388 -0
  48. package/lib/machine-exec.ts +14 -3
  49. package/lib/machine-provider-aws.ts +50 -8
  50. package/lib/machine-provider-local.ts +4 -0
  51. package/lib/machine-provider.ts +4 -0
  52. package/lib/managed-operation-drain.ts +8 -0
  53. package/lib/managed-runtime.ts +371 -7
  54. package/lib/mcp-capture.ts +860 -0
  55. package/lib/mcp-connections.ts +8 -1
  56. package/lib/mcp-gateway.ts +168 -10
  57. package/lib/obs.ts +2 -6
  58. package/lib/orchestrator.ts +1847 -236
  59. package/lib/person-capture.ts +327 -0
  60. package/lib/project-env-legacy-tags.ts +130 -0
  61. package/lib/project-env-task-replica.ts +1752 -0
  62. package/lib/runner-boot.ts +10 -0
  63. package/lib/runtime-activation-tail.ts +50 -3
  64. package/lib/runtime-archive.ts +68 -1
  65. package/lib/runtime-reconnect.ts +86 -0
  66. package/lib/runtime-state.ts +429 -13
  67. package/lib/safe-http.ts +364 -0
  68. package/lib/secrets-proxy/task-trust.ts +34 -3
  69. package/lib/sidecar-resume.ts +18 -5
  70. package/lib/skills.ts +47 -8
  71. package/lib/standard-image.ts +157 -103
  72. package/lib/stopped-task-status-queue.ts +68 -10
  73. package/lib/task-credential-cleanup.ts +525 -0
  74. package/lib/task-diff-sandbox.ts +464 -0
  75. package/lib/task-diff.ts +66 -163
  76. package/lib/task-env-source.ts +68 -14
  77. package/lib/task-environment/anchor.ts +31 -0
  78. package/lib/task-environment/apple-container.ts +69 -28
  79. package/lib/task-environment/docker.ts +101 -20
  80. package/lib/task-environment/machine-environment.ts +61 -0
  81. package/lib/task-environment/machine-task-up.ts +459 -96
  82. package/lib/task-environment/machine.ts +101 -11
  83. package/lib/task-environment/self.ts +108 -50
  84. package/lib/task-environment/types.ts +89 -5
  85. package/lib/task-environment/workspace-files.ts +623 -25
  86. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  87. package/lib/task-gateway-recovery.ts +4 -1
  88. package/lib/task-gateway-state.ts +15 -0
  89. package/lib/task-machine-swap-safety.ts +729 -0
  90. package/lib/token-broker-adopt.ts +344 -0
  91. package/lib/token-broker-lambda.ts +56 -2
  92. package/lib/token-broker-local.ts +28 -4
  93. package/lib/token-broker-revision.ts +310 -0
  94. package/lib/token-broker-ssm.ts +368 -28
  95. package/lib/token-lanes/openai-oauth.ts +7 -3
  96. package/lib/token-lanes/xai-oauth.ts +7 -3
  97. package/lib/transcript.ts +9 -5
  98. package/package.json +6 -2
  99. package/scripts/agent/task-up.sh +242 -100
  100. package/src/cloud-credentials.ts +2399 -0
  101. package/src/event-outbox.ts +8 -6
  102. package/src/index.ts +1386 -875
  103. package/src/main.ts +1062 -140
  104. package/src/outbound-frame.ts +31 -0
  105. package/src/project-env-policy.ts +125 -10
  106. package/src/protocol.ts +699 -23
  107. package/src/ui/server.ts +48 -10
@@ -0,0 +1,2544 @@
1
+ /**
2
+ * ADR-154 task-scoped credential leases.
3
+ *
4
+ * This is deliberately separate from the ADR-133 singleton lease manager.
5
+ * A registry owns one in-memory bundle per live task assignment and never
6
+ * exposes a credentials session as a general-purpose AWS credential. The
7
+ * only AWS operation a credentials session can perform here is an exact,
8
+ * version-pinned GetParameters request.
9
+ */
10
+
11
+ import { createHash, randomUUID } from "node:crypto";
12
+
13
+ import {
14
+ GetParametersCommand,
15
+ SSMClient,
16
+ type Parameter,
17
+ } from "@aws-sdk/client-ssm";
18
+
19
+ import {
20
+ canonicalManifestJson,
21
+ parseCredentialChangedFrame,
22
+ parseCredentialRefreshResultFrame,
23
+ parseLeaseGrantV1Frame,
24
+ parseLeaseParameterName,
25
+ parseLeaseStaleFrame,
26
+ parseLeaseUnavailableFrame,
27
+ parseLeasedValue,
28
+ taskLeaseV1ExpiresAt,
29
+ type CredentialChangedFrame,
30
+ type CredentialLocation,
31
+ type CredentialRefreshFrame,
32
+ type CredentialRefreshResultFrame,
33
+ type LeaseFiles,
34
+ type LeaseGrantV1Frame,
35
+ type LeaseManifestEngine,
36
+ type LeaseManifestMcp,
37
+ type LeaseManifestStatic,
38
+ type LeaseRequestV1Frame,
39
+ type LeaseSession,
40
+ type LeaseStaleFrame,
41
+ type LeaseUnavailableFrame,
42
+ type LeasedValue,
43
+ type TaskLeaseV1,
44
+ } from "../src/cloud-credentials";
45
+ import type { ProjectEnvValueRef } from "../src/protocol";
46
+
47
+ export const LEASE_RENEW_LEAD_MS = 10 * 60_000;
48
+ export const LEASE_REQUEST_TIMEOUT_MS = 20_000;
49
+ export const LEASE_RETRY_MS = 30_000;
50
+ export const LEASE_MIN_REVISION_GRACE_MS = 60_000;
51
+
52
+ const MAX_SSM_GET_PARAMETERS = 10;
53
+ const PROJECT_PATH_SEGMENT = /^[A-Za-z0-9_.-]+$/;
54
+ const PROJECT_ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
55
+ const PROJECT_ENV_STAGING_ID =
56
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
57
+ const OP_ID = /^[A-Za-z0-9_-]{1,128}$/;
58
+ const MAX_ERROR_CHARS = 512;
59
+
60
+ type Timer = ReturnType<typeof setTimeout>;
61
+
62
+ export interface TaskAssignment {
63
+ taskId: string;
64
+ runEpoch: number;
65
+ }
66
+
67
+ export type LeaseGateCloseReason =
68
+ | "starting"
69
+ | "recovery"
70
+ | "superseded"
71
+ | "minimum-revision"
72
+ | "manifest-narrowed"
73
+ | "denied"
74
+ | "expired"
75
+ | "stopped"
76
+ | "parked"
77
+ | "teardown"
78
+ | "cleanup-failed"
79
+ /** A launch slot of the run could not be retired in its runtime (§6):
80
+ * only the run's full cleanup proves no launch of it runs. */
81
+ | "fence-failed"
82
+ /** The runner process is ending: no cleanup here, the next process's
83
+ * recovery sweep runs it (§5, §6). */
84
+ | "shutdown";
85
+
86
+ export type CredentialCleanupReason = LeaseGateCloseReason | "lease-replaced";
87
+
88
+ export interface CredentialCleanupIdentity {
89
+ class: "engine" | "mcp" | "github" | "ssh";
90
+ credentialId: string;
91
+ revision: string | null;
92
+ }
93
+
94
+ export interface CredentialCleanupRequest {
95
+ assignment: TaskAssignment;
96
+ mode: "selective" | "full";
97
+ reason: CredentialCleanupReason;
98
+ /** Qualified old identities, never merely unscoped credential ids. */
99
+ removed: readonly CredentialCleanupIdentity[];
100
+ /** Full cleanup on a task machine must also remove `/run/uai` material. */
101
+ removeFilesSession: boolean;
102
+ }
103
+
104
+ export type EngineLeasedValue = Extract<
105
+ LeasedValue,
106
+ { class: "engine" }
107
+ >;
108
+ export type McpLeasedValue = Extract<LeasedValue, { class: "mcp" }>;
109
+ export type GithubLeasedValue = Extract<LeasedValue, { class: "github" }>;
110
+ export type SshLeasedValue = Extract<LeasedValue, { class: "ssh" }>;
111
+ export type CredentialMaterialAvailability =
112
+ | "available"
113
+ | "access-denied"
114
+ | "not-required";
115
+
116
+ export interface TaskEngineCredential {
117
+ manifest: Readonly<LeaseManifestEngine>;
118
+ availability: Exclude<CredentialMaterialAvailability, "not-required">;
119
+ material: Readonly<EngineLeasedValue> | null;
120
+ observedVersion: number | null;
121
+ }
122
+
123
+ export interface TaskMcpConnection {
124
+ manifest: Readonly<LeaseManifestMcp>;
125
+ availability: CredentialMaterialAvailability;
126
+ material: Readonly<McpLeasedValue> | null;
127
+ observedVersion: number | null;
128
+ }
129
+
130
+ export interface TaskStaticCredential<T extends LeasedValue> {
131
+ manifest: Readonly<LeaseManifestStatic>;
132
+ availability: Exclude<CredentialMaterialAvailability, "not-required">;
133
+ material: Readonly<T> | null;
134
+ observedVersion: number | null;
135
+ }
136
+
137
+ /** A task-facing view. It contains material, but never STS credentials. */
138
+ export interface TaskCredentialView {
139
+ assignment: Readonly<TaskAssignment>;
140
+ leaseId: string;
141
+ manifestRevision: number;
142
+ manifestDigest: string;
143
+ expiresAt: number;
144
+ projectIds: readonly string[];
145
+ /** Preserves the manifest's try order exactly. */
146
+ engines: readonly TaskEngineCredential[];
147
+ mcp: readonly TaskMcpConnection[];
148
+ github: TaskStaticCredential<GithubLeasedValue> | null;
149
+ ssh: TaskStaticCredential<SshLeasedValue> | null;
150
+ }
151
+
152
+ /** The one deliberately narrow escape hatch for Mountpoint on task machines. */
153
+ export interface TaskLeaseFilesAccess {
154
+ assignment: Readonly<TaskAssignment>;
155
+ region: string;
156
+ files: Readonly<LeaseFiles>;
157
+ credentials: Readonly<{
158
+ accessKeyId: string;
159
+ secretAccessKey: string;
160
+ sessionToken: string;
161
+ expiresAt: number;
162
+ }>;
163
+ }
164
+
165
+ export interface CredentialInstallRequest {
166
+ assignment: TaskAssignment;
167
+ /** The workspace and person the cloud granted this lease for: what the
168
+ * env replica and consumers key on, never a guess from task input. */
169
+ owner: Readonly<{ orgId: string; userId: string }>;
170
+ previous: TaskCredentialView | null;
171
+ next: TaskCredentialView;
172
+ files: TaskLeaseFilesAccess | null;
173
+ reason: "grant" | "credential-change";
174
+ projectSetChanged: boolean;
175
+ /** This grant's project values, readable while it is being installed (the
176
+ * env replica's snapshot, before the gate opens) and then while it is the
177
+ * task's usable grant; never after it is abandoned or replaced. */
178
+ projectValues: ProjectEnvTaskValueReader;
179
+ /** False once this install can no longer commit (its grant withdrawn,
180
+ * replaced, refused or ended): a long step, such as awaiting the env
181
+ * snapshot, gives up then. */
182
+ isCurrent(): boolean;
183
+ }
184
+
185
+ export interface StructuredProjectValueRead {
186
+ projectId: string;
187
+ key: string;
188
+ valueRef: ProjectEnvValueRef;
189
+ }
190
+
191
+ export interface ProjectValueReadResult {
192
+ /** The exact authenticated envelope; the replica opens it independently. */
193
+ rawValue: string;
194
+ version: number;
195
+ parameter: string;
196
+ }
197
+
198
+ /** Adapter consumed by the v1 task-keyed env replica. */
199
+ export interface ProjectEnvTaskValueReader {
200
+ readStaged(
201
+ projectId: string,
202
+ key: string,
203
+ stagingId: string,
204
+ ): Promise<string | null>;
205
+ readLegacy(
206
+ projectId: string,
207
+ key: string,
208
+ version: number,
209
+ ): Promise<{ value: string; version: number } | null>;
210
+ }
211
+
212
+ export interface ExactSsmParameter {
213
+ name: string;
214
+ type: string;
215
+ value: string;
216
+ version: number;
217
+ }
218
+
219
+ export interface ExactSsmParameterBatch {
220
+ parameters: readonly ExactSsmParameter[];
221
+ invalidParameters: readonly string[];
222
+ }
223
+
224
+ /** A testable, deliberately tiny surface over one explicitly credentialed client. */
225
+ export interface CredentialSessionParameterReader {
226
+ getParameters(selectors: readonly string[]): Promise<ExactSsmParameterBatch>;
227
+ destroy?(): void | Promise<void>;
228
+ }
229
+
230
+ export type CredentialSessionReaderFactory = (args: {
231
+ region: string;
232
+ session: Readonly<LeaseSession>;
233
+ assignment: Readonly<TaskAssignment>;
234
+ }) => CredentialSessionParameterReader;
235
+
236
+ export interface TaskCredentialLeaseRegistryOptions {
237
+ runnerKind: "owned" | "task-machine";
238
+ send(frame: LeaseRequestV1Frame | CredentialRefreshFrame): boolean;
239
+ /**
240
+ * Must synchronously stop new consumers/spawns for this assignment.
241
+ * Throwing quarantines the assignment until a successful close is followed
242
+ * by full cleanup; callers must never treat an exception as a closed gate.
243
+ */
244
+ closeTaskGate(
245
+ assignment: Readonly<TaskAssignment>,
246
+ reason: LeaseGateCloseReason,
247
+ ): void;
248
+ /**
249
+ * A grant was accepted for the assignment (synchronously, before anything
250
+ * of it is read): what the returned function undoes ends when that grant's
251
+ * handling does. The env replica refuses the run's frames meanwhile, until
252
+ * the grant's own reader activates.
253
+ */
254
+ grantAccepted?(assignment: Readonly<TaskAssignment>): (() => void) | void;
255
+ /** Called only after all private reads and validation have succeeded. */
256
+ openTaskGate?(
257
+ assignment: Readonly<TaskAssignment>,
258
+ view: TaskCredentialView,
259
+ ): void;
260
+ cleanupExternal(request: CredentialCleanupRequest): Promise<void>;
261
+ /**
262
+ * Installs files/proxy/engine consumers transactionally. The env-replica
263
+ * integration uses `projectSetChanged` to await an exact committed snapshot
264
+ * before returning; the registry does not open its gate before this resolves.
265
+ */
266
+ installExternal?(request: CredentialInstallRequest): Promise<void>;
267
+ createSessionReader?: CredentialSessionReaderFactory;
268
+ now?: () => number;
269
+ setTimer?: typeof setTimeout;
270
+ clearTimer?: typeof clearTimeout;
271
+ randomId?: () => string;
272
+ onError?(error: Error, assignment: Readonly<TaskAssignment>): void;
273
+ }
274
+
275
+ export interface LeaseDenyFrame {
276
+ kind: "lease.deny";
277
+ opId: string;
278
+ error: string;
279
+ }
280
+
281
+ type CredentialDescriptor = {
282
+ class: CredentialCleanupIdentity["class"];
283
+ credentialId: string;
284
+ revision: string | null;
285
+ parameter: string | null;
286
+ version: number | null;
287
+ sessionId: string | null;
288
+ authKind: "env" | "config-dir" | "oauth" | "token" | "none" | null;
289
+ location: CredentialLocation | null;
290
+ };
291
+
292
+ type CredentialRecord = {
293
+ descriptor: CredentialDescriptor;
294
+ availability: CredentialMaterialAvailability;
295
+ material: LeasedValue | null;
296
+ observedVersion: number | null;
297
+ };
298
+
299
+ class ManagedSessionReader {
300
+ readonly id: string;
301
+ readonly #reader: CredentialSessionParameterReader;
302
+ #sealed = false;
303
+ #inFlight = 0;
304
+ #drained: Array<() => void> = [];
305
+ #destroyed = false;
306
+
307
+ constructor(id: string, reader: CredentialSessionParameterReader) {
308
+ this.id = id;
309
+ this.#reader = reader;
310
+ }
311
+
312
+ async read(selectors: readonly string[]): Promise<ExactSsmParameterBatch> {
313
+ if (this.#sealed) throw new CredentialLeaseUnavailableError("credential reader is sealed");
314
+ this.#inFlight++;
315
+ try {
316
+ return await this.#reader.getParameters(selectors);
317
+ } finally {
318
+ this.#inFlight--;
319
+ if (this.#inFlight === 0) {
320
+ for (const resolve of this.#drained.splice(0)) resolve();
321
+ }
322
+ }
323
+ }
324
+
325
+ seal(): void {
326
+ this.#sealed = true;
327
+ }
328
+
329
+ async destroyWhenDrained(): Promise<void> {
330
+ this.seal();
331
+ if (this.#inFlight > 0) {
332
+ await new Promise<void>((resolve) => this.#drained.push(resolve));
333
+ }
334
+ if (this.#destroyed) return;
335
+ this.#destroyed = true;
336
+ await this.#reader.destroy?.();
337
+ }
338
+ }
339
+
340
+ type LeaseBundle = {
341
+ lease: TaskLeaseV1;
342
+ expiresAt: number;
343
+ readers: Map<string, ManagedSessionReader>;
344
+ records: Map<string, CredentialRecord>;
345
+ projectSessions: Map<string, string>;
346
+ view: TaskCredentialView;
347
+ };
348
+
349
+ type PendingLease = {
350
+ opId: string;
351
+ timer: Timer;
352
+ answering: boolean;
353
+ };
354
+
355
+ type RefreshPending = {
356
+ opId: string;
357
+ key: string;
358
+ assignment: TaskAssignment;
359
+ credentialId: string;
360
+ revision: string;
361
+ bundleGeneration: number;
362
+ timer: Timer;
363
+ resolve(result: CredentialRefreshResultFrame): void;
364
+ };
365
+
366
+ type ReadyWaiter = {
367
+ resolve(view: TaskCredentialView): void;
368
+ reject(error: Error): void;
369
+ signal?: AbortSignal;
370
+ abort?: () => void;
371
+ };
372
+
373
+ type TaskState = {
374
+ assignment: TaskAssignment;
375
+ phase: "recovering" | "waiting" | "usable" | "narrowing" | "lost" | "ended";
376
+ gateOpen: boolean;
377
+ gateCloseFailed: boolean;
378
+ bundle: LeaseBundle | null;
379
+ bundleGeneration: number;
380
+ wantedRevision: number;
381
+ minRevision: number;
382
+ /**
383
+ * Non-secret identities from the last installed bundle. A below-floor
384
+ * bundle is destroyed after its first selective cleanup, but the cloud may
385
+ * advance the floor again before a replacement grant arrives. Keep just
386
+ * enough identity to qualify those later accumulated removals.
387
+ */
388
+ identitiesByCredentialId: Map<string, CredentialCleanupIdentity>;
389
+ removed: Map<string, CredentialCleanupIdentity>;
390
+ cleaned: Set<string>;
391
+ pendingLease: PendingLease | null;
392
+ refreshByOp: Map<string, RefreshPending>;
393
+ refreshByCredential: Map<string, Promise<CredentialRefreshResultFrame>>;
394
+ renewalTimer: Timer | null;
395
+ retryTimer: Timer | null;
396
+ expiryTimer: Timer | null;
397
+ floorTimer: Timer | null;
398
+ /** A failed cleanup's retry. A full cleanup cancels the one pending when
399
+ * it starts, and while it is under way or failed no selective one is
400
+ * armed: a selective retry never masks a full cleanup's. */
401
+ cleanupRetryTimer: Timer | null;
402
+ floorDeadlineAt: number | null;
403
+ cleanupTail: Promise<void>;
404
+ fullCleanup: Promise<void> | null;
405
+ startup:
406
+ | { kind: "recovery" }
407
+ | { kind: "predecessor"; state: TaskState }
408
+ | null;
409
+ startupPromise: Promise<void> | null;
410
+ waiters: Set<ReadyWaiter>;
411
+ sequence: number;
412
+ /**
413
+ * Transitions that closed the gate to clean credentials up (a grant's
414
+ * narrowing, a refusal) and have not yet opened it on their own view.
415
+ * While one holds it, no other open may happen: a view committed before
416
+ * (a grant still draining its predecessor's readers) would otherwise
417
+ * reopen the gate on what is being cleaned up.
418
+ */
419
+ gateHolds: number;
420
+ };
421
+
422
+ export class CredentialLeaseError extends Error {
423
+ constructor(message: string) {
424
+ super(message);
425
+ this.name = "CredentialLeaseError";
426
+ }
427
+ }
428
+
429
+ export class CredentialLeaseUnavailableError extends CredentialLeaseError {
430
+ constructor(message = "task credentials are not usable") {
431
+ super(message);
432
+ this.name = "CredentialLeaseUnavailableError";
433
+ }
434
+ }
435
+
436
+ function assignmentMatches(a: TaskAssignment, b: TaskAssignment): boolean {
437
+ return a.taskId === b.taskId && a.runEpoch === b.runEpoch;
438
+ }
439
+
440
+ function cloneAssignment(assignment: TaskAssignment): TaskAssignment {
441
+ return { taskId: assignment.taskId, runEpoch: assignment.runEpoch };
442
+ }
443
+
444
+ function validAssignment(assignment: TaskAssignment): boolean {
445
+ return (
446
+ typeof assignment.taskId === "string" &&
447
+ assignment.taskId.length > 0 &&
448
+ assignment.taskId.length <= 128 &&
449
+ !/[\u0000-\u001f\u007f]/.test(assignment.taskId) &&
450
+ Number.isSafeInteger(assignment.runEpoch) &&
451
+ assignment.runEpoch > 0
452
+ );
453
+ }
454
+
455
+ function digestManifest(lease: TaskLeaseV1): string {
456
+ return createHash("sha256")
457
+ .update(canonicalManifestJson(lease.manifest), "utf8")
458
+ .digest("hex");
459
+ }
460
+
461
+ function credentialKey(identity: CredentialCleanupIdentity): string {
462
+ return JSON.stringify([identity.class, identity.credentialId, identity.revision]);
463
+ }
464
+
465
+ function descriptorIdentity(descriptor: CredentialDescriptor): CredentialCleanupIdentity {
466
+ return {
467
+ class: descriptor.class,
468
+ credentialId: descriptor.credentialId,
469
+ revision: descriptor.revision,
470
+ };
471
+ }
472
+
473
+ function descriptorKey(descriptor: CredentialDescriptor): string {
474
+ return credentialKey(descriptorIdentity(descriptor));
475
+ }
476
+
477
+ function descriptorFromEngine(entry: LeaseManifestEngine): CredentialDescriptor {
478
+ return {
479
+ class: "engine",
480
+ credentialId: entry.credentialId,
481
+ revision: entry.revision,
482
+ parameter: entry.parameter,
483
+ version: entry.version,
484
+ sessionId: entry.sessionId,
485
+ authKind: entry.authKind,
486
+ location: parseLeaseParameterName(entry.parameter),
487
+ };
488
+ }
489
+
490
+ function descriptorFromMcp(entry: LeaseManifestMcp): CredentialDescriptor {
491
+ return {
492
+ class: "mcp",
493
+ credentialId: entry.credentialId,
494
+ revision: entry.revision,
495
+ parameter: entry.parameter,
496
+ version: entry.version,
497
+ sessionId: entry.sessionId,
498
+ authKind: entry.authKind,
499
+ location: entry.parameter === null ? null : parseLeaseParameterName(entry.parameter),
500
+ };
501
+ }
502
+
503
+ function descriptorFromStatic(
504
+ entry: LeaseManifestStatic,
505
+ credentialClass: "github" | "ssh",
506
+ ): CredentialDescriptor {
507
+ return {
508
+ class: credentialClass,
509
+ credentialId: entry.credentialId,
510
+ revision: entry.revision,
511
+ parameter: entry.parameter,
512
+ version: entry.version,
513
+ sessionId: entry.sessionId,
514
+ authKind: null,
515
+ location: parseLeaseParameterName(entry.parameter),
516
+ };
517
+ }
518
+
519
+ function manifestDescriptors(lease: TaskLeaseV1): CredentialDescriptor[] {
520
+ const out: CredentialDescriptor[] = [
521
+ ...lease.manifest.engines.map(descriptorFromEngine),
522
+ ...lease.manifest.mcp.map(descriptorFromMcp),
523
+ ];
524
+ if (lease.manifest.github) out.push(descriptorFromStatic(lease.manifest.github, "github"));
525
+ if (lease.manifest.ssh) out.push(descriptorFromStatic(lease.manifest.ssh, "ssh"));
526
+ return out;
527
+ }
528
+
529
+ function materialMatchesDescriptor(
530
+ material: LeasedValue,
531
+ descriptor: CredentialDescriptor,
532
+ ): boolean {
533
+ if (material.class !== descriptor.class) return false;
534
+ if (material.class === "engine") return material.authKind === descriptor.authKind;
535
+ if (material.class === "mcp") return material.authKind === descriptor.authKind;
536
+ return descriptor.authKind === null;
537
+ }
538
+
539
+ function freezeMaterial<T extends LeasedValue>(material: T): Readonly<T> {
540
+ Object.freeze(material.location);
541
+ if (material.class === "engine") {
542
+ if (material.authKind === "env") Object.freeze(material.env);
543
+ else Object.freeze(material.files);
544
+ } else if (material.class === "mcp" && material.authKind === "token") {
545
+ Object.freeze(material.header);
546
+ }
547
+ return Object.freeze(material);
548
+ }
549
+
550
+ function freezeView(
551
+ lease: TaskLeaseV1,
552
+ expiresAt: number,
553
+ records: ReadonlyMap<string, CredentialRecord>,
554
+ ): TaskCredentialView {
555
+ const requireRecord = (credentialId: string): CredentialRecord => {
556
+ const record = records.get(credentialId);
557
+ if (!record) throw new CredentialLeaseError("credential material is incomplete");
558
+ return record;
559
+ };
560
+ const engines = lease.manifest.engines.map((manifest) => {
561
+ const record = requireRecord(manifest.credentialId);
562
+ if (
563
+ record.availability === "not-required" ||
564
+ (record.availability === "available" &&
565
+ (record.material?.class !== "engine" || record.observedVersion === null)) ||
566
+ (record.availability === "access-denied" &&
567
+ (record.material !== null || record.observedVersion !== null))
568
+ ) {
569
+ throw new CredentialLeaseError("engine material is incomplete");
570
+ }
571
+ return Object.freeze({
572
+ manifest: Object.freeze({ ...manifest }),
573
+ availability: record.availability,
574
+ material: record.material as Readonly<EngineLeasedValue> | null,
575
+ observedVersion: record.observedVersion,
576
+ });
577
+ });
578
+ const mcp = lease.manifest.mcp.map((manifest) => {
579
+ const record = requireRecord(manifest.credentialId);
580
+ if (
581
+ (manifest.authKind === "none" &&
582
+ (record.availability !== "not-required" || record.material !== null)) ||
583
+ (manifest.authKind !== "none" && record.availability === "not-required") ||
584
+ (record.availability === "available" && record.material?.class !== "mcp") ||
585
+ (record.availability === "access-denied" && record.material !== null)
586
+ ) {
587
+ throw new CredentialLeaseError("MCP material is incomplete");
588
+ }
589
+ return Object.freeze({
590
+ manifest: Object.freeze({ ...manifest }),
591
+ availability: record.availability,
592
+ material: record.material as Readonly<McpLeasedValue> | null,
593
+ observedVersion: record.observedVersion,
594
+ });
595
+ });
596
+ const staticCredential = <T extends GithubLeasedValue | SshLeasedValue>(
597
+ manifest: LeaseManifestStatic | null,
598
+ credentialClass: T["class"],
599
+ ): TaskStaticCredential<T> | null => {
600
+ if (!manifest) return null;
601
+ const record = requireRecord(manifest.credentialId);
602
+ if (
603
+ record.availability === "not-required" ||
604
+ (record.availability === "available" &&
605
+ (record.material?.class !== credentialClass || record.observedVersion === null)) ||
606
+ (record.availability === "access-denied" &&
607
+ (record.material !== null || record.observedVersion !== null))
608
+ ) {
609
+ throw new CredentialLeaseError(`${credentialClass} material is incomplete`);
610
+ }
611
+ return Object.freeze({
612
+ manifest: Object.freeze({ ...manifest }),
613
+ availability: record.availability,
614
+ material: record.material as T | null,
615
+ observedVersion: record.observedVersion,
616
+ });
617
+ };
618
+ return Object.freeze({
619
+ assignment: Object.freeze({ taskId: lease.taskId, runEpoch: lease.runEpoch }),
620
+ leaseId: lease.leaseId,
621
+ manifestRevision: lease.manifestRevision,
622
+ manifestDigest: lease.manifestDigest,
623
+ expiresAt,
624
+ projectIds: Object.freeze(lease.manifest.projects.map((project) => project.projectId)),
625
+ engines: Object.freeze(engines),
626
+ mcp: Object.freeze(mcp),
627
+ github: staticCredential<GithubLeasedValue>(lease.manifest.github, "github"),
628
+ ssh: staticCredential<SshLeasedValue>(lease.manifest.ssh, "ssh"),
629
+ });
630
+ }
631
+
632
+ function projectSetKey(lease: TaskLeaseV1): string {
633
+ return JSON.stringify([...lease.manifest.projects.map((item) => item.projectId)].sort());
634
+ }
635
+
636
+ function validateProjectValueRead(request: StructuredProjectValueRead): void {
637
+ if (!PROJECT_PATH_SEGMENT.test(request.projectId) || !PROJECT_ENV_KEY.test(request.key)) {
638
+ throw new CredentialLeaseError("invalid project env identity");
639
+ }
640
+ if (request.valueRef.kind === "row") {
641
+ throw new CredentialLeaseError("a cloud lease cannot read a row-backed value");
642
+ }
643
+ }
644
+
645
+ function projectValueReaderOf(
646
+ read: (request: StructuredProjectValueRead) => Promise<ProjectValueReadResult | null>,
647
+ ): ProjectEnvTaskValueReader {
648
+ return {
649
+ readStaged: async (projectId, key, stagingId) =>
650
+ (await read({ projectId, key, valueRef: { kind: "staged", stagingId } }))?.rawValue ?? null,
651
+ readLegacy: async (projectId, key, version) => {
652
+ const result = await read({ projectId, key, valueRef: { kind: "legacy", ssmVersion: version } });
653
+ return result ? { value: result.rawValue, version: result.version } : null;
654
+ },
655
+ };
656
+ }
657
+
658
+ function defaultSessionReaderFactory(args: {
659
+ region: string;
660
+ session: Readonly<LeaseSession>;
661
+ }): CredentialSessionParameterReader {
662
+ const client = new SSMClient({
663
+ region: args.region,
664
+ credentials: {
665
+ accessKeyId: args.session.accessKeyId,
666
+ secretAccessKey: args.session.secretAccessKey,
667
+ sessionToken: args.session.sessionToken,
668
+ expiration: new Date(args.session.expiresAt),
669
+ },
670
+ });
671
+ return {
672
+ async getParameters(selectors) {
673
+ const output = await client.send(
674
+ new GetParametersCommand({ Names: [...selectors], WithDecryption: true }),
675
+ );
676
+ const normalize = (parameter: Parameter): ExactSsmParameter => {
677
+ if (
678
+ typeof parameter.Name !== "string" ||
679
+ typeof parameter.Type !== "string" ||
680
+ typeof parameter.Value !== "string" ||
681
+ typeof parameter.Version !== "number"
682
+ ) {
683
+ throw new CredentialLeaseError("Parameter Store returned an incomplete parameter");
684
+ }
685
+ return {
686
+ name: parameter.Name,
687
+ type: parameter.Type,
688
+ value: parameter.Value,
689
+ version: parameter.Version,
690
+ };
691
+ };
692
+ return {
693
+ parameters: (output.Parameters ?? []).map(normalize),
694
+ invalidParameters: output.InvalidParameters ?? [],
695
+ };
696
+ },
697
+ destroy: () => client.destroy(),
698
+ };
699
+ }
700
+
701
+ function parseLeaseDenyFrame(value: unknown): LeaseDenyFrame | null {
702
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
703
+ const frame = value as Record<string, unknown>;
704
+ if (
705
+ Object.keys(frame).length !== 3 ||
706
+ !Object.hasOwn(frame, "kind") ||
707
+ !Object.hasOwn(frame, "opId") ||
708
+ !Object.hasOwn(frame, "error") ||
709
+ frame.kind !== "lease.deny" ||
710
+ typeof frame.opId !== "string" ||
711
+ !OP_ID.test(frame.opId) ||
712
+ typeof frame.error !== "string" ||
713
+ frame.error.length === 0 ||
714
+ frame.error.length > MAX_ERROR_CHARS
715
+ ) {
716
+ return null;
717
+ }
718
+ return { kind: "lease.deny", opId: frame.opId, error: frame.error };
719
+ }
720
+
721
+ function unavailableRefresh(opId: string): CredentialRefreshResultFrame {
722
+ return {
723
+ kind: "credential.refresh.result",
724
+ opId,
725
+ outcome: "unavailable",
726
+ version: null,
727
+ expiresAt: null,
728
+ };
729
+ }
730
+
731
+ function exactJsonObject(
732
+ raw: string,
733
+ required: readonly string[],
734
+ optional: readonly string[] = [],
735
+ ): Record<string, unknown> | null {
736
+ let value: unknown;
737
+ try {
738
+ value = JSON.parse(raw);
739
+ } catch {
740
+ return null;
741
+ }
742
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
743
+ const object = value as Record<string, unknown>;
744
+ const keys = Object.keys(object);
745
+ if (
746
+ required.some((key) => !Object.hasOwn(object, key)) ||
747
+ keys.some((key) => !required.includes(key) && !optional.includes(key))
748
+ ) {
749
+ return null;
750
+ }
751
+ return object;
752
+ }
753
+
754
+ function openProjectValue(args: {
755
+ raw: string;
756
+ lease: TaskLeaseV1;
757
+ projectId: string;
758
+ key: string;
759
+ valueRef: Exclude<ProjectEnvValueRef, { kind: "row" }>;
760
+ }): string | null {
761
+ if (args.valueRef.kind === "staged") {
762
+ const object = exactJsonObject(args.raw, [
763
+ "v",
764
+ "kind",
765
+ "orgId",
766
+ "projectId",
767
+ "key",
768
+ "stagingId",
769
+ "value",
770
+ ]);
771
+ return object &&
772
+ object.v === 2 &&
773
+ object.kind === "project-env" &&
774
+ object.orgId === args.lease.orgId &&
775
+ object.projectId === args.projectId &&
776
+ object.key === args.key &&
777
+ object.stagingId === args.valueRef.stagingId &&
778
+ typeof object.value === "string"
779
+ ? object.value
780
+ : null;
781
+ }
782
+ const object = exactJsonObject(
783
+ args.raw,
784
+ ["v", "kind", "projectId", "key", "value", "updatedAt"],
785
+ ["orgId"],
786
+ );
787
+ return object &&
788
+ object.v === 1 &&
789
+ object.kind === "project-env" &&
790
+ object.projectId === args.projectId &&
791
+ object.key === args.key &&
792
+ typeof object.value === "string" &&
793
+ typeof object.updatedAt === "number" &&
794
+ Number.isFinite(object.updatedAt) &&
795
+ (object.orgId === undefined || object.orgId === args.lease.orgId)
796
+ ? object.value
797
+ : null;
798
+ }
799
+
800
+ export class TaskCredentialLeaseRegistry {
801
+ readonly #opts: TaskCredentialLeaseRegistryOptions & {
802
+ now: () => number;
803
+ setTimer: typeof setTimeout;
804
+ clearTimer: typeof clearTimeout;
805
+ randomId: () => string;
806
+ createSessionReader: CredentialSessionReaderFactory;
807
+ };
808
+ readonly #tasks = new Map<string, TaskState>();
809
+
810
+ constructor(options: TaskCredentialLeaseRegistryOptions) {
811
+ this.#opts = {
812
+ ...options,
813
+ now: options.now ?? Date.now,
814
+ setTimer: options.setTimer ?? setTimeout,
815
+ clearTimer: options.clearTimer ?? clearTimeout,
816
+ randomId: options.randomId ?? randomUUID,
817
+ createSessionReader: options.createSessionReader ?? defaultSessionReaderFactory,
818
+ };
819
+ }
820
+
821
+ async startAssignment(
822
+ assignment: TaskAssignment,
823
+ options: { recovery?: boolean } = {},
824
+ ): Promise<void> {
825
+ if (!validAssignment(assignment)) throw new CredentialLeaseError("invalid task assignment");
826
+ const current = this.#tasks.get(assignment.taskId);
827
+ if (current) {
828
+ if (assignment.runEpoch < current.assignment.runEpoch) {
829
+ throw new CredentialLeaseError("stale task assignment");
830
+ }
831
+ if (assignment.runEpoch === current.assignment.runEpoch) {
832
+ if (current.phase === "recovering") {
833
+ await this.#finishStartup(current);
834
+ } else if (current.phase !== "ended") {
835
+ this.#request(current);
836
+ }
837
+ return;
838
+ }
839
+ this.#endSynchronously(current, "superseded");
840
+ }
841
+
842
+ const needsStartupCleanup = Boolean(current || options.recovery);
843
+ const state = this.#newState(
844
+ assignment,
845
+ needsStartupCleanup ? "recovering" : "waiting",
846
+ );
847
+ state.startup = current
848
+ ? { kind: "predecessor", state: current }
849
+ : options.recovery
850
+ ? { kind: "recovery" }
851
+ : null;
852
+ this.#tasks.set(assignment.taskId, state);
853
+ const gateClosed = this.#closeGate(
854
+ state,
855
+ options.recovery ? "recovery" : "starting",
856
+ );
857
+
858
+ if (needsStartupCleanup) {
859
+ await this.#finishStartup(state);
860
+ if (!gateClosed) {
861
+ await this.#fullCleanupAndContinue(state, "cleanup-failed");
862
+ }
863
+ } else if (!gateClosed) {
864
+ await this.#fullCleanupAndContinue(state, "cleanup-failed");
865
+ } else {
866
+ this.#request(state);
867
+ }
868
+ }
869
+
870
+ /** Same-process authenticated reconnect: preserve safe bundles, replace requests. */
871
+ bridgeAuthenticated(): void {
872
+ for (const state of this.#tasks.values()) {
873
+ if (state.phase === "ended") continue;
874
+ this.#cancelPendingLease(state);
875
+ this.#request(state);
876
+ }
877
+ }
878
+
879
+ async handleGrant(value: unknown): Promise<void> {
880
+ const frame = parseLeaseGrantV1Frame(value);
881
+ if (!frame) return;
882
+ const state = this.#tasks.get(frame.lease.taskId);
883
+ if (
884
+ !state ||
885
+ state.assignment.runEpoch !== frame.lease.runEpoch ||
886
+ !state.pendingLease ||
887
+ state.pendingLease.opId !== frame.opId ||
888
+ state.pendingLease.answering
889
+ ) {
890
+ return;
891
+ }
892
+ state.pendingLease.answering = true;
893
+ this.#opts.clearTimer(state.pendingLease.timer);
894
+ // Accepted, synchronously: the reader this grant's install will read
895
+ // through is not active yet. What the hook arms ends with this grant's
896
+ // handling, whatever its outcome, and no other grant's.
897
+ let settle: (() => void) | void = undefined;
898
+ try {
899
+ settle = this.#opts.grantAccepted?.(cloneAssignment(state.assignment));
900
+ } catch (error) {
901
+ this.#report(error, state.assignment);
902
+ }
903
+ try {
904
+ await this.#answerGrant(state, frame);
905
+ } finally {
906
+ settle?.();
907
+ }
908
+ }
909
+
910
+ async #answerGrant(state: TaskState, frame: LeaseGrantV1Frame): Promise<void> {
911
+ const baseGeneration = state.bundleGeneration;
912
+ let candidate: LeaseBundle | null = null;
913
+ let externalInstallAttempted = false;
914
+ let installing = false;
915
+ let holding = false;
916
+ const release = (): void => {
917
+ if (!holding) return;
918
+ holding = false;
919
+ this.#releaseHold(state);
920
+ };
921
+ try {
922
+ this.#validateGrantBeforeRead(state, frame);
923
+ candidate = await this.#materializeBundle(state, frame.lease);
924
+ this.#assertCandidateCurrent(state, frame, baseGeneration, candidate);
925
+ await this.#preserveObservedVersions(
926
+ state,
927
+ frame,
928
+ baseGeneration,
929
+ candidate,
930
+ );
931
+ this.#assertCandidateCurrent(state, frame, baseGeneration, candidate);
932
+
933
+ const old = state.bundle;
934
+ // A credential that left the task goes before the candidate is
935
+ // installed, and so does one the task could read and now cannot
936
+ // (refused, §6): its files and whatever uses them.
937
+ const narrowed = old
938
+ ? [...this.#removedIdentities(old, candidate), ...this.#refusedIdentities(old, candidate)]
939
+ : [
940
+ ...this.#removedKnownIdentities(state, candidate),
941
+ ...this.#refusedKnownIdentities(state, candidate),
942
+ ];
943
+ if (narrowed.length > 0) {
944
+ this.#closeGate(state, "manifest-narrowed");
945
+ if (old) this.#sealBundle(old);
946
+ // Closed until this grant opens it on its own view.
947
+ state.gateHolds++;
948
+ holding = true;
949
+ await this.#runCleanup(state, {
950
+ assignment: cloneAssignment(state.assignment),
951
+ mode: "selective",
952
+ reason: "lease-replaced",
953
+ removed: narrowed,
954
+ removeFilesSession: false,
955
+ });
956
+ this.#assertCandidateCurrent(state, frame, baseGeneration, candidate);
957
+ }
958
+
959
+ this.#assertCandidateCurrent(state, frame, baseGeneration, candidate);
960
+ const previousView = old?.view ?? null;
961
+ const files = this.#filesAccess(candidate);
962
+ const granted = candidate;
963
+ // From its install until its own open: still this grant's candidate,
964
+ // or committed and not yet opened.
965
+ const installingNow = (): boolean =>
966
+ installing &&
967
+ this.#isActive(state) &&
968
+ !state.gateCloseFailed &&
969
+ state.fullCleanup === null &&
970
+ granted.expiresAt > this.#opts.now() &&
971
+ granted.lease.manifestRevision >= state.minRevision &&
972
+ (state.bundle === granted ||
973
+ (state.pendingLease?.opId === frame.opId && state.bundleGeneration === baseGeneration));
974
+ const installRequest: CredentialInstallRequest = {
975
+ assignment: cloneAssignment(state.assignment),
976
+ owner: { orgId: candidate.lease.orgId, userId: candidate.lease.userId },
977
+ previous: previousView,
978
+ next: candidate.view,
979
+ files,
980
+ reason: "grant",
981
+ projectSetChanged: !old || projectSetKey(old.lease) !== projectSetKey(candidate.lease),
982
+ projectValues: this.#grantProjectValueReader(state, granted, installingNow),
983
+ isCurrent: installingNow,
984
+ };
985
+ externalInstallAttempted = this.#opts.installExternal !== undefined;
986
+ installing = true;
987
+ await this.#runInstall(state, installRequest);
988
+ this.#assertCandidateCurrent(state, frame, baseGeneration, candidate);
989
+
990
+ state.pendingLease = null;
991
+ state.bundle = candidate;
992
+ candidate = null;
993
+ state.bundleGeneration++;
994
+ const installedBundle = state.bundle;
995
+ const installedGeneration = state.bundleGeneration;
996
+ state.identitiesByCredentialId = new Map(
997
+ [...installedBundle.records.values()].map((record) => [
998
+ record.descriptor.credentialId,
999
+ descriptorIdentity(record.descriptor),
1000
+ ]),
1001
+ );
1002
+ state.wantedRevision = Math.max(
1003
+ state.wantedRevision,
1004
+ installedBundle.lease.manifestRevision,
1005
+ );
1006
+ if (installedBundle.lease.manifestRevision >= state.minRevision) {
1007
+ this.#clearFloor(state);
1008
+ }
1009
+ this.#armBundleTimers(state);
1010
+ if (old) await this.#destroyBundle(old);
1011
+ // A narrowing's release opens the view committed now: this grant's, or
1012
+ // a newer one whose own open its hold kept closed meanwhile.
1013
+ try {
1014
+ if (holding) release();
1015
+ else this.#openGate(state, installedBundle, installedGeneration);
1016
+ } finally {
1017
+ installing = false;
1018
+ }
1019
+ if (
1020
+ this.#isActive(state) &&
1021
+ state.bundle &&
1022
+ state.bundle.lease.manifestRevision < state.wantedRevision
1023
+ ) {
1024
+ this.#request(state);
1025
+ }
1026
+ } catch (error) {
1027
+ installing = false;
1028
+ // The callback may have written files before a concurrent stale/stop
1029
+ // fence invalidated this candidate. Its serialized cleanup must run
1030
+ // after that write; never assume a failed/aborted preparation was
1031
+ // side-effect free. A narrowing that did not reach its own open takes
1032
+ // the same path, and takes it before its hold goes: the full cleanup
1033
+ // makes the assignment lost at once, so a view committed before the
1034
+ // narrowing (a grant still draining its predecessor) cannot reopen the
1035
+ // gate on credentials whose cleanup failed or never finished.
1036
+ const escalation =
1037
+ (holding || externalInstallAttempted || state.gateCloseFailed) && this.#isActive(state)
1038
+ ? this.#fullCleanupAndContinue(state, "manifest-narrowed")
1039
+ : null;
1040
+ release();
1041
+ if (state.pendingLease?.opId === frame.opId) state.pendingLease = null;
1042
+ if (candidate) await this.#destroyBundle(candidate);
1043
+ this.#report(error, state.assignment);
1044
+ if (escalation) {
1045
+ try {
1046
+ await escalation;
1047
+ } catch {
1048
+ // The cleanup path has already closed the gate and armed its retry.
1049
+ }
1050
+ } else if (this.#isActive(state)) {
1051
+ this.#armRetry(state);
1052
+ }
1053
+ }
1054
+ }
1055
+
1056
+ handleUnavailable(value: unknown): void {
1057
+ const frame = parseLeaseUnavailableFrame(value);
1058
+ if (!frame) return;
1059
+ const state = this.#stateForPending(frame.opId);
1060
+ if (!state) return;
1061
+ this.#cancelPendingLease(state);
1062
+ this.#armRetry(state);
1063
+ }
1064
+
1065
+ async handleDenied(value: unknown): Promise<void> {
1066
+ const frame = parseLeaseDenyFrame(value);
1067
+ if (!frame) return;
1068
+ const state = this.#stateForPending(frame.opId);
1069
+ if (!state) return;
1070
+ this.#cancelPendingLease(state);
1071
+ this.#closeGate(state, "denied");
1072
+ await this.#fullCleanupAndContinue(state, "denied");
1073
+ }
1074
+
1075
+ handleStale(value: unknown): void {
1076
+ const frame = parseLeaseStaleFrame(value);
1077
+ if (!frame) return;
1078
+ const state = this.#tasks.get(frame.taskId);
1079
+ if (!state || state.assignment.runEpoch !== frame.runEpoch || state.phase === "ended") {
1080
+ return;
1081
+ }
1082
+ // Notice delivery and lease issuance are independent async paths on the
1083
+ // cloud. A notice can therefore be read from the pending row, then arrive
1084
+ // after a grant at or above its floor has already installed. That grant's
1085
+ // replacement cleanup has dealt with what left; resolving `removed`
1086
+ // against the new bundle would instead target a re-added/new revision.
1087
+ const installedRevision = state.bundle?.lease.manifestRevision ?? 0;
1088
+ const handledFloorRevision = Math.max(state.minRevision, installedRevision);
1089
+ if (
1090
+ frame.wantedRevision <= state.wantedRevision &&
1091
+ (frame.minRevision ?? 0) <= handledFloorRevision
1092
+ ) {
1093
+ return;
1094
+ }
1095
+ state.wantedRevision = Math.max(state.wantedRevision, frame.wantedRevision);
1096
+ if (frame.minRevision !== null) {
1097
+ const floorAlreadyHandled = frame.minRevision <= handledFloorRevision;
1098
+ state.minRevision = Math.max(state.minRevision, frame.minRevision);
1099
+ if (!floorAlreadyHandled) {
1100
+ for (const credentialId of frame.removed) {
1101
+ const record = state.bundle?.records.get(credentialId);
1102
+ const identity = record
1103
+ ? descriptorIdentity(record.descriptor)
1104
+ : state.identitiesByCredentialId.get(credentialId);
1105
+ if (!identity) continue;
1106
+ state.removed.set(credentialKey(identity), identity);
1107
+ }
1108
+ if (
1109
+ (state.bundle && state.bundle.lease.manifestRevision < state.minRevision) ||
1110
+ this.#hasUncleanedRemovals(state)
1111
+ ) {
1112
+ this.#beginNarrowing(state);
1113
+ }
1114
+ }
1115
+ }
1116
+ this.#cancelPendingLease(state);
1117
+ this.#request(state);
1118
+ }
1119
+
1120
+ async handleCredentialChanged(value: unknown): Promise<void> {
1121
+ const frame = parseCredentialChangedFrame(value);
1122
+ if (!frame) return;
1123
+ const state = this.#tasks.get(frame.taskId);
1124
+ if (!state || state.assignment.runEpoch !== frame.runEpoch) return;
1125
+ try {
1126
+ await this.#applyCredentialVersion(state, frame);
1127
+ } catch (error) {
1128
+ this.#report(error, state.assignment);
1129
+ if (this.#isActive(state)) this.#request(state);
1130
+ }
1131
+ }
1132
+
1133
+ /**
1134
+ * ADR-154 §6: a launch slot of `assignment`'s exact run could not be
1135
+ * retired in its runtime, so a launch handed over before its retirement
1136
+ * may still start there. The run's full cleanup runs: its gate closed at
1137
+ * once, every engine of the run stopped in every runtime it ran in (or the
1138
+ * runtime stopped), then a new grant. One that fails is retried, its gate
1139
+ * closed meanwhile. Nothing for a run that is not the task's live one: its
1140
+ * own cleanup covers it.
1141
+ */
1142
+ escalateLaunchFence(assignment: TaskAssignment): void {
1143
+ const state = this.#tasks.get(assignment.taskId);
1144
+ if (!state || !assignmentMatches(state.assignment, assignment) || !this.#isActive(state)) return;
1145
+ this.#background(this.#fullCleanupAndContinue(state, "fence-failed"), state.assignment);
1146
+ }
1147
+
1148
+ requestCredentialRefresh(
1149
+ assignment: TaskAssignment,
1150
+ credentialId: string,
1151
+ revision: string,
1152
+ ): Promise<CredentialRefreshResultFrame> {
1153
+ const state = this.#requireUsableState(assignment);
1154
+ const record = state.bundle!.records.get(credentialId);
1155
+ if (!record || record.descriptor.revision !== revision || revision === null) {
1156
+ return Promise.reject(new CredentialLeaseError("credential is not in the active manifest"));
1157
+ }
1158
+ const key = `${credentialId}\u0000${revision}`;
1159
+ const existing = state.refreshByCredential.get(key);
1160
+ if (existing) return existing;
1161
+ const opId = this.#opId("refresh", state);
1162
+ let pending!: RefreshPending;
1163
+ const promise = new Promise<CredentialRefreshResultFrame>((resolve) => {
1164
+ const timer = this.#opts.setTimer(() => {
1165
+ state.refreshByOp.delete(opId);
1166
+ state.refreshByCredential.delete(key);
1167
+ resolve(unavailableRefresh(opId));
1168
+ }, LEASE_REQUEST_TIMEOUT_MS);
1169
+ timer.unref?.();
1170
+ pending = {
1171
+ opId,
1172
+ key,
1173
+ assignment: cloneAssignment(assignment),
1174
+ credentialId,
1175
+ revision,
1176
+ bundleGeneration: state.bundleGeneration,
1177
+ timer,
1178
+ resolve,
1179
+ };
1180
+ state.refreshByOp.set(opId, pending);
1181
+ });
1182
+ state.refreshByCredential.set(key, promise);
1183
+ const sent = this.#safeSend({
1184
+ kind: "credential.refresh",
1185
+ opId,
1186
+ taskId: assignment.taskId,
1187
+ runEpoch: assignment.runEpoch,
1188
+ credentialId,
1189
+ revision,
1190
+ });
1191
+ if (!sent) this.#settleRefresh(state, pending, unavailableRefresh(opId));
1192
+ return promise;
1193
+ }
1194
+
1195
+ async handleRefreshResult(value: unknown): Promise<void> {
1196
+ const frame = parseCredentialRefreshResultFrame(value);
1197
+ if (!frame) return;
1198
+ let state: TaskState | null = null;
1199
+ let pending: RefreshPending | null = null;
1200
+ for (const candidate of this.#tasks.values()) {
1201
+ const found = candidate.refreshByOp.get(frame.opId);
1202
+ if (found) {
1203
+ state = candidate;
1204
+ pending = found;
1205
+ break;
1206
+ }
1207
+ }
1208
+ if (!state || !pending) return;
1209
+ if (
1210
+ !this.#isActive(state) ||
1211
+ !assignmentMatches(state.assignment, pending.assignment) ||
1212
+ state.bundleGeneration !== pending.bundleGeneration
1213
+ ) {
1214
+ this.#settleRefresh(state, pending, unavailableRefresh(frame.opId));
1215
+ return;
1216
+ }
1217
+ if (frame.outcome === "refreshed" || frame.outcome === "fresh") {
1218
+ try {
1219
+ await this.#applyCredentialVersion(state, {
1220
+ kind: "credential.changed",
1221
+ taskId: state.assignment.taskId,
1222
+ runEpoch: state.assignment.runEpoch,
1223
+ credentialId: pending.credentialId,
1224
+ revision: pending.revision,
1225
+ version: frame.version!,
1226
+ });
1227
+ } catch (error) {
1228
+ this.#report(error, state.assignment);
1229
+ this.#request(state);
1230
+ }
1231
+ } else if (frame.outcome === "needs_sign_in") {
1232
+ // The authoritative stale frame supplies any floor and removals.
1233
+ this.#request(state);
1234
+ }
1235
+ this.#settleRefresh(state, pending, frame);
1236
+ }
1237
+
1238
+ async closeAssignment(
1239
+ assignment: TaskAssignment,
1240
+ reason: "stop" | "park" | "teardown" | "superseded" = "stop",
1241
+ ): Promise<void> {
1242
+ const state = this.#tasks.get(assignment.taskId);
1243
+ if (!state || !assignmentMatches(state.assignment, assignment)) return;
1244
+ const gateReason: LeaseGateCloseReason =
1245
+ reason === "stop"
1246
+ ? "stopped"
1247
+ : reason === "park"
1248
+ ? "parked"
1249
+ : reason === "teardown"
1250
+ ? "teardown"
1251
+ : "superseded";
1252
+ this.#endSynchronously(state, gateReason);
1253
+ await this.#completeEndedCleanup(state, gateReason);
1254
+ if (this.#tasks.get(assignment.taskId) === state) this.#tasks.delete(assignment.taskId);
1255
+ }
1256
+
1257
+ /**
1258
+ * The runner process is ending. Every assignment ends now, synchronously:
1259
+ * its gate closed, its timers cancelled, its waiters and refreshes
1260
+ * answered, its readers sealed. No cleanup runs here: agents that survive
1261
+ * the restart keep what they hold (§5), and the next process's recovery
1262
+ * sweep cleans up before it reattaches them, as after a crash. Nothing
1263
+ * that arrives later finds an assignment. Resolves once the readers in
1264
+ * flight have drained.
1265
+ */
1266
+ async shutdown(): Promise<void> {
1267
+ const states = [...this.#tasks.values()];
1268
+ this.#tasks.clear();
1269
+ for (const state of states) {
1270
+ if (state.phase !== "ended") this.#endSynchronously(state, "shutdown");
1271
+ }
1272
+ await Promise.allSettled(
1273
+ states.map((state) => (state.bundle ? this.#destroyBundle(state.bundle) : undefined)),
1274
+ );
1275
+ }
1276
+
1277
+ currentView(assignment: TaskAssignment): TaskCredentialView | null {
1278
+ const state = this.#tasks.get(assignment.taskId);
1279
+ if (!state || !assignmentMatches(state.assignment, assignment)) return null;
1280
+ if (!this.#bundleIsUsable(state)) return null;
1281
+ return state.bundle!.view;
1282
+ }
1283
+
1284
+ filesAccess(assignment: TaskAssignment): TaskLeaseFilesAccess | null {
1285
+ const state = this.#tasks.get(assignment.taskId);
1286
+ if (!state || !assignmentMatches(state.assignment, assignment)) return null;
1287
+ if (!this.#bundleIsUsable(state)) return null;
1288
+ return this.#filesAccess(state.bundle!);
1289
+ }
1290
+
1291
+ waitUntilUsable(
1292
+ assignment: TaskAssignment,
1293
+ signal?: AbortSignal,
1294
+ ): Promise<TaskCredentialView> {
1295
+ const current = this.currentView(assignment);
1296
+ if (current) return Promise.resolve(current);
1297
+ const state = this.#tasks.get(assignment.taskId);
1298
+ if (!state || !assignmentMatches(state.assignment, assignment) || state.phase === "ended") {
1299
+ return Promise.reject(new CredentialLeaseUnavailableError("task assignment is not active"));
1300
+ }
1301
+ if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("aborted"));
1302
+ return new Promise<TaskCredentialView>((resolve, reject) => {
1303
+ const waiter: ReadyWaiter = { resolve, reject, signal };
1304
+ if (signal) {
1305
+ waiter.abort = () => {
1306
+ state.waiters.delete(waiter);
1307
+ reject(signal.reason ?? new Error("aborted"));
1308
+ };
1309
+ signal.addEventListener("abort", waiter.abort, { once: true });
1310
+ }
1311
+ state.waiters.add(waiter);
1312
+ });
1313
+ }
1314
+
1315
+ async readProjectValue(
1316
+ assignment: TaskAssignment,
1317
+ request: StructuredProjectValueRead,
1318
+ ): Promise<ProjectValueReadResult | null> {
1319
+ validateProjectValueRead(request);
1320
+ const state = this.#requireUsableState(assignment);
1321
+ const bundle = state.bundle!;
1322
+ const generation = state.bundleGeneration;
1323
+ return this.#readProjectValueFrom(bundle, () => this.#assertReadFence(state, bundle, generation), request);
1324
+ }
1325
+
1326
+ projectValueReader(assignment: TaskAssignment): ProjectEnvTaskValueReader {
1327
+ const bound = cloneAssignment(assignment);
1328
+ return projectValueReaderOf((request) => this.readProjectValue(bound, request));
1329
+ }
1330
+
1331
+ /**
1332
+ * One grant's project values: through its own sessions while its grant
1333
+ * installs it (the env replica's snapshot is read then, before the gate
1334
+ * opens on it), then while it is the task's usable grant (a credential
1335
+ * change keeps the grant's lease and sessions). Never through another
1336
+ * grant's, and never once the grant is abandoned, replaced or ended.
1337
+ */
1338
+ #grantProjectValueReader(
1339
+ state: TaskState,
1340
+ granted: LeaseBundle,
1341
+ installing: () => boolean,
1342
+ ): ProjectEnvTaskValueReader {
1343
+ return projectValueReaderOf(async (request) => {
1344
+ validateProjectValueRead(request);
1345
+ if (state.bundle?.lease === granted.lease && this.#bundleIsUsable(state)) {
1346
+ const bundle = state.bundle;
1347
+ const generation = state.bundleGeneration;
1348
+ return this.#readProjectValueFrom(bundle, () => this.#assertReadFence(state, bundle, generation), request);
1349
+ }
1350
+ if (!installing()) throw new CredentialLeaseUnavailableError();
1351
+ return this.#readProjectValueFrom(
1352
+ granted,
1353
+ () => {
1354
+ if (!installing()) {
1355
+ throw new CredentialLeaseUnavailableError("credential read crossed an assignment fence");
1356
+ }
1357
+ },
1358
+ request,
1359
+ );
1360
+ });
1361
+ }
1362
+
1363
+ async #readProjectValueFrom(
1364
+ bundle: LeaseBundle,
1365
+ assertFence: () => void,
1366
+ request: StructuredProjectValueRead,
1367
+ ): Promise<ProjectValueReadResult | null> {
1368
+ if (request.valueRef.kind === "row") {
1369
+ throw new CredentialLeaseError("a cloud lease cannot read a row-backed value");
1370
+ }
1371
+ const sessionId = bundle.projectSessions.get(request.projectId);
1372
+ if (!sessionId) throw new CredentialLeaseError("project is not in the active manifest");
1373
+ const reader = bundle.readers.get(sessionId);
1374
+ if (!reader) throw new CredentialLeaseError("project credentials session is missing");
1375
+
1376
+ let parameter: string;
1377
+ let selector: string;
1378
+ let expectedVersion: number | null;
1379
+ if (request.valueRef.kind === "staged") {
1380
+ if (!PROJECT_ENV_STAGING_ID.test(request.valueRef.stagingId)) {
1381
+ throw new CredentialLeaseError("invalid project env staging id");
1382
+ }
1383
+ const keyDigest = createHash("sha256").update(request.key, "utf8").digest("hex");
1384
+ parameter = `/uai/projects/${request.projectId}/env-values/${keyDigest}/${request.valueRef.stagingId}`;
1385
+ selector = parameter;
1386
+ expectedVersion = null;
1387
+ } else {
1388
+ if (!Number.isSafeInteger(request.valueRef.ssmVersion) || request.valueRef.ssmVersion <= 0) {
1389
+ throw new CredentialLeaseError("invalid legacy project env version");
1390
+ }
1391
+ parameter = `/uai/projects/${request.projectId}/env/${request.key}`;
1392
+ selector = `${parameter}:${request.valueRef.ssmVersion}`;
1393
+ expectedVersion = request.valueRef.ssmVersion;
1394
+ }
1395
+ const result = await reader.read([selector]);
1396
+ assertFence();
1397
+ if (result.invalidParameters.length === 1 && result.invalidParameters[0] === selector) {
1398
+ if (result.parameters.length !== 0) {
1399
+ throw new CredentialLeaseError("Parameter Store returned conflicting project results");
1400
+ }
1401
+ return null;
1402
+ }
1403
+ if (result.invalidParameters.length !== 0 || result.parameters.length !== 1) {
1404
+ throw new CredentialLeaseError("Parameter Store returned an inexact project result");
1405
+ }
1406
+ const observed = result.parameters[0]!;
1407
+ if (
1408
+ observed.name !== parameter ||
1409
+ observed.type !== "SecureString" ||
1410
+ !Number.isSafeInteger(observed.version) ||
1411
+ observed.version <= 0 ||
1412
+ (expectedVersion !== null && observed.version !== expectedVersion)
1413
+ ) {
1414
+ throw new CredentialLeaseError("Parameter Store returned the wrong project parameter");
1415
+ }
1416
+ const opened = openProjectValue({
1417
+ raw: observed.value,
1418
+ lease: bundle.lease,
1419
+ projectId: request.projectId,
1420
+ key: request.key,
1421
+ valueRef: request.valueRef,
1422
+ });
1423
+ if (opened === null) {
1424
+ throw new CredentialLeaseError("project value envelope does not match its reference");
1425
+ }
1426
+ return { rawValue: observed.value, version: observed.version, parameter };
1427
+ }
1428
+
1429
+ #newState(assignment: TaskAssignment, phase: TaskState["phase"]): TaskState {
1430
+ return {
1431
+ assignment: cloneAssignment(assignment),
1432
+ phase,
1433
+ gateOpen: false,
1434
+ gateCloseFailed: false,
1435
+ bundle: null,
1436
+ bundleGeneration: 0,
1437
+ wantedRevision: 0,
1438
+ minRevision: 0,
1439
+ identitiesByCredentialId: new Map(),
1440
+ removed: new Map(),
1441
+ cleaned: new Set(),
1442
+ pendingLease: null,
1443
+ refreshByOp: new Map(),
1444
+ refreshByCredential: new Map(),
1445
+ renewalTimer: null,
1446
+ retryTimer: null,
1447
+ expiryTimer: null,
1448
+ floorTimer: null,
1449
+ cleanupRetryTimer: null,
1450
+ floorDeadlineAt: null,
1451
+ cleanupTail: Promise.resolve(),
1452
+ fullCleanup: null,
1453
+ startup: null,
1454
+ startupPromise: null,
1455
+ waiters: new Set(),
1456
+ sequence: 0,
1457
+ gateHolds: 0,
1458
+ };
1459
+ }
1460
+
1461
+ #isActive(state: TaskState): boolean {
1462
+ return this.#tasks.get(state.assignment.taskId) === state && state.phase !== "ended";
1463
+ }
1464
+
1465
+ #finishStartup(state: TaskState): Promise<void> {
1466
+ if (!state.startup) {
1467
+ if (this.#isActive(state)) {
1468
+ state.phase = "waiting";
1469
+ this.#request(state);
1470
+ }
1471
+ return Promise.resolve();
1472
+ }
1473
+ if (state.startupPromise) return state.startupPromise;
1474
+ const startup = state.startup;
1475
+ const operation = (async () => {
1476
+ if (startup.kind === "predecessor") {
1477
+ await this.#completeEndedCleanup(startup.state, "superseded");
1478
+ } else {
1479
+ await this.#runCleanup(state, {
1480
+ assignment: cloneAssignment(state.assignment),
1481
+ mode: "full",
1482
+ reason: "recovery",
1483
+ removed: [],
1484
+ removeFilesSession: this.#opts.runnerKind === "task-machine",
1485
+ });
1486
+ }
1487
+ state.startup = null;
1488
+ if (!this.#isActive(state)) return;
1489
+ state.phase = "waiting";
1490
+ this.#request(state);
1491
+ })();
1492
+ state.startupPromise = operation;
1493
+ return operation.finally(() => {
1494
+ if (state.startupPromise === operation) state.startupPromise = null;
1495
+ });
1496
+ }
1497
+
1498
+ #stateForPending(opId: string): TaskState | null {
1499
+ for (const state of this.#tasks.values()) {
1500
+ if (state.pendingLease?.opId === opId) return state;
1501
+ }
1502
+ return null;
1503
+ }
1504
+
1505
+ #opId(prefix: string, state: TaskState): string {
1506
+ const random = this.#opts.randomId().replace(/[^A-Za-z0-9_-]/g, "").slice(0, 64);
1507
+ const opId = `${prefix}-${++state.sequence}-${random || "op"}`;
1508
+ if (!OP_ID.test(opId)) throw new CredentialLeaseError("could not create a safe operation id");
1509
+ return opId;
1510
+ }
1511
+
1512
+ #safeSend(frame: LeaseRequestV1Frame | CredentialRefreshFrame): boolean {
1513
+ try {
1514
+ return this.#opts.send(frame);
1515
+ } catch {
1516
+ return false;
1517
+ }
1518
+ }
1519
+
1520
+ #request(state: TaskState): void {
1521
+ if (
1522
+ !this.#isActive(state) ||
1523
+ state.gateCloseFailed ||
1524
+ state.phase === "recovering" ||
1525
+ state.phase === "lost" ||
1526
+ state.fullCleanup !== null ||
1527
+ state.pendingLease
1528
+ ) {
1529
+ return;
1530
+ }
1531
+ if (state.retryTimer) {
1532
+ this.#opts.clearTimer(state.retryTimer);
1533
+ state.retryTimer = null;
1534
+ }
1535
+ if (state.renewalTimer) {
1536
+ this.#opts.clearTimer(state.renewalTimer);
1537
+ state.renewalTimer = null;
1538
+ }
1539
+ const opId = this.#opId("lease", state);
1540
+ const timer = this.#opts.setTimer(() => {
1541
+ if (state.pendingLease?.opId !== opId) return;
1542
+ state.pendingLease = null;
1543
+ this.#armRetry(state);
1544
+ }, LEASE_REQUEST_TIMEOUT_MS);
1545
+ timer.unref?.();
1546
+ state.pendingLease = { opId, timer, answering: false };
1547
+ const sent = this.#safeSend({
1548
+ kind: "lease.request",
1549
+ opId,
1550
+ taskId: state.assignment.taskId,
1551
+ runEpoch: state.assignment.runEpoch,
1552
+ });
1553
+ if (!sent) {
1554
+ this.#cancelPendingLease(state);
1555
+ this.#armRetry(state);
1556
+ }
1557
+ }
1558
+
1559
+ #armRetry(state: TaskState): void {
1560
+ if (!this.#isActive(state) || state.retryTimer || state.pendingLease) return;
1561
+ const timer = this.#opts.setTimer(() => {
1562
+ if (state.retryTimer !== timer) return;
1563
+ state.retryTimer = null;
1564
+ this.#request(state);
1565
+ }, LEASE_RETRY_MS);
1566
+ timer.unref?.();
1567
+ state.retryTimer = timer;
1568
+ }
1569
+
1570
+ #cancelPendingLease(state: TaskState): void {
1571
+ if (!state.pendingLease) return;
1572
+ this.#opts.clearTimer(state.pendingLease.timer);
1573
+ state.pendingLease = null;
1574
+ }
1575
+
1576
+ #validateGrantBeforeRead(state: TaskState, frame: LeaseGrantV1Frame): void {
1577
+ const { lease } = frame;
1578
+ if (
1579
+ state.phase === "recovering" ||
1580
+ state.phase === "lost" ||
1581
+ state.phase === "ended" ||
1582
+ state.gateCloseFailed ||
1583
+ state.fullCleanup !== null
1584
+ ) {
1585
+ throw new CredentialLeaseUnavailableError("task cleanup is in progress");
1586
+ }
1587
+ if (!assignmentMatches(state.assignment, lease)) {
1588
+ throw new CredentialLeaseError("grant belongs to another task assignment");
1589
+ }
1590
+ if (digestManifest(lease) !== lease.manifestDigest) {
1591
+ throw new CredentialLeaseError("grant manifest digest does not match");
1592
+ }
1593
+ if (lease.manifestRevision < state.minRevision) {
1594
+ throw new CredentialLeaseError("grant is below the task's minimum revision");
1595
+ }
1596
+ if (state.bundle && lease.manifestRevision < state.bundle.lease.manifestRevision) {
1597
+ throw new CredentialLeaseError("grant is older than the installed manifest");
1598
+ }
1599
+ if (
1600
+ state.bundle &&
1601
+ lease.manifestRevision === state.bundle.lease.manifestRevision &&
1602
+ lease.manifestDigest !== state.bundle.lease.manifestDigest
1603
+ ) {
1604
+ throw new CredentialLeaseError(
1605
+ "grant changed the manifest without advancing its revision",
1606
+ );
1607
+ }
1608
+ if (taskLeaseV1ExpiresAt(lease) <= this.#opts.now()) {
1609
+ throw new CredentialLeaseError("grant is expired");
1610
+ }
1611
+ const filesSessions = lease.sessions.filter((session) => session.purpose === "files");
1612
+ if (this.#opts.runnerKind === "owned") {
1613
+ if (filesSessions.length !== 0 || lease.files !== null) {
1614
+ throw new CredentialLeaseError("owned runner received a Files session");
1615
+ }
1616
+ } else if (filesSessions.length !== 1 || lease.files === null) {
1617
+ throw new CredentialLeaseError("task machine did not receive exactly one Files session");
1618
+ }
1619
+ const credentialIds = manifestDescriptors(lease).map((item) => item.credentialId);
1620
+ if (new Set(credentialIds).size !== credentialIds.length) {
1621
+ throw new CredentialLeaseError("credential ids are not unique across the manifest");
1622
+ }
1623
+ const parameters = manifestDescriptors(lease)
1624
+ .map((item) => item.parameter)
1625
+ .filter((item): item is string => item !== null);
1626
+ if (new Set(parameters).size !== parameters.length) {
1627
+ throw new CredentialLeaseError("credential parameters are not unique across the manifest");
1628
+ }
1629
+ }
1630
+
1631
+ async #materializeBundle(state: TaskState, lease: TaskLeaseV1): Promise<LeaseBundle> {
1632
+ const readers = new Map<string, ManagedSessionReader>();
1633
+ try {
1634
+ for (const session of lease.sessions) {
1635
+ if (session.purpose !== "credentials") continue;
1636
+ readers.set(
1637
+ session.id,
1638
+ new ManagedSessionReader(
1639
+ session.id,
1640
+ this.#opts.createSessionReader({
1641
+ region: lease.region,
1642
+ session,
1643
+ assignment: cloneAssignment(state.assignment),
1644
+ }),
1645
+ ),
1646
+ );
1647
+ }
1648
+ const descriptors = manifestDescriptors(lease);
1649
+ const records = new Map<string, CredentialRecord>();
1650
+ const grouped = new Map<string, CredentialDescriptor[]>();
1651
+ for (const descriptor of descriptors) {
1652
+ if (descriptor.authKind === "none") {
1653
+ records.set(descriptor.credentialId, {
1654
+ descriptor,
1655
+ availability: "not-required",
1656
+ material: null,
1657
+ observedVersion: null,
1658
+ });
1659
+ continue;
1660
+ }
1661
+ if (
1662
+ !descriptor.parameter ||
1663
+ !descriptor.version ||
1664
+ !descriptor.sessionId ||
1665
+ !descriptor.location
1666
+ ) {
1667
+ throw new CredentialLeaseError("manifest credential is incomplete");
1668
+ }
1669
+ const group = grouped.get(descriptor.sessionId) ?? [];
1670
+ group.push(descriptor);
1671
+ grouped.set(descriptor.sessionId, group);
1672
+ }
1673
+ for (const [sessionId, group] of grouped) {
1674
+ const reader = readers.get(sessionId);
1675
+ if (!reader) throw new CredentialLeaseError("manifest names a missing session");
1676
+ for (let offset = 0; offset < group.length; offset += MAX_SSM_GET_PARAMETERS) {
1677
+ const batch = group.slice(offset, offset + MAX_SSM_GET_PARAMETERS);
1678
+ const selectors = batch.map((item) => `${item.parameter!}:${item.version!}`);
1679
+ await this.#readMaterialBatch(reader, batch, selectors, records);
1680
+ }
1681
+ }
1682
+ const projectSessions = new Map(
1683
+ lease.manifest.projects.map((project) => [project.projectId, project.sessionId]),
1684
+ );
1685
+ const expiresAt = taskLeaseV1ExpiresAt(lease);
1686
+ const view = freezeView(lease, expiresAt, records);
1687
+ return { lease, expiresAt, readers, records, projectSessions, view };
1688
+ } catch (error) {
1689
+ await Promise.all([...readers.values()].map((reader) => reader.destroyWhenDrained()));
1690
+ throw error;
1691
+ }
1692
+ }
1693
+
1694
+ #assertMaterialBatch(
1695
+ descriptors: readonly CredentialDescriptor[],
1696
+ selectors: readonly string[],
1697
+ result: ExactSsmParameterBatch,
1698
+ records: Map<string, CredentialRecord>,
1699
+ ): void {
1700
+ if (result.invalidParameters.length !== 0) {
1701
+ throw new CredentialLeaseError("Parameter Store rejected a credential selector");
1702
+ }
1703
+ if (result.parameters.length !== descriptors.length) {
1704
+ throw new CredentialLeaseError("Parameter Store omitted or added credential material");
1705
+ }
1706
+ const expected = new Map(descriptors.map((item) => [item.parameter!, item]));
1707
+ const seen = new Set<string>();
1708
+ for (const parameter of result.parameters) {
1709
+ const descriptor = expected.get(parameter.name);
1710
+ if (
1711
+ !descriptor ||
1712
+ seen.has(parameter.name) ||
1713
+ parameter.type !== "SecureString" ||
1714
+ parameter.version !== descriptor.version ||
1715
+ typeof parameter.value !== "string"
1716
+ ) {
1717
+ throw new CredentialLeaseError("Parameter Store returned inexact credential material");
1718
+ }
1719
+ const selector = `${parameter.name}:${parameter.version}`;
1720
+ if (!selectors.includes(selector)) {
1721
+ throw new CredentialLeaseError("Parameter Store returned an unrequested credential version");
1722
+ }
1723
+ const material = parseLeasedValue(parameter.value, descriptor.location!);
1724
+ if (!material || !materialMatchesDescriptor(material, descriptor)) {
1725
+ throw new CredentialLeaseError("credential envelope does not match its manifest entry");
1726
+ }
1727
+ seen.add(parameter.name);
1728
+ records.set(descriptor.credentialId, {
1729
+ descriptor,
1730
+ availability: "available",
1731
+ material: freezeMaterial(material),
1732
+ observedVersion: parameter.version,
1733
+ });
1734
+ }
1735
+ }
1736
+
1737
+ async #readMaterialBatch(
1738
+ reader: ManagedSessionReader,
1739
+ descriptors: readonly CredentialDescriptor[],
1740
+ selectors: readonly string[],
1741
+ records: Map<string, CredentialRecord>,
1742
+ ): Promise<void> {
1743
+ try {
1744
+ const result = await reader.read(selectors);
1745
+ this.#assertMaterialBatch(descriptors, selectors, result, records);
1746
+ return;
1747
+ } catch (error) {
1748
+ if (!this.#isAccessDenied(error)) throw error;
1749
+ }
1750
+
1751
+ // A policy rollout can briefly leave one exact parameter unreadable. AWS
1752
+ // rejects the complete batch in that case, so retry only exact singleton
1753
+ // selectors to isolate it. No listing or broad fallback is ever used.
1754
+ for (let index = 0; index < descriptors.length; index++) {
1755
+ const descriptor = descriptors[index]!;
1756
+ const selector = selectors[index]!;
1757
+ try {
1758
+ const result = await reader.read([selector]);
1759
+ this.#assertMaterialBatch([descriptor], [selector], result, records);
1760
+ } catch (error) {
1761
+ if (!this.#isAccessDenied(error)) throw error;
1762
+ records.set(descriptor.credentialId, {
1763
+ descriptor,
1764
+ availability: "access-denied",
1765
+ material: null,
1766
+ observedVersion: null,
1767
+ });
1768
+ }
1769
+ }
1770
+ }
1771
+
1772
+ #isAccessDenied(error: unknown): boolean {
1773
+ if (!error || typeof error !== "object") return false;
1774
+ const candidate = error as {
1775
+ name?: unknown;
1776
+ message?: unknown;
1777
+ $metadata?: { httpStatusCode?: unknown };
1778
+ };
1779
+ return (
1780
+ candidate.name === "AccessDenied" ||
1781
+ candidate.name === "AccessDeniedException" ||
1782
+ candidate.$metadata?.httpStatusCode === 403 ||
1783
+ (typeof candidate.message === "string" && /\bAccessDenied\b/i.test(candidate.message))
1784
+ );
1785
+ }
1786
+
1787
+ #assertCandidateCurrent(
1788
+ state: TaskState,
1789
+ frame: LeaseGrantV1Frame,
1790
+ baseGeneration: number,
1791
+ candidate: LeaseBundle,
1792
+ ): void {
1793
+ if (
1794
+ !this.#isActive(state) ||
1795
+ state.gateCloseFailed ||
1796
+ state.pendingLease?.opId !== frame.opId ||
1797
+ state.bundleGeneration !== baseGeneration ||
1798
+ candidate.lease.manifestRevision < state.minRevision ||
1799
+ candidate.expiresAt <= this.#opts.now()
1800
+ ) {
1801
+ throw new CredentialLeaseUnavailableError("grant became stale while it was being read");
1802
+ }
1803
+ }
1804
+
1805
+ async #preserveObservedVersions(
1806
+ state: TaskState,
1807
+ frame: LeaseGrantV1Frame,
1808
+ baseGeneration: number,
1809
+ candidate: LeaseBundle,
1810
+ ): Promise<void> {
1811
+ const current = state.bundle;
1812
+ if (!current) return;
1813
+ let changed = false;
1814
+ for (const oldRecord of current.records.values()) {
1815
+ const nextRecord = candidate.records.get(oldRecord.descriptor.credentialId);
1816
+ const oldKnownVersion = Math.max(
1817
+ oldRecord.descriptor.version ?? 0,
1818
+ oldRecord.observedVersion ?? 0,
1819
+ );
1820
+ const nextKnownVersion = Math.max(
1821
+ nextRecord?.descriptor.version ?? 0,
1822
+ nextRecord?.observedVersion ?? 0,
1823
+ );
1824
+ if (
1825
+ !nextRecord ||
1826
+ oldKnownVersion === 0 ||
1827
+ oldKnownVersion <= nextKnownVersion ||
1828
+ descriptorKey(oldRecord.descriptor) !== descriptorKey(nextRecord.descriptor) ||
1829
+ !nextRecord.descriptor.parameter ||
1830
+ !nextRecord.descriptor.sessionId
1831
+ ) {
1832
+ continue;
1833
+ }
1834
+ const reader = candidate.readers.get(nextRecord.descriptor.sessionId);
1835
+ if (!reader) throw new CredentialLeaseError("candidate credential session is missing");
1836
+ const descriptor = {
1837
+ ...nextRecord.descriptor,
1838
+ version: oldKnownVersion,
1839
+ };
1840
+ const selector = `${descriptor.parameter}:${descriptor.version}`;
1841
+ await this.#readMaterialBatch(
1842
+ reader,
1843
+ [descriptor],
1844
+ [selector],
1845
+ candidate.records,
1846
+ );
1847
+ this.#assertCandidateCurrent(
1848
+ state,
1849
+ frame,
1850
+ baseGeneration,
1851
+ candidate,
1852
+ );
1853
+ changed = true;
1854
+ }
1855
+ if (changed) {
1856
+ candidate.view = freezeView(candidate.lease, candidate.expiresAt, candidate.records);
1857
+ }
1858
+ }
1859
+
1860
+ #removedIdentities(old: LeaseBundle, next: LeaseBundle): CredentialCleanupIdentity[] {
1861
+ const nextKeys = new Set(
1862
+ [...next.records.values()].map((record) => descriptorKey(record.descriptor)),
1863
+ );
1864
+ return [...old.records.values()]
1865
+ .filter((record) => !nextKeys.has(descriptorKey(record.descriptor)))
1866
+ .map((record) => descriptorIdentity(record.descriptor));
1867
+ }
1868
+
1869
+ #removedKnownIdentities(
1870
+ state: TaskState,
1871
+ next: LeaseBundle,
1872
+ ): CredentialCleanupIdentity[] {
1873
+ const nextKeys = new Set(
1874
+ [...next.records.values()].map((record) => descriptorKey(record.descriptor)),
1875
+ );
1876
+ return [...state.identitiesByCredentialId.values()].filter((identity) => {
1877
+ const key = credentialKey(identity);
1878
+ return !nextKeys.has(key) && !state.cleaned.has(key);
1879
+ });
1880
+ }
1881
+
1882
+ /** Credentials `old` could read that `next` names the same way and cannot:
1883
+ * refused, so cleaned up like a removal before `next` is installed. */
1884
+ #refusedIdentities(old: LeaseBundle, next: LeaseBundle): CredentialCleanupIdentity[] {
1885
+ return [...old.records.values()]
1886
+ .filter((record) => {
1887
+ const replacement = next.records.get(record.descriptor.credentialId);
1888
+ return (
1889
+ record.availability === "available" &&
1890
+ replacement !== undefined &&
1891
+ replacement.availability === "access-denied" &&
1892
+ descriptorKey(replacement.descriptor) === descriptorKey(record.descriptor)
1893
+ );
1894
+ })
1895
+ .map((record) => descriptorIdentity(record.descriptor));
1896
+ }
1897
+
1898
+ /**
1899
+ * Without a bundle to compare (one below the floor was dropped after its
1900
+ * selective cleanup, leaving the rest of its files), every known identity
1901
+ * not yet cleaned that `next` names and cannot read is treated as refused:
1902
+ * cleaning one that was never readable deletes nothing.
1903
+ */
1904
+ #refusedKnownIdentities(state: TaskState, next: LeaseBundle): CredentialCleanupIdentity[] {
1905
+ return [...next.records.values()]
1906
+ .filter((record) => {
1907
+ if (record.availability !== "access-denied") return false;
1908
+ const key = descriptorKey(record.descriptor);
1909
+ const known = state.identitiesByCredentialId.get(record.descriptor.credentialId);
1910
+ return known !== undefined && credentialKey(known) === key && !state.cleaned.has(key);
1911
+ })
1912
+ .map((record) => descriptorIdentity(record.descriptor));
1913
+ }
1914
+
1915
+ #filesAccess(bundle: LeaseBundle): TaskLeaseFilesAccess | null {
1916
+ if (!bundle.lease.files) return null;
1917
+ const session = bundle.lease.sessions.find((item) => item.purpose === "files");
1918
+ if (!session) throw new CredentialLeaseError("Files metadata has no Files session");
1919
+ return Object.freeze({
1920
+ assignment: Object.freeze({
1921
+ taskId: bundle.lease.taskId,
1922
+ runEpoch: bundle.lease.runEpoch,
1923
+ }),
1924
+ region: bundle.lease.region,
1925
+ files: Object.freeze({ ...bundle.lease.files }),
1926
+ credentials: Object.freeze({
1927
+ accessKeyId: session.accessKeyId,
1928
+ secretAccessKey: session.secretAccessKey,
1929
+ sessionToken: session.sessionToken,
1930
+ expiresAt: session.expiresAt,
1931
+ }),
1932
+ });
1933
+ }
1934
+
1935
+ #bundleIsUsable(state: TaskState): boolean {
1936
+ return Boolean(
1937
+ !state.gateCloseFailed &&
1938
+ state.gateOpen &&
1939
+ state.bundle &&
1940
+ state.bundle.expiresAt > this.#opts.now() &&
1941
+ state.bundle.lease.manifestRevision >= state.minRevision &&
1942
+ state.phase === "usable",
1943
+ );
1944
+ }
1945
+
1946
+ #requireUsableState(assignment: TaskAssignment): TaskState {
1947
+ const state = this.#tasks.get(assignment.taskId);
1948
+ if (!state || !assignmentMatches(state.assignment, assignment) || !this.#bundleIsUsable(state)) {
1949
+ throw new CredentialLeaseUnavailableError();
1950
+ }
1951
+ return state;
1952
+ }
1953
+
1954
+ #assertReadFence(state: TaskState, bundle: LeaseBundle, generation: number): void {
1955
+ if (
1956
+ !this.#isActive(state) ||
1957
+ !state.gateOpen ||
1958
+ state.bundle !== bundle ||
1959
+ state.bundleGeneration !== generation ||
1960
+ bundle.expiresAt <= this.#opts.now() ||
1961
+ bundle.lease.manifestRevision < state.minRevision
1962
+ ) {
1963
+ throw new CredentialLeaseUnavailableError("credential read crossed an assignment fence");
1964
+ }
1965
+ }
1966
+
1967
+ /** A committed bundle may open only while this exact state still owns the
1968
+ * assignment and no cleanup fence has made it unusable. In particular,
1969
+ * `handleGrant` can wait for the previous bundle's readers after committing
1970
+ * its replacement; Stop, supersession, expiry or a revision floor may close
1971
+ * the gate during that wait. */
1972
+ #mayOpenGate(
1973
+ state: TaskState,
1974
+ bundle: LeaseBundle,
1975
+ generation: number,
1976
+ ): boolean {
1977
+ return Boolean(
1978
+ this.#isActive(state) &&
1979
+ state.phase !== "recovering" &&
1980
+ state.phase !== "lost" &&
1981
+ state.gateHolds === 0 &&
1982
+ state.fullCleanup === null &&
1983
+ !state.gateCloseFailed &&
1984
+ state.bundle === bundle &&
1985
+ state.bundleGeneration === generation &&
1986
+ bundle.lease.manifestRevision >= state.minRevision &&
1987
+ bundle.expiresAt > this.#opts.now(),
1988
+ );
1989
+ }
1990
+
1991
+ /**
1992
+ * A transition lets go of the gate. An open its hold kept closed is not
1993
+ * lost: once no transition holds the gate, it opens on the view committed
1994
+ * now, if it may. A transition that failed made the assignment lost before
1995
+ * releasing, so nothing opens then.
1996
+ */
1997
+ #releaseHold(state: TaskState): void {
1998
+ state.gateHolds--;
1999
+ if (state.gateHolds > 0 || state.gateOpen || !state.bundle) return;
2000
+ this.#openGate(state, state.bundle, state.bundleGeneration);
2001
+ }
2002
+
2003
+ #openGate(state: TaskState, bundle: LeaseBundle, generation: number): void {
2004
+ if (!this.#mayOpenGate(state, bundle, generation)) return;
2005
+ try {
2006
+ this.#opts.openTaskGate?.(cloneAssignment(state.assignment), bundle.view);
2007
+ } catch (error) {
2008
+ this.#closeGate(state, "cleanup-failed");
2009
+ throw error;
2010
+ }
2011
+ // The callback is synchronous but may be re-entrant. If it crossed a
2012
+ // fence, undo the external open before returning; the transition that
2013
+ // moved the fence owns any cleanup which follows.
2014
+ if (!this.#mayOpenGate(state, bundle, generation)) {
2015
+ if (!this.#closeGate(state, "cleanup-failed")) {
2016
+ throw new CredentialLeaseUnavailableError(
2017
+ "task gate reopened across a fence and could not be closed",
2018
+ );
2019
+ }
2020
+ return;
2021
+ }
2022
+ state.gateOpen = true;
2023
+ state.phase = "usable";
2024
+ for (const waiter of state.waiters) {
2025
+ if (waiter.signal && waiter.abort) {
2026
+ waiter.signal.removeEventListener("abort", waiter.abort);
2027
+ }
2028
+ waiter.resolve(bundle.view);
2029
+ }
2030
+ state.waiters.clear();
2031
+ }
2032
+
2033
+ #closeGate(state: TaskState, reason: LeaseGateCloseReason): boolean {
2034
+ state.gateOpen = false;
2035
+ try {
2036
+ this.#opts.closeTaskGate(cloneAssignment(state.assignment), reason);
2037
+ return true;
2038
+ } catch (error) {
2039
+ state.gateCloseFailed = true;
2040
+ this.#report(error, state.assignment);
2041
+ return false;
2042
+ }
2043
+ }
2044
+
2045
+ #armBundleTimers(state: TaskState): void {
2046
+ if (!state.bundle) return;
2047
+ if (state.renewalTimer) this.#opts.clearTimer(state.renewalTimer);
2048
+ if (state.expiryTimer) this.#opts.clearTimer(state.expiryTimer);
2049
+ const generation = state.bundleGeneration;
2050
+ const renewDelay = Math.max(
2051
+ 1_000,
2052
+ state.bundle.expiresAt - LEASE_RENEW_LEAD_MS - this.#opts.now(),
2053
+ );
2054
+ const renewal = this.#opts.setTimer(() => {
2055
+ if (state.renewalTimer !== renewal || state.bundleGeneration !== generation) return;
2056
+ state.renewalTimer = null;
2057
+ this.#request(state);
2058
+ }, renewDelay);
2059
+ renewal.unref?.();
2060
+ state.renewalTimer = renewal;
2061
+
2062
+ const expiryDelay = Math.max(0, state.bundle.expiresAt - this.#opts.now());
2063
+ const expiry = this.#opts.setTimer(() => {
2064
+ if (state.expiryTimer !== expiry || state.bundleGeneration !== generation) return;
2065
+ state.expiryTimer = null;
2066
+ this.#closeGate(state, "expired");
2067
+ this.#background(this.#fullCleanupAndContinue(state, "expired"), state.assignment);
2068
+ }, expiryDelay);
2069
+ expiry.unref?.();
2070
+ state.expiryTimer = expiry;
2071
+ }
2072
+
2073
+ #beginNarrowing(state: TaskState): void {
2074
+ // A full cleanup under way, or one that failed and is to be retried,
2075
+ // takes everything a narrowing would: the run stays quarantined until it
2076
+ // succeeds, never moved out of it by a narrowing.
2077
+ if (state.phase === "lost" || state.fullCleanup !== null) return;
2078
+ const belowFloor = Boolean(
2079
+ state.bundle && state.bundle.lease.manifestRevision < state.minRevision,
2080
+ );
2081
+ const removed = [...state.removed.entries()]
2082
+ .filter(([key]) => !state.cleaned.has(key))
2083
+ .map(([, identity]) => identity);
2084
+ if (!belowFloor && removed.length === 0) return;
2085
+ state.phase = "narrowing";
2086
+ const gateClosed = this.#closeGate(state, "minimum-revision");
2087
+ if (state.bundle) this.#sealBundle(state.bundle);
2088
+ if (!gateClosed) {
2089
+ this.#background(
2090
+ this.#fullCleanupAndContinue(state, "minimum-revision"),
2091
+ state.assignment,
2092
+ );
2093
+ return;
2094
+ }
2095
+ if (state.floorDeadlineAt === null) {
2096
+ state.floorDeadlineAt = this.#opts.now() + LEASE_MIN_REVISION_GRACE_MS;
2097
+ const deadline = state.floorDeadlineAt;
2098
+ const timer = this.#opts.setTimer(() => {
2099
+ if (state.floorTimer !== timer || state.floorDeadlineAt !== deadline) return;
2100
+ state.floorTimer = null;
2101
+ if (
2102
+ this.#isActive(state) &&
2103
+ (!state.bundle || state.bundle.lease.manifestRevision < state.minRevision)
2104
+ ) {
2105
+ this.#background(
2106
+ this.#fullCleanupAndContinue(state, "minimum-revision"),
2107
+ state.assignment,
2108
+ );
2109
+ }
2110
+ }, LEASE_MIN_REVISION_GRACE_MS);
2111
+ timer.unref?.();
2112
+ state.floorTimer = timer;
2113
+ }
2114
+ if (removed.length > 0) {
2115
+ const bundle = state.bundle;
2116
+ const cleanup = this.#runCleanup(state, {
2117
+ assignment: cloneAssignment(state.assignment),
2118
+ mode: "selective",
2119
+ reason: "minimum-revision",
2120
+ removed,
2121
+ removeFilesSession: false,
2122
+ })
2123
+ .then(async () => {
2124
+ for (const identity of removed) state.cleaned.add(credentialKey(identity));
2125
+ // A below-floor bundle is no longer authority. Once its external
2126
+ // material is gone, drop all of its in-memory material and STS
2127
+ // clients too; retaining unrelated values buys nothing while the
2128
+ // task gate is closed.
2129
+ if (
2130
+ bundle &&
2131
+ this.#isActive(state) &&
2132
+ state.bundle === bundle &&
2133
+ bundle.lease.manifestRevision < state.minRevision
2134
+ ) {
2135
+ state.bundle = null;
2136
+ state.bundleGeneration++;
2137
+ await this.#destroyBundle(bundle);
2138
+ }
2139
+ })
2140
+ .catch((error: unknown) => {
2141
+ this.#report(error, state.assignment);
2142
+ this.#armSelectiveCleanupRetry(state);
2143
+ });
2144
+ // `cleanup` handles and schedules its own failure; keep it detached.
2145
+ void cleanup;
2146
+ }
2147
+ }
2148
+
2149
+ #armSelectiveCleanupRetry(state: TaskState): void {
2150
+ if (!this.#isActive(state) || state.cleanupRetryTimer) return;
2151
+ // A full cleanup takes what this would.
2152
+ if (state.phase === "lost" || state.fullCleanup !== null) return;
2153
+ const timer = this.#opts.setTimer(() => {
2154
+ if (state.cleanupRetryTimer !== timer) return;
2155
+ state.cleanupRetryTimer = null;
2156
+ if (
2157
+ (state.bundle && state.bundle.lease.manifestRevision < state.minRevision) ||
2158
+ this.#hasUncleanedRemovals(state)
2159
+ ) {
2160
+ this.#beginNarrowing(state);
2161
+ }
2162
+ }, LEASE_RETRY_MS);
2163
+ timer.unref?.();
2164
+ state.cleanupRetryTimer = timer;
2165
+ }
2166
+
2167
+ #hasUncleanedRemovals(state: TaskState): boolean {
2168
+ return [...state.removed.keys()].some((key) => !state.cleaned.has(key));
2169
+ }
2170
+
2171
+ #clearFloor(state: TaskState): void {
2172
+ if (state.floorTimer) this.#opts.clearTimer(state.floorTimer);
2173
+ state.floorTimer = null;
2174
+ state.floorDeadlineAt = null;
2175
+ state.removed.clear();
2176
+ state.cleaned.clear();
2177
+ }
2178
+
2179
+ async #applyCredentialVersion(
2180
+ state: TaskState,
2181
+ frame: CredentialChangedFrame,
2182
+ ): Promise<void> {
2183
+ if (!this.#bundleIsUsable(state)) return;
2184
+ const bundle = state.bundle!;
2185
+ const generation = state.bundleGeneration;
2186
+ const current = bundle.records.get(frame.credentialId);
2187
+ if (
2188
+ !current ||
2189
+ current.descriptor.revision !== frame.revision ||
2190
+ current.descriptor.authKind === "none" ||
2191
+ !current.descriptor.parameter ||
2192
+ !current.descriptor.sessionId ||
2193
+ !current.descriptor.location ||
2194
+ frame.version <=
2195
+ Math.max(current.descriptor.version ?? 0, current.observedVersion ?? 0)
2196
+ ) {
2197
+ return;
2198
+ }
2199
+ const reader = bundle.readers.get(current.descriptor.sessionId);
2200
+ if (!reader) throw new CredentialLeaseError("credential session is missing");
2201
+ const selector = `${current.descriptor.parameter}:${frame.version}`;
2202
+ const replacement = new Map(bundle.records);
2203
+ const descriptor = { ...current.descriptor, version: frame.version };
2204
+ await this.#readMaterialBatch(
2205
+ reader,
2206
+ [descriptor],
2207
+ [selector],
2208
+ replacement,
2209
+ );
2210
+ this.#assertReadFence(state, bundle, generation);
2211
+ const next: LeaseBundle = {
2212
+ ...bundle,
2213
+ records: replacement,
2214
+ view: freezeView(bundle.lease, bundle.expiresAt, replacement),
2215
+ };
2216
+ // A credential the task could read and now cannot was refused (§6): with
2217
+ // the gate closed, its files and whatever uses them go before the view
2218
+ // that holds it unreadable is installed, as for a removal. The gate is
2219
+ // held closed until this transition opens it again on that view.
2220
+ const refused =
2221
+ current.availability === "available" &&
2222
+ replacement.get(frame.credentialId)?.availability === "access-denied";
2223
+ let holding = false;
2224
+ const release = (): void => {
2225
+ if (!holding) return;
2226
+ holding = false;
2227
+ this.#releaseHold(state);
2228
+ };
2229
+ if (refused) {
2230
+ if (!this.#closeGate(state, "manifest-narrowed")) {
2231
+ // A gate that cannot close is quarantined: the full cleanup owns it.
2232
+ this.#background(this.#fullCleanupAndContinue(state, "manifest-narrowed"), state.assignment);
2233
+ return;
2234
+ }
2235
+ state.gateHolds++;
2236
+ holding = true;
2237
+ }
2238
+ let installAttempted = false;
2239
+ try {
2240
+ if (refused) {
2241
+ await this.#runCleanup(state, {
2242
+ assignment: cloneAssignment(state.assignment),
2243
+ mode: "selective",
2244
+ reason: "lease-replaced",
2245
+ removed: [descriptorIdentity(current.descriptor)],
2246
+ removeFilesSession: false,
2247
+ });
2248
+ this.#assertRefusalFence(state, bundle, generation);
2249
+ }
2250
+ const installRequest: CredentialInstallRequest = {
2251
+ assignment: cloneAssignment(state.assignment),
2252
+ owner: { orgId: bundle.lease.orgId, userId: bundle.lease.userId },
2253
+ previous: bundle.view,
2254
+ next: next.view,
2255
+ files: this.#filesAccess(next),
2256
+ reason: "credential-change",
2257
+ projectSetChanged: false,
2258
+ // The same grant: its values read while it is usable.
2259
+ projectValues: this.#grantProjectValueReader(state, bundle, () => false),
2260
+ // The fence this transition checks once the install is over, now:
2261
+ // a floor, an expiry or a stop meanwhile ends it.
2262
+ isCurrent: () => {
2263
+ try {
2264
+ if (refused) this.#assertRefusalFence(state, bundle, generation);
2265
+ else this.#assertReadFence(state, bundle, generation);
2266
+ return true;
2267
+ } catch {
2268
+ return false;
2269
+ }
2270
+ },
2271
+ };
2272
+ installAttempted = this.#opts.installExternal !== undefined;
2273
+ await this.#runInstall(state, installRequest);
2274
+ if (refused) this.#assertRefusalFence(state, bundle, generation);
2275
+ else this.#assertReadFence(state, bundle, generation);
2276
+ state.bundle = next;
2277
+ state.bundleGeneration++;
2278
+ this.#armBundleTimers(state);
2279
+ // A refusal opens through its release, inside this boundary: an open
2280
+ // that fails, its compensating close included, escalates below.
2281
+ release();
2282
+ } catch (error) {
2283
+ // As for a grant, what a failed or aborted step may have done is never
2284
+ // assumed undone: an attempted install, a gate that could not close,
2285
+ // or a refusal that did not reach its own open goes to the full
2286
+ // cleanup. That makes the assignment lost at once, before any hold
2287
+ // goes, so a view committed before cannot reopen the gate either.
2288
+ if ((holding || installAttempted || state.gateCloseFailed) && this.#isActive(state)) {
2289
+ const escalation = this.#fullCleanupAndContinue(state, "manifest-narrowed");
2290
+ release();
2291
+ try {
2292
+ await escalation;
2293
+ } catch {
2294
+ // Full cleanup remains closed and owns its retry.
2295
+ }
2296
+ }
2297
+ throw error;
2298
+ } finally {
2299
+ release();
2300
+ }
2301
+ }
2302
+
2303
+ /** After a refused credential's cleanup, with the gate this transition
2304
+ * closed: the same bundle and generation, nothing else having taken over. */
2305
+ #assertRefusalFence(state: TaskState, bundle: LeaseBundle, generation: number): void {
2306
+ if (
2307
+ !this.#isActive(state) ||
2308
+ state.phase !== "usable" ||
2309
+ state.gateCloseFailed ||
2310
+ state.fullCleanup !== null ||
2311
+ state.bundle !== bundle ||
2312
+ state.bundleGeneration !== generation ||
2313
+ bundle.expiresAt <= this.#opts.now() ||
2314
+ bundle.lease.manifestRevision < state.minRevision
2315
+ ) {
2316
+ throw new CredentialLeaseUnavailableError("a refused credential's cleanup crossed an assignment fence");
2317
+ }
2318
+ }
2319
+
2320
+ #settleRefresh(
2321
+ state: TaskState,
2322
+ pending: RefreshPending,
2323
+ result: CredentialRefreshResultFrame,
2324
+ ): void {
2325
+ if (state.refreshByOp.get(pending.opId) !== pending) return;
2326
+ this.#opts.clearTimer(pending.timer);
2327
+ state.refreshByOp.delete(pending.opId);
2328
+ state.refreshByCredential.delete(pending.key);
2329
+ pending.resolve(result);
2330
+ }
2331
+
2332
+ #endSynchronously(state: TaskState, reason: LeaseGateCloseReason): void {
2333
+ state.phase = "ended";
2334
+ this.#closeGate(state, reason);
2335
+ this.#cancelTimers(state);
2336
+ if (state.bundle) this.#sealBundle(state.bundle);
2337
+ for (const pending of [...state.refreshByOp.values()]) {
2338
+ this.#settleRefresh(state, pending, unavailableRefresh(pending.opId));
2339
+ }
2340
+ for (const waiter of state.waiters) {
2341
+ if (waiter.signal && waiter.abort) {
2342
+ waiter.signal.removeEventListener("abort", waiter.abort);
2343
+ }
2344
+ waiter.reject(new CredentialLeaseUnavailableError("task assignment ended"));
2345
+ }
2346
+ state.waiters.clear();
2347
+ }
2348
+
2349
+ async #completeEndedCleanup(
2350
+ state: TaskState,
2351
+ reason: LeaseGateCloseReason,
2352
+ ): Promise<void> {
2353
+ if (state.startup) await this.#finishStartup(state);
2354
+ if (state.gateCloseFailed && this.#closeGate(state, reason)) {
2355
+ state.gateCloseFailed = false;
2356
+ }
2357
+ const cleanupRequest: CredentialCleanupRequest = {
2358
+ assignment: cloneAssignment(state.assignment),
2359
+ mode: "full",
2360
+ reason,
2361
+ removed: [],
2362
+ removeFilesSession: this.#opts.runnerKind === "task-machine",
2363
+ };
2364
+ await this.#runCleanup(state, cleanupRequest);
2365
+ const bundle = state.bundle;
2366
+ state.bundle = null;
2367
+ state.bundleGeneration++;
2368
+ if (bundle) await this.#destroyBundle(bundle);
2369
+ state.identitiesByCredentialId.clear();
2370
+ // A synchronous open callback can re-enter Stop/supersession and then
2371
+ // fail its compensating close while this cleanup is awaiting consumers.
2372
+ // Do not let the earlier successful close mask that later failure. A
2373
+ // successful retry must itself be followed by full cleanup: consumers
2374
+ // could have admitted work while the external gate remained open.
2375
+ while (state.gateCloseFailed) {
2376
+ if (!this.#closeGate(state, reason)) {
2377
+ throw new CredentialLeaseUnavailableError(
2378
+ "task gate closure failed; assignment remains quarantined",
2379
+ );
2380
+ }
2381
+ state.gateCloseFailed = false;
2382
+ await this.#runCleanup(state, cleanupRequest);
2383
+ }
2384
+ }
2385
+
2386
+ async #fullCleanupAndContinue(
2387
+ state: TaskState,
2388
+ reason: Extract<
2389
+ LeaseGateCloseReason,
2390
+ | "denied"
2391
+ | "expired"
2392
+ | "minimum-revision"
2393
+ | "manifest-narrowed"
2394
+ | "cleanup-failed"
2395
+ | "fence-failed"
2396
+ >,
2397
+ ): Promise<void> {
2398
+ if (state.fullCleanup) return state.fullCleanup;
2399
+ // Set the phase before invoking the synchronous gate callback below, so
2400
+ // even a re-entrant reconnect cannot create a request during cleanup.
2401
+ state.phase = "lost";
2402
+ // It takes everything a pending cleanup retry would; should it fail, it
2403
+ // arms its own.
2404
+ this.#cancelCleanupRetry(state);
2405
+ const operation = (async () => {
2406
+ if (this.#closeGate(state, reason)) state.gateCloseFailed = false;
2407
+ if (state.bundle) this.#sealBundle(state.bundle);
2408
+ const cleanupRequest: CredentialCleanupRequest = {
2409
+ assignment: cloneAssignment(state.assignment),
2410
+ mode: "full",
2411
+ reason,
2412
+ removed: [],
2413
+ removeFilesSession: this.#opts.runnerKind === "task-machine",
2414
+ };
2415
+ await this.#runCleanup(state, cleanupRequest);
2416
+ if (!this.#isActive(state)) return;
2417
+ const bundle = state.bundle;
2418
+ state.bundle = null;
2419
+ state.bundleGeneration++;
2420
+ if (bundle) await this.#destroyBundle(bundle);
2421
+ state.identitiesByCredentialId.clear();
2422
+ while (state.gateCloseFailed) {
2423
+ if (!this.#closeGate(state, reason)) {
2424
+ throw new CredentialLeaseUnavailableError(
2425
+ "task gate closure failed; assignment remains quarantined",
2426
+ );
2427
+ }
2428
+ state.gateCloseFailed = false;
2429
+ await this.#runCleanup(state, cleanupRequest);
2430
+ if (!this.#isActive(state)) return;
2431
+ }
2432
+ if (!this.#isActive(state)) return;
2433
+ state.phase = "waiting";
2434
+ this.#clearFloor(state);
2435
+ })();
2436
+ state.fullCleanup = operation;
2437
+ let completed = false;
2438
+ try {
2439
+ await operation;
2440
+ completed = true;
2441
+ } catch (error) {
2442
+ this.#report(error, state.assignment);
2443
+ if (this.#isActive(state)) {
2444
+ state.phase = "lost";
2445
+ this.#closeGate(state, "cleanup-failed");
2446
+ this.#armCleanupRetry(state, reason);
2447
+ }
2448
+ throw error;
2449
+ } finally {
2450
+ if (state.fullCleanup === operation) state.fullCleanup = null;
2451
+ if (completed) {
2452
+ // A permanent revocation answers every request with another denial.
2453
+ // Keep asking, but do not turn that into a tight
2454
+ // deny/cleanup/request loop against the cloud.
2455
+ if (reason === "denied") this.#armRetry(state);
2456
+ else this.#request(state);
2457
+ }
2458
+ }
2459
+ }
2460
+
2461
+ #armCleanupRetry(
2462
+ state: TaskState,
2463
+ reason: Extract<
2464
+ LeaseGateCloseReason,
2465
+ | "denied"
2466
+ | "expired"
2467
+ | "minimum-revision"
2468
+ | "manifest-narrowed"
2469
+ | "cleanup-failed"
2470
+ | "fence-failed"
2471
+ >,
2472
+ ): void {
2473
+ if (!this.#isActive(state) || state.cleanupRetryTimer) return;
2474
+ const timer = this.#opts.setTimer(() => {
2475
+ if (state.cleanupRetryTimer !== timer) return;
2476
+ state.cleanupRetryTimer = null;
2477
+ this.#background(this.#fullCleanupAndContinue(state, reason), state.assignment);
2478
+ }, LEASE_RETRY_MS);
2479
+ timer.unref?.();
2480
+ state.cleanupRetryTimer = timer;
2481
+ }
2482
+
2483
+ #cancelCleanupRetry(state: TaskState): void {
2484
+ if (!state.cleanupRetryTimer) return;
2485
+ this.#opts.clearTimer(state.cleanupRetryTimer);
2486
+ state.cleanupRetryTimer = null;
2487
+ }
2488
+
2489
+ #runCleanup(state: TaskState, request: CredentialCleanupRequest): Promise<void> {
2490
+ const operation = state.cleanupTail
2491
+ .catch(() => undefined)
2492
+ .then(() => this.#opts.cleanupExternal(request));
2493
+ state.cleanupTail = operation;
2494
+ return operation;
2495
+ }
2496
+
2497
+ #runInstall(state: TaskState, request: CredentialInstallRequest): Promise<void> {
2498
+ if (state.gateCloseFailed) {
2499
+ return Promise.reject(
2500
+ new CredentialLeaseUnavailableError("task gate closure is quarantined"),
2501
+ );
2502
+ }
2503
+ if (!this.#opts.installExternal) return state.cleanupTail;
2504
+ const operation = state.cleanupTail.then(() => this.#opts.installExternal!(request));
2505
+ state.cleanupTail = operation;
2506
+ return operation;
2507
+ }
2508
+
2509
+ #cancelTimers(state: TaskState): void {
2510
+ this.#cancelPendingLease(state);
2511
+ for (const key of [
2512
+ "renewalTimer",
2513
+ "retryTimer",
2514
+ "expiryTimer",
2515
+ "floorTimer",
2516
+ "cleanupRetryTimer",
2517
+ ] as const) {
2518
+ const timer = state[key];
2519
+ if (timer) this.#opts.clearTimer(timer);
2520
+ state[key] = null;
2521
+ }
2522
+ }
2523
+
2524
+ #sealBundle(bundle: LeaseBundle): void {
2525
+ for (const reader of bundle.readers.values()) reader.seal();
2526
+ }
2527
+
2528
+ async #destroyBundle(bundle: LeaseBundle): Promise<void> {
2529
+ this.#sealBundle(bundle);
2530
+ await Promise.all([...bundle.readers.values()].map((reader) => reader.destroyWhenDrained()));
2531
+ }
2532
+
2533
+ #background(promise: Promise<unknown>, assignment: TaskAssignment): void {
2534
+ void promise.catch((error: unknown) => this.#report(error, assignment));
2535
+ }
2536
+
2537
+ #report(error: unknown, assignment: TaskAssignment): void {
2538
+ const safe =
2539
+ error instanceof CredentialLeaseError
2540
+ ? new Error(error.message)
2541
+ : new Error("credential lease operation failed");
2542
+ this.#opts.onError?.(safe, cloneAssignment(assignment));
2543
+ }
2544
+ }