@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.
@@ -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, the same credential revision, not sealed. A cleanup seals
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
- interface TaskPersonCredentials {
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: { github: Slot<LeasedGithub>; ssh: Slot<LeasedSshKey> };
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<T extends GithubLeasedValue | SshLeasedValue, V extends { credentialId: string; revision: string }>(
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 (credential.availability !== "available" || !credential.material) return { kind: "unavailable", ...identity };
119
- return { kind: "present", value: { ...identity, ...value(credential.material) } as V };
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): { github: Slot<LeasedGithub>; ssh: Slot<LeasedSshKey> } {
147
+ function slotsOf(view: TaskCredentialView): PersonCredentialSlots {
123
148
  return {
124
- github: slotOf<GithubLeasedValue, LeasedGithub>(view.github, (material) => ({ token: material.token })),
125
- ssh: slotOf<SshLeasedValue, LeasedSshKey>(view.ssh, (material) => ({
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
- /** The same credential at the same revision, both readable. */
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
- return (
135
- a.kind === "present" &&
136
- b.kind === "present" &&
137
- a.value.credentialId === b.value.credentialId &&
138
- a.value.revision === b.value.revision
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, the revision or the gate moved. */
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 revision is still the task's.
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 (live.kind !== "present" || live.value.revision !== revision) throw new PersonCredentialWriteRefused();
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 (live.kind !== "present" || live.value.revision !== revision) throw new PersonCredentialWriteRefused();
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
- return [...served.values()].filter(isLeased).map((connection) => ({ id: connection.credentialId, slug: connection.slug }));
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 {