@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
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
* is recorded in the cleanup inventory before anything is written to it.
|
|
24
24
|
* - Writes for one task run one at a time, and each exec is admitted at the
|
|
25
25
|
* provider's last moment against the task's live state: the same
|
|
26
|
-
* assignment,
|
|
26
|
+
* assignment, credential identity, revision and material version, not
|
|
27
|
+
* sealed. A cleanup seals
|
|
27
28
|
* (or drops the credentials it names), then waits for every write under
|
|
28
29
|
* way to end, so nothing it is about to delete can be written again after.
|
|
29
30
|
*/
|
|
@@ -64,6 +65,16 @@ export interface LeasedSshKey {
|
|
|
64
65
|
publicKey: string;
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
export type PersonCredentialLane = "github" | "ssh";
|
|
69
|
+
|
|
70
|
+
/** Writer-only authority: clone callers do not need to know the version. */
|
|
71
|
+
type VersionedCredential<T> = T & { version: number };
|
|
72
|
+
|
|
73
|
+
interface PersonCredentialSlots {
|
|
74
|
+
github: Slot<VersionedCredential<LeasedGithub>>;
|
|
75
|
+
ssh: Slot<VersionedCredential<LeasedSshKey>>;
|
|
76
|
+
}
|
|
77
|
+
|
|
67
78
|
/** A manifest lane: not named, named but unreadable, or in hand. */
|
|
68
79
|
type Slot<T extends { credentialId: string; revision: string }> =
|
|
69
80
|
| { kind: "absent" }
|
|
@@ -85,14 +96,12 @@ interface GitHold {
|
|
|
85
96
|
release(): void;
|
|
86
97
|
}
|
|
87
98
|
|
|
88
|
-
|
|
99
|
+
/** The inherited slots are what the registry's gate opened on: the exact
|
|
100
|
+
* committed view, absent while closed. Only these serve holds and writers. */
|
|
101
|
+
interface TaskPersonCredentials extends PersonCredentialSlots {
|
|
89
102
|
assignment: TaskAssignment;
|
|
90
|
-
/** What the registry's gate opened on (the exact committed view): the
|
|
91
|
-
* only thing holds and writers read. Absent while closed. */
|
|
92
|
-
github: Slot<LeasedGithub>;
|
|
93
|
-
ssh: Slot<LeasedSshKey>;
|
|
94
103
|
/** What the last install proposed: never served until a gate opens on it. */
|
|
95
|
-
candidate:
|
|
104
|
+
candidate: PersonCredentialSlots;
|
|
96
105
|
/** The registry's gate for this run: nothing is held or written while
|
|
97
106
|
* closed. */
|
|
98
107
|
open: boolean;
|
|
@@ -109,33 +118,59 @@ interface TaskPersonCredentials {
|
|
|
109
118
|
|
|
110
119
|
const byTask = new Map<string, TaskPersonCredentials>();
|
|
111
120
|
|
|
112
|
-
function slotOf<
|
|
121
|
+
function slotOf<
|
|
122
|
+
T extends GithubLeasedValue | SshLeasedValue,
|
|
123
|
+
V extends { credentialId: string; revision: string; version: number },
|
|
124
|
+
>(
|
|
113
125
|
credential: TaskStaticCredential<T> | null,
|
|
114
|
-
value: (material: Readonly<T>) => Omit<V, "credentialId" | "revision">,
|
|
126
|
+
value: (material: Readonly<T>) => Omit<V, "credentialId" | "revision" | "version">,
|
|
115
127
|
): Slot<V> {
|
|
116
128
|
if (!credential) return { kind: "absent" };
|
|
117
129
|
const identity = { credentialId: credential.manifest.credentialId, revision: credential.manifest.revision };
|
|
118
|
-
if (
|
|
119
|
-
|
|
130
|
+
if (
|
|
131
|
+
credential.availability !== "available" ||
|
|
132
|
+
!credential.material ||
|
|
133
|
+
credential.observedVersion === null
|
|
134
|
+
) {
|
|
135
|
+
return { kind: "unavailable", ...identity };
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
kind: "present",
|
|
139
|
+
value: {
|
|
140
|
+
...identity,
|
|
141
|
+
version: credential.observedVersion,
|
|
142
|
+
...value(credential.material),
|
|
143
|
+
} as V,
|
|
144
|
+
};
|
|
120
145
|
}
|
|
121
146
|
|
|
122
|
-
function slotsOf(view: TaskCredentialView):
|
|
147
|
+
function slotsOf(view: TaskCredentialView): PersonCredentialSlots {
|
|
123
148
|
return {
|
|
124
|
-
github: slotOf<GithubLeasedValue, LeasedGithub
|
|
125
|
-
ssh: slotOf<SshLeasedValue, LeasedSshKey
|
|
149
|
+
github: slotOf<GithubLeasedValue, VersionedCredential<LeasedGithub>>(view.github, (material) => ({ token: material.token })),
|
|
150
|
+
ssh: slotOf<SshLeasedValue, VersionedCredential<LeasedSshKey>>(view.ssh, (material) => ({
|
|
126
151
|
privateKey: material.privateKey,
|
|
127
152
|
publicKey: material.publicKey,
|
|
128
153
|
})),
|
|
129
154
|
};
|
|
130
155
|
}
|
|
131
156
|
|
|
132
|
-
|
|
157
|
+
function identityOf(slot: Slot<CredentialIdentity>): CredentialIdentity | null {
|
|
158
|
+
return slot.kind === "present"
|
|
159
|
+
? { credentialId: slot.value.credentialId, revision: slot.value.revision }
|
|
160
|
+
: slot.kind === "unavailable"
|
|
161
|
+
? { credentialId: slot.credentialId, revision: slot.revision }
|
|
162
|
+
: null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The same named credential at the same revision, readable or not. */
|
|
133
166
|
function sameIdentity<T extends CredentialIdentity>(a: Slot<T>, b: Slot<T>): boolean {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
167
|
+
const left = identityOf(a);
|
|
168
|
+
const right = identityOf(b);
|
|
169
|
+
return Boolean(
|
|
170
|
+
left &&
|
|
171
|
+
right &&
|
|
172
|
+
left.credentialId === right.credentialId &&
|
|
173
|
+
left.revision === right.revision,
|
|
139
174
|
);
|
|
140
175
|
}
|
|
141
176
|
|
|
@@ -194,12 +229,6 @@ export async function cleanupPersonCredentials(request: CredentialCleanupRequest
|
|
|
194
229
|
removed.credentialId === identity.credentialId &&
|
|
195
230
|
(removed.revision === null || removed.revision === identity.revision),
|
|
196
231
|
));
|
|
197
|
-
const identityOf = (slot: Slot<CredentialIdentity>): CredentialIdentity | null =>
|
|
198
|
-
slot.kind === "present"
|
|
199
|
-
? { credentialId: slot.value.credentialId, revision: slot.value.revision }
|
|
200
|
-
: slot.kind === "unavailable"
|
|
201
|
-
? { credentialId: slot.credentialId, revision: slot.revision }
|
|
202
|
-
: null;
|
|
203
232
|
if (request.mode === "full") {
|
|
204
233
|
current.sealed = true;
|
|
205
234
|
current.open = false;
|
|
@@ -286,6 +315,44 @@ export interface TaskGitCredentialHold {
|
|
|
286
315
|
release(): void;
|
|
287
316
|
}
|
|
288
317
|
|
|
318
|
+
/**
|
|
319
|
+
* A task-runtime reconciliation's hold on the exact open person-credential
|
|
320
|
+
* gate. Unlike a clone's hold, it does not require either credential to be
|
|
321
|
+
* readable and exposes no material. Cleanup aborts it and waits for release,
|
|
322
|
+
* so a retry delay or runtime reconstruction cannot outlive the run whose
|
|
323
|
+
* files it is about to write.
|
|
324
|
+
*/
|
|
325
|
+
export interface TaskPersonCredentialGateHold {
|
|
326
|
+
signal: AbortSignal;
|
|
327
|
+
release(): void;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function holdPersonCredentialGate(
|
|
331
|
+
assignment: TaskAssignment,
|
|
332
|
+
): TaskPersonCredentialGateHold {
|
|
333
|
+
const state = liveState(assignment);
|
|
334
|
+
const controller = new AbortController();
|
|
335
|
+
let settle!: () => void;
|
|
336
|
+
const released = new Promise<void>((resolve) => {
|
|
337
|
+
settle = resolve;
|
|
338
|
+
});
|
|
339
|
+
let releasedOnce = false;
|
|
340
|
+
const hold: GitHold = {
|
|
341
|
+
controller,
|
|
342
|
+
github: identityOf(state.github),
|
|
343
|
+
ssh: identityOf(state.ssh),
|
|
344
|
+
released,
|
|
345
|
+
release: () => {
|
|
346
|
+
if (releasedOnce) return;
|
|
347
|
+
releasedOnce = true;
|
|
348
|
+
state.holds.delete(hold);
|
|
349
|
+
settle();
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
state.holds.add(hold);
|
|
353
|
+
return { signal: controller.signal, release: hold.release };
|
|
354
|
+
}
|
|
355
|
+
|
|
289
356
|
/**
|
|
290
357
|
* Hold the owner's GitHub sign-in and SSH key for one host-side git
|
|
291
358
|
* operation of the exact assignment, while the registry's gate is open on
|
|
@@ -361,7 +428,7 @@ export const TASK_SSH_ALLOWED_SIGNERS_PATH = "/home/node/.ssh/allowed_signers";
|
|
|
361
428
|
export const TASK_SSH_SIGNING_GITCONFIG_PATH = "/home/node/.ssh/uai-signing.gitconfig";
|
|
362
429
|
/** SSH as git's transport to GitHub, while the task has no GitHub sign-in. */
|
|
363
430
|
export const TASK_SSH_TRANSPORT_GITCONFIG_PATH = "/home/node/.ssh/uai-transport.gitconfig";
|
|
364
|
-
/** The writer's step was refused: the run,
|
|
431
|
+
/** The writer's step was refused: the run, material or gate moved. */
|
|
365
432
|
export class PersonCredentialWriteRefused extends Error {
|
|
366
433
|
constructor() {
|
|
367
434
|
super("the task's credentials changed or were cleaned up; the write was not made");
|
|
@@ -505,11 +572,18 @@ export function writeTaskGithub(environment: Environment, assignment: TaskAssign
|
|
|
505
572
|
const slot = current().github;
|
|
506
573
|
if (slot.kind === "absent") return false;
|
|
507
574
|
if (slot.kind === "unavailable") throw new CredentialLeaseUnavailableError("the GitHub sign-in could not be read");
|
|
508
|
-
const { credentialId, revision, token } = slot.value;
|
|
509
|
-
// Admitted only while this exact
|
|
575
|
+
const { credentialId, revision, version, token } = slot.value;
|
|
576
|
+
// Admitted only while this exact material version is still the task's.
|
|
510
577
|
const admit = (): void => {
|
|
511
578
|
const live = current().github;
|
|
512
|
-
if (
|
|
579
|
+
if (
|
|
580
|
+
live.kind !== "present" ||
|
|
581
|
+
live.value.credentialId !== credentialId ||
|
|
582
|
+
live.value.revision !== revision ||
|
|
583
|
+
live.value.version !== version
|
|
584
|
+
) {
|
|
585
|
+
throw new PersonCredentialWriteRefused();
|
|
586
|
+
}
|
|
513
587
|
};
|
|
514
588
|
inventory(environment, assignment, "github", credentialId, withStaged(TASK_GH_HOSTS_PATH, TASK_GH_GITCONFIG_PATH));
|
|
515
589
|
const hosts = await run(environment, ["/bin/sh", "-c", GH_LOGIN_SCRIPT], `${token}\n`, "gh auth login", admit, "root");
|
|
@@ -548,10 +622,17 @@ export function writeTaskSshKey(environment: Environment, assignment: TaskAssign
|
|
|
548
622
|
const slot = current().ssh;
|
|
549
623
|
if (slot.kind === "absent") return false;
|
|
550
624
|
if (slot.kind === "unavailable") throw new CredentialLeaseUnavailableError("the SSH key could not be read");
|
|
551
|
-
const { credentialId, revision, privateKey, publicKey } = slot.value;
|
|
625
|
+
const { credentialId, revision, version, privateKey, publicKey } = slot.value;
|
|
552
626
|
const admit = (): void => {
|
|
553
627
|
const live = current().ssh;
|
|
554
|
-
if (
|
|
628
|
+
if (
|
|
629
|
+
live.kind !== "present" ||
|
|
630
|
+
live.value.credentialId !== credentialId ||
|
|
631
|
+
live.value.revision !== revision ||
|
|
632
|
+
live.value.version !== version
|
|
633
|
+
) {
|
|
634
|
+
throw new PersonCredentialWriteRefused();
|
|
635
|
+
}
|
|
555
636
|
};
|
|
556
637
|
inventory(
|
|
557
638
|
environment,
|
|
@@ -589,6 +670,33 @@ export function writeTaskSshKey(environment: Environment, assignment: TaskAssign
|
|
|
589
670
|
});
|
|
590
671
|
}
|
|
591
672
|
|
|
673
|
+
/**
|
|
674
|
+
* Reconcile the task-visible person credentials in their coupled order.
|
|
675
|
+
* GitHub chooses HTTPS and removes SSH transport; SSH then observes whether
|
|
676
|
+
* GitHub is present and either keeps HTTPS or restores SSH transport. Every
|
|
677
|
+
* individual exec remains fenced by the exact run and material version.
|
|
678
|
+
* Absent lanes rely on the coordinator having removed their inventoried
|
|
679
|
+
* files. When GitHub is removed, callers must select the surviving SSH lane
|
|
680
|
+
* even if its credential has not changed, so transport is restored too.
|
|
681
|
+
*/
|
|
682
|
+
export async function writeTaskPersonCredentials(
|
|
683
|
+
environment: Environment,
|
|
684
|
+
assignment: TaskAssignment,
|
|
685
|
+
lanes: readonly PersonCredentialLane[] = ["github", "ssh"],
|
|
686
|
+
signal?: AbortSignal,
|
|
687
|
+
): Promise<void> {
|
|
688
|
+
const selected = new Set(lanes);
|
|
689
|
+
for (const [lane, write] of [
|
|
690
|
+
["github", writeTaskGithub],
|
|
691
|
+
["ssh", writeTaskSshKey],
|
|
692
|
+
] as const) {
|
|
693
|
+
if (!selected.has(lane)) continue;
|
|
694
|
+
signal?.throwIfAborted();
|
|
695
|
+
await write(environment, assignment);
|
|
696
|
+
}
|
|
697
|
+
signal?.throwIfAborted();
|
|
698
|
+
}
|
|
699
|
+
|
|
592
700
|
function sameAssignment(a: TaskAssignment, b: TaskAssignment): boolean {
|
|
593
701
|
return a.taskId === b.taskId && a.runEpoch === b.runEpoch;
|
|
594
702
|
}
|
|
@@ -282,7 +282,12 @@ export function leasedMcpFor(taskId: string): TaskMcpLease | undefined {
|
|
|
282
282
|
export function leasedMcpAclEntries(taskId: string): Array<{ id: string; slug: string }> {
|
|
283
283
|
const served = tasks.get(taskId)?.served;
|
|
284
284
|
if (!served) return [];
|
|
285
|
-
|
|
285
|
+
// Membership follows the committed manifest, not whether its material was
|
|
286
|
+
// readable on this pass. A named-but-unreadable connection remains in agent
|
|
287
|
+
// config so the gateway can distinguish the temporary 503 from an absent
|
|
288
|
+
// connection's 404, and so a later material refresh needs no process
|
|
289
|
+
// restart just to make the tool visible again.
|
|
290
|
+
return [...served.values()].map((connection) => ({ id: connection.credentialId, slug: connection.slug }));
|
|
286
291
|
}
|
|
287
292
|
|
|
288
293
|
export function setMcpCredentialRefresher(next: McpCredentialRefresher | null): void {
|