@runuai/host 0.9.152 → 0.9.154
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 +24 -4
- package/lib/cloud-credential-runtime.ts +322 -17
- package/lib/credential-cleanup-inventory.ts +2 -0
- package/lib/credential-consumers/files.ts +934 -0
- package/lib/credential-consumers/github.ts +140 -32
- package/lib/credential-consumers/mcp.ts +6 -1
- package/lib/mcp-gateway.ts +576 -122
- package/lib/orchestrator.ts +91 -9
- 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-files-machine-retirement.ts +88 -0
- package/lib/task-files-readiness.ts +127 -0
- package/lib/task-person-credentials.ts +76 -0
- package/package.json +1 -1
- package/src/cloud-credentials.ts +10 -0
- package/src/index.ts +40 -7
- package/src/main.ts +49 -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
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Restore GitHub/SSH files when a running task's cloud credentials become
|
|
3
|
+
* usable, after recovery or a live change. The persisted provider locator
|
|
4
|
+
* is the only runtime authority; a missing runtime is a retryable failure.
|
|
5
|
+
* Fresh task-up uses the same writer on its newly provisioned handle.
|
|
6
|
+
*/
|
|
7
|
+
import type { HostTask } from "../db/schema";
|
|
8
|
+
import { CredentialLeaseUnavailableError, type TaskAssignment } from "./cloud-credential-leases";
|
|
9
|
+
import { writeTaskPersonCredentials, type PersonCredentialLane } from "./credential-consumers/github";
|
|
10
|
+
import type { TaskWriteEnvironment } from "./credential-consumers/task-files";
|
|
11
|
+
import { getHostTask, readTaskRunEpochState } from "./runtime-state";
|
|
12
|
+
import { reconstructPersistedTaskEnvironment } from "./task-environment";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The composer retries a rejection while the same material is current and
|
|
16
|
+
* aborts this signal before cleanup or takeover. In particular, a fresh
|
|
17
|
+
* task that is still starting must retry: a rotation after its runtime-ready
|
|
18
|
+
* writes but before it is marked running must not be silently missed.
|
|
19
|
+
*/
|
|
20
|
+
export async function reconcileRunningTaskPersonCredentials(
|
|
21
|
+
assignment: Readonly<TaskAssignment>,
|
|
22
|
+
lanes: readonly PersonCredentialLane[],
|
|
23
|
+
signal: AbortSignal,
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
const current = (): HostTask => {
|
|
26
|
+
signal.throwIfAborted();
|
|
27
|
+
const task = getHostTask(assignment.taskId);
|
|
28
|
+
const run = readTaskRunEpochState(assignment.taskId);
|
|
29
|
+
if (
|
|
30
|
+
task === null ||
|
|
31
|
+
task.statusMirror !== "running" ||
|
|
32
|
+
task.runEpoch !== assignment.runEpoch ||
|
|
33
|
+
run === null ||
|
|
34
|
+
!run.assignmentOpen ||
|
|
35
|
+
run.highestRunEpoch !== assignment.runEpoch ||
|
|
36
|
+
run.runtimeEstablishedEpoch !== assignment.runEpoch
|
|
37
|
+
) {
|
|
38
|
+
throw new CredentialLeaseUnavailableError("the task's running credential assignment is not current");
|
|
39
|
+
}
|
|
40
|
+
return task;
|
|
41
|
+
};
|
|
42
|
+
const task = current();
|
|
43
|
+
const assertCurrent = (): void => {
|
|
44
|
+
const live = current();
|
|
45
|
+
if (
|
|
46
|
+
live.environmentProvider !== task.environmentProvider ||
|
|
47
|
+
live.environmentLocator !== task.environmentLocator ||
|
|
48
|
+
live.envMode !== task.envMode
|
|
49
|
+
) {
|
|
50
|
+
throw new CredentialLeaseUnavailableError("the task's credential runtime has changed");
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const environment = await reconstructPersistedTaskEnvironment(task);
|
|
54
|
+
assertCurrent();
|
|
55
|
+
if (environment === null) {
|
|
56
|
+
throw new CredentialLeaseUnavailableError("the task's credential runtime is unavailable");
|
|
57
|
+
}
|
|
58
|
+
// Keep the exact running assignment and cancellation at each provider's
|
|
59
|
+
// last exec boundary too: reconstruction or address lookup can suspend
|
|
60
|
+
// while Stop or a new run replaces the runtime.
|
|
61
|
+
const writable: TaskWriteEnvironment = {
|
|
62
|
+
descriptor: environment.descriptor,
|
|
63
|
+
exec: (request) => {
|
|
64
|
+
assertCurrent();
|
|
65
|
+
return environment.exec({
|
|
66
|
+
...request,
|
|
67
|
+
admit: () => {
|
|
68
|
+
assertCurrent();
|
|
69
|
+
request.admit?.();
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
await writeTaskPersonCredentials(writable, assignment, lanes, signal);
|
|
75
|
+
assertCurrent();
|
|
76
|
+
}
|
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
|
@@ -15,17 +15,18 @@ import {
|
|
|
15
15
|
whileOnOwnCredentials,
|
|
16
16
|
type LegacyStartPermit,
|
|
17
17
|
} from "../lib/cloud-credential-host";
|
|
18
|
-
import {
|
|
18
|
+
import type { TaskAssignment } from "../lib/cloud-credential-leases";
|
|
19
19
|
import {
|
|
20
20
|
StartInterruptedError,
|
|
21
21
|
type CloudCredentialRuntime,
|
|
22
22
|
type TaskCredentialStart,
|
|
23
23
|
type TaskUpHold,
|
|
24
24
|
} from "../lib/cloud-credential-runtime";
|
|
25
|
-
import {
|
|
25
|
+
import { writeTaskPersonCredentials } from "../lib/credential-consumers/github";
|
|
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,29 @@ 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) => {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
310
|
+
if (filesV1) {
|
|
311
|
+
await runtime.prepareTaskFiles(
|
|
312
|
+
start.assignment,
|
|
313
|
+
environment as TaskEnvironmentHandle<unknown>,
|
|
314
|
+
filesMode,
|
|
315
|
+
signal,
|
|
316
|
+
);
|
|
305
317
|
}
|
|
318
|
+
await writeTaskPersonCredentials(environment, start.assignment, undefined, signal);
|
|
306
319
|
},
|
|
320
|
+
...(filesV1 ? { filesV1: true as const } : {}),
|
|
307
321
|
};
|
|
308
322
|
}
|
|
309
323
|
|
|
@@ -1138,6 +1152,7 @@ async function taskUpOn(
|
|
|
1138
1152
|
environmentLocator: existingTask.environmentLocator,
|
|
1139
1153
|
envMode: existingTask.envMode,
|
|
1140
1154
|
projectEnvSpec: existingTask.projectEnvSpec,
|
|
1155
|
+
sharedFilesMode: existingTask.sharedFilesMode,
|
|
1141
1156
|
runEpoch: existingTask.runEpoch,
|
|
1142
1157
|
}
|
|
1143
1158
|
: {
|
|
@@ -1149,6 +1164,7 @@ async function taskUpOn(
|
|
|
1149
1164
|
environmentLocator: existingTask.environmentLocator,
|
|
1150
1165
|
envMode: existingTask.envMode,
|
|
1151
1166
|
projectEnvSpec: existingTask.projectEnvSpec,
|
|
1167
|
+
sharedFilesMode: existingTask.sharedFilesMode,
|
|
1152
1168
|
runEpoch: existingTask.runEpoch,
|
|
1153
1169
|
}
|
|
1154
1170
|
: {
|
|
@@ -1173,6 +1189,12 @@ async function taskUpOn(
|
|
|
1173
1189
|
} else {
|
|
1174
1190
|
recordTaskStarting(input.task.id, input.task.runEpoch);
|
|
1175
1191
|
}
|
|
1192
|
+
// Files-v1 recovery gets its mode only from a launch already admitted on
|
|
1193
|
+
// cloud credentials, never from the task-writable marker. A legacy start
|
|
1194
|
+
// must leave the column null so cutover can distinguish and retire it.
|
|
1195
|
+
const sharedFilesMode = cloud
|
|
1196
|
+
? recordTaskSharedFilesMode(input.task.id, input.task)
|
|
1197
|
+
: null;
|
|
1176
1198
|
// Cross-process managed maintenance and task admission use a two-sided
|
|
1177
1199
|
// handshake: update/uninstall writes its durable marker before checking
|
|
1178
1200
|
// this DB row; task-up writes `starting` before checking admission again.
|
|
@@ -1318,7 +1340,18 @@ async function taskUpOn(
|
|
|
1318
1340
|
input,
|
|
1319
1341
|
{
|
|
1320
1342
|
taskGateway,
|
|
1321
|
-
...(cloud?.start && cloud.hold
|
|
1343
|
+
...(cloud?.start && cloud.hold
|
|
1344
|
+
? {
|
|
1345
|
+
leased: leasedTaskUp(
|
|
1346
|
+
cloud.runtime,
|
|
1347
|
+
cloud.start,
|
|
1348
|
+
cloud.hold,
|
|
1349
|
+
sharedFilesMode ?? (() => {
|
|
1350
|
+
throw new Error("a cloud-credential start has no trusted Files mode");
|
|
1351
|
+
})(),
|
|
1352
|
+
),
|
|
1353
|
+
}
|
|
1354
|
+
: {}),
|
|
1322
1355
|
},
|
|
1323
1356
|
recordPrepared,
|
|
1324
1357
|
);
|