@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.
Files changed (107) hide show
  1. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  2. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  3. package/db/migrations/meta/_journal.json +14 -0
  4. package/db/schema.ts +188 -0
  5. package/lib/agent-cli.ts +11 -13
  6. package/lib/agent.ts +177 -7
  7. package/lib/agents/durable-proc.ts +56 -44
  8. package/lib/agents/registry.ts +19 -3
  9. package/lib/agents/session-state.ts +23 -10
  10. package/lib/agents/transport.ts +101 -69
  11. package/lib/apple-one-off.ts +277 -0
  12. package/lib/apple-runtime-recycle.ts +4 -3
  13. package/lib/attachments.ts +19 -17
  14. package/lib/capture-flow.ts +319 -0
  15. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  16. package/lib/cloud-credential-host.ts +512 -0
  17. package/lib/cloud-credential-leases.ts +2544 -0
  18. package/lib/cloud-credential-runtime.ts +1035 -0
  19. package/lib/codex-auth.ts +21 -1
  20. package/lib/container-runtime.ts +76 -0
  21. package/lib/credential-cleanup-inventory.ts +405 -0
  22. package/lib/credential-consumers/engine-anchor.ts +1876 -0
  23. package/lib/credential-consumers/engines.ts +773 -0
  24. package/lib/credential-consumers/github.ts +594 -0
  25. package/lib/credential-consumers/mcp.ts +384 -0
  26. package/lib/credential-consumers/task-files.ts +127 -0
  27. package/lib/credential-store-pickup.ts +15 -2
  28. package/lib/credential-store.ts +361 -33
  29. package/lib/credentials-activation-authority.ts +546 -0
  30. package/lib/credentials-purge.ts +1006 -0
  31. package/lib/db.ts +11 -0
  32. package/lib/docker-exec.ts +3 -1
  33. package/lib/engine-accounts.ts +42 -32
  34. package/lib/engine-connect.ts +47 -2
  35. package/lib/engine-credential-env.ts +50 -0
  36. package/lib/engine-login.ts +83 -1
  37. package/lib/env-file.ts +62 -8
  38. package/lib/env.ts +5 -0
  39. package/lib/fs-aliases.ts +49 -0
  40. package/lib/git-clone-credentials.ts +334 -0
  41. package/lib/git-diff.ts +219 -177
  42. package/lib/git-identity.ts +59 -14
  43. package/lib/github-origin.ts +28 -0
  44. package/lib/github-tokens.ts +56 -21
  45. package/lib/host-env.ts +17 -4
  46. package/lib/launch-mode.ts +25 -0
  47. package/lib/legacy-runtime-retirement.ts +388 -0
  48. package/lib/machine-exec.ts +14 -3
  49. package/lib/machine-provider-aws.ts +50 -8
  50. package/lib/machine-provider-local.ts +4 -0
  51. package/lib/machine-provider.ts +4 -0
  52. package/lib/managed-operation-drain.ts +8 -0
  53. package/lib/managed-runtime.ts +371 -7
  54. package/lib/mcp-capture.ts +860 -0
  55. package/lib/mcp-connections.ts +8 -1
  56. package/lib/mcp-gateway.ts +168 -10
  57. package/lib/obs.ts +2 -6
  58. package/lib/orchestrator.ts +1847 -236
  59. package/lib/person-capture.ts +327 -0
  60. package/lib/project-env-legacy-tags.ts +130 -0
  61. package/lib/project-env-task-replica.ts +1752 -0
  62. package/lib/runner-boot.ts +10 -0
  63. package/lib/runtime-activation-tail.ts +50 -3
  64. package/lib/runtime-archive.ts +68 -1
  65. package/lib/runtime-reconnect.ts +86 -0
  66. package/lib/runtime-state.ts +429 -13
  67. package/lib/safe-http.ts +364 -0
  68. package/lib/secrets-proxy/task-trust.ts +34 -3
  69. package/lib/sidecar-resume.ts +18 -5
  70. package/lib/skills.ts +47 -8
  71. package/lib/standard-image.ts +157 -103
  72. package/lib/stopped-task-status-queue.ts +68 -10
  73. package/lib/task-credential-cleanup.ts +525 -0
  74. package/lib/task-diff-sandbox.ts +464 -0
  75. package/lib/task-diff.ts +66 -163
  76. package/lib/task-env-source.ts +68 -14
  77. package/lib/task-environment/anchor.ts +31 -0
  78. package/lib/task-environment/apple-container.ts +69 -28
  79. package/lib/task-environment/docker.ts +101 -20
  80. package/lib/task-environment/machine-environment.ts +61 -0
  81. package/lib/task-environment/machine-task-up.ts +459 -96
  82. package/lib/task-environment/machine.ts +101 -11
  83. package/lib/task-environment/self.ts +108 -50
  84. package/lib/task-environment/types.ts +89 -5
  85. package/lib/task-environment/workspace-files.ts +623 -25
  86. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  87. package/lib/task-gateway-recovery.ts +4 -1
  88. package/lib/task-gateway-state.ts +15 -0
  89. package/lib/task-machine-swap-safety.ts +729 -0
  90. package/lib/token-broker-adopt.ts +344 -0
  91. package/lib/token-broker-lambda.ts +56 -2
  92. package/lib/token-broker-local.ts +28 -4
  93. package/lib/token-broker-revision.ts +310 -0
  94. package/lib/token-broker-ssm.ts +368 -28
  95. package/lib/token-lanes/openai-oauth.ts +7 -3
  96. package/lib/token-lanes/xai-oauth.ts +7 -3
  97. package/lib/transcript.ts +9 -5
  98. package/package.json +6 -2
  99. package/scripts/agent/task-up.sh +242 -100
  100. package/src/cloud-credentials.ts +2399 -0
  101. package/src/event-outbox.ts +8 -6
  102. package/src/index.ts +1386 -875
  103. package/src/main.ts +1062 -140
  104. package/src/outbound-frame.ts +31 -0
  105. package/src/project-env-policy.ts +125 -10
  106. package/src/protocol.ts +699 -23
  107. 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
+ }