@runuai/host 0.9.155 → 0.9.157

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,521 @@
1
+ /**
2
+ * Durable host half of task-machine Files retirement.
3
+ *
4
+ * A Files boundary that cannot be proved closed must never be repaired by a
5
+ * sidecar process restart: the task's other processes outlive that restart
6
+ * (`uai-sidecar.service` uses KillMode=process). Establish one exact
7
+ * task/run request, persist it before containment when storage permits, kill
8
+ * every other process in the sidecar's cgroup, and replay the same operation
9
+ * until the cloud durably accepts it. A storage failure cannot skip the kill
10
+ * or cloud handoff: the same in-memory operation retries persistence too.
11
+ * Only a different kernel boot id clears the record; that full boot is the
12
+ * proof boundary the Files consumer needs.
13
+ */
14
+
15
+ import {
16
+ closeSync,
17
+ constants,
18
+ fstatSync,
19
+ fsyncSync,
20
+ lstatSync,
21
+ openSync,
22
+ readFileSync,
23
+ renameSync,
24
+ unlinkSync,
25
+ writeFileSync,
26
+ } from "node:fs";
27
+ import { dirname, resolve } from "node:path";
28
+
29
+ import {
30
+ isHostOpId,
31
+ TASK_MACHINE_RETIRE_REASONS,
32
+ type TaskMachineRetireAckFrame,
33
+ type TaskMachineRetireFrame,
34
+ } from "../src/protocol";
35
+ import { newId } from "./ulid";
36
+ import { isSafeHostTaskId } from "./task-identity";
37
+ import type {
38
+ TaskFilesMachineRetirementHandler,
39
+ TaskFilesMachineRetirementReason,
40
+ TaskFilesMachineRetirementRequest,
41
+ } from "./task-files-machine-retirement";
42
+
43
+ export const TASK_FILES_MACHINE_RETIREMENT_RECORD =
44
+ "task-files-machine-retirement-v1.json";
45
+ export const TASK_FILES_MACHINE_RETIREMENT_RETRY_MS = 5_000;
46
+ const RECORD_SCHEMA_VERSION = 1;
47
+ const MAX_RECORD_BYTES = 4_096;
48
+ const CGROUP_ROOT = "/sys/fs/cgroup";
49
+ const SELF_CGROUP = "/proc/self/cgroup";
50
+ const KERNEL_BOOT_ID = "/proc/sys/kernel/random/boot_id";
51
+
52
+ interface RetirementRecord {
53
+ schemaVersion: 1;
54
+ machineBootId: string;
55
+ opId: string;
56
+ taskId: string;
57
+ runEpoch: number;
58
+ reason: TaskFilesMachineRetirementReason;
59
+ }
60
+
61
+ type TimerHandle = unknown;
62
+
63
+ export interface TaskFilesMachineRetirementOutboxDeps {
64
+ recordPath: string;
65
+ /** Authenticated bridge send. False means the next reconnect/tick retries. */
66
+ send(frame: TaskMachineRetireFrame): boolean;
67
+ machineBootId?: () => string;
68
+ quarantine?: () => void;
69
+ newOpId?: () => string;
70
+ setTimer?: (callback: () => void, ms: number) => TimerHandle;
71
+ clearTimer?: (timer: TimerHandle) => void;
72
+ log?: (line: string) => void;
73
+ }
74
+
75
+ export type RetirementOutboxStartup =
76
+ | { kind: "ready" }
77
+ | { kind: "pending"; request: TaskFilesMachineRetirementRequest }
78
+ | { kind: "damaged"; error: Error };
79
+
80
+ function isReason(value: unknown): value is TaskFilesMachineRetirementReason {
81
+ return (TASK_MACHINE_RETIRE_REASONS as readonly unknown[]).includes(value);
82
+ }
83
+
84
+ function parseRecord(text: string): RetirementRecord {
85
+ let value: unknown;
86
+ try {
87
+ value = JSON.parse(text);
88
+ } catch (error) {
89
+ throw new Error("the task-machine retirement record is not JSON", {
90
+ cause: error,
91
+ });
92
+ }
93
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
94
+ throw new Error("the task-machine retirement record is not an object");
95
+ }
96
+ const row = value as Record<string, unknown>;
97
+ const keys = Object.keys(row).sort();
98
+ const expected = [
99
+ "machineBootId",
100
+ "opId",
101
+ "reason",
102
+ "runEpoch",
103
+ "schemaVersion",
104
+ "taskId",
105
+ ].sort();
106
+ if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
107
+ throw new Error("the task-machine retirement record has unknown fields");
108
+ }
109
+ if (
110
+ row.schemaVersion !== RECORD_SCHEMA_VERSION ||
111
+ typeof row.machineBootId !== "string" ||
112
+ !/^[0-9a-f-]{16,64}$/i.test(row.machineBootId) ||
113
+ !isHostOpId(row.opId) ||
114
+ !isSafeHostTaskId(row.taskId) ||
115
+ !Number.isSafeInteger(row.runEpoch) ||
116
+ (row.runEpoch as number) < 1 ||
117
+ !isReason(row.reason)
118
+ ) {
119
+ throw new Error("the task-machine retirement record is invalid");
120
+ }
121
+ return row as unknown as RetirementRecord;
122
+ }
123
+
124
+ function readSecureRecord(path: string): RetirementRecord | null {
125
+ let info;
126
+ try {
127
+ info = lstatSync(path);
128
+ } catch (error) {
129
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
130
+ throw error;
131
+ }
132
+ const expectedUid = process.getuid?.() ?? info.uid;
133
+ if (
134
+ !info.isFile() ||
135
+ info.isSymbolicLink() ||
136
+ info.uid !== expectedUid ||
137
+ (info.mode & 0o077) !== 0 ||
138
+ info.size > MAX_RECORD_BYTES
139
+ ) {
140
+ throw new Error("the task-machine retirement record is not a private owned file");
141
+ }
142
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
143
+ try {
144
+ const opened = fstatSync(fd);
145
+ if (opened.dev !== info.dev || opened.ino !== info.ino) {
146
+ throw new Error("the task-machine retirement record changed while opening");
147
+ }
148
+ return parseRecord(readFileSync(fd, "utf8"));
149
+ } finally {
150
+ closeSync(fd);
151
+ }
152
+ }
153
+
154
+ function syncDirectory(path: string): void {
155
+ const fd = openSync(dirname(path), constants.O_RDONLY | constants.O_DIRECTORY);
156
+ try {
157
+ fsyncSync(fd);
158
+ } finally {
159
+ closeSync(fd);
160
+ }
161
+ }
162
+
163
+ function writeRecord(path: string, record: RetirementRecord): void {
164
+ const staged = `${path}.${String(process.pid)}.${record.opId}.tmp`;
165
+ let fd: number | null = null;
166
+ try {
167
+ fd = openSync(
168
+ staged,
169
+ constants.O_WRONLY |
170
+ constants.O_CREAT |
171
+ constants.O_EXCL |
172
+ constants.O_NOFOLLOW,
173
+ 0o600,
174
+ );
175
+ writeFileSync(fd, `${JSON.stringify(record)}\n`, "utf8");
176
+ fsyncSync(fd);
177
+ closeSync(fd);
178
+ fd = null;
179
+ renameSync(staged, path);
180
+ syncDirectory(path);
181
+ } catch (error) {
182
+ if (fd !== null) closeSync(fd);
183
+ try {
184
+ unlinkSync(staged);
185
+ } catch (cleanupError) {
186
+ if ((cleanupError as NodeJS.ErrnoException).code !== "ENOENT") {
187
+ throw new AggregateError(
188
+ [error, cleanupError],
189
+ "the task-machine retirement record and its staging file could not be persisted",
190
+ );
191
+ }
192
+ }
193
+ throw error;
194
+ }
195
+ }
196
+
197
+ function removeRecord(path: string): void {
198
+ try {
199
+ unlinkSync(path);
200
+ } catch (error) {
201
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
202
+ throw error;
203
+ }
204
+ syncDirectory(path);
205
+ }
206
+
207
+ export function kernelBootId(): string {
208
+ const value = readFileSync(KERNEL_BOOT_ID, "utf8").trim();
209
+ if (!/^[0-9a-f-]{16,64}$/i.test(value)) {
210
+ throw new Error("the machine kernel boot id is invalid");
211
+ }
212
+ return value;
213
+ }
214
+
215
+ function selfCgroupProcessesPath(): string {
216
+ const line = readFileSync(SELF_CGROUP, "utf8")
217
+ .split("\n")
218
+ .find((entry) => entry.startsWith("0::"));
219
+ if (!line) throw new Error("the sidecar has no unified cgroup");
220
+ const relative = line.slice(3);
221
+ if (
222
+ !relative.startsWith("/") ||
223
+ relative.includes("\0") ||
224
+ relative.split("/").includes("..")
225
+ ) {
226
+ throw new Error("the sidecar cgroup path is unsafe");
227
+ }
228
+ const path = resolve(CGROUP_ROOT, `.${relative}`, "cgroup.procs");
229
+ if (!path.startsWith(`${CGROUP_ROOT}/`)) {
230
+ throw new Error("the sidecar cgroup escaped the cgroup root");
231
+ }
232
+ return path;
233
+ }
234
+
235
+ /** Every process beside this runner in the sidecar service cgroup. */
236
+ export function taskMachinePeerProcessIds(): number[] {
237
+ return readFileSync(selfCgroupProcessesPath(), "utf8")
238
+ .split("\n")
239
+ .map((value) => Number(value.trim()))
240
+ .filter(
241
+ (pid) =>
242
+ Number.isSafeInteger(pid) && pid > 1 && pid !== process.pid,
243
+ );
244
+ }
245
+
246
+ /** Kill everything in the sidecar service cgroup except this root runner.
247
+ * The task's agents, editor, terminals and sudo children all descend from the
248
+ * service; KillMode=process is precisely why they otherwise survive. */
249
+ export function quarantineTaskMachineProcesses(): void {
250
+ quarantineTaskMachineProcessesWith({
251
+ list: () => [process.pid, ...taskMachinePeerProcessIds()],
252
+ kill: (pid) => process.kill(pid, "SIGKILL"),
253
+ self: process.pid,
254
+ });
255
+ }
256
+
257
+ export interface TaskMachineProcessQuarantineDeps {
258
+ list(): number[];
259
+ kill(pid: number): void;
260
+ self: number;
261
+ }
262
+
263
+ /** Exported seam keeps the real cgroup algorithm executable in tests. */
264
+ export function quarantineTaskMachineProcessesWith(
265
+ deps: TaskMachineProcessQuarantineDeps,
266
+ ): void {
267
+ const others = deps
268
+ .list()
269
+ .filter((pid) => Number.isSafeInteger(pid) && pid > 1 && pid !== deps.self);
270
+ if (others.length === 0) return;
271
+ for (const pid of others) {
272
+ try {
273
+ deps.kill(pid);
274
+ } catch (error) {
275
+ if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
276
+ }
277
+ }
278
+ const remaining = deps
279
+ .list()
280
+ .filter((pid) => Number.isSafeInteger(pid) && pid > 1 && pid !== deps.self);
281
+ if (remaining.length > 0) {
282
+ throw new Error(
283
+ `task processes remained after the retirement quarantine (${remaining.join(", ")})`,
284
+ );
285
+ }
286
+ }
287
+
288
+ export class TaskFilesMachineRetirementOutbox {
289
+ readonly #deps: Required<
290
+ Pick<
291
+ TaskFilesMachineRetirementOutboxDeps,
292
+ "machineBootId" | "quarantine" | "newOpId" | "setTimer" | "clearTimer" | "log"
293
+ >
294
+ > &
295
+ Pick<TaskFilesMachineRetirementOutboxDeps, "recordPath" | "send">;
296
+ readonly startup: RetirementOutboxStartup;
297
+ #record: RetirementRecord | null = null;
298
+ #recordPersisted = false;
299
+ #timer: TimerHandle | null = null;
300
+ #accepted = false;
301
+ #damaged = false;
302
+ #lastPersistenceError: string | null = null;
303
+ #lastQuarantineError: string | null = null;
304
+ #stopped = false;
305
+
306
+ constructor(deps: TaskFilesMachineRetirementOutboxDeps) {
307
+ this.#deps = {
308
+ ...deps,
309
+ machineBootId: deps.machineBootId ?? kernelBootId,
310
+ quarantine: deps.quarantine ?? quarantineTaskMachineProcesses,
311
+ newOpId: deps.newOpId ?? newId,
312
+ setTimer: deps.setTimer ?? ((callback, ms) => setTimeout(callback, ms)),
313
+ clearTimer: deps.clearTimer ?? ((timer) => clearTimeout(timer as NodeJS.Timeout)),
314
+ log: deps.log ?? ((line) => console.warn(line)),
315
+ };
316
+ try {
317
+ const stored = readSecureRecord(deps.recordPath);
318
+ if (stored === null) {
319
+ this.startup = { kind: "ready" };
320
+ return;
321
+ }
322
+ if (stored.machineBootId !== this.#deps.machineBootId()) {
323
+ removeRecord(deps.recordPath);
324
+ this.startup = { kind: "ready" };
325
+ return;
326
+ }
327
+ this.#record = stored;
328
+ this.#recordPersisted = true;
329
+ this.startup = {
330
+ kind: "pending",
331
+ request: {
332
+ assignment: { taskId: stored.taskId, runEpoch: stored.runEpoch },
333
+ reason: stored.reason,
334
+ error: new Error("task-machine retirement is still pending on this boot"),
335
+ },
336
+ };
337
+ } catch (error) {
338
+ this.startup = {
339
+ kind: "damaged",
340
+ error: error instanceof Error ? error : new Error(String(error)),
341
+ };
342
+ }
343
+ }
344
+
345
+ readonly handler: TaskFilesMachineRetirementHandler = (request) => {
346
+ if (this.#stopped) throw new Error("task-machine retirement is shut down");
347
+ const existing = this.#record;
348
+ if (
349
+ existing &&
350
+ (existing.taskId !== request.assignment.taskId ||
351
+ existing.runEpoch !== request.assignment.runEpoch)
352
+ ) {
353
+ // First failure owns this one-task machine. A later assignment cannot
354
+ // replace the exact run the cloud must stop.
355
+ return undefined;
356
+ }
357
+ if (!existing) {
358
+ const record: RetirementRecord = {
359
+ schemaVersion: RECORD_SCHEMA_VERSION,
360
+ machineBootId: this.#deps.machineBootId(),
361
+ opId: this.#deps.newOpId(),
362
+ taskId: request.assignment.taskId,
363
+ runEpoch: request.assignment.runEpoch,
364
+ reason: request.reason,
365
+ };
366
+ this.#record = record;
367
+ }
368
+ // The cycle persists first when it can. If storage refuses the write, it
369
+ // still quarantines and sends this same op, then retries all three: a
370
+ // full disk must not leave the already-running task uncontained.
371
+ this.#cycle();
372
+ return undefined;
373
+ };
374
+
375
+ /** A same-boot record that cannot be read may name an irreversible Files
376
+ * failure. It has no safe exact run to send to the cloud, but it still
377
+ * closes local admission and retries the cgroup quarantine until this
378
+ * process stops. */
379
+ quarantineDamagedStartup(): void {
380
+ if (this.startup.kind !== "damaged" || this.#stopped) return;
381
+ this.#damaged = true;
382
+ this.#cycle();
383
+ }
384
+
385
+ /** The authenticated bridge is back; replay a same-boot request. */
386
+ bridgeAuthenticated(): void {
387
+ this.#cycle();
388
+ }
389
+
390
+ acknowledge(frame: TaskMachineRetireAckFrame): void {
391
+ const record = this.#record;
392
+ if (
393
+ !record ||
394
+ frame.opId !== record.opId ||
395
+ frame.taskId !== record.taskId ||
396
+ frame.runEpoch !== record.runEpoch
397
+ ) {
398
+ return;
399
+ }
400
+ if (frame.outcome === "accepted") {
401
+ this.#accepted = true;
402
+ this.#deps.log(
403
+ `[credentials] task ${record.taskId} run ${String(record.runEpoch)}: exact machine retirement accepted; waiting for the machine to stop`,
404
+ );
405
+ this.#reconcileTimer();
406
+ return;
407
+ }
408
+ // A terminal rejection must never reopen this boot. Stop retrying a
409
+ // request the cloud has authoritatively refused, but retain the durable
410
+ // latch for an operator-visible fail-closed machine.
411
+ this.#accepted = true;
412
+ this.#deps.log(
413
+ `[credentials] task ${record.taskId} run ${String(record.runEpoch)}: machine retirement ${frame.outcome}${frame.error ? ` (${frame.error})` : ""}; this boot remains quarantined`,
414
+ );
415
+ this.#reconcileTimer();
416
+ }
417
+
418
+ stop(): void {
419
+ this.#stopped = true;
420
+ this.#cancelTimer();
421
+ }
422
+
423
+ #cycle(): void {
424
+ const record = this.#record;
425
+ if (this.#stopped || (!record && !this.#damaged)) return;
426
+ this.#tryPersistRecord();
427
+ this.#tryQuarantine();
428
+ // A failed quarantine must not strand the durable handoff. The cloud's
429
+ // accepted exact-instance stop is the final fence; local retry continues
430
+ // independently until either the cgroup is empty or the instance dies.
431
+ if (record && !this.#accepted) {
432
+ this.#deps.send({
433
+ kind: "task.machine.retire",
434
+ opId: record.opId,
435
+ taskId: record.taskId,
436
+ runEpoch: record.runEpoch,
437
+ reason: record.reason,
438
+ });
439
+ }
440
+ this.#reconcileTimer();
441
+ }
442
+
443
+ #tryPersistRecord(): void {
444
+ const record = this.#record;
445
+ if (!record || this.#recordPersisted) return;
446
+ try {
447
+ writeRecord(this.#deps.recordPath, record);
448
+ this.#recordPersisted = true;
449
+ if (this.#lastPersistenceError !== null) {
450
+ this.#deps.log(
451
+ "[credentials] task-machine retirement record is now durable",
452
+ );
453
+ }
454
+ this.#lastPersistenceError = null;
455
+ } catch (error) {
456
+ const detail = error instanceof Error ? error.message : String(error);
457
+ if (detail !== this.#lastPersistenceError) {
458
+ this.#deps.log(
459
+ `[credentials] task-machine retirement record could not be persisted (${detail}); containment proceeds in memory and persistence retries`,
460
+ );
461
+ this.#lastPersistenceError = detail;
462
+ }
463
+ }
464
+ }
465
+
466
+ #tryQuarantine(): void {
467
+ try {
468
+ this.#deps.quarantine();
469
+ if (this.#lastQuarantineError !== null) {
470
+ this.#deps.log(
471
+ "[credentials] task-machine process quarantine is now complete",
472
+ );
473
+ }
474
+ this.#lastQuarantineError = null;
475
+ } catch (error) {
476
+ const detail = error instanceof Error ? error.message : String(error);
477
+ if (detail !== this.#lastQuarantineError) {
478
+ this.#deps.log(
479
+ `[credentials] task-machine process quarantine is incomplete (${detail}); retrying while the cloud stop proceeds`,
480
+ );
481
+ this.#lastQuarantineError = detail;
482
+ }
483
+ }
484
+ }
485
+
486
+ #needsRetry(): boolean {
487
+ return (
488
+ !this.#stopped &&
489
+ (this.#record !== null || this.#damaged)
490
+ );
491
+ }
492
+
493
+ #reconcileTimer(): void {
494
+ if (this.#needsRetry()) this.#armTimer();
495
+ else this.#cancelTimer();
496
+ }
497
+
498
+ #armTimer(): void {
499
+ if (this.#timer || !this.#needsRetry()) return;
500
+ const timer = this.#deps.setTimer(() => {
501
+ if (this.#timer !== timer) return;
502
+ this.#timer = null;
503
+ this.#cycle();
504
+ }, TASK_FILES_MACHINE_RETIREMENT_RETRY_MS);
505
+ if (
506
+ typeof timer === "object" &&
507
+ timer !== null &&
508
+ "unref" in timer &&
509
+ typeof (timer as { unref?: unknown }).unref === "function"
510
+ ) {
511
+ (timer as { unref(): void }).unref();
512
+ }
513
+ this.#timer = timer;
514
+ }
515
+
516
+ #cancelTimer(): void {
517
+ if (!this.#timer) return;
518
+ this.#deps.clearTimer(this.#timer);
519
+ this.#timer = null;
520
+ }
521
+ }
@@ -1,9 +1,7 @@
1
+ import type { TaskMachineRetireReason } from "../src/protocol";
1
2
  import type { TaskAssignment } from "./cloud-credential-leases";
2
3
 
3
- export type TaskFilesMachineRetirementReason =
4
- | "setup-unproven"
5
- | "visibility-unproven"
6
- | "mount-retirement-unproven";
4
+ export type TaskFilesMachineRetirementReason = TaskMachineRetireReason;
7
5
 
8
6
  export interface TaskFilesMachineRetirementRequest {
9
7
  assignment: Readonly<TaskAssignment>;
@@ -25,6 +23,7 @@ export type TaskFilesMachineRetirementHandler = (
25
23
 
26
24
  let handler: TaskFilesMachineRetirementHandler | null = null;
27
25
  let retiring: TaskFilesMachineRetirementRequest | null = null;
26
+ let damaged: Error | null = null;
28
27
  let handedOff = false;
29
28
 
30
29
  export function setTaskFilesMachineRetirementHandler(
@@ -35,12 +34,40 @@ export function setTaskFilesMachineRetirementHandler(
35
34
 
36
35
  /** Files v1 must not be advertised, or activation entered, without this. */
37
36
  export function taskFilesMachineRetirementReady(): boolean {
38
- return handler !== null && retiring === null;
37
+ return handler !== null && retiring === null && damaged === null;
38
+ }
39
+
40
+ /** An unreadable same-boot outbox record may be an irreversible retirement
41
+ * request. With no safe exact run to reconstruct, keep every task-process
42
+ * admission closed for the rest of this boot. */
43
+ export function latchDamagedTaskFilesMachineRetirement(error: Error): void {
44
+ damaged ??= error;
45
+ }
46
+
47
+ /**
48
+ * The self provider calls this immediately around every task-process
49
+ * admission. Once terminal Files retirement wins, no in-flight command may
50
+ * repopulate the service cgroup after the retirement handler proves it empty.
51
+ */
52
+ export function assertTaskFilesMachineProcessAllowed(): void {
53
+ if (damaged !== null) {
54
+ throw new Error("task-machine Files retirement state is damaged", {
55
+ cause: damaged,
56
+ });
57
+ }
58
+ if (retiring !== null) {
59
+ throw new Error("task-machine Files retirement is pending");
60
+ }
39
61
  }
40
62
 
41
63
  export function requestTaskFilesMachineRetirement(
42
64
  request: TaskFilesMachineRetirementRequest,
43
65
  ): void {
66
+ if (damaged !== null) {
67
+ throw new Error("task-machine Files retirement state is damaged", {
68
+ cause: damaged,
69
+ });
70
+ }
44
71
  if (!handler) {
45
72
  throw new Error("task-machine Files retirement is not installed", {
46
73
  cause: request.error,
@@ -84,5 +111,6 @@ export function requestTaskFilesMachineRetirement(
84
111
  export function resetTaskFilesMachineRetirementForTests(): void {
85
112
  handler = null;
86
113
  retiring = null;
114
+ damaged = null;
87
115
  handedOff = false;
88
116
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Fresh-incarnation proof for the task-machine cloud-credential cutover.
3
+ *
4
+ * Owned runners retire their legacy task runtimes during activation. A Uai
5
+ * Cloud sidecar cannot use that planner: its task environment is the machine
6
+ * itself, which deliberately has no removable local container runtime. The
7
+ * cloud therefore marks an incarnation v1-required when it is launched. The
8
+ * sidecar may activate only before that incarnation has ever admitted a task,
9
+ * with no task process beside the runner and with Files-v1's local safety
10
+ * boundary fully installed. The proof is checked once before local commit and
11
+ * again after the v0 lease/store work has drained.
12
+ */
13
+
14
+ import { TASK_MACHINE_CREDENTIALS_V1_ENV } from "../src/cloud-credentials";
15
+
16
+ export interface TaskMachineCredentialsActivationPlan {
17
+ readonly kind: "fresh-task-machine";
18
+ readonly taskId: string;
19
+ readonly machineBootId: string;
20
+ }
21
+
22
+ export interface TaskMachineCredentialsActivationDeps {
23
+ required(): boolean;
24
+ taskId(): string | null;
25
+ machineBootId(): string;
26
+ filesReady(): boolean;
27
+ retirementReady(): boolean;
28
+ taskRows(): readonly { taskId: string }[];
29
+ peerProcessIds(): readonly number[];
30
+ }
31
+
32
+ /** Exact opt-in from root-owned task-machine launch state. */
33
+ export function taskMachineCredentialsV1Required(
34
+ environment: NodeJS.ProcessEnv = process.env,
35
+ ): boolean {
36
+ return environment[TASK_MACHINE_CREDENTIALS_V1_ENV] === "1";
37
+ }
38
+
39
+ export function planFreshTaskMachineCredentialsActivation(
40
+ deps: TaskMachineCredentialsActivationDeps,
41
+ ): TaskMachineCredentialsActivationPlan {
42
+ if (!deps.required()) {
43
+ throw new Error("this task-machine incarnation is not v1-required");
44
+ }
45
+ const taskId = deps.taskId();
46
+ if (taskId === null) {
47
+ throw new Error("the v1 task machine has no exact task assignment");
48
+ }
49
+ if (!deps.filesReady()) {
50
+ throw new Error("the v1 task machine cannot install its Files session");
51
+ }
52
+ if (!deps.retirementReady()) {
53
+ throw new Error("the v1 task machine cannot durably retire itself");
54
+ }
55
+ const rows = deps.taskRows();
56
+ if (rows.length > 0) {
57
+ throw new Error(
58
+ `the v1 task-machine incarnation is not fresh: it already has task state for ${rows
59
+ .map((row) => row.taskId)
60
+ .join(", ")}`,
61
+ );
62
+ }
63
+ const peers = deps.peerProcessIds();
64
+ if (peers.length > 0) {
65
+ throw new Error(
66
+ `the v1 task-machine incarnation is not quiescent: process ${peers.join(", ")} is still in its service cgroup`,
67
+ );
68
+ }
69
+ return {
70
+ kind: "fresh-task-machine",
71
+ taskId,
72
+ machineBootId: deps.machineBootId(),
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Re-prove the same incarnation after local commit and after all v0 work has
78
+ * drained. Nothing may have slipped between the first proof and that drain.
79
+ */
80
+ export function verifyFreshTaskMachineCredentialsActivation(
81
+ plan: TaskMachineCredentialsActivationPlan,
82
+ deps: TaskMachineCredentialsActivationDeps,
83
+ ): void {
84
+ const current = planFreshTaskMachineCredentialsActivation(deps);
85
+ if (
86
+ current.taskId !== plan.taskId ||
87
+ current.machineBootId !== plan.machineBootId
88
+ ) {
89
+ throw new Error(
90
+ "the v1 task-machine incarnation changed during credential activation",
91
+ );
92
+ }
93
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.155",
3
+ "version": "0.9.157",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",