@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.
- package/db/migrations/0013_github_credential_generations.sql +11 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +18 -0
- package/lib/env.ts +6 -1
- package/lib/github-tokens.ts +633 -69
- package/package.json +1 -1
- package/src/event-outbox.ts +104 -0
- package/src/main.ts +299 -29
- package/src/protocol.ts +123 -6
package/lib/github-tokens.ts
CHANGED
|
@@ -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
|
|
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
|
-
) =>
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
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
|
-
) =>
|
|
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:
|
|
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
|
-
//
|
|
378
|
-
// revokeAtGitHub catches its own failures; the
|
|
379
|
-
// test/alternate implementations
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
):
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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;
|