@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.11",
3
+ "version": "0.9.13",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
@@ -0,0 +1,104 @@
1
+ /**
2
+ * ADR-103: the host-side event outbox.
3
+ *
4
+ * Every HostEvent gets a monotonic seq and its serialized wire frame is held
5
+ * here until the cloud acks it. The bridge connection drains the outbox in
6
+ * order — live traffic and reconnect replay are the same code path, so a
7
+ * WSS blip (a cloud deploy) can no longer drop events on the floor.
8
+ *
9
+ * Bounded, in-memory. A host PROCESS restart still loses unsent events —
10
+ * that path is ADR-061's (the restarted host reattaches to the container
11
+ * and resumes streaming); this outbox closes the disconnect gap only.
12
+ */
13
+
14
+ import type { HostEvent } from "./protocol";
15
+
16
+ export interface OutboxEntry {
17
+ seq: number;
18
+ /** The full serialized `{kind:"event", seq, event}` frame, ready to send. */
19
+ raw: string;
20
+ }
21
+
22
+ /** Overflow bounds. Generous: a disconnect longer than this many events is a
23
+ * real outage, not a deploy blip, and dropping OLDEST keeps the tail — the
24
+ * most recent turn — intact for replay. */
25
+ const MAX_ENTRIES = 10_000;
26
+ const MAX_BYTES = 32 * 1024 * 1024;
27
+
28
+ export class EventOutbox {
29
+ private entries: OutboxEntry[] = [];
30
+ private totalBytes = 0;
31
+ private nextSeq = 1;
32
+ /** Highest seq ever transmitted on any connection. */
33
+ private sentUpTo = 0;
34
+ /** Entries dropped by overflow since the last drain — for one loud log. */
35
+ private droppedSinceDrain = 0;
36
+
37
+ /** Serialize + append. Returns the entry's seq. */
38
+ enqueue(event: HostEvent): number {
39
+ const seq = this.nextSeq++;
40
+ const raw = JSON.stringify({ kind: "event", seq, event });
41
+ this.entries.push({ seq, raw });
42
+ this.totalBytes += raw.length;
43
+ while (
44
+ this.entries.length > MAX_ENTRIES ||
45
+ (this.totalBytes > MAX_BYTES && this.entries.length > 1)
46
+ ) {
47
+ const dropped = this.entries.shift();
48
+ if (!dropped) break;
49
+ this.totalBytes -= dropped.raw.length;
50
+ // Only a NEVER-SENT entry is a real loss; an unacked-but-sent one most
51
+ // likely landed and just lost its ack.
52
+ if (dropped.seq > this.sentUpTo) this.droppedSinceDrain += 1;
53
+ }
54
+ return seq;
55
+ }
56
+
57
+ /** Cumulative ack: forget everything up to and including `seq`. */
58
+ ack(seq: number): void {
59
+ let i = 0;
60
+ for (const entry of this.entries) {
61
+ if (entry.seq > seq) break;
62
+ this.totalBytes -= entry.raw.length;
63
+ i += 1;
64
+ }
65
+ if (i > 0) this.entries.splice(0, i);
66
+ if (seq > this.sentUpTo) this.sentUpTo = seq;
67
+ }
68
+
69
+ /**
70
+ * Rewind the transmit cursor for a reconnect (ADR-103 resume handshake).
71
+ * `afterSeq` is the cloud's watermark: everything newer re-sends on the
72
+ * next drain. `null` = the cloud has no state for this boot — replay
73
+ * nothing; only never-transmitted entries go out.
74
+ */
75
+ resume(afterSeq: number | null): void {
76
+ if (afterSeq !== null && afterSeq < this.sentUpTo) this.sentUpTo = afterSeq;
77
+ }
78
+
79
+ /**
80
+ * Entries due for transmission, in order. The caller sends them and the
81
+ * cursor advances — drain is idempotent per entry until `resume` rewinds.
82
+ */
83
+ drain(): OutboxEntry[] {
84
+ const due = this.entries.filter((e) => e.seq > this.sentUpTo);
85
+ const last = due[due.length - 1];
86
+ if (last) this.sentUpTo = last.seq;
87
+ return due;
88
+ }
89
+
90
+ /** Never-sent entries lost to overflow since the last call; resets. */
91
+ takeDroppedCount(): number {
92
+ const n = this.droppedSinceDrain;
93
+ this.droppedSinceDrain = 0;
94
+ return n;
95
+ }
96
+
97
+ get size(): number {
98
+ return this.entries.length;
99
+ }
100
+
101
+ get bytes(): number {
102
+ return this.totalBytes;
103
+ }
104
+ }
package/src/main.ts CHANGED
@@ -25,12 +25,17 @@ import {
25
25
  import { getHostTask } from "../lib/runtime-state";
26
26
  import { getOrchestrator, recoveryComplete } from "../lib/orchestrator";
27
27
  import {
28
+ claimGithubCredentialGeneration,
28
29
  connectedUserIds,
30
+ GitHubRepositoryAccessError,
29
31
  isTransientGithubError,
32
+ listUserInstallationIds,
33
+ listUserInstallationRepositoryIds,
30
34
  onConnectClear,
31
35
  onConnectSet,
32
36
  onGithubChange,
33
37
  reconcileTaskGitAuth,
38
+ rollbackClaimedGitHubCredentialSet,
34
39
  runGithubConnectionTransition,
35
40
  setAuthExpiredHandler,
36
41
  } from "../lib/github-tokens";
@@ -77,8 +82,13 @@ import { canAdvertiseTypedSecretaryDispatch } from "../lib/agents/mode";
77
82
  import "../lib/agents/factory";
78
83
  import { ensureStandardImage, standardRuntimes } from "../lib/standard-image";
79
84
  import { hostCommands, hostEvents } from "./index";
85
+ import { EventOutbox } from "./event-outbox";
80
86
  import {
81
87
  HostErrorCode,
88
+ EVENT_REPLAY_PROTOCOL_FEATURE,
89
+ GITHUB_CREDENTIAL_GENERATION_PROTOCOL_FEATURE,
90
+ GITHUB_INSTALLATION_VERIFICATION_PROTOCOL_FEATURE,
91
+ GITHUB_REPOSITORY_ACCESS_PROTOCOL_FEATURE,
82
92
  SECRETARY_TYPED_DISPATCH_PROTOCOL_FEATURE,
83
93
  TRANSCRIPT_TARGETS_PROTOCOL_FEATURE,
84
94
  type CloudToHost,
@@ -90,6 +100,7 @@ import {
90
100
  type HostCommands,
91
101
  type HostToCloud,
92
102
  type PermissionDecision,
103
+ parseGitHubCredentialGeneration,
93
104
  parseChannelMode,
94
105
  parseTranscriptTargets,
95
106
  type TaskAgent,
@@ -140,6 +151,33 @@ let shutdownRequested = false;
140
151
  let pendingBinaryTunnelId: string | null = null;
141
152
  const tunnels = new TunnelRegistry();
142
153
 
154
+ // ADR-103: event outbox. BOOT_ID scopes seqs to this process lifetime; the
155
+ // cloud's replay watermark only applies within a matching boot. Events are
156
+ // enqueued by a PROCESS-level subscription (below, before connect()) so a
157
+ // dropped WSS no longer drops events — they drain on reconnect, after the
158
+ // cloud names its watermark via event.resume.
159
+ const BOOT_ID = newId();
160
+ const eventOutbox = new EventOutbox();
161
+ // Transmission is held per-connection until the cloud's event.resume sets the
162
+ // replay cursor (or the fallback timer concedes the cloud predates ADR-103).
163
+ let eventFlushEnabled = false;
164
+ let resumeFallbackTimer: NodeJS.Timeout | null = null;
165
+ const RESUME_FALLBACK_MS = 3_000;
166
+
167
+ function flushEvents(): void {
168
+ const socket = ws;
169
+ if (!eventFlushEnabled || !socket || socket.readyState !== WebSocket.OPEN) {
170
+ return;
171
+ }
172
+ const dropped = eventOutbox.takeDroppedCount();
173
+ if (dropped > 0) {
174
+ console.warn(
175
+ `[host-agent] event outbox overflowed: ${dropped} unsent event(s) lost`,
176
+ );
177
+ }
178
+ for (const entry of eventOutbox.drain()) socket.send(entry.raw);
179
+ }
180
+
143
181
  interface PausableSource {
144
182
  pause(): unknown;
145
183
  resume(): unknown;
@@ -178,6 +216,13 @@ void ensureStandardImage();
178
216
  // self-heals such containers: it re-copies and chowns every running task.
179
217
  void recoveryComplete().then(() => reinjectCodexRunningTasks());
180
218
  watchCodexAuth();
219
+ // ADR-103: subscribe ONCE, for the process — not per connection. Every event
220
+ // lands in the outbox regardless of socket state; flushEvents is a no-op
221
+ // while disconnected and the backlog drains after the resume handshake.
222
+ hostEvents.subscribe((event) => {
223
+ eventOutbox.enqueue(event);
224
+ flushEvents();
225
+ });
181
226
  connect();
182
227
  // Local browser UI (ADR-028) — same single process, alongside the WSS client.
183
228
  // Best-effort: a UI bind failure must not take the host service down.
@@ -225,6 +270,10 @@ function buildCapabilities(): HostCapabilities {
225
270
  version: packageVersion(),
226
271
  protocolFeatures: [
227
272
  TRANSCRIPT_TARGETS_PROTOCOL_FEATURE,
273
+ EVENT_REPLAY_PROTOCOL_FEATURE,
274
+ GITHUB_CREDENTIAL_GENERATION_PROTOCOL_FEATURE,
275
+ GITHUB_INSTALLATION_VERIFICATION_PROTOCOL_FEATURE,
276
+ GITHUB_REPOSITORY_ACCESS_PROTOCOL_FEATURE,
228
277
  // The echo adapter cannot execute the in-task CLI. Advertising typed
229
278
  // dispatch in mock mode would let the composer create a Secretary that
230
279
  // has no way to wake crew.
@@ -263,19 +312,22 @@ function connect(): void {
263
312
 
264
313
  let lastTraffic = Date.now();
265
314
  let ready = false;
266
- let unsubscribe: (() => void) | null = null;
267
315
  let pingTimer: NodeJS.Timeout | null = null;
268
316
  let deadTimer: NodeJS.Timeout | null = null;
269
317
 
270
318
  const cleanup = (): void => {
271
319
  if (pingTimer) clearInterval(pingTimer);
272
320
  if (deadTimer) clearInterval(deadTimer);
273
- if (unsubscribe) unsubscribe();
321
+ eventFlushEnabled = false;
322
+ if (resumeFallbackTimer) {
323
+ clearTimeout(resumeFallbackTimer);
324
+ resumeFallbackTimer = null;
325
+ }
274
326
  if (ws === socket) ws = null;
275
327
  };
276
328
 
277
329
  socket.on("open", () => {
278
- send(socket, { kind: "auth", token, hostId });
330
+ send(socket, { kind: "auth", token, hostId, bootId: BOOT_ID });
279
331
  // Advertise capabilities immediately after auth (ADR-021). The bridge
280
332
  // rejects with close-code 4001 if auth fails, so sending here is harmless
281
333
  // on a bad token and saves a round-trip on a good one. Re-sent on every
@@ -291,10 +343,14 @@ function connect(): void {
291
343
  setHostObsTag(hostId);
292
344
  addHostBreadcrumb("bridge", "connected");
293
345
 
294
- unsubscribe = hostEvents.subscribe((event) => {
295
- if (socket.readyState !== WebSocket.OPEN) return;
296
- send(socket, { kind: "event", event });
297
- });
346
+ // ADR-103: hold event transmission until the cloud names its replay
347
+ // watermark (event.resume). A pre-103 cloud never will — after the
348
+ // fallback, drain never-transmitted entries only (no replay, no dupes).
349
+ resumeFallbackTimer = setTimeout(() => {
350
+ resumeFallbackTimer = null;
351
+ eventFlushEnabled = true;
352
+ flushEvents();
353
+ }, RESUME_FALLBACK_MS);
298
354
 
299
355
  pingTimer = setInterval(() => {
300
356
  if (socket.readyState === WebSocket.OPEN) {
@@ -327,6 +383,20 @@ function connect(): void {
327
383
  switch (frame.kind) {
328
384
  case "pong":
329
385
  break;
386
+ case "event.resume":
387
+ // ADR-103: the cloud named its watermark — rewind the transmit
388
+ // cursor to it (null = no state, replay nothing) and start draining.
389
+ if (resumeFallbackTimer) {
390
+ clearTimeout(resumeFallbackTimer);
391
+ resumeFallbackTimer = null;
392
+ }
393
+ eventOutbox.resume(frame.afterSeq);
394
+ eventFlushEnabled = true;
395
+ flushEvents();
396
+ break;
397
+ case "event.ack":
398
+ eventOutbox.ack(frame.seq);
399
+ break;
330
400
  case "command":
331
401
  void handleCommand(socket, frame);
332
402
  break;
@@ -353,24 +423,67 @@ function connect(): void {
353
423
  case "gh.connect.set": {
354
424
  // Account switches fence every host-side Git operation using the old
355
425
  // credential before the replacement grant is stored or reinjected.
356
- void runGithubConnectionTransition(frame.userId, () =>
357
- onConnectSet(frame, {
426
+ void runGithubConnectionTransition(frame.userId, async () => {
427
+ const claim = claimGithubCredentialGeneration(
428
+ frame.userId,
429
+ frame.generation,
430
+ "set",
431
+ );
432
+ if (!claim) {
433
+ return {
434
+ ok: false as const,
435
+ code: "stale_generation" as const,
436
+ error: "stale GitHub credential generation",
437
+ };
438
+ }
439
+ const transitionDeps = {
358
440
  invalidateCredentials: invalidateTaskGithubGitCredentials,
359
- reconcile: (taskId, userId) =>
441
+ reconcile: (taskId: string, userId: string) =>
360
442
  getOrchestrator().runTaskLifecycle(taskId, () =>
361
443
  reconcileTaskGitAuth(taskId, userId),
362
444
  ),
363
- }),
364
- ).then(
445
+ };
446
+ let result: Awaited<ReturnType<typeof onConnectSet>>;
447
+ try {
448
+ result = await onConnectSet(
449
+ { ...frame, generation: claim.generation },
450
+ transitionDeps,
451
+ );
452
+ } catch (err) {
453
+ result = {
454
+ ok: false,
455
+ error: err instanceof Error ? err.message : String(err),
456
+ };
457
+ }
458
+ if (!result.ok) {
459
+ // Storage can succeed before live-task reconciliation fails. Never
460
+ // emit a negative set ack while that untrusted credential remains
461
+ // active: persist the same-generation clear tombstone first, then
462
+ // best-effort scrub/revoke under the same serialized transition.
463
+ await rollbackClaimedGitHubCredentialSet(
464
+ frame.userId,
465
+ claim.generation,
466
+ transitionDeps,
467
+ );
468
+ }
469
+ return result;
470
+ }).then(
365
471
  (result) =>
366
472
  send(
367
473
  socket,
368
474
  result.ok
369
- ? { kind: "gh.connect.ack", userId: frame.userId, ok: true }
475
+ ? {
476
+ kind: "gh.connect.ack",
477
+ opId: frame.opId,
478
+ userId: frame.userId,
479
+ ok: true,
480
+ }
370
481
  : {
371
482
  kind: "gh.connect.ack",
483
+ opId: frame.opId,
372
484
  userId: frame.userId,
373
485
  ok: false,
486
+ code: "code" in result ? result.code : undefined,
374
487
  error: result.error ?? "store failed",
375
488
  },
376
489
  ),
@@ -379,6 +492,7 @@ function connect(): void {
379
492
  console.warn(`[github] connect.set failed: ${error}`);
380
493
  send(socket, {
381
494
  kind: "gh.connect.ack",
495
+ opId: frame.opId,
382
496
  userId: frame.userId,
383
497
  ok: false,
384
498
  error,
@@ -387,31 +501,130 @@ function connect(): void {
387
501
  );
388
502
  break;
389
503
  }
504
+ case "gh.installations.list": {
505
+ // Read-only: no token leaves the host, only the installation ids
506
+ // GitHub attests to for this user. A failure acks ok:false rather
507
+ // than an empty list — the cloud prunes bindings absent from the
508
+ // result, and "couldn't ask" must never be read as "installed
509
+ // nowhere".
510
+ void listUserInstallationIds(
511
+ frame.userId,
512
+ {},
513
+ frame.generation ?? 0,
514
+ ).then(
515
+ (installationIds) =>
516
+ send(socket, {
517
+ kind: "gh.installations.ack",
518
+ opId: frame.opId,
519
+ ok: true,
520
+ installationIds,
521
+ }),
522
+ (err) => {
523
+ const error = err instanceof Error ? err.message : String(err);
524
+ const code =
525
+ err instanceof GitHubRepositoryAccessError
526
+ ? err.code
527
+ : "github_unavailable";
528
+ console.warn(
529
+ `[github] installation list failed for ${frame.userId}: ${error}`,
530
+ );
531
+ send(socket, {
532
+ kind: "gh.installations.ack",
533
+ opId: frame.opId,
534
+ ok: false,
535
+ code,
536
+ error,
537
+ });
538
+ },
539
+ );
540
+ break;
541
+ }
542
+ case "gh.repositories.list": {
543
+ void listUserInstallationRepositoryIds(
544
+ frame.userId,
545
+ frame.installationId,
546
+ {},
547
+ frame.generation ?? 0,
548
+ ).then(
549
+ ({ repositoryIds, truncated }) =>
550
+ send(socket, {
551
+ kind: "gh.repositories.ack",
552
+ opId: frame.opId,
553
+ ok: true,
554
+ repositoryIds,
555
+ truncated,
556
+ }),
557
+ (err) => {
558
+ const error = err instanceof Error ? err.message : String(err);
559
+ const code =
560
+ err instanceof GitHubRepositoryAccessError
561
+ ? err.code
562
+ : "github_unavailable";
563
+ console.warn(
564
+ `[github] repository list failed for ${frame.userId}/${frame.installationId}: ${error}`,
565
+ );
566
+ send(socket, {
567
+ kind: "gh.repositories.ack",
568
+ opId: frame.opId,
569
+ ok: false,
570
+ code,
571
+ error,
572
+ });
573
+ },
574
+ );
575
+ break;
576
+ }
390
577
  case "gh.connect.clear": {
391
- // Local deletion happens synchronously inside onConnectClear and the
392
- // remote revoke starts immediately in the background. Delay the ack
393
- // only for live-container logout + transport transition, so the UI
394
- // cannot report Disconnected while a task can still use the old token.
395
- void runGithubConnectionTransition(frame.userId, () =>
396
- onConnectClear(frame.userId, {
578
+ // The serialized transition removes the local credential, waits for
579
+ // best-effort remote revocation, and scrubs live containers before it
580
+ // acknowledges. That keeps a delayed revoke from racing a later set.
581
+ void runGithubConnectionTransition(frame.userId, async () => {
582
+ const claim = claimGithubCredentialGeneration(
583
+ frame.userId,
584
+ frame.generation,
585
+ "clear",
586
+ );
587
+ if (!claim) {
588
+ return {
589
+ ok: false as const,
590
+ code: "stale_generation" as const,
591
+ error: "stale GitHub credential generation",
592
+ };
593
+ }
594
+ await onConnectClear(frame.userId, {
397
595
  invalidateCredentials: invalidateTaskGithubGitCredentials,
398
596
  reconcile: (taskId, userId) =>
399
597
  getOrchestrator().runTaskLifecycle(taskId, () =>
400
598
  reconcileTaskGitAuth(taskId, userId),
401
599
  ),
402
- }),
403
- ).then(
404
- () =>
405
- send(socket, {
406
- kind: "gh.connect.ack",
407
- userId: frame.userId,
408
- ok: true,
409
- }),
600
+ });
601
+ return { ok: true as const };
602
+ }).then(
603
+ (result) =>
604
+ send(
605
+ socket,
606
+ result.ok
607
+ ? {
608
+ kind: "gh.connect.ack",
609
+ opId: frame.opId,
610
+ userId: frame.userId,
611
+ ok: true,
612
+ }
613
+ : {
614
+ kind: "gh.connect.ack",
615
+ opId: frame.opId,
616
+ userId: frame.userId,
617
+ ok: false,
618
+ code: result.code,
619
+ error: result.error,
620
+ },
621
+ ),
410
622
  (err) => {
411
623
  const error = err instanceof Error ? err.message : String(err);
412
624
  console.warn(`[github] connect.clear failed: ${error}`);
413
625
  send(socket, {
414
626
  kind: "gh.connect.ack",
627
+ opId: frame.opId,
415
628
  userId: frame.userId,
416
629
  ok: false,
417
630
  error,
@@ -1072,6 +1285,15 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
1072
1285
  if (frame.kind === "pong" && typeof frame.ts === "number") {
1073
1286
  return { kind: "pong", ts: frame.ts };
1074
1287
  }
1288
+ if (
1289
+ frame.kind === "event.resume" &&
1290
+ (frame.afterSeq === null || typeof frame.afterSeq === "number")
1291
+ ) {
1292
+ return { kind: "event.resume", afterSeq: frame.afterSeq };
1293
+ }
1294
+ if (frame.kind === "event.ack" && typeof frame.seq === "number") {
1295
+ return { kind: "event.ack", seq: frame.seq };
1296
+ }
1075
1297
  if (
1076
1298
  frame.kind === "command" &&
1077
1299
  typeof frame.commandId === "string" &&
@@ -1127,12 +1349,17 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
1127
1349
  typeof frame.installationId === "number" &&
1128
1350
  typeof frame.githubLogin === "string" &&
1129
1351
  (frame.targetType === "User" || frame.targetType === "Organization") &&
1352
+ (frame.opId === undefined || typeof frame.opId === "string") &&
1353
+ parseGitHubCredentialGeneration(frame.generation) !== null &&
1130
1354
  // Exactly one token kind: accessToken (ADR-033) or refreshToken (ADR-027).
1131
1355
  (typeof frame.accessToken === "string" ||
1132
1356
  typeof frame.refreshToken === "string")
1133
1357
  ) {
1134
1358
  return {
1135
1359
  kind: "gh.connect.set",
1360
+ opId: typeof frame.opId === "string" ? frame.opId : undefined,
1361
+ generation:
1362
+ parseGitHubCredentialGeneration(frame.generation) ?? undefined,
1136
1363
  userId: frame.userId,
1137
1364
  installationId: frame.installationId,
1138
1365
  githubLogin: frame.githubLogin,
@@ -1147,8 +1374,51 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
1147
1374
  : undefined,
1148
1375
  };
1149
1376
  }
1150
- if (frame.kind === "gh.connect.clear" && typeof frame.userId === "string") {
1151
- return { kind: "gh.connect.clear", userId: frame.userId };
1377
+ if (
1378
+ frame.kind === "gh.connect.clear" &&
1379
+ typeof frame.userId === "string" &&
1380
+ (frame.opId === undefined || typeof frame.opId === "string") &&
1381
+ parseGitHubCredentialGeneration(frame.generation) !== null
1382
+ ) {
1383
+ return {
1384
+ kind: "gh.connect.clear",
1385
+ opId: typeof frame.opId === "string" ? frame.opId : undefined,
1386
+ generation:
1387
+ parseGitHubCredentialGeneration(frame.generation) ?? undefined,
1388
+ userId: frame.userId,
1389
+ };
1390
+ }
1391
+ if (
1392
+ frame.kind === "gh.installations.list" &&
1393
+ typeof frame.opId === "string" &&
1394
+ typeof frame.userId === "string" &&
1395
+ parseGitHubCredentialGeneration(frame.generation) !== null
1396
+ ) {
1397
+ return {
1398
+ kind: "gh.installations.list",
1399
+ opId: frame.opId,
1400
+ generation:
1401
+ parseGitHubCredentialGeneration(frame.generation) ?? undefined,
1402
+ userId: frame.userId,
1403
+ };
1404
+ }
1405
+ if (
1406
+ frame.kind === "gh.repositories.list" &&
1407
+ typeof frame.opId === "string" &&
1408
+ typeof frame.userId === "string" &&
1409
+ typeof frame.installationId === "number" &&
1410
+ Number.isSafeInteger(frame.installationId) &&
1411
+ frame.installationId > 0 &&
1412
+ parseGitHubCredentialGeneration(frame.generation) !== null
1413
+ ) {
1414
+ return {
1415
+ kind: "gh.repositories.list",
1416
+ opId: frame.opId,
1417
+ generation:
1418
+ parseGitHubCredentialGeneration(frame.generation) ?? undefined,
1419
+ userId: frame.userId,
1420
+ installationId: frame.installationId,
1421
+ };
1152
1422
  }
1153
1423
  if (
1154
1424
  (frame.kind === "ssh.key.get" ||