@runuai/host 0.9.11 → 0.9.13

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.
@@ -7,7 +7,7 @@
7
7
  * exchange is a host→cloud HTTP POST to /api/github/oauth/exchange.
8
8
  */
9
9
 
10
- import { eq } from "drizzle-orm";
10
+ import { and, eq } from "drizzle-orm";
11
11
 
12
12
  import { getDb, schema } from "./db";
13
13
  import { sealAesGcm, openAesGcm } from "./secrets";
@@ -16,7 +16,12 @@ import {
16
16
  configureTaskSshTransport,
17
17
  ensureTaskSshIdentity,
18
18
  } from "./git-identity";
19
- import type { CloudToHost } from "../src/protocol";
19
+ import {
20
+ MAX_GITHUB_INSTALLATIONS,
21
+ MAX_GITHUB_REPOSITORIES_PER_INSTALLATION,
22
+ type CloudToHost,
23
+ type GitHubRepositoryAccessErrorCode,
24
+ } from "../src/protocol";
20
25
 
21
26
  const REFRESH_LEAD_MS = 5 * 60 * 1000; // refresh 5 min before expiry
22
27
  const EXEC_TIMEOUT_MS = 10_000;
@@ -145,6 +150,102 @@ export function runGithubConnectionTransition<T>(
145
150
  return result;
146
151
  }
147
152
 
153
+ /**
154
+ * Persist a monotonic credential mutation fence before changing the token row.
155
+ * The generation survives disconnect because it lives in a separate table, so
156
+ * a delayed older connect cannot recreate a credential after a newer clear.
157
+ */
158
+ export type GitHubCredentialMutation = "set" | "clear";
159
+
160
+ export interface GitHubCredentialGenerationClaim {
161
+ generation: number;
162
+ replay: boolean;
163
+ }
164
+
165
+ export function claimGithubCredentialGeneration(
166
+ userId: string,
167
+ requestedGeneration: number | undefined,
168
+ mutation: GitHubCredentialMutation,
169
+ ): GitHubCredentialGenerationClaim | null {
170
+ if (
171
+ requestedGeneration !== undefined &&
172
+ (!Number.isSafeInteger(requestedGeneration) || requestedGeneration < 0)
173
+ ) {
174
+ return null;
175
+ }
176
+ return getDb().transaction((tx) => {
177
+ const current = tx
178
+ .select({
179
+ generation: schema.githubCredentialGenerations.generation,
180
+ mutation: schema.githubCredentialGenerations.mutation,
181
+ })
182
+ .from(schema.githubCredentialGenerations)
183
+ .where(eq(schema.githubCredentialGenerations.userId, userId))
184
+ .get();
185
+ // Generationless frames belong permanently to the legacy generation-zero
186
+ // namespace. Never derive a newer value from local state: a delayed old
187
+ // cloud frame must not outrank an explicit generation from a newer cloud.
188
+ // Once a clear has closed generation zero, the normal clear->set replay
189
+ // rule below deliberately requires a cloud upgrade before reconnecting.
190
+ const generation = requestedGeneration ?? 0;
191
+ if (!Number.isSafeInteger(generation) || generation < 0) return null;
192
+ if (current && current.generation > generation) return null;
193
+ if (current && current.generation === generation) {
194
+ if (current.mutation === mutation) {
195
+ return { generation, replay: true };
196
+ }
197
+ // Clearing the exact generation that was just stored is the safe
198
+ // compensation path when the cloud mirror is superseded between host
199
+ // storage and DB attachment. The reverse transition would resurrect a
200
+ // cleared credential and is always rejected.
201
+ if (current.mutation === "set" && mutation === "clear") {
202
+ tx.update(schema.githubCredentialGenerations)
203
+ .set({ mutation: "clear", updatedAt: Date.now() })
204
+ .where(eq(schema.githubCredentialGenerations.userId, userId))
205
+ .run();
206
+ return { generation, replay: false };
207
+ }
208
+ return null;
209
+ }
210
+ const values = { userId, generation, mutation, updatedAt: Date.now() };
211
+ tx.insert(schema.githubCredentialGenerations)
212
+ .values(values)
213
+ .onConflictDoUpdate({
214
+ target: schema.githubCredentialGenerations.userId,
215
+ set: { generation, mutation, updatedAt: values.updatedAt },
216
+ })
217
+ .run();
218
+ return { generation, replay: false };
219
+ });
220
+ }
221
+
222
+ export function githubCredentialGenerationMatches(
223
+ userId: string,
224
+ generation: number,
225
+ ): boolean {
226
+ if (!Number.isSafeInteger(generation) || generation < 0) return false;
227
+ return getDb().transaction((tx) => {
228
+ const fence = tx
229
+ .select({
230
+ generation: schema.githubCredentialGenerations.generation,
231
+ mutation: schema.githubCredentialGenerations.mutation,
232
+ })
233
+ .from(schema.githubCredentialGenerations)
234
+ .where(eq(schema.githubCredentialGenerations.userId, userId))
235
+ .get();
236
+ const token = tx
237
+ .select({ generation: schema.githubTokens.generation })
238
+ .from(schema.githubTokens)
239
+ .where(eq(schema.githubTokens.userId, userId))
240
+ .get();
241
+ return (
242
+ fence?.mutation === "set" &&
243
+ fence.generation === generation &&
244
+ token?.generation === generation
245
+ );
246
+ });
247
+ }
248
+
148
249
  // --- token store ------------------------------------------------------------
149
250
 
150
251
  /**
@@ -157,12 +258,13 @@ export interface ConnectSetDeps {
157
258
  invalidateCredentials?: (userId: string) => Promise<void>;
158
259
  readStored?: (
159
260
  userId: string,
160
- ) => { token: string; kind: string } | null;
261
+ ) => StoredGitHubGrant | null;
161
262
  deleteLocal?: (userId: string) => void;
162
263
  storeLocal?: (
163
264
  userId: string,
164
265
  fields: {
165
266
  installationId: number;
267
+ generation: number;
166
268
  refreshTokenCt: Buffer;
167
269
  refreshTokenNonce: Buffer;
168
270
  refreshTokenExpiresAt: number | null;
@@ -183,15 +285,17 @@ export async function onConnectSet(
183
285
  ): Promise<{ ok: boolean; error?: string }> {
184
286
  let oldGrantRemoved = false;
185
287
  let replacementStored = false;
186
- let priorGrant: { token: string; kind: string } | null = null;
288
+ let priorGrant: StoredGitHubGrant | null = null;
187
289
  try {
188
290
  const token = frame.accessToken ?? frame.refreshToken;
189
291
  if (!token) return { ok: false, error: "gh.connect.set carried no token" };
190
292
  const kind = frame.accessToken ? "access" : "refresh";
293
+ const credentialGeneration = frame.generation ?? 0;
191
294
  const sealed = (deps.seal ?? sealAesGcm)(token);
192
295
  const now = Date.now();
193
296
  const fields = {
194
297
  installationId: frame.installationId,
298
+ generation: credentialGeneration,
195
299
  refreshTokenCt: sealed.ct,
196
300
  refreshTokenNonce: sealed.nonce,
197
301
  refreshTokenExpiresAt: frame.refreshTokenExpiresAt ?? null,
@@ -211,6 +315,22 @@ export async function onConnectSet(
211
315
  );
212
316
  }
213
317
 
318
+ // A retry of the exact same operation is safe and still reconciles tasks;
319
+ // reusing one generation for different credential material is not. Without
320
+ // this check a replayed opId/generation could replace the token while the
321
+ // cloud continued to trust the original binding generation.
322
+ if (
323
+ priorGrant?.generation === credentialGeneration &&
324
+ (priorGrant.token !== token ||
325
+ priorGrant.kind !== kind ||
326
+ priorGrant.installationId !== frame.installationId)
327
+ ) {
328
+ return {
329
+ ok: false,
330
+ error: "conflicting GitHub credential replay for one generation",
331
+ };
332
+ }
333
+
214
334
  // Remove the old grant before fencing host-side Git. A task that begins
215
335
  // while account switching is in progress must not mint or seed the old
216
336
  // user's credential. The replacement is already sealed above, so failures
@@ -225,7 +345,7 @@ export async function onConnectSet(
225
345
  priorGrant?.kind === "access" &&
226
346
  priorGrant.token !== token
227
347
  ) {
228
- startBestEffortRevoke(
348
+ await bestEffortRevoke(
229
349
  frame.userId,
230
350
  priorGrant.token,
231
351
  deps.revoke ?? revokeAtGitHub,
@@ -291,6 +411,7 @@ function storeTokenRow(
291
411
  userId: string,
292
412
  fields: {
293
413
  installationId: number;
414
+ generation: number;
294
415
  refreshTokenCt: Buffer;
295
416
  refreshTokenNonce: Buffer;
296
417
  refreshTokenExpiresAt: number | null;
@@ -298,26 +419,35 @@ function storeTokenRow(
298
419
  updatedAt: number;
299
420
  },
300
421
  ): void {
301
- getDb()
302
- .insert(schema.githubTokens)
303
- .values({ userId, ...fields })
304
- .onConflictDoUpdate({ target: schema.githubTokens.userId, set: fields })
305
- .run();
422
+ getDb().transaction((tx) => {
423
+ const fence = tx
424
+ .select({
425
+ generation: schema.githubCredentialGenerations.generation,
426
+ mutation: schema.githubCredentialGenerations.mutation,
427
+ })
428
+ .from(schema.githubCredentialGenerations)
429
+ .where(eq(schema.githubCredentialGenerations.userId, userId))
430
+ .get();
431
+ if (
432
+ fence?.mutation !== "set" ||
433
+ fence.generation !== fields.generation
434
+ ) {
435
+ throw new Error("GitHub credential generation changed before storage");
436
+ }
437
+ tx.insert(schema.githubTokens)
438
+ .values({ userId, ...fields })
439
+ .onConflictDoUpdate({ target: schema.githubTokens.userId, set: fields })
440
+ .run();
441
+ });
306
442
  }
307
443
 
308
- function startBestEffortRevoke(
444
+ async function bestEffortRevoke(
309
445
  userId: string,
310
446
  token: string,
311
447
  revoke: (token: string) => Promise<void>,
312
- ): void {
448
+ ): Promise<void> {
313
449
  try {
314
- void revoke(token).catch((err) =>
315
- console.warn(
316
- `[github] user ${userId}: token revoke failed after local removal: ${
317
- err instanceof Error ? err.message : err
318
- }`,
319
- ),
320
- );
450
+ await revoke(token);
321
451
  } catch (err) {
322
452
  console.warn(
323
453
  `[github] user ${userId}: token revoke failed after local removal: ${
@@ -329,11 +459,11 @@ function startBestEffortRevoke(
329
459
 
330
460
  /**
331
461
  * Disconnect GitHub on this host (gh.connect.clear handler). Deletes the token
332
- * locally FIRST (so capabilities re-advertise immediately and the UI reflects
333
- * removal without waiting on the network), starts best-effort revocation, then
334
- * waits only for every live container to drop the credential and select its
335
- * disconnected transport. The bridge ack is therefore truthful without being
336
- * gated on GitHub's revoke round-trip.
462
+ * locally FIRST (so capability readers stop trusting it immediately), waits
463
+ * for best-effort revocation, then waits for every live container to drop the
464
+ * credential and select its disconnected transport. The surrounding
465
+ * generation transition is serialized so a delayed revoke cannot target a
466
+ * subsequently stored replacement.
337
467
  *
338
468
  * ADR-033: only a non-expiring ACCESS token can be revoked by token
339
469
  * (`DELETE /applications/{client_id}/token` matches access tokens only — a
@@ -344,7 +474,7 @@ function startBestEffortRevoke(
344
474
  export interface ConnectClearDeps {
345
475
  readStored?: (
346
476
  userId: string,
347
- ) => { token: string; kind: string } | null;
477
+ ) => StoredGitHubGrant | null;
348
478
  deleteLocal?: (userId: string) => void;
349
479
  activeTaskIds?: (userId: string) => string[];
350
480
  reconcile?: (taskId: string, userId: string) => Promise<boolean>;
@@ -356,7 +486,7 @@ export async function onConnectClear(
356
486
  userId: string,
357
487
  deps: ConnectClearDeps = {},
358
488
  ): Promise<void> {
359
- let stored: { token: string; kind: string } | null = null;
489
+ let stored: StoredGitHubGrant | null = null;
360
490
  try {
361
491
  stored = (deps.readStored ?? readStoredToken)(userId);
362
492
  } catch (err) {
@@ -374,11 +504,11 @@ export async function onConnectClear(
374
504
  // even when token decryption or GitHub's revoke endpoint is unavailable.
375
505
  (deps.deleteLocal ?? deleteToken)(userId);
376
506
 
377
- // Start remote revocation immediately but do not put it on the ack path.
378
- // revokeAtGitHub catches its own failures; the catch also makes injected
379
- // test/alternate implementations safely fire-and-forget.
507
+ // Await best-effort remote revocation while the caller still owns the
508
+ // per-user transition lock. revokeAtGitHub catches its own failures; the
509
+ // wrapper also contains failures from injected test/alternate implementations.
380
510
  if (stored?.kind === "access") {
381
- startBestEffortRevoke(
511
+ await bestEffortRevoke(
382
512
  userId,
383
513
  stored.token,
384
514
  deps.revoke ?? revokeAtGitHub,
@@ -411,10 +541,50 @@ export async function onConnectClear(
411
541
  if (failed) throw failed.reason;
412
542
  }
413
543
 
414
- /** Decrypt the user's stored token + its kind, or null if none. */
544
+ /** Fail-closed rollback for a generation that was claimed for set but could
545
+ * not be fully reconciled. The tombstone transition happens before cleanup is
546
+ * awaited, so task/token readers stop trusting the credential immediately. */
547
+ export async function rollbackClaimedGitHubCredentialSet(
548
+ userId: string,
549
+ generation: number,
550
+ deps: ConnectClearDeps = {},
551
+ ): Promise<boolean> {
552
+ const rollback = claimGithubCredentialGeneration(
553
+ userId,
554
+ generation,
555
+ "clear",
556
+ );
557
+ if (!rollback) return false;
558
+ try {
559
+ await onConnectClear(userId, deps);
560
+ } catch (err) {
561
+ console.warn(
562
+ `[github] user ${userId}: claimed set rollback cleanup failed: ${
563
+ err instanceof Error ? err.message : err
564
+ }`,
565
+ );
566
+ }
567
+ return true;
568
+ }
569
+
570
+ interface StoredGitHubGrant {
571
+ token: string;
572
+ kind: string;
573
+ installationId?: number;
574
+ generation?: number;
575
+ }
576
+
577
+ type PersistedGitHubGrant = StoredGitHubGrant & {
578
+ installationId: number;
579
+ generation: number;
580
+ };
581
+
582
+ /** Decrypt any stored token, including an inert row from an older generation.
583
+ * Transition handlers need the old ciphertext solely so they can revoke it;
584
+ * task and verification readers use `readCurrentStoredToken` below. */
415
585
  function readStoredToken(
416
586
  userId: string,
417
- ): { token: string; kind: string } | null {
587
+ ): PersistedGitHubGrant | null {
418
588
  const row = getDb()
419
589
  .select()
420
590
  .from(schema.githubTokens)
@@ -425,7 +595,20 @@ function readStoredToken(
425
595
  Buffer.from(row.refreshTokenCt as Uint8Array),
426
596
  Buffer.from(row.refreshTokenNonce as Uint8Array),
427
597
  );
428
- return { token, kind: row.kind };
598
+ return {
599
+ token,
600
+ kind: row.kind,
601
+ installationId: row.installationId,
602
+ generation: row.generation,
603
+ };
604
+ }
605
+
606
+ function readCurrentStoredToken(userId: string): PersistedGitHubGrant | null {
607
+ const stored = readStoredToken(userId);
608
+ return stored &&
609
+ githubCredentialGenerationMatches(userId, stored.generation)
610
+ ? stored
611
+ : null;
429
612
  }
430
613
 
431
614
  export function deleteToken(userId: string): void {
@@ -438,13 +621,7 @@ export function deleteToken(userId: string): void {
438
621
  }
439
622
 
440
623
  export function hasToken(userId: string): boolean {
441
- return (
442
- getDb()
443
- .select({ userId: schema.githubTokens.userId })
444
- .from(schema.githubTokens)
445
- .where(eq(schema.githubTokens.userId, userId))
446
- .get() != null
447
- );
624
+ return readCurrentStoredToken(userId) !== null;
448
625
  }
449
626
 
450
627
  /** All cloud user ids with a GitHub token on this host (for capabilities). */
@@ -452,6 +629,20 @@ export function connectedUserIds(): string[] {
452
629
  return getDb()
453
630
  .select({ userId: schema.githubTokens.userId })
454
631
  .from(schema.githubTokens)
632
+ .innerJoin(
633
+ schema.githubCredentialGenerations,
634
+ and(
635
+ eq(
636
+ schema.githubCredentialGenerations.userId,
637
+ schema.githubTokens.userId,
638
+ ),
639
+ eq(
640
+ schema.githubCredentialGenerations.generation,
641
+ schema.githubTokens.generation,
642
+ ),
643
+ ),
644
+ )
645
+ .where(eq(schema.githubCredentialGenerations.mutation, "set"))
455
646
  .all()
456
647
  .map((r) => r.userId);
457
648
  }
@@ -617,6 +808,326 @@ const accessCache = new Map<
617
808
  { accessToken: string; expiresAt: number }
618
809
  >();
619
810
 
811
+ const INSTALLATION_PAGE_SIZE = 100;
812
+ const REPOSITORY_PAGE_SIZE = 100;
813
+ const REPOSITORY_PAGE_CAP = Math.ceil(
814
+ MAX_GITHUB_REPOSITORIES_PER_INSTALLATION / REPOSITORY_PAGE_SIZE,
815
+ );
816
+ const GITHUB_LIST_TIMEOUT_MS = 10_000;
817
+ // Bound only the GitHub listing phase. The installation bridge allows 60s so a
818
+ // legacy refresh-token exchange (up to 30s) can precede this 25s window; the
819
+ // repository bridge uses 30s because the complete installation call has just
820
+ // warmed that user's access-token cache. Every REST page gets the smaller of
821
+ // its normal timeout and the remaining listing budget.
822
+ const GITHUB_LIST_OPERATION_TIMEOUT_MS = 25_000;
823
+
824
+ function githubListSignal(deadline: number): AbortSignal {
825
+ const remaining = deadline - Date.now();
826
+ if (remaining <= 0) throw new Error("github list operation timed out");
827
+ return AbortSignal.timeout(Math.min(GITHUB_LIST_TIMEOUT_MS, remaining));
828
+ }
829
+
830
+ export class GitHubRepositoryAccessError extends Error {
831
+ constructor(
832
+ readonly code: GitHubRepositoryAccessErrorCode,
833
+ message: string,
834
+ ) {
835
+ super(message);
836
+ this.name = "GitHubRepositoryAccessError";
837
+ }
838
+ }
839
+
840
+ interface GitHubListDeps {
841
+ requestAccessToken?: typeof requestAccessToken;
842
+ fetch?: typeof fetch;
843
+ credentialGenerationMatches?: typeof githubCredentialGenerationMatches;
844
+ }
845
+
846
+ /**
847
+ * GitHub REST origin. Mirrors the cloud's lib/github/origins.ts contract,
848
+ * including its most important property: production ignores the override
849
+ * entirely, so a staged URL can never be pointed at a real user's token.
850
+ */
851
+ function githubApiOrigin(): string {
852
+ const fallback = "https://api.github.com";
853
+ if (process.env.NODE_ENV === "production") return fallback;
854
+ const configured = process.env.UAI_GITHUB_API_URL?.trim();
855
+ if (!configured) return fallback;
856
+ const url = new URL(configured);
857
+ if (
858
+ (url.protocol !== "http:" && url.protocol !== "https:") ||
859
+ url.username ||
860
+ url.password
861
+ ) {
862
+ throw new Error("UAI_GITHUB_API_URL must be an HTTP(S) origin");
863
+ }
864
+ return url.origin;
865
+ }
866
+
867
+ /**
868
+ * Every installation GitHub currently lists for this user, read with the token
869
+ * this host already holds — the host-side half of `gh.installations.list`.
870
+ *
871
+ * This is what lets a misbound installation row be pruned without the user
872
+ * reconnecting: the cloud mirror predates `(user_id, installation_id)` keying
873
+ * and cannot be trusted, but the host can ask GitHub on the user's behalf at
874
+ * any time, with no browser round trip.
875
+ *
876
+ * THROWS rather than returning a partial list. The caller prunes bindings that
877
+ * are absent from the result, so a truncated or failed read would delete
878
+ * legitimate grants — absence is only meaningful in a list we fully retrieved.
879
+ */
880
+ export async function listUserInstallationIds(
881
+ userId: string,
882
+ deps: GitHubListDeps = {},
883
+ generation = 0,
884
+ ): Promise<number[]> {
885
+ if (
886
+ !(deps.credentialGenerationMatches ?? githubCredentialGenerationMatches)(
887
+ userId,
888
+ generation,
889
+ )
890
+ ) {
891
+ throw new GitHubRepositoryAccessError(
892
+ "reauth_required",
893
+ "the host GitHub credential belongs to a different binding generation",
894
+ );
895
+ }
896
+ let tok: Awaited<ReturnType<typeof requestAccessToken>>;
897
+ try {
898
+ tok = await (deps.requestAccessToken ?? requestAccessToken)(userId);
899
+ } catch (err) {
900
+ const reason = err instanceof Error ? err.message : String(err);
901
+ throw new GitHubRepositoryAccessError(
902
+ isTransientGithubError(reason) ? "github_unavailable" : "reauth_required",
903
+ reason,
904
+ );
905
+ }
906
+ if (!tok) {
907
+ throw new GitHubRepositoryAccessError(
908
+ "token_missing",
909
+ `no github token stored for user ${userId}`,
910
+ );
911
+ }
912
+ const ids = new Set<number>();
913
+ const deadline = Date.now() + GITHUB_LIST_OPERATION_TIMEOUT_MS;
914
+ let res: Response;
915
+ try {
916
+ res = await (deps.fetch ?? fetch)(
917
+ `${githubApiOrigin()}/user/installations` +
918
+ `?per_page=${INSTALLATION_PAGE_SIZE}&page=1`,
919
+ {
920
+ headers: {
921
+ authorization: `Bearer ${tok.accessToken}`,
922
+ accept: "application/vnd.github+json",
923
+ "user-agent": "uai-host",
924
+ },
925
+ signal: githubListSignal(deadline),
926
+ },
927
+ );
928
+ } catch (err) {
929
+ throw new GitHubRepositoryAccessError(
930
+ "github_unavailable",
931
+ err instanceof Error ? err.message : String(err),
932
+ );
933
+ }
934
+ if (!res.ok) {
935
+ throw new GitHubRepositoryAccessError(
936
+ res.status === 401 ? "reauth_required" : "github_unavailable",
937
+ `github installation list failed: HTTP ${res.status}`,
938
+ );
939
+ }
940
+ let data: { total_count?: unknown; installations?: unknown };
941
+ try {
942
+ data = (await res.json()) as {
943
+ total_count?: unknown;
944
+ installations?: unknown;
945
+ };
946
+ } catch {
947
+ throw new GitHubRepositoryAccessError(
948
+ "invalid_response",
949
+ "github installation list returned invalid json",
950
+ );
951
+ }
952
+ if (
953
+ !Number.isSafeInteger(data.total_count) ||
954
+ (data.total_count as number) < 0 ||
955
+ (data.total_count as number) > MAX_GITHUB_INSTALLATIONS ||
956
+ !Array.isArray(data.installations) ||
957
+ data.installations.length !== data.total_count
958
+ ) {
959
+ throw new GitHubRepositoryAccessError(
960
+ "invalid_response",
961
+ "github installation list returned an incomplete body",
962
+ );
963
+ }
964
+ for (const inst of data.installations) {
965
+ // Reject the whole response rather than skipping the entry. The caller
966
+ // prunes bindings absent from this list, so a silently dropped or duplicate
967
+ // id is indistinguishable from a complete GitHub verdict.
968
+ if (!inst || typeof inst !== "object") {
969
+ throw new GitHubRepositoryAccessError(
970
+ "invalid_response",
971
+ "github returned a malformed installation entry",
972
+ );
973
+ }
974
+ const id = (inst as { id?: unknown }).id;
975
+ if (
976
+ !Number.isSafeInteger(id) ||
977
+ (id as number) <= 0 ||
978
+ ids.has(id as number)
979
+ ) {
980
+ throw new GitHubRepositoryAccessError(
981
+ "invalid_response",
982
+ "github returned a malformed or duplicate installation id",
983
+ );
984
+ }
985
+ ids.add(id as number);
986
+ }
987
+
988
+ // A newer set/clear may have landed while GitHub was answering. Returning
989
+ // the old actor's complete list after that point would let the cloud prune
990
+ // bindings under the newer credential, so re-check at the publication edge.
991
+ if (
992
+ !(deps.credentialGenerationMatches ?? githubCredentialGenerationMatches)(
993
+ userId,
994
+ generation,
995
+ )
996
+ ) {
997
+ throw new GitHubRepositoryAccessError(
998
+ "reauth_required",
999
+ "the host GitHub credential changed during installation verification",
1000
+ );
1001
+ }
1002
+
1003
+ return [...ids];
1004
+ }
1005
+
1006
+ export async function listUserInstallationRepositoryIds(
1007
+ userId: string,
1008
+ installationId: number,
1009
+ deps: GitHubListDeps = {},
1010
+ generation = 0,
1011
+ ): Promise<{ repositoryIds: number[]; truncated: boolean }> {
1012
+ if (
1013
+ !(deps.credentialGenerationMatches ?? githubCredentialGenerationMatches)(
1014
+ userId,
1015
+ generation,
1016
+ )
1017
+ ) {
1018
+ throw new GitHubRepositoryAccessError(
1019
+ "reauth_required",
1020
+ "the host GitHub credential belongs to a different binding generation",
1021
+ );
1022
+ }
1023
+ let tok: Awaited<ReturnType<typeof requestAccessToken>>;
1024
+ try {
1025
+ tok = await (deps.requestAccessToken ?? requestAccessToken)(userId);
1026
+ } catch (err) {
1027
+ const reason = err instanceof Error ? err.message : String(err);
1028
+ throw new GitHubRepositoryAccessError(
1029
+ isTransientGithubError(reason) ? "github_unavailable" : "reauth_required",
1030
+ reason,
1031
+ );
1032
+ }
1033
+ if (!tok) {
1034
+ throw new GitHubRepositoryAccessError(
1035
+ "token_missing",
1036
+ `no github token stored for user ${userId}`,
1037
+ );
1038
+ }
1039
+ const repositoryIds = new Set<number>();
1040
+ let truncated = false;
1041
+ const deadline = Date.now() + GITHUB_LIST_OPERATION_TIMEOUT_MS;
1042
+ for (let page = 1; page <= REPOSITORY_PAGE_CAP; page++) {
1043
+ let res: Response;
1044
+ try {
1045
+ res = await (deps.fetch ?? fetch)(
1046
+ `${githubApiOrigin()}/user/installations/${installationId}/repositories` +
1047
+ `?per_page=${REPOSITORY_PAGE_SIZE}&page=${page}`,
1048
+ {
1049
+ headers: {
1050
+ authorization: `Bearer ${tok.accessToken}`,
1051
+ accept: "application/vnd.github+json",
1052
+ "user-agent": "uai-host",
1053
+ },
1054
+ signal: githubListSignal(deadline),
1055
+ },
1056
+ );
1057
+ } catch (err) {
1058
+ throw new GitHubRepositoryAccessError(
1059
+ "github_unavailable",
1060
+ err instanceof Error ? err.message : String(err),
1061
+ );
1062
+ }
1063
+ if (!res.ok) {
1064
+ const code: GitHubRepositoryAccessErrorCode =
1065
+ res.status === 401
1066
+ ? "reauth_required"
1067
+ : res.status === 404
1068
+ ? "installation_inaccessible"
1069
+ : "github_unavailable";
1070
+ throw new GitHubRepositoryAccessError(
1071
+ code,
1072
+ `github repository list failed for installation ${installationId}: HTTP ${res.status}`,
1073
+ );
1074
+ }
1075
+ let data: { repositories?: unknown };
1076
+ try {
1077
+ data = (await res.json()) as { repositories?: unknown };
1078
+ } catch {
1079
+ throw new GitHubRepositoryAccessError(
1080
+ "invalid_response",
1081
+ "github repository list returned invalid json",
1082
+ );
1083
+ }
1084
+ if (!Array.isArray(data.repositories)) {
1085
+ throw new GitHubRepositoryAccessError(
1086
+ "invalid_response",
1087
+ "github repository list returned an invalid body",
1088
+ );
1089
+ }
1090
+ const batch = data.repositories;
1091
+ for (const repo of batch) {
1092
+ if (!repo || typeof repo !== "object") {
1093
+ // Repository results are an allowlist. Omitting an unparseable entry
1094
+ // can only withhold access, so preserve the valid IDs but make the
1095
+ // partial result explicit. (Installation-list entries are different:
1096
+ // omission there could drive a destructive prune and must throw.)
1097
+ truncated = true;
1098
+ continue;
1099
+ }
1100
+ const id = (repo as { id?: unknown }).id;
1101
+ if (!Number.isSafeInteger(id) || (id as number) <= 0) {
1102
+ truncated = true;
1103
+ continue;
1104
+ }
1105
+ repositoryIds.add(id as number);
1106
+ }
1107
+ if (batch.length < REPOSITORY_PAGE_SIZE) break;
1108
+ if (page === REPOSITORY_PAGE_CAP) truncated = true;
1109
+ }
1110
+ if (
1111
+ !(deps.credentialGenerationMatches ?? githubCredentialGenerationMatches)(
1112
+ userId,
1113
+ generation,
1114
+ )
1115
+ ) {
1116
+ throw new GitHubRepositoryAccessError(
1117
+ "reauth_required",
1118
+ "the host GitHub credential changed during repository verification",
1119
+ );
1120
+ }
1121
+ return {
1122
+ repositoryIds: [...repositoryIds].slice(
1123
+ 0,
1124
+ MAX_GITHUB_REPOSITORIES_PER_INSTALLATION,
1125
+ ),
1126
+ truncated:
1127
+ truncated || repositoryIds.size > MAX_GITHUB_REPOSITORIES_PER_INSTALLATION,
1128
+ };
1129
+ }
1130
+
620
1131
  /** Test hook: reset the access-token cache. */
621
1132
  export function clearAllAccessCache(): void {
622
1133
  accessCache.clear();
@@ -630,17 +1141,10 @@ export function requestAccessToken(
630
1141
  const generation = githubAuthGeneration(userId);
631
1142
  // ADR-033 non-expiring path: the stored token IS the access token — return it
632
1143
  // directly, no exchange/cache/rotation. `expiresAt: null` ⇒ no refresh.
633
- const row = getDb()
634
- .select({ kind: schema.githubTokens.kind })
635
- .from(schema.githubTokens)
636
- .where(eq(schema.githubTokens.userId, userId))
637
- .get();
1144
+ const row = readCurrentStoredToken(userId);
638
1145
  if (!row) return Promise.resolve(null);
639
1146
  if (row.kind === "access") {
640
- const stored = readStoredToken(userId);
641
- return Promise.resolve(
642
- stored ? { accessToken: stored.token, expiresAt: null } : null,
643
- );
1147
+ return Promise.resolve({ accessToken: row.token, expiresAt: null });
644
1148
  }
645
1149
 
646
1150
  // Legacy expiring path (ADR-027): cache + in-flight dedupe + exchange.
@@ -671,11 +1175,26 @@ async function doRequestAccessToken(
671
1175
  userId: string,
672
1176
  generation: number,
673
1177
  ): Promise<{ accessToken: string; expiresAt: number } | null> {
674
- const row = getDb()
675
- .select()
676
- .from(schema.githubTokens)
677
- .where(eq(schema.githubTokens.userId, userId))
678
- .get();
1178
+ const row = getDb().transaction((tx) => {
1179
+ const token = tx
1180
+ .select()
1181
+ .from(schema.githubTokens)
1182
+ .where(eq(schema.githubTokens.userId, userId))
1183
+ .get();
1184
+ if (!token) return null;
1185
+ const fence = tx
1186
+ .select({
1187
+ generation: schema.githubCredentialGenerations.generation,
1188
+ mutation: schema.githubCredentialGenerations.mutation,
1189
+ })
1190
+ .from(schema.githubCredentialGenerations)
1191
+ .where(eq(schema.githubCredentialGenerations.userId, userId))
1192
+ .get();
1193
+ return fence?.mutation === "set" &&
1194
+ fence.generation === token.generation
1195
+ ? token
1196
+ : null;
1197
+ });
679
1198
  if (!row) return null;
680
1199
 
681
1200
  const refreshToken = openAesGcm(
@@ -715,17 +1234,37 @@ async function doRequestAccessToken(
715
1234
  githubAuthGeneration(userId) === generation
716
1235
  ) {
717
1236
  const sealed = sealAesGcm(data.refreshToken);
718
- getDb()
719
- .update(schema.githubTokens)
720
- .set({
721
- refreshTokenCt: sealed.ct,
722
- refreshTokenNonce: sealed.nonce,
723
- refreshTokenExpiresAt:
724
- data.refreshTokenExpiresAt ?? row.refreshTokenExpiresAt,
725
- updatedAt: Date.now(),
726
- })
727
- .where(eq(schema.githubTokens.userId, userId))
728
- .run();
1237
+ getDb().transaction((tx) => {
1238
+ const fence = tx
1239
+ .select({
1240
+ generation: schema.githubCredentialGenerations.generation,
1241
+ mutation: schema.githubCredentialGenerations.mutation,
1242
+ })
1243
+ .from(schema.githubCredentialGenerations)
1244
+ .where(eq(schema.githubCredentialGenerations.userId, userId))
1245
+ .get();
1246
+ if (
1247
+ fence?.mutation !== "set" ||
1248
+ fence.generation !== row.generation
1249
+ ) {
1250
+ return;
1251
+ }
1252
+ tx.update(schema.githubTokens)
1253
+ .set({
1254
+ refreshTokenCt: sealed.ct,
1255
+ refreshTokenNonce: sealed.nonce,
1256
+ refreshTokenExpiresAt:
1257
+ data.refreshTokenExpiresAt ?? row.refreshTokenExpiresAt,
1258
+ updatedAt: Date.now(),
1259
+ })
1260
+ .where(
1261
+ and(
1262
+ eq(schema.githubTokens.userId, userId),
1263
+ eq(schema.githubTokens.generation, row.generation),
1264
+ ),
1265
+ )
1266
+ .run();
1267
+ });
729
1268
  }
730
1269
  return { accessToken: data.accessToken, expiresAt: data.expiresAt };
731
1270
  }
@@ -1055,10 +1594,35 @@ export async function setupTaskGithub(
1055
1594
  if (!isCurrent()) return false;
1056
1595
  const pat = process.env.UAI_GH_PAT_FALLBACK;
1057
1596
  if (pat) {
1058
- await _inject(taskId, pat);
1059
- clearGithubRetry(taskId);
1060
- console.log(`[github] task ${taskId}: using PAT fallback (no refresh)`);
1061
- return true;
1597
+ // The PAT is ONE host-wide credential, and injectIntoContainer wires it
1598
+ // into `gh` AND all git transport. Injecting it for whoever happens to
1599
+ // own the task therefore authenticates that user's container as the
1600
+ // PAT's owner, handing every unconnected user the owner's full
1601
+ // read/write repository access. That was invisible while a host had one
1602
+ // user and became a silent cross-user credential leak the moment it had
1603
+ // two. Bind the PAT to the single user it belongs to and fail closed
1604
+ // otherwise: a host that upgrades into multi-user must not start
1605
+ // leaking because nobody remembered to unset an env var.
1606
+ const patOwner = process.env.UAI_GH_PAT_FALLBACK_USER_ID;
1607
+ if (!patOwner) {
1608
+ console.warn(
1609
+ `[github] task ${taskId}: UAI_GH_PAT_FALLBACK is set but ` +
1610
+ "UAI_GH_PAT_FALLBACK_USER_ID is not — refusing to inject a " +
1611
+ "host-wide PAT into a task. Set the owner's user id to keep the " +
1612
+ "single-user fallback, or remove the PAT and connect GitHub per " +
1613
+ "user.",
1614
+ );
1615
+ } else if (patOwner !== userId) {
1616
+ console.warn(
1617
+ `[github] task ${taskId}: PAT fallback is owned by ${patOwner}, ` +
1618
+ `not task owner ${userId} — skipping.`,
1619
+ );
1620
+ } else {
1621
+ await _inject(taskId, pat);
1622
+ clearGithubRetry(taskId);
1623
+ console.log(`[github] task ${taskId}: using PAT fallback (no refresh)`);
1624
+ return true;
1625
+ }
1062
1626
  }
1063
1627
  console.log(`[github] task ${taskId}: gh not configured for user ${userId}`);
1064
1628
  return false;