@runuai/host 0.9.149 → 0.9.151
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/meta/_journal.json +14 -0
- package/db/schema.ts +188 -0
- package/lib/agent-cli.ts +11 -13
- package/lib/agent.ts +177 -7
- package/lib/agents/durable-proc.ts +56 -44
- package/lib/agents/registry.ts +19 -3
- package/lib/agents/session-state.ts +23 -10
- package/lib/agents/transport.ts +101 -69
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +4 -3
- package/lib/attachments.ts +19 -17
- package/lib/capture-flow.ts +319 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2544 -0
- package/lib/cloud-credential-runtime.ts +1035 -0
- package/lib/codex-auth.ts +21 -1
- package/lib/container-runtime.ts +76 -0
- package/lib/credential-cleanup-inventory.ts +405 -0
- package/lib/credential-consumers/engine-anchor.ts +1876 -0
- package/lib/credential-consumers/engines.ts +773 -0
- package/lib/credential-consumers/github.ts +594 -0
- package/lib/credential-consumers/mcp.ts +384 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-store-pickup.ts +15 -2
- package/lib/credential-store.ts +361 -33
- package/lib/credentials-activation-authority.ts +546 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/db.ts +11 -0
- package/lib/docker-exec.ts +3 -1
- package/lib/engine-accounts.ts +42 -32
- package/lib/engine-connect.ts +47 -2
- package/lib/engine-credential-env.ts +50 -0
- package/lib/engine-login.ts +83 -1
- package/lib/env-file.ts +62 -8
- package/lib/env.ts +5 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +219 -177
- package/lib/git-identity.ts +59 -14
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +56 -21
- package/lib/host-env.ts +17 -4
- package/lib/launch-mode.ts +25 -0
- package/lib/legacy-runtime-retirement.ts +388 -0
- package/lib/machine-exec.ts +14 -3
- package/lib/machine-provider-aws.ts +50 -8
- package/lib/machine-provider-local.ts +4 -0
- package/lib/machine-provider.ts +4 -0
- package/lib/managed-operation-drain.ts +8 -0
- package/lib/managed-runtime.ts +371 -7
- package/lib/mcp-capture.ts +860 -0
- package/lib/mcp-connections.ts +8 -1
- package/lib/mcp-gateway.ts +168 -10
- package/lib/obs.ts +2 -6
- package/lib/orchestrator.ts +1847 -236
- package/lib/person-capture.ts +327 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +50 -3
- package/lib/runtime-archive.ts +68 -1
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +429 -13
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/task-trust.ts +34 -3
- package/lib/sidecar-resume.ts +18 -5
- package/lib/skills.ts +47 -8
- package/lib/standard-image.ts +157 -103
- package/lib/stopped-task-status-queue.ts +68 -10
- package/lib/task-credential-cleanup.ts +525 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +66 -163
- package/lib/task-env-source.ts +68 -14
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +69 -28
- package/lib/task-environment/docker.ts +101 -20
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +459 -96
- package/lib/task-environment/machine.ts +101 -11
- package/lib/task-environment/self.ts +108 -50
- package/lib/task-environment/types.ts +89 -5
- package/lib/task-environment/workspace-files.ts +623 -25
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-gateway-recovery.ts +4 -1
- package/lib/task-gateway-state.ts +15 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +56 -2
- package/lib/token-broker-local.ts +28 -4
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +368 -28
- package/lib/token-lanes/openai-oauth.ts +7 -3
- package/lib/token-lanes/xai-oauth.ts +7 -3
- package/lib/transcript.ts +9 -5
- package/package.json +6 -2
- package/scripts/agent/task-up.sh +242 -100
- package/src/cloud-credentials.ts +2399 -0
- package/src/event-outbox.ts +8 -6
- package/src/index.ts +1386 -875
- package/src/main.ts +1062 -140
- package/src/outbound-frame.ts +31 -0
- package/src/project-env-policy.ts +125 -10
- package/src/protocol.ts +699 -23
- package/src/ui/server.ts +48 -10
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-154 §6, §12 on the runner: a task owner's GitHub sign-in and SSH key,
|
|
3
|
+
* from the task's lease.
|
|
4
|
+
*
|
|
5
|
+
* - The registry installs each grant and credential change here; the token
|
|
6
|
+
* and key live in memory for the task's run only. A credential the
|
|
7
|
+
* manifest names but the lease could not read is held as unavailable, never
|
|
8
|
+
* as absent: whoever asks for it fails and retries, and never falls back to
|
|
9
|
+
* another transport as if the person had none.
|
|
10
|
+
* - Task-up's clone and fetches hold them (`holdTaskGitCredentials`) for
|
|
11
|
+
* the exact assignment, only while the registry's gate is open on it;
|
|
12
|
+
* nothing reads the runner's own store. A hold is the operation's only
|
|
13
|
+
* access: a gate that closes, or a cleanup that takes either credential,
|
|
14
|
+
* aborts it and waits for its release, so nothing uses them after.
|
|
15
|
+
* - Inside the task they are written only by streaming over an exec's stdin,
|
|
16
|
+
* in a clean environment (`env -i`, absolute paths): the token into the
|
|
17
|
+
* managed `gh` configuration (file-backed, never a keyring), the key where
|
|
18
|
+
* ADR-029 puts it, each file through an intermediate renamed into place
|
|
19
|
+
* without following anything the task put in the way. What git needs from
|
|
20
|
+
* them (credential helper, transport, signing) is in files the global git
|
|
21
|
+
* configuration includes, so the cleanup that deletes the credential's
|
|
22
|
+
* files takes its configuration too. Every path, intermediates included,
|
|
23
|
+
* is recorded in the cleanup inventory before anything is written to it.
|
|
24
|
+
* - Writes for one task run one at a time, and each exec is admitted at the
|
|
25
|
+
* provider's last moment against the task's live state: the same
|
|
26
|
+
* assignment, the same credential revision, not sealed. A cleanup seals
|
|
27
|
+
* (or drops the credentials it names), then waits for every write under
|
|
28
|
+
* way to end, so nothing it is about to delete can be written again after.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
CredentialLeaseUnavailableError,
|
|
33
|
+
type CredentialCleanupRequest,
|
|
34
|
+
type CredentialInstallRequest,
|
|
35
|
+
type GithubLeasedValue,
|
|
36
|
+
type SshLeasedValue,
|
|
37
|
+
type TaskAssignment,
|
|
38
|
+
type TaskCredentialView,
|
|
39
|
+
type TaskStaticCredential,
|
|
40
|
+
} from "../cloud-credential-leases";
|
|
41
|
+
import { rememberCredentialCleanupPath } from "../credential-cleanup-inventory";
|
|
42
|
+
import {
|
|
43
|
+
assertTaskRuntime,
|
|
44
|
+
runInTask as run,
|
|
45
|
+
STAGED_SUFFIX,
|
|
46
|
+
TASK_FILE_WRITE_HELPER,
|
|
47
|
+
withStaged,
|
|
48
|
+
writeTaskFileArgv as writeFileArgv,
|
|
49
|
+
type TaskWriteEnvironment as Environment,
|
|
50
|
+
} from "./task-files";
|
|
51
|
+
|
|
52
|
+
export { STAGED_SUFFIX, TASK_FILE_WRITE_HELPER };
|
|
53
|
+
|
|
54
|
+
export interface LeasedGithub {
|
|
55
|
+
credentialId: string;
|
|
56
|
+
revision: string;
|
|
57
|
+
token: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface LeasedSshKey {
|
|
61
|
+
credentialId: string;
|
|
62
|
+
revision: string;
|
|
63
|
+
privateKey: string;
|
|
64
|
+
publicKey: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A manifest lane: not named, named but unreadable, or in hand. */
|
|
68
|
+
type Slot<T extends { credentialId: string; revision: string }> =
|
|
69
|
+
| { kind: "absent" }
|
|
70
|
+
| { kind: "unavailable"; credentialId: string; revision: string }
|
|
71
|
+
| { kind: "present"; value: T };
|
|
72
|
+
|
|
73
|
+
interface CredentialIdentity {
|
|
74
|
+
credentialId: string;
|
|
75
|
+
revision: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One host-side git operation's hold on the owner's credentials. */
|
|
79
|
+
interface GitHold {
|
|
80
|
+
controller: AbortController;
|
|
81
|
+
/** The credentials it was given: what a cleanup must take it for. */
|
|
82
|
+
github: CredentialIdentity | null;
|
|
83
|
+
ssh: CredentialIdentity | null;
|
|
84
|
+
released: Promise<void>;
|
|
85
|
+
release(): void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface TaskPersonCredentials {
|
|
89
|
+
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
|
+
/** What the last install proposed: never served until a gate opens on it. */
|
|
95
|
+
candidate: { github: Slot<LeasedGithub>; ssh: Slot<LeasedSshKey> };
|
|
96
|
+
/** The registry's gate for this run: nothing is held or written while
|
|
97
|
+
* closed. */
|
|
98
|
+
open: boolean;
|
|
99
|
+
/** Host-side git operations holding the credentials now. */
|
|
100
|
+
holds: Set<GitHold>;
|
|
101
|
+
/** A full cleanup began: no write is admitted again for this run. */
|
|
102
|
+
sealed: boolean;
|
|
103
|
+
/** The writes for this task, one after another. */
|
|
104
|
+
queue: Promise<void>;
|
|
105
|
+
/** Writes admitted and not yet ended. */
|
|
106
|
+
writing: number;
|
|
107
|
+
drained: Array<() => void>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const byTask = new Map<string, TaskPersonCredentials>();
|
|
111
|
+
|
|
112
|
+
function slotOf<T extends GithubLeasedValue | SshLeasedValue, V extends { credentialId: string; revision: string }>(
|
|
113
|
+
credential: TaskStaticCredential<T> | null,
|
|
114
|
+
value: (material: Readonly<T>) => Omit<V, "credentialId" | "revision">,
|
|
115
|
+
): Slot<V> {
|
|
116
|
+
if (!credential) return { kind: "absent" };
|
|
117
|
+
const identity = { credentialId: credential.manifest.credentialId, revision: credential.manifest.revision };
|
|
118
|
+
if (credential.availability !== "available" || !credential.material) return { kind: "unavailable", ...identity };
|
|
119
|
+
return { kind: "present", value: { ...identity, ...value(credential.material) } as V };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function slotsOf(view: TaskCredentialView): { github: Slot<LeasedGithub>; ssh: Slot<LeasedSshKey> } {
|
|
123
|
+
return {
|
|
124
|
+
github: slotOf<GithubLeasedValue, LeasedGithub>(view.github, (material) => ({ token: material.token })),
|
|
125
|
+
ssh: slotOf<SshLeasedValue, LeasedSshKey>(view.ssh, (material) => ({
|
|
126
|
+
privateKey: material.privateKey,
|
|
127
|
+
publicKey: material.publicKey,
|
|
128
|
+
})),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The same credential at the same revision, both readable. */
|
|
133
|
+
function sameIdentity<T extends CredentialIdentity>(a: Slot<T>, b: Slot<T>): boolean {
|
|
134
|
+
return (
|
|
135
|
+
a.kind === "present" &&
|
|
136
|
+
b.kind === "present" &&
|
|
137
|
+
a.value.credentialId === b.value.credentialId &&
|
|
138
|
+
a.value.revision === b.value.revision
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Install the owner's GitHub sign-in and SSH key from a grant or a change:
|
|
144
|
+
* a candidate, served by nothing until the registry opens its gate on it. A
|
|
145
|
+
* credential change of this run takes effect now on a credential already
|
|
146
|
+
* served that is otherwise the same (a newer version of it).
|
|
147
|
+
*/
|
|
148
|
+
export function installPersonCredentials(request: CredentialInstallRequest): void {
|
|
149
|
+
const candidate = slotsOf(request.next);
|
|
150
|
+
const current = byTask.get(request.assignment.taskId);
|
|
151
|
+
if (current && !current.sealed && sameAssignment(current.assignment, request.assignment)) {
|
|
152
|
+
// The same run: its queue and the writes under way kept, each admitted
|
|
153
|
+
// against what is served at its moment.
|
|
154
|
+
current.candidate = candidate;
|
|
155
|
+
if (request.reason === "credential-change" && current.open) {
|
|
156
|
+
if (sameIdentity(current.github, candidate.github)) current.github = candidate.github;
|
|
157
|
+
if (sameIdentity(current.ssh, candidate.ssh)) current.ssh = candidate.ssh;
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
// A new run, or one after a full cleanup began: a fresh state, so nothing
|
|
162
|
+
// queued for the old one is ever admitted for it.
|
|
163
|
+
byTask.set(request.assignment.taskId, {
|
|
164
|
+
assignment: { ...request.assignment },
|
|
165
|
+
github: { kind: "absent" },
|
|
166
|
+
ssh: { kind: "absent" },
|
|
167
|
+
candidate,
|
|
168
|
+
open: false,
|
|
169
|
+
holds: new Set(),
|
|
170
|
+
sealed: false,
|
|
171
|
+
queue: Promise.resolve(),
|
|
172
|
+
writing: 0,
|
|
173
|
+
drained: [],
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Drop what a cleanup names from memory, then wait for every write under way
|
|
179
|
+
* for this task to end: a full cleanup seals the task first, a selective one
|
|
180
|
+
* drops the credentials it names, so no exec is admitted for them again and
|
|
181
|
+
* the inventoried paths cannot be written after the coordinator deletes them.
|
|
182
|
+
*/
|
|
183
|
+
export async function cleanupPersonCredentials(request: CredentialCleanupRequest): Promise<void> {
|
|
184
|
+
const current = byTask.get(request.assignment.taskId);
|
|
185
|
+
if (!current || !sameAssignment(current.assignment, request.assignment)) return;
|
|
186
|
+
// The exact identity a cleanup names: its revision too, when it names one,
|
|
187
|
+
// so a stale revision's cleanup never takes a newer one of the same id.
|
|
188
|
+
const takes = (credentialClass: "github" | "ssh", identity: CredentialIdentity | null) =>
|
|
189
|
+
request.mode === "full" ||
|
|
190
|
+
(identity !== null &&
|
|
191
|
+
request.removed.some(
|
|
192
|
+
(removed) =>
|
|
193
|
+
removed.class === credentialClass &&
|
|
194
|
+
removed.credentialId === identity.credentialId &&
|
|
195
|
+
(removed.revision === null || removed.revision === identity.revision),
|
|
196
|
+
));
|
|
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
|
+
if (request.mode === "full") {
|
|
204
|
+
current.sealed = true;
|
|
205
|
+
current.open = false;
|
|
206
|
+
current.github = { kind: "absent" };
|
|
207
|
+
current.ssh = { kind: "absent" };
|
|
208
|
+
current.candidate = { github: { kind: "absent" }, ssh: { kind: "absent" } };
|
|
209
|
+
} else {
|
|
210
|
+
if (takes("github", identityOf(current.github))) current.github = { kind: "absent" };
|
|
211
|
+
if (takes("ssh", identityOf(current.ssh))) current.ssh = { kind: "absent" };
|
|
212
|
+
if (takes("github", identityOf(current.candidate.github))) current.candidate.github = { kind: "absent" };
|
|
213
|
+
if (takes("ssh", identityOf(current.candidate.ssh))) current.candidate.ssh = { kind: "absent" };
|
|
214
|
+
}
|
|
215
|
+
// Every operation holding what this takes stops now; with every one a gate
|
|
216
|
+
// close already stopped, whatever narrowed, each is waited for.
|
|
217
|
+
const taken = [...current.holds].filter(
|
|
218
|
+
(hold) => hold.controller.signal.aborted || takes("github", hold.github) || takes("ssh", hold.ssh),
|
|
219
|
+
);
|
|
220
|
+
for (const hold of taken) hold.controller.abort(new PersonCredentialWriteRefused());
|
|
221
|
+
await Promise.all([drain(current), ...taken.map((hold) => hold.released)]);
|
|
222
|
+
if (request.mode === "full" && byTask.get(request.assignment.taskId) === current) {
|
|
223
|
+
byTask.delete(request.assignment.taskId);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function drain(state: TaskPersonCredentials): Promise<void> {
|
|
228
|
+
if (state.writing === 0) return Promise.resolve();
|
|
229
|
+
return new Promise((resolve) => state.drained.push(resolve));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Process shutdown: every GitHub sign-in or SSH key write under way, for
|
|
233
|
+
* any task, over: an exec it had admitted ends, the next one is refused. */
|
|
234
|
+
export function drainPersonCredentialWrites(): Promise<void> {
|
|
235
|
+
return Promise.all([...byTask.values()].map(drain)).then(() => undefined);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The live state for this exact assignment, or a failure: never "none". */
|
|
239
|
+
/** The live, open state for this exact assignment, or a failure: never
|
|
240
|
+
* "none". Nothing is read, held or written while the gate is closed. */
|
|
241
|
+
function liveState(assignment: TaskAssignment): TaskPersonCredentials {
|
|
242
|
+
const state = byTask.get(assignment.taskId);
|
|
243
|
+
if (!state || state.sealed || !sameAssignment(state.assignment, assignment)) {
|
|
244
|
+
throw new CredentialLeaseUnavailableError("the task's credentials are not installed for this run");
|
|
245
|
+
}
|
|
246
|
+
if (!state.open) throw new CredentialLeaseUnavailableError("the task's credentials are not open for this run");
|
|
247
|
+
return state;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The registry opened its gate on this exact committed view: serve exactly
|
|
251
|
+
* its GitHub sign-in and SSH key. An older run's opening changes nothing. */
|
|
252
|
+
export function openPersonCredentialGate(assignment: TaskAssignment, view: TaskCredentialView): void {
|
|
253
|
+
const state = byTask.get(assignment.taskId);
|
|
254
|
+
if (!state || state.sealed || !sameAssignment(state.assignment, assignment)) return;
|
|
255
|
+
const served = slotsOf(view);
|
|
256
|
+
state.github = served.github;
|
|
257
|
+
state.ssh = served.ssh;
|
|
258
|
+
state.open = true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The registry closed its gate: nothing is served, held or written, and
|
|
262
|
+
* every operation holding the credentials of this run or an older one
|
|
263
|
+
* stops now. */
|
|
264
|
+
export function closePersonCredentialGate(assignment: TaskAssignment): void {
|
|
265
|
+
const state = byTask.get(assignment.taskId);
|
|
266
|
+
if (!state || state.assignment.runEpoch > assignment.runEpoch) return;
|
|
267
|
+
state.open = false;
|
|
268
|
+
state.github = { kind: "absent" };
|
|
269
|
+
state.ssh = { kind: "absent" };
|
|
270
|
+
for (const hold of state.holds) hold.controller.abort(new PersonCredentialWriteRefused());
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** A host-side git operation's hold on the owner's GitHub sign-in and SSH
|
|
274
|
+
* key (task-up's clone and fetches). */
|
|
275
|
+
export interface TaskGitCredentialHold {
|
|
276
|
+
/** The exact run it was given for: providers refuse it for any other. */
|
|
277
|
+
assignment: Readonly<TaskAssignment>;
|
|
278
|
+
/** Null only when the manifest names no GitHub sign-in. */
|
|
279
|
+
github: LeasedGithub | null;
|
|
280
|
+
/** Null only when the manifest names no SSH key. */
|
|
281
|
+
ssh: LeasedSshKey | null;
|
|
282
|
+
/** Aborted when the gate closes or a cleanup takes either credential:
|
|
283
|
+
* the operation stops using them at once, then releases. */
|
|
284
|
+
signal: AbortSignal;
|
|
285
|
+
/** The operation no longer uses them. Idempotent. */
|
|
286
|
+
release(): void;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Hold the owner's GitHub sign-in and SSH key for one host-side git
|
|
291
|
+
* operation of the exact assignment, while the registry's gate is open on
|
|
292
|
+
* it. Throws `CredentialLeaseUnavailableError` when the gate is closed, the
|
|
293
|
+
* run moved on, or the manifest names a credential the lease could not
|
|
294
|
+
* read: the caller retries or fails, and never clones another way as if
|
|
295
|
+
* there were none. A cleanup waits for the hold's release.
|
|
296
|
+
*/
|
|
297
|
+
export function holdTaskGitCredentials(assignment: TaskAssignment): TaskGitCredentialHold {
|
|
298
|
+
const state = liveState(assignment);
|
|
299
|
+
if (state.github.kind === "unavailable") throw new CredentialLeaseUnavailableError("the GitHub sign-in could not be read");
|
|
300
|
+
if (state.ssh.kind === "unavailable") throw new CredentialLeaseUnavailableError("the SSH key could not be read");
|
|
301
|
+
const github = state.github.kind === "present" ? state.github.value : null;
|
|
302
|
+
const ssh = state.ssh.kind === "present" ? state.ssh.value : null;
|
|
303
|
+
const controller = new AbortController();
|
|
304
|
+
let release!: () => void;
|
|
305
|
+
const released = new Promise<void>((resolve) => {
|
|
306
|
+
release = resolve;
|
|
307
|
+
});
|
|
308
|
+
const hold: GitHold = {
|
|
309
|
+
controller,
|
|
310
|
+
github: github && { credentialId: github.credentialId, revision: github.revision },
|
|
311
|
+
ssh: ssh && { credentialId: ssh.credentialId, revision: ssh.revision },
|
|
312
|
+
released,
|
|
313
|
+
release: () => {
|
|
314
|
+
state.holds.delete(hold);
|
|
315
|
+
release();
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
state.holds.add(hold);
|
|
319
|
+
return { assignment: { ...assignment }, github, ssh, signal: controller.signal, release: hold.release };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The task owner's GitHub token for task-up's clone, for the exact
|
|
324
|
+
* assignment: null only when the manifest names no GitHub sign-in. Throws
|
|
325
|
+
* when it names one the lease could not read, or the run moved on: the
|
|
326
|
+
* caller retries or fails, and never clones another way as if there were
|
|
327
|
+
* none.
|
|
328
|
+
*/
|
|
329
|
+
export function leasedGithubToken(assignment: TaskAssignment): { accessToken: string; expiresAt: null } | null {
|
|
330
|
+
const slot = liveState(assignment).github;
|
|
331
|
+
if (slot.kind === "absent") return null;
|
|
332
|
+
if (slot.kind === "unavailable") throw new CredentialLeaseUnavailableError("the GitHub sign-in could not be read");
|
|
333
|
+
return { accessToken: slot.value.token, expiresAt: null };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The task owner's SSH key for the exact assignment: null only when the
|
|
337
|
+
* manifest names none; throws when it names one the lease could not read. */
|
|
338
|
+
export function leasedSshKey(assignment: TaskAssignment): LeasedSshKey | null {
|
|
339
|
+
const slot = liveState(assignment).ssh;
|
|
340
|
+
if (slot.kind === "absent") return null;
|
|
341
|
+
if (slot.kind === "unavailable") throw new CredentialLeaseUnavailableError("the SSH key could not be read");
|
|
342
|
+
return slot.value;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Tests only. */
|
|
346
|
+
export function resetLeasedPersonCredentialsForTests(): void {
|
|
347
|
+
byTask.clear();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// Writing them into the task
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
export const TASK_GH_HOSTS_PATH = "/home/node/.config/gh/hosts.yml";
|
|
355
|
+
/** The GitHub sign-in's git configuration: credential helper, HTTPS transport. */
|
|
356
|
+
export const TASK_GH_GITCONFIG_PATH = "/home/node/.config/gh/uai.gitconfig";
|
|
357
|
+
export const TASK_SSH_KEY_PATH = "/home/node/.ssh/id_ed25519";
|
|
358
|
+
export const TASK_SSH_PUBLIC_KEY_PATH = "/home/node/.ssh/id_ed25519.pub";
|
|
359
|
+
export const TASK_SSH_ALLOWED_SIGNERS_PATH = "/home/node/.ssh/allowed_signers";
|
|
360
|
+
/** The key's signing configuration (ADR-029). */
|
|
361
|
+
export const TASK_SSH_SIGNING_GITCONFIG_PATH = "/home/node/.ssh/uai-signing.gitconfig";
|
|
362
|
+
/** SSH as git's transport to GitHub, while the task has no GitHub sign-in. */
|
|
363
|
+
export const TASK_SSH_TRANSPORT_GITCONFIG_PATH = "/home/node/.ssh/uai-transport.gitconfig";
|
|
364
|
+
/** The writer's step was refused: the run, the revision or the gate moved. */
|
|
365
|
+
export class PersonCredentialWriteRefused extends Error {
|
|
366
|
+
constructor() {
|
|
367
|
+
super("the task's credentials changed or were cleaned up; the write was not made");
|
|
368
|
+
this.name = "PersonCredentialWriteRefused";
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Queue one write for the task: it starts once the writes before it ended,
|
|
374
|
+
* counts as under way until it ends (a cleanup waits for it), and each of
|
|
375
|
+
* its execs is admitted only while `stillCurrent` holds for the task's live
|
|
376
|
+
* state at that moment.
|
|
377
|
+
*/
|
|
378
|
+
function enqueueWrite(
|
|
379
|
+
assignment: TaskAssignment,
|
|
380
|
+
write: (admit: () => void, current: () => TaskPersonCredentials) => Promise<boolean>,
|
|
381
|
+
): Promise<boolean> {
|
|
382
|
+
let state: TaskPersonCredentials;
|
|
383
|
+
try {
|
|
384
|
+
state = liveState(assignment);
|
|
385
|
+
} catch (error) {
|
|
386
|
+
return Promise.reject(error);
|
|
387
|
+
}
|
|
388
|
+
state.writing += 1;
|
|
389
|
+
const settle = (): void => {
|
|
390
|
+
state.writing -= 1;
|
|
391
|
+
if (state.writing === 0) for (const resolve of state.drained.splice(0)) resolve();
|
|
392
|
+
};
|
|
393
|
+
const current = (): TaskPersonCredentials => {
|
|
394
|
+
const live = byTask.get(assignment.taskId);
|
|
395
|
+
// Every admission: this exact run's state, its gate open on the view it
|
|
396
|
+
// serves now.
|
|
397
|
+
if (live !== state || state.sealed || !state.open || !sameAssignment(state.assignment, assignment)) {
|
|
398
|
+
throw new PersonCredentialWriteRefused();
|
|
399
|
+
}
|
|
400
|
+
return state;
|
|
401
|
+
};
|
|
402
|
+
const result = state.queue.then(() => write(() => void current(), current));
|
|
403
|
+
state.queue = result.then(
|
|
404
|
+
() => undefined,
|
|
405
|
+
() => undefined,
|
|
406
|
+
);
|
|
407
|
+
return result.finally(settle);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Have the global git configuration include `path`, once. Everything a
|
|
412
|
+
* credential needs from git lives in such a file, inventoried with the
|
|
413
|
+
* credential: the cleanup that deletes it takes the configuration with it,
|
|
414
|
+
* and git ignores an include whose file is gone.
|
|
415
|
+
*/
|
|
416
|
+
function includeOnce(path: string): string {
|
|
417
|
+
return (
|
|
418
|
+
`/usr/bin/git config --global --get-all --fixed-value include.path '${path}' >/dev/null || ` +
|
|
419
|
+
`/usr/bin/git config --global --add include.path '${path}'`
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** The GitHub sign-in's git configuration: `gh` is git's credential helper
|
|
424
|
+
* for GitHub, and git reaches GitHub over HTTPS. */
|
|
425
|
+
const GITHUB_GITCONFIG = [
|
|
426
|
+
'[credential "https://github.com"]',
|
|
427
|
+
"\thelper =",
|
|
428
|
+
"\thelper = !/usr/bin/gh auth git-credential",
|
|
429
|
+
'[credential "https://gist.github.com"]',
|
|
430
|
+
"\thelper =",
|
|
431
|
+
"\thelper = !/usr/bin/gh auth git-credential",
|
|
432
|
+
'[url "https://github.com/"]',
|
|
433
|
+
"\tinsteadOf = git@github.com:",
|
|
434
|
+
"\tinsteadOf = ssh://git@github.com/",
|
|
435
|
+
"",
|
|
436
|
+
].join("\n");
|
|
437
|
+
|
|
438
|
+
/** ADR-029: commits and tags signed with the key, verified with its signers file. */
|
|
439
|
+
const SSH_SIGNING_GITCONFIG = [
|
|
440
|
+
"[gpg]",
|
|
441
|
+
"\tformat = ssh",
|
|
442
|
+
'[gpg "ssh"]',
|
|
443
|
+
`\tallowedSignersFile = ${TASK_SSH_ALLOWED_SIGNERS_PATH}`,
|
|
444
|
+
"[user]",
|
|
445
|
+
`\tsigningkey = ${TASK_SSH_PUBLIC_KEY_PATH}`,
|
|
446
|
+
"[commit]",
|
|
447
|
+
"\tgpgsign = true",
|
|
448
|
+
"[tag]",
|
|
449
|
+
"\tgpgsign = true",
|
|
450
|
+
"",
|
|
451
|
+
].join("\n");
|
|
452
|
+
|
|
453
|
+
/** SSH as git's transport to GitHub. */
|
|
454
|
+
const SSH_TRANSPORT_GITCONFIG = [
|
|
455
|
+
'[url "git@github.com:"]',
|
|
456
|
+
"\tinsteadOf = https://github.com/",
|
|
457
|
+
"[core]",
|
|
458
|
+
"\tsshCommand = ssh -o StrictHostKeyChecking=accept-new",
|
|
459
|
+
"",
|
|
460
|
+
].join("\n");
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* `gh` logs in, as root, into a configuration directory of its own in
|
|
464
|
+
* memory (`/dev/shm`, root-only, removed on the way out), out of the task's
|
|
465
|
+
* reach: nothing the task does to its own tree while `gh` checks the token
|
|
466
|
+
* over the network can carry the token's write anywhere. The hosts file `gh`
|
|
467
|
+
* made is printed for the writer, which puts it in place through the
|
|
468
|
+
* no-follow helper.
|
|
469
|
+
*/
|
|
470
|
+
const GH_LOGIN_SCRIPT = [
|
|
471
|
+
"set -eu",
|
|
472
|
+
"umask 077",
|
|
473
|
+
"dir=$(/usr/bin/mktemp -d /dev/shm/uai-gh.XXXXXXXX)",
|
|
474
|
+
"trap '/bin/rm -rf \"$dir\"' EXIT",
|
|
475
|
+
'GH_CONFIG_DIR="$dir" /usr/bin/gh auth login --with-token --insecure-storage --hostname github.com --git-protocol https >/dev/null',
|
|
476
|
+
'/bin/cat "$dir/hosts.yml"',
|
|
477
|
+
].join("\n");
|
|
478
|
+
|
|
479
|
+
function inventory(environment: Environment, assignment: TaskAssignment, lane: "github" | "ssh", credentialId: string, paths: readonly string[]): void {
|
|
480
|
+
for (const path of paths) {
|
|
481
|
+
rememberCredentialCleanupPath({
|
|
482
|
+
taskId: assignment.taskId,
|
|
483
|
+
runEpoch: assignment.runEpoch,
|
|
484
|
+
runtime: environment.descriptor.locator,
|
|
485
|
+
lane,
|
|
486
|
+
path,
|
|
487
|
+
credentialId,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* The owner's GitHub sign-in into the task: `gh` logs in with the token on
|
|
494
|
+
* stdin, file-backed (`--insecure-storage`: never a keyring the inventory
|
|
495
|
+
* does not name), in a directory of its own out of the task's reach, and
|
|
496
|
+
* its hosts file goes into the task through the no-follow helper. The
|
|
497
|
+
* GitHub git configuration goes in an included file: `gh` as git's
|
|
498
|
+
* credential helper, HTTPS as its transport, and SSH transport no longer
|
|
499
|
+
* selected. Every path is inventoried first. False when the lease names no
|
|
500
|
+
* GitHub sign-in; throws when it names one it could not read.
|
|
501
|
+
*/
|
|
502
|
+
export function writeTaskGithub(environment: Environment, assignment: TaskAssignment): Promise<boolean> {
|
|
503
|
+
assertTaskRuntime(environment, assignment.taskId);
|
|
504
|
+
return enqueueWrite(assignment, async (_admit, current) => {
|
|
505
|
+
const slot = current().github;
|
|
506
|
+
if (slot.kind === "absent") return false;
|
|
507
|
+
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 revision is still the task's.
|
|
510
|
+
const admit = (): void => {
|
|
511
|
+
const live = current().github;
|
|
512
|
+
if (live.kind !== "present" || live.value.revision !== revision) throw new PersonCredentialWriteRefused();
|
|
513
|
+
};
|
|
514
|
+
inventory(environment, assignment, "github", credentialId, withStaged(TASK_GH_HOSTS_PATH, TASK_GH_GITCONFIG_PATH));
|
|
515
|
+
const hosts = await run(environment, ["/bin/sh", "-c", GH_LOGIN_SCRIPT], `${token}\n`, "gh auth login", admit, "root");
|
|
516
|
+
if (hosts.length === 0) throw new Error("gh auth login failed in the task (no hosts file)");
|
|
517
|
+
await run(environment, writeFileArgv(TASK_GH_HOSTS_PATH, "600"), Buffer.from(hosts).toString("utf8"), "GitHub sign-in", admit);
|
|
518
|
+
await run(environment, writeFileArgv(TASK_GH_GITCONFIG_PATH, "644"), GITHUB_GITCONFIG, "GitHub git configuration", admit);
|
|
519
|
+
await run(
|
|
520
|
+
environment,
|
|
521
|
+
[
|
|
522
|
+
"/bin/sh",
|
|
523
|
+
"-c",
|
|
524
|
+
`set -eu\n/bin/rm -f -- '${TASK_SSH_TRANSPORT_GITCONFIG_PATH}'\n${includeOnce(TASK_GH_GITCONFIG_PATH)}`,
|
|
525
|
+
],
|
|
526
|
+
null,
|
|
527
|
+
"git HTTPS transport setup",
|
|
528
|
+
admit,
|
|
529
|
+
);
|
|
530
|
+
return true;
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* The owner's SSH key into the task: the private half, owner-only, its
|
|
536
|
+
* public half and the signers file git verifies with, then the signing
|
|
537
|
+
* configuration in an included file (ADR-029). Without a GitHub sign-in in
|
|
538
|
+
* the task at that moment SSH is also git's transport to GitHub, in an
|
|
539
|
+
* included file of its own; with one, any such file goes. After the GitHub
|
|
540
|
+
* sign-in leaves a task that keeps its key, writing the key again selects
|
|
541
|
+
* SSH transport. Every path, intermediates included, is inventoried first.
|
|
542
|
+
* False when the lease names no SSH key; throws when it names one it could
|
|
543
|
+
* not read.
|
|
544
|
+
*/
|
|
545
|
+
export function writeTaskSshKey(environment: Environment, assignment: TaskAssignment): Promise<boolean> {
|
|
546
|
+
assertTaskRuntime(environment, assignment.taskId);
|
|
547
|
+
return enqueueWrite(assignment, async (_admit, current) => {
|
|
548
|
+
const slot = current().ssh;
|
|
549
|
+
if (slot.kind === "absent") return false;
|
|
550
|
+
if (slot.kind === "unavailable") throw new CredentialLeaseUnavailableError("the SSH key could not be read");
|
|
551
|
+
const { credentialId, revision, privateKey, publicKey } = slot.value;
|
|
552
|
+
const admit = (): void => {
|
|
553
|
+
const live = current().ssh;
|
|
554
|
+
if (live.kind !== "present" || live.value.revision !== revision) throw new PersonCredentialWriteRefused();
|
|
555
|
+
};
|
|
556
|
+
inventory(
|
|
557
|
+
environment,
|
|
558
|
+
assignment,
|
|
559
|
+
"ssh",
|
|
560
|
+
credentialId,
|
|
561
|
+
withStaged(
|
|
562
|
+
TASK_SSH_KEY_PATH,
|
|
563
|
+
TASK_SSH_PUBLIC_KEY_PATH,
|
|
564
|
+
TASK_SSH_ALLOWED_SIGNERS_PATH,
|
|
565
|
+
TASK_SSH_SIGNING_GITCONFIG_PATH,
|
|
566
|
+
TASK_SSH_TRANSPORT_GITCONFIG_PATH,
|
|
567
|
+
),
|
|
568
|
+
);
|
|
569
|
+
const write = (path: string, mode: "600" | "644", content: string, what: string, admitted = admit) =>
|
|
570
|
+
run(environment, writeFileArgv(path, mode), content.endsWith("\n") ? content : `${content}\n`, what, admitted);
|
|
571
|
+
await write(TASK_SSH_KEY_PATH, "600", privateKey, `writing ${TASK_SSH_KEY_PATH}`);
|
|
572
|
+
await write(TASK_SSH_PUBLIC_KEY_PATH, "644", publicKey, `writing ${TASK_SSH_PUBLIC_KEY_PATH}`);
|
|
573
|
+
await write(TASK_SSH_ALLOWED_SIGNERS_PATH, "644", `* ${publicKey.trim()}`, `writing ${TASK_SSH_ALLOWED_SIGNERS_PATH}`);
|
|
574
|
+
await write(TASK_SSH_SIGNING_GITCONFIG_PATH, "644", SSH_SIGNING_GITCONFIG, "SSH signing configuration");
|
|
575
|
+
await run(environment, ["/bin/sh", "-c", includeOnce(TASK_SSH_SIGNING_GITCONFIG_PATH)], null, "SSH signing setup", admit);
|
|
576
|
+
// The transport follows the task's GitHub sign-in as it is now, not as
|
|
577
|
+
// it was when this write began.
|
|
578
|
+
if (current().github.kind === "absent") {
|
|
579
|
+
const sshTransport = (): void => {
|
|
580
|
+
admit();
|
|
581
|
+
if (current().github.kind !== "absent") throw new PersonCredentialWriteRefused();
|
|
582
|
+
};
|
|
583
|
+
await write(TASK_SSH_TRANSPORT_GITCONFIG_PATH, "644", SSH_TRANSPORT_GITCONFIG, "SSH transport configuration", sshTransport);
|
|
584
|
+
await run(environment, ["/bin/sh", "-c", includeOnce(TASK_SSH_TRANSPORT_GITCONFIG_PATH)], null, "SSH transport setup", sshTransport);
|
|
585
|
+
} else {
|
|
586
|
+
await run(environment, ["/bin/rm", "-f", "--", TASK_SSH_TRANSPORT_GITCONFIG_PATH], null, "SSH transport removal", admit);
|
|
587
|
+
}
|
|
588
|
+
return true;
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function sameAssignment(a: TaskAssignment, b: TaskAssignment): boolean {
|
|
593
|
+
return a.taskId === b.taskId && a.runEpoch === b.runEpoch;
|
|
594
|
+
}
|