@runuai/host 0.9.151 → 0.9.153
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/db/migrations/0029_task_shared_files_mode.sql +5 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +10 -0
- package/lib/agent.ts +4 -0
- package/lib/browser-testing.ts +8 -3
- package/lib/cloud-credential-leases.ts +15 -4
- package/lib/cloud-credential-runtime.ts +142 -3
- package/lib/credential-cleanup-inventory.ts +2 -0
- package/lib/credential-consumers/engine-anchor.ts +5 -1
- package/lib/credential-consumers/files.ts +934 -0
- package/lib/credential-consumers/mcp.ts +6 -1
- package/lib/mcp-gateway.ts +576 -122
- package/lib/orchestrator.ts +99 -10
- package/lib/runtime-state.ts +88 -0
- package/lib/shared-files-s3.ts +743 -3
- package/lib/sidecar-resume.ts +9 -0
- package/lib/task-credential-cleanup.ts +36 -5
- package/lib/task-environment/machine-task-up.ts +35 -19
- package/lib/task-environment/workspace-files.ts +33 -6
- package/lib/task-files-machine-retirement.ts +88 -0
- package/lib/task-files-readiness.ts +127 -0
- package/package.json +1 -1
- package/src/cloud-credentials.ts +10 -0
- package/src/index.ts +38 -2
- package/src/main.ts +47 -2
package/lib/sidecar-resume.ts
CHANGED
|
@@ -37,6 +37,10 @@ export interface SidecarResumeDeps {
|
|
|
37
37
|
workspacePath: string,
|
|
38
38
|
task: { sharedFiles?: string },
|
|
39
39
|
) => Promise<string | null>;
|
|
40
|
+
/** ADR-154 Files v1: install and mount the exact recovered run's session
|
|
41
|
+
* from trusted host state. Unlike the legacy marker path, failure is
|
|
42
|
+
* terminal for this recovery setup and is not converted to a warning. */
|
|
43
|
+
prepareFiles?: (handle: TaskEnvironmentHandle<unknown>) => Promise<void>;
|
|
40
44
|
loader?: (handle: TaskEnvironmentHandle<unknown>) => Promise<void>;
|
|
41
45
|
editor?: (handle: TaskEnvironmentHandle<unknown>, workspacePath: string) => Promise<void>;
|
|
42
46
|
proxy?: typeof reconcileSidecarTaskProxy;
|
|
@@ -100,6 +104,11 @@ export async function resumeSidecarTask(
|
|
|
100
104
|
const ended = (): boolean => deps.signal?.aborted === true;
|
|
101
105
|
const interrupted = (): SidecarResumeResult => ({ outcome: "interrupted", warnings });
|
|
102
106
|
|
|
107
|
+
if (deps.prepareFiles) {
|
|
108
|
+
if (ended()) return interrupted();
|
|
109
|
+
await deps.prepareFiles(handle);
|
|
110
|
+
}
|
|
111
|
+
|
|
103
112
|
if (task.envMode === "loader") {
|
|
104
113
|
if (ended()) return interrupted();
|
|
105
114
|
try {
|
|
@@ -50,8 +50,13 @@ export interface TaskCredentialCleanupHooks {
|
|
|
50
50
|
refuseNewWork(request: TaskCredentialCleanupRequest): void;
|
|
51
51
|
closeGate(request: TaskCredentialCleanupRequest): void;
|
|
52
52
|
drainWriters(request: TaskCredentialCleanupRequest): Promise<void>;
|
|
53
|
-
closeConsumers(request: TaskCredentialCleanupRequest): Promise<void>;
|
|
54
53
|
quiesceProcesses(request: TaskCredentialCleanupRequest): Promise<void>;
|
|
54
|
+
/** Retire process-backed consumers after leased engine agents have
|
|
55
|
+
* quiesced. Other task processes may still hold Files; a strict unmount
|
|
56
|
+
* which cannot prove them gone is terminal machine retirement. Their
|
|
57
|
+
* synchronous gate-close hook has already made Files unavailable to new
|
|
58
|
+
* work. */
|
|
59
|
+
closeConsumers(request: TaskCredentialCleanupRequest): Promise<void>;
|
|
55
60
|
dropMemory(request: TaskCredentialCleanupRequest): void;
|
|
56
61
|
}
|
|
57
62
|
|
|
@@ -403,8 +408,20 @@ export class TaskCredentialCleanupCoordinator {
|
|
|
403
408
|
|
|
404
409
|
async #run(request: TaskCredentialCleanupRequest): Promise<void> {
|
|
405
410
|
await this.#deps.hooks.drainWriters(request);
|
|
406
|
-
await this.#deps.hooks.closeConsumers(request);
|
|
407
411
|
await this.#deps.hooks.quiesceProcesses(request);
|
|
412
|
+
let consumersFailed = false;
|
|
413
|
+
let consumerError: unknown;
|
|
414
|
+
try {
|
|
415
|
+
await this.#deps.hooks.closeConsumers(request);
|
|
416
|
+
} catch (error) {
|
|
417
|
+
// A strict Files unmount can fail (for example, EBUSY) after every gate
|
|
418
|
+
// and writer has closed. The root-only session material must still be
|
|
419
|
+
// removed below. Keep its inventory rows and the pending-cleanup row as
|
|
420
|
+
// durable mount witnesses, then return this original proof failure so a
|
|
421
|
+
// later process retries the unmount from the recorded runtime locator.
|
|
422
|
+
consumersFailed = true;
|
|
423
|
+
consumerError = error;
|
|
424
|
+
}
|
|
408
425
|
|
|
409
426
|
const allRows = listCredentialCleanupPaths(request.taskId, this.#deps.db);
|
|
410
427
|
const selectedRows = selectRows(allRows, request);
|
|
@@ -428,10 +445,19 @@ export class TaskCredentialCleanupCoordinator {
|
|
|
428
445
|
forgetRow(row, this.#deps.db);
|
|
429
446
|
}
|
|
430
447
|
let failed: HostCredentialCleanupPath[] = [];
|
|
431
|
-
|
|
448
|
+
// A consumer failure means some non-Files process may still hold its
|
|
449
|
+
// credential. Preserve every such path and its witness for the retry.
|
|
450
|
+
// Files is the exception: its root-only session must be revoked even
|
|
451
|
+
// when the already-hidden mount cannot yet be proven absent.
|
|
452
|
+
const removableRows = consumersFailed
|
|
453
|
+
? rows.filter((row) => row.lane === "files-session")
|
|
454
|
+
: rows;
|
|
455
|
+
for (const row of removableRows) {
|
|
432
456
|
try {
|
|
433
457
|
await (this.#deps.removePath ?? removeCredentialCleanupPath)(row);
|
|
434
|
-
|
|
458
|
+
if (!consumersFailed || row.lane !== "files-session") {
|
|
459
|
+
forgetRow(row, this.#deps.db);
|
|
460
|
+
}
|
|
435
461
|
} catch {
|
|
436
462
|
failed.push(row);
|
|
437
463
|
}
|
|
@@ -439,7 +465,11 @@ export class TaskCredentialCleanupCoordinator {
|
|
|
439
465
|
|
|
440
466
|
// A whole-task cleanup may prove absence by destroying the exact writable
|
|
441
467
|
// runtime instead. Selective removal must never take this broader action.
|
|
442
|
-
if (
|
|
468
|
+
if (
|
|
469
|
+
!consumersFailed &&
|
|
470
|
+
request.credentialIds === undefined &&
|
|
471
|
+
failed.length > 0
|
|
472
|
+
) {
|
|
443
473
|
const byRuntime = new Map<string, HostCredentialCleanupPath[]>();
|
|
444
474
|
for (const row of failed) {
|
|
445
475
|
if (row.lane === "files-session") continue;
|
|
@@ -481,6 +511,7 @@ export class TaskCredentialCleanupCoordinator {
|
|
|
481
511
|
}
|
|
482
512
|
failed = failed.filter((row) => !proven.has(row));
|
|
483
513
|
}
|
|
514
|
+
if (consumersFailed) throw consumerError;
|
|
484
515
|
if (failed.length > 0) {
|
|
485
516
|
throw new TaskCredentialCleanupError(
|
|
486
517
|
request.taskId,
|
|
@@ -188,6 +188,12 @@ function projectDirectoryName(slug: string): string {
|
|
|
188
188
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(slug) || slug.includes("..")) {
|
|
189
189
|
throw new Error(`unsafe project slug for machine clone: ${slug}`);
|
|
190
190
|
}
|
|
191
|
+
// Files v1 publishes the task's leased S3 roots at this fixed workspace
|
|
192
|
+
// entry. Refuse a project with the same name before clone can follow the
|
|
193
|
+
// published link and write repository data into the Files bucket.
|
|
194
|
+
if (slug === "files") {
|
|
195
|
+
throw new Error("the project slug files is reserved for shared files");
|
|
196
|
+
}
|
|
191
197
|
return slug;
|
|
192
198
|
}
|
|
193
199
|
|
|
@@ -947,6 +953,17 @@ export function createMachineHostTaskEnvironmentProvider(
|
|
|
947
953
|
): Promise<TaskEnvironmentProvisioned<TaskUpResult, TaskDownResult>> {
|
|
948
954
|
const { input } = request;
|
|
949
955
|
const envMode = request.envMode ?? "loader";
|
|
956
|
+
// Validate every task-controlled workspace directory before the
|
|
957
|
+
// provider is allowed to create a machine or a leased runtime-ready
|
|
958
|
+
// writer can publish anything into it. Validating lazily in the clone
|
|
959
|
+
// loop is too late: an invalid later project would leave all of those
|
|
960
|
+
// earlier effects behind.
|
|
961
|
+
const projects = [...input.projects]
|
|
962
|
+
.sort((a, b) => a.position - b.position)
|
|
963
|
+
.map((project) => ({
|
|
964
|
+
project,
|
|
965
|
+
directoryName: projectDirectoryName(project.slug),
|
|
966
|
+
}));
|
|
950
967
|
// ADR-154 §12: a task on cloud credentials holds its grant for the
|
|
951
968
|
// whole start; withdrawn (its gate closed, a cleanup reached it), the
|
|
952
969
|
// start stops wherever it is, its clone included, and the machine goes.
|
|
@@ -1060,10 +1077,7 @@ export function createMachineHostTaskEnvironmentProvider(
|
|
|
1060
1077
|
);
|
|
1061
1078
|
if (proxyWarning !== null) console.warn(`[machine] task ${request.taskId}: ${proxyWarning}`);
|
|
1062
1079
|
}
|
|
1063
|
-
const
|
|
1064
|
-
(a, b) => a.position - b.position,
|
|
1065
|
-
);
|
|
1066
|
-
const wantsGithub = projects.some((project) =>
|
|
1080
|
+
const wantsGithub = projects.some(({ project }) =>
|
|
1067
1081
|
isGithubRepoUrl(project.repoUrl),
|
|
1068
1082
|
);
|
|
1069
1083
|
// ADR-154 §12: a task on cloud credentials clones with the token its
|
|
@@ -1073,9 +1087,9 @@ export function createMachineHostTaskEnvironmentProvider(
|
|
|
1073
1087
|
: clone
|
|
1074
1088
|
? clone.github && { accessToken: clone.github.token }
|
|
1075
1089
|
: await deps.githubToken(input.task.ownerUserId);
|
|
1076
|
-
for (const project of projects) {
|
|
1090
|
+
for (const { project, directoryName } of projects) {
|
|
1077
1091
|
if (clone?.signal.aborted) throw withdrawal();
|
|
1078
|
-
const destination = `${workspacePath}/${
|
|
1092
|
+
const destination = `${workspacePath}/${directoryName}`;
|
|
1079
1093
|
const outcome = await untilWithdrawn(
|
|
1080
1094
|
cloneProject(
|
|
1081
1095
|
own,
|
|
@@ -1106,20 +1120,22 @@ export function createMachineHostTaskEnvironmentProvider(
|
|
|
1106
1120
|
envSchemas = await stage(captureMachineEnvSchemas(request.taskId, own, clones));
|
|
1107
1121
|
|
|
1108
1122
|
// ADR-062 shared files: point-in-time copy (see copySharedFiles).
|
|
1109
|
-
const sharedWarning =
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1123
|
+
const sharedWarning = leased?.filesV1
|
|
1124
|
+
? null
|
|
1125
|
+
: await stage(
|
|
1126
|
+
(deps.sharedFiles ?? copySharedFiles)(own, workspacePath, {
|
|
1127
|
+
...(input.task.ownerOrgId ? { ownerOrgId: input.task.ownerOrgId } : {}),
|
|
1128
|
+
ownerUserId: input.task.ownerUserId,
|
|
1129
|
+
...(input.task.sharedFiles ? { sharedFiles: input.task.sharedFiles } : {}),
|
|
1130
|
+
}).catch((error: unknown) => {
|
|
1131
|
+
console.warn(
|
|
1132
|
+
`[machine] task ${request.taskId}: shared files copy failed: ${
|
|
1133
|
+
error instanceof Error ? error.message : String(error)
|
|
1134
|
+
}`,
|
|
1135
|
+
);
|
|
1136
|
+
return null;
|
|
1137
|
+
}),
|
|
1119
1138
|
);
|
|
1120
|
-
return null;
|
|
1121
|
-
}),
|
|
1122
|
-
);
|
|
1123
1139
|
if (sharedWarning) warnings.push(sharedWarning);
|
|
1124
1140
|
|
|
1125
1141
|
// Codex default-slot credentials: the legacy delivery paths are all
|
|
@@ -100,9 +100,19 @@ export interface RuntimeNamespace {
|
|
|
100
100
|
/** macOS (11 and later): fail if any component of the path is a link. */
|
|
101
101
|
const O_NOFOLLOW_ANY = 0x20000000;
|
|
102
102
|
const DIRECTORY = constants.O_RDONLY | constants.O_DIRECTORY;
|
|
103
|
-
/** macOS: a directory, a link nowhere in its path
|
|
104
|
-
*
|
|
105
|
-
|
|
103
|
+
/** macOS: a directory, a link nowhere in its path, the last component
|
|
104
|
+
* included. Never with O_NOFOLLOW: O_NOFOLLOW_ANY already refuses a final
|
|
105
|
+
* link, and macOS 27 rejects the two together with EINVAL (live
|
|
106
|
+
* 2026-10-11: every workspace read and write failed on the Studio). */
|
|
107
|
+
const DARWIN = process.platform === "darwin";
|
|
108
|
+
/** Elsewhere (tests forcing this mode on Linux, where O_NOFOLLOW_ANY means
|
|
109
|
+
* nothing) O_NOFOLLOW stays, so a final link is still refused there. */
|
|
110
|
+
const DIRECTORY_NOFOLLOW_ANY = DIRECTORY | O_NOFOLLOW_ANY | (DARWIN ? 0 : constants.O_NOFOLLOW);
|
|
111
|
+
/** The flags one file is opened with in this mode: on macOS `flags` without
|
|
112
|
+
* O_NOFOLLOW, which O_NOFOLLOW_ANY covers and cannot be combined with. */
|
|
113
|
+
function nofollowAnyFlags(flags: number): number {
|
|
114
|
+
return (DARWIN ? flags & ~constants.O_NOFOLLOW : flags) | O_NOFOLLOW_ANY;
|
|
115
|
+
}
|
|
106
116
|
|
|
107
117
|
type Containment = "proc-fd" | "nofollow-any" | "verify";
|
|
108
118
|
let containment: Containment | null = null;
|
|
@@ -128,7 +138,9 @@ function probeContainment(): Containment {
|
|
|
128
138
|
}
|
|
129
139
|
}
|
|
130
140
|
if (process.platform === "darwin") {
|
|
131
|
-
|
|
141
|
+
// Resolved: macOS's temporary directory is below /var, itself a link,
|
|
142
|
+
// which O_NOFOLLOW_ANY refuses on every path through it.
|
|
143
|
+
const scratch = realpathSync(mkdtempSync(join(tmpdir(), "uai-nofollow-any-")));
|
|
132
144
|
try {
|
|
133
145
|
mkdirSync(join(scratch, "real"));
|
|
134
146
|
writeFileSync(join(scratch, "real", "file"), "");
|
|
@@ -138,8 +150,23 @@ function probeContainment(): Containment {
|
|
|
138
150
|
// Opened through the link: the flag is not honored here.
|
|
139
151
|
return "verify";
|
|
140
152
|
} catch (error) {
|
|
141
|
-
|
|
153
|
+
if ((error as NodeJS.ErrnoException).code !== "ELOOP") return "verify";
|
|
154
|
+
}
|
|
155
|
+
// The exact flags the real opens use must work on a plain file and
|
|
156
|
+
// directory too: a flag the kernel refuses in combination would
|
|
157
|
+
// otherwise fail every workspace operation, not just links.
|
|
158
|
+
try {
|
|
159
|
+
closeSync(
|
|
160
|
+
openSync(
|
|
161
|
+
join(scratch, "real", "file"),
|
|
162
|
+
nofollowAnyFlags(constants.O_WRONLY | constants.O_APPEND | constants.O_NONBLOCK | constants.O_NOFOLLOW),
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
closeSync(openSync(join(scratch, "real"), DIRECTORY_NOFOLLOW_ANY));
|
|
166
|
+
} catch {
|
|
167
|
+
return "verify";
|
|
142
168
|
}
|
|
169
|
+
return "nofollow-any";
|
|
143
170
|
} finally {
|
|
144
171
|
rmSync(scratch, { recursive: true, force: true });
|
|
145
172
|
}
|
|
@@ -255,7 +282,7 @@ function openWithin(root: string, parts: readonly string[], flags: number): numb
|
|
|
255
282
|
}
|
|
256
283
|
}
|
|
257
284
|
if (mode === "nofollow-any") {
|
|
258
|
-
return openSync(join(base, ...parts), finalFlags
|
|
285
|
+
return openSync(join(base, ...parts), nofollowAnyFlags(finalFlags), 0o644);
|
|
259
286
|
}
|
|
260
287
|
// Neither: a read verifies what it opened; nothing is written.
|
|
261
288
|
if ((flags & (constants.O_WRONLY | constants.O_RDWR | constants.O_CREAT)) !== 0) {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { TaskAssignment } from "./cloud-credential-leases";
|
|
2
|
+
|
|
3
|
+
export type TaskFilesMachineRetirementReason =
|
|
4
|
+
| "setup-unproven"
|
|
5
|
+
| "visibility-unproven"
|
|
6
|
+
| "mount-retirement-unproven";
|
|
7
|
+
|
|
8
|
+
export interface TaskFilesMachineRetirementRequest {
|
|
9
|
+
assignment: Readonly<TaskAssignment>;
|
|
10
|
+
reason: TaskFilesMachineRetirementReason;
|
|
11
|
+
error: Error;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Cutover-owned terminal path for a one-task machine whose Files boundary
|
|
16
|
+
* cannot be proved closed. The handler must synchronously quarantine local
|
|
17
|
+
* task work, then durably retry an authenticated exact-run machine-retirement
|
|
18
|
+
* request until the cloud acknowledges it or the machine dies. Exiting only
|
|
19
|
+
* the sidecar is explicitly insufficient: its systemd unit uses
|
|
20
|
+
* KillMode=process and leaves the task processes alive.
|
|
21
|
+
*/
|
|
22
|
+
export type TaskFilesMachineRetirementHandler = (
|
|
23
|
+
request: TaskFilesMachineRetirementRequest,
|
|
24
|
+
) => undefined;
|
|
25
|
+
|
|
26
|
+
let handler: TaskFilesMachineRetirementHandler | null = null;
|
|
27
|
+
let retiring: TaskFilesMachineRetirementRequest | null = null;
|
|
28
|
+
let handedOff = false;
|
|
29
|
+
|
|
30
|
+
export function setTaskFilesMachineRetirementHandler(
|
|
31
|
+
next: TaskFilesMachineRetirementHandler | null,
|
|
32
|
+
): void {
|
|
33
|
+
handler = next;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Files v1 must not be advertised, or activation entered, without this. */
|
|
37
|
+
export function taskFilesMachineRetirementReady(): boolean {
|
|
38
|
+
return handler !== null && retiring === null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function requestTaskFilesMachineRetirement(
|
|
42
|
+
request: TaskFilesMachineRetirementRequest,
|
|
43
|
+
): void {
|
|
44
|
+
if (!handler) {
|
|
45
|
+
throw new Error("task-machine Files retirement is not installed", {
|
|
46
|
+
cause: request.error,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (retiring === null) {
|
|
50
|
+
// First exact failure wins before any callback: a reconnect/capability
|
|
51
|
+
// pass in the callback's first await can no longer advertise this machine.
|
|
52
|
+
retiring = request;
|
|
53
|
+
} else if (
|
|
54
|
+
retiring.assignment.taskId !== request.assignment.taskId ||
|
|
55
|
+
retiring.assignment.runEpoch !== request.assignment.runEpoch
|
|
56
|
+
) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (handedOff) return;
|
|
60
|
+
// A synchronous handoff failure must be retryable without reopening the
|
|
61
|
+
// capability or letting a later assignment replace the first failure.
|
|
62
|
+
// Once this returns, the handler owns durable retries itself.
|
|
63
|
+
const result: unknown = handler(retiring);
|
|
64
|
+
if (result !== undefined) {
|
|
65
|
+
// A JavaScript caller can evade the type even though `undefined` (rather
|
|
66
|
+
// than `void`) rejects async handlers in TypeScript. Observe a returned
|
|
67
|
+
// promise so its later rejection is not unhandled, but never count an
|
|
68
|
+
// asynchronous callback as the required synchronous handoff.
|
|
69
|
+
if (
|
|
70
|
+
typeof result === "object" &&
|
|
71
|
+
result !== null &&
|
|
72
|
+
"then" in result &&
|
|
73
|
+
typeof (result as { then?: unknown }).then === "function"
|
|
74
|
+
) {
|
|
75
|
+
void Promise.resolve(result).catch(() => {});
|
|
76
|
+
}
|
|
77
|
+
throw new Error("task-machine Files retirement must be handed off synchronously");
|
|
78
|
+
}
|
|
79
|
+
handedOff = true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Tests model a fresh machine process without weakening the production
|
|
83
|
+
* one-way latch. */
|
|
84
|
+
export function resetTaskFilesMachineRetirementForTests(): void {
|
|
85
|
+
handler = null;
|
|
86
|
+
retiring = null;
|
|
87
|
+
handedOff = false;
|
|
88
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import {
|
|
2
|
+
accessSync,
|
|
3
|
+
chmodSync,
|
|
4
|
+
constants,
|
|
5
|
+
lstatSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
statfsSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
|
|
10
|
+
export const TASK_FILES_MOUNT_S3_PATH = "/usr/bin/mount-s3";
|
|
11
|
+
export const TASK_FILES_FUSE_PATH = "/dev/fuse";
|
|
12
|
+
export const TASK_FILES_HELPER_PATHS = [
|
|
13
|
+
"/usr/bin/python3",
|
|
14
|
+
"/usr/bin/env",
|
|
15
|
+
"/usr/bin/mountpoint",
|
|
16
|
+
"/usr/bin/umount",
|
|
17
|
+
"/bin/sh",
|
|
18
|
+
] as const;
|
|
19
|
+
export const TASK_FILES_RUNTIME_ROOT = "/run";
|
|
20
|
+
export const TASK_FILES_SESSION_ROOT = "/run/uai";
|
|
21
|
+
|
|
22
|
+
// linux/magic.h: credentials may be written only to a memory-backed runtime.
|
|
23
|
+
export const TMPFS_MAGIC = 0x01021994;
|
|
24
|
+
|
|
25
|
+
export type TaskFilesV1Readiness =
|
|
26
|
+
| { ready: true }
|
|
27
|
+
| {
|
|
28
|
+
ready: false;
|
|
29
|
+
reason:
|
|
30
|
+
| "not-task-machine"
|
|
31
|
+
| "not-root"
|
|
32
|
+
| "mount-s3-unavailable"
|
|
33
|
+
| "fuse-unavailable"
|
|
34
|
+
| "helper-unavailable"
|
|
35
|
+
| "run-not-tmpfs"
|
|
36
|
+
| "session-root-unsafe";
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export interface TaskFilesV1ReadinessDeps {
|
|
40
|
+
getuid(): number;
|
|
41
|
+
access(path: string, mode: number): void;
|
|
42
|
+
prepareSessionRoot(): void;
|
|
43
|
+
statfs(path: string): { type: number | bigint };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function prepareSessionRoot(): void {
|
|
47
|
+
try {
|
|
48
|
+
mkdirSync(TASK_FILES_SESSION_ROOT, { mode: 0o700 });
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
51
|
+
}
|
|
52
|
+
const info = lstatSync(TASK_FILES_SESSION_ROOT);
|
|
53
|
+
if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== 0) {
|
|
54
|
+
throw new Error("the Files session root is not a root-owned directory");
|
|
55
|
+
}
|
|
56
|
+
chmodSync(TASK_FILES_SESSION_ROOT, 0o700);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const DEFAULT_DEPS: TaskFilesV1ReadinessDeps = {
|
|
60
|
+
getuid: () => process.getuid?.() ?? -1,
|
|
61
|
+
access: accessSync,
|
|
62
|
+
prepareSessionRoot,
|
|
63
|
+
statfs: (path) => statfsSync(path),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Local half of the Files-v1 capability check. Cloud mode is irrelevant: a
|
|
68
|
+
* machine advertises what this process can safely do before activation. Keep
|
|
69
|
+
* this synchronous so the connect capability frame cannot race a later
|
|
70
|
+
* prerequisite probe.
|
|
71
|
+
*/
|
|
72
|
+
export function taskFilesV1Readiness(
|
|
73
|
+
taskMachine: boolean,
|
|
74
|
+
deps: TaskFilesV1ReadinessDeps = DEFAULT_DEPS,
|
|
75
|
+
): TaskFilesV1Readiness {
|
|
76
|
+
if (!taskMachine) return { ready: false, reason: "not-task-machine" };
|
|
77
|
+
|
|
78
|
+
let uid: number;
|
|
79
|
+
try {
|
|
80
|
+
uid = deps.getuid();
|
|
81
|
+
} catch {
|
|
82
|
+
return { ready: false, reason: "not-root" };
|
|
83
|
+
}
|
|
84
|
+
if (uid !== 0) return { ready: false, reason: "not-root" };
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
deps.access(TASK_FILES_MOUNT_S3_PATH, constants.X_OK);
|
|
88
|
+
} catch {
|
|
89
|
+
return { ready: false, reason: "mount-s3-unavailable" };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
deps.access(TASK_FILES_FUSE_PATH, constants.R_OK | constants.W_OK);
|
|
94
|
+
} catch {
|
|
95
|
+
return { ready: false, reason: "fuse-unavailable" };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
for (const path of TASK_FILES_HELPER_PATHS) {
|
|
100
|
+
deps.access(path, constants.X_OK);
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
return { ready: false, reason: "helper-unavailable" };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
if (Number(deps.statfs(TASK_FILES_RUNTIME_ROOT).type) !== TMPFS_MAGIC) {
|
|
108
|
+
return { ready: false, reason: "run-not-tmpfs" };
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
return { ready: false, reason: "run-not-tmpfs" };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
deps.prepareSessionRoot();
|
|
116
|
+
// `/run` being tmpfs is insufficient: an existing `/run/uai` may be a
|
|
117
|
+
// bind mount onto persistent storage. Capability advertisement proves
|
|
118
|
+
// the exact directory in which the session will be written.
|
|
119
|
+
if (Number(deps.statfs(TASK_FILES_SESSION_ROOT).type) !== TMPFS_MAGIC) {
|
|
120
|
+
return { ready: false, reason: "session-root-unsafe" };
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
return { ready: false, reason: "session-root-unsafe" };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { ready: true };
|
|
127
|
+
}
|
package/package.json
CHANGED
package/src/cloud-credentials.ts
CHANGED
|
@@ -21,6 +21,16 @@
|
|
|
21
21
|
/** Advertised by a runner that reads credentials only through v1 leases. */
|
|
22
22
|
export const HOST_CLOUD_CREDENTIALS_FEATURE = "host-cloud-credentials-v1";
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Advertised by a task machine only when it can install a task-scoped Files
|
|
26
|
+
* session and mount it from the v1 grant. This is deliberately separate from
|
|
27
|
+
* the general credentials feature: owned runners do not need Files-on-machine
|
|
28
|
+
* support to leave the legacy credential mode. The cloud must require both
|
|
29
|
+
* features for a task machine's activation, taskUp dispatch and v1 lease;
|
|
30
|
+
* this advertisement alone is not an admission check.
|
|
31
|
+
*/
|
|
32
|
+
export const HOST_TASK_FILES_LEASE_FEATURE = "host-task-files-lease-v1";
|
|
33
|
+
|
|
24
34
|
// ---------------------------------------------------------------------------
|
|
25
35
|
// Parameter layout
|
|
26
36
|
// ---------------------------------------------------------------------------
|
package/src/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { writeTaskGithub, writeTaskSshKey } from "../lib/credential-consumers/gi
|
|
|
26
26
|
import { agentClisReady, ensureStandardImage } from "../lib/standard-image";
|
|
27
27
|
import { cloneRepo } from "../lib/repo-clone";
|
|
28
28
|
import { handleFilesOp } from "../lib/shared-files";
|
|
29
|
+
import type { SharedFilesMode } from "../lib/shared-files-s3";
|
|
29
30
|
import {
|
|
30
31
|
getOrchestrator,
|
|
31
32
|
inspectTaskRuntimeContainer,
|
|
@@ -79,6 +80,7 @@ import {
|
|
|
79
80
|
recordTaskOwner,
|
|
80
81
|
recordTaskProjectEnvSpec,
|
|
81
82
|
recordTaskRuntimeEstablished,
|
|
83
|
+
recordTaskSharedFilesMode,
|
|
82
84
|
recordTaskStarting,
|
|
83
85
|
recordTaskUpResult,
|
|
84
86
|
taskProjectEnvSpec,
|
|
@@ -293,17 +295,32 @@ interface CloudCredentialTaskStart {
|
|
|
293
295
|
/** The start's lease for task-up: its whole grant's hold, and what the
|
|
294
296
|
* runner writes into the runtime once it exists (the owner's GitHub sign-in
|
|
295
297
|
* and SSH key, recorded for cleanup) before anything in it runs. */
|
|
296
|
-
function leasedTaskUp(
|
|
298
|
+
function leasedTaskUp(
|
|
299
|
+
runtime: CloudCredentialRuntime,
|
|
300
|
+
start: TaskCredentialStart,
|
|
301
|
+
hold: TaskUpHold,
|
|
302
|
+
filesMode: SharedFilesMode,
|
|
303
|
+
): LeasedTaskUp {
|
|
304
|
+
const filesV1 = runtime.taskFilesV1();
|
|
297
305
|
return {
|
|
298
306
|
// The whole grant's signal: its gate closing, a cleanup reaching it or
|
|
299
307
|
// the start being interrupted all end task-up.
|
|
300
308
|
clone: { ...hold.git, signal: hold.signal },
|
|
301
309
|
runtimeReady: async (environment, signal) => {
|
|
310
|
+
if (filesV1) {
|
|
311
|
+
await runtime.prepareTaskFiles(
|
|
312
|
+
start.assignment,
|
|
313
|
+
environment as TaskEnvironmentHandle<unknown>,
|
|
314
|
+
filesMode,
|
|
315
|
+
signal,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
302
318
|
for (const write of [writeTaskGithub, writeTaskSshKey]) {
|
|
303
319
|
if (signal.aborted) throw new CredentialLeaseUnavailableError("the task's credentials were withdrawn");
|
|
304
320
|
await write(environment, start.assignment);
|
|
305
321
|
}
|
|
306
322
|
},
|
|
323
|
+
...(filesV1 ? { filesV1: true as const } : {}),
|
|
307
324
|
};
|
|
308
325
|
}
|
|
309
326
|
|
|
@@ -1138,6 +1155,7 @@ async function taskUpOn(
|
|
|
1138
1155
|
environmentLocator: existingTask.environmentLocator,
|
|
1139
1156
|
envMode: existingTask.envMode,
|
|
1140
1157
|
projectEnvSpec: existingTask.projectEnvSpec,
|
|
1158
|
+
sharedFilesMode: existingTask.sharedFilesMode,
|
|
1141
1159
|
runEpoch: existingTask.runEpoch,
|
|
1142
1160
|
}
|
|
1143
1161
|
: {
|
|
@@ -1149,6 +1167,7 @@ async function taskUpOn(
|
|
|
1149
1167
|
environmentLocator: existingTask.environmentLocator,
|
|
1150
1168
|
envMode: existingTask.envMode,
|
|
1151
1169
|
projectEnvSpec: existingTask.projectEnvSpec,
|
|
1170
|
+
sharedFilesMode: existingTask.sharedFilesMode,
|
|
1152
1171
|
runEpoch: existingTask.runEpoch,
|
|
1153
1172
|
}
|
|
1154
1173
|
: {
|
|
@@ -1173,6 +1192,12 @@ async function taskUpOn(
|
|
|
1173
1192
|
} else {
|
|
1174
1193
|
recordTaskStarting(input.task.id, input.task.runEpoch);
|
|
1175
1194
|
}
|
|
1195
|
+
// Files-v1 recovery gets its mode only from a launch already admitted on
|
|
1196
|
+
// cloud credentials, never from the task-writable marker. A legacy start
|
|
1197
|
+
// must leave the column null so cutover can distinguish and retire it.
|
|
1198
|
+
const sharedFilesMode = cloud
|
|
1199
|
+
? recordTaskSharedFilesMode(input.task.id, input.task)
|
|
1200
|
+
: null;
|
|
1176
1201
|
// Cross-process managed maintenance and task admission use a two-sided
|
|
1177
1202
|
// handshake: update/uninstall writes its durable marker before checking
|
|
1178
1203
|
// this DB row; task-up writes `starting` before checking admission again.
|
|
@@ -1318,7 +1343,18 @@ async function taskUpOn(
|
|
|
1318
1343
|
input,
|
|
1319
1344
|
{
|
|
1320
1345
|
taskGateway,
|
|
1321
|
-
...(cloud?.start && cloud.hold
|
|
1346
|
+
...(cloud?.start && cloud.hold
|
|
1347
|
+
? {
|
|
1348
|
+
leased: leasedTaskUp(
|
|
1349
|
+
cloud.runtime,
|
|
1350
|
+
cloud.start,
|
|
1351
|
+
cloud.hold,
|
|
1352
|
+
sharedFilesMode ?? (() => {
|
|
1353
|
+
throw new Error("a cloud-credential start has no trusted Files mode");
|
|
1354
|
+
})(),
|
|
1355
|
+
),
|
|
1356
|
+
}
|
|
1357
|
+
: {}),
|
|
1322
1358
|
},
|
|
1323
1359
|
recordPrepared,
|
|
1324
1360
|
);
|