@runuai/host 0.9.11 → 0.9.12

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.12",
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>",
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";
@@ -79,6 +84,9 @@ import { ensureStandardImage, standardRuntimes } from "../lib/standard-image";
79
84
  import { hostCommands, hostEvents } from "./index";
80
85
  import {
81
86
  HostErrorCode,
87
+ GITHUB_CREDENTIAL_GENERATION_PROTOCOL_FEATURE,
88
+ GITHUB_INSTALLATION_VERIFICATION_PROTOCOL_FEATURE,
89
+ GITHUB_REPOSITORY_ACCESS_PROTOCOL_FEATURE,
82
90
  SECRETARY_TYPED_DISPATCH_PROTOCOL_FEATURE,
83
91
  TRANSCRIPT_TARGETS_PROTOCOL_FEATURE,
84
92
  type CloudToHost,
@@ -90,6 +98,7 @@ import {
90
98
  type HostCommands,
91
99
  type HostToCloud,
92
100
  type PermissionDecision,
101
+ parseGitHubCredentialGeneration,
93
102
  parseChannelMode,
94
103
  parseTranscriptTargets,
95
104
  type TaskAgent,
@@ -225,6 +234,9 @@ function buildCapabilities(): HostCapabilities {
225
234
  version: packageVersion(),
226
235
  protocolFeatures: [
227
236
  TRANSCRIPT_TARGETS_PROTOCOL_FEATURE,
237
+ GITHUB_CREDENTIAL_GENERATION_PROTOCOL_FEATURE,
238
+ GITHUB_INSTALLATION_VERIFICATION_PROTOCOL_FEATURE,
239
+ GITHUB_REPOSITORY_ACCESS_PROTOCOL_FEATURE,
228
240
  // The echo adapter cannot execute the in-task CLI. Advertising typed
229
241
  // dispatch in mock mode would let the composer create a Secretary that
230
242
  // has no way to wake crew.
@@ -353,24 +365,67 @@ function connect(): void {
353
365
  case "gh.connect.set": {
354
366
  // Account switches fence every host-side Git operation using the old
355
367
  // credential before the replacement grant is stored or reinjected.
356
- void runGithubConnectionTransition(frame.userId, () =>
357
- onConnectSet(frame, {
368
+ void runGithubConnectionTransition(frame.userId, async () => {
369
+ const claim = claimGithubCredentialGeneration(
370
+ frame.userId,
371
+ frame.generation,
372
+ "set",
373
+ );
374
+ if (!claim) {
375
+ return {
376
+ ok: false as const,
377
+ code: "stale_generation" as const,
378
+ error: "stale GitHub credential generation",
379
+ };
380
+ }
381
+ const transitionDeps = {
358
382
  invalidateCredentials: invalidateTaskGithubGitCredentials,
359
- reconcile: (taskId, userId) =>
383
+ reconcile: (taskId: string, userId: string) =>
360
384
  getOrchestrator().runTaskLifecycle(taskId, () =>
361
385
  reconcileTaskGitAuth(taskId, userId),
362
386
  ),
363
- }),
364
- ).then(
387
+ };
388
+ let result: Awaited<ReturnType<typeof onConnectSet>>;
389
+ try {
390
+ result = await onConnectSet(
391
+ { ...frame, generation: claim.generation },
392
+ transitionDeps,
393
+ );
394
+ } catch (err) {
395
+ result = {
396
+ ok: false,
397
+ error: err instanceof Error ? err.message : String(err),
398
+ };
399
+ }
400
+ if (!result.ok) {
401
+ // Storage can succeed before live-task reconciliation fails. Never
402
+ // emit a negative set ack while that untrusted credential remains
403
+ // active: persist the same-generation clear tombstone first, then
404
+ // best-effort scrub/revoke under the same serialized transition.
405
+ await rollbackClaimedGitHubCredentialSet(
406
+ frame.userId,
407
+ claim.generation,
408
+ transitionDeps,
409
+ );
410
+ }
411
+ return result;
412
+ }).then(
365
413
  (result) =>
366
414
  send(
367
415
  socket,
368
416
  result.ok
369
- ? { kind: "gh.connect.ack", userId: frame.userId, ok: true }
417
+ ? {
418
+ kind: "gh.connect.ack",
419
+ opId: frame.opId,
420
+ userId: frame.userId,
421
+ ok: true,
422
+ }
370
423
  : {
371
424
  kind: "gh.connect.ack",
425
+ opId: frame.opId,
372
426
  userId: frame.userId,
373
427
  ok: false,
428
+ code: "code" in result ? result.code : undefined,
374
429
  error: result.error ?? "store failed",
375
430
  },
376
431
  ),
@@ -379,6 +434,7 @@ function connect(): void {
379
434
  console.warn(`[github] connect.set failed: ${error}`);
380
435
  send(socket, {
381
436
  kind: "gh.connect.ack",
437
+ opId: frame.opId,
382
438
  userId: frame.userId,
383
439
  ok: false,
384
440
  error,
@@ -387,31 +443,130 @@ function connect(): void {
387
443
  );
388
444
  break;
389
445
  }
446
+ case "gh.installations.list": {
447
+ // Read-only: no token leaves the host, only the installation ids
448
+ // GitHub attests to for this user. A failure acks ok:false rather
449
+ // than an empty list — the cloud prunes bindings absent from the
450
+ // result, and "couldn't ask" must never be read as "installed
451
+ // nowhere".
452
+ void listUserInstallationIds(
453
+ frame.userId,
454
+ {},
455
+ frame.generation ?? 0,
456
+ ).then(
457
+ (installationIds) =>
458
+ send(socket, {
459
+ kind: "gh.installations.ack",
460
+ opId: frame.opId,
461
+ ok: true,
462
+ installationIds,
463
+ }),
464
+ (err) => {
465
+ const error = err instanceof Error ? err.message : String(err);
466
+ const code =
467
+ err instanceof GitHubRepositoryAccessError
468
+ ? err.code
469
+ : "github_unavailable";
470
+ console.warn(
471
+ `[github] installation list failed for ${frame.userId}: ${error}`,
472
+ );
473
+ send(socket, {
474
+ kind: "gh.installations.ack",
475
+ opId: frame.opId,
476
+ ok: false,
477
+ code,
478
+ error,
479
+ });
480
+ },
481
+ );
482
+ break;
483
+ }
484
+ case "gh.repositories.list": {
485
+ void listUserInstallationRepositoryIds(
486
+ frame.userId,
487
+ frame.installationId,
488
+ {},
489
+ frame.generation ?? 0,
490
+ ).then(
491
+ ({ repositoryIds, truncated }) =>
492
+ send(socket, {
493
+ kind: "gh.repositories.ack",
494
+ opId: frame.opId,
495
+ ok: true,
496
+ repositoryIds,
497
+ truncated,
498
+ }),
499
+ (err) => {
500
+ const error = err instanceof Error ? err.message : String(err);
501
+ const code =
502
+ err instanceof GitHubRepositoryAccessError
503
+ ? err.code
504
+ : "github_unavailable";
505
+ console.warn(
506
+ `[github] repository list failed for ${frame.userId}/${frame.installationId}: ${error}`,
507
+ );
508
+ send(socket, {
509
+ kind: "gh.repositories.ack",
510
+ opId: frame.opId,
511
+ ok: false,
512
+ code,
513
+ error,
514
+ });
515
+ },
516
+ );
517
+ break;
518
+ }
390
519
  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, {
520
+ // The serialized transition removes the local credential, waits for
521
+ // best-effort remote revocation, and scrubs live containers before it
522
+ // acknowledges. That keeps a delayed revoke from racing a later set.
523
+ void runGithubConnectionTransition(frame.userId, async () => {
524
+ const claim = claimGithubCredentialGeneration(
525
+ frame.userId,
526
+ frame.generation,
527
+ "clear",
528
+ );
529
+ if (!claim) {
530
+ return {
531
+ ok: false as const,
532
+ code: "stale_generation" as const,
533
+ error: "stale GitHub credential generation",
534
+ };
535
+ }
536
+ await onConnectClear(frame.userId, {
397
537
  invalidateCredentials: invalidateTaskGithubGitCredentials,
398
538
  reconcile: (taskId, userId) =>
399
539
  getOrchestrator().runTaskLifecycle(taskId, () =>
400
540
  reconcileTaskGitAuth(taskId, userId),
401
541
  ),
402
- }),
403
- ).then(
404
- () =>
405
- send(socket, {
406
- kind: "gh.connect.ack",
407
- userId: frame.userId,
408
- ok: true,
409
- }),
542
+ });
543
+ return { ok: true as const };
544
+ }).then(
545
+ (result) =>
546
+ send(
547
+ socket,
548
+ result.ok
549
+ ? {
550
+ kind: "gh.connect.ack",
551
+ opId: frame.opId,
552
+ userId: frame.userId,
553
+ ok: true,
554
+ }
555
+ : {
556
+ kind: "gh.connect.ack",
557
+ opId: frame.opId,
558
+ userId: frame.userId,
559
+ ok: false,
560
+ code: result.code,
561
+ error: result.error,
562
+ },
563
+ ),
410
564
  (err) => {
411
565
  const error = err instanceof Error ? err.message : String(err);
412
566
  console.warn(`[github] connect.clear failed: ${error}`);
413
567
  send(socket, {
414
568
  kind: "gh.connect.ack",
569
+ opId: frame.opId,
415
570
  userId: frame.userId,
416
571
  ok: false,
417
572
  error,
@@ -1127,12 +1282,17 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
1127
1282
  typeof frame.installationId === "number" &&
1128
1283
  typeof frame.githubLogin === "string" &&
1129
1284
  (frame.targetType === "User" || frame.targetType === "Organization") &&
1285
+ (frame.opId === undefined || typeof frame.opId === "string") &&
1286
+ parseGitHubCredentialGeneration(frame.generation) !== null &&
1130
1287
  // Exactly one token kind: accessToken (ADR-033) or refreshToken (ADR-027).
1131
1288
  (typeof frame.accessToken === "string" ||
1132
1289
  typeof frame.refreshToken === "string")
1133
1290
  ) {
1134
1291
  return {
1135
1292
  kind: "gh.connect.set",
1293
+ opId: typeof frame.opId === "string" ? frame.opId : undefined,
1294
+ generation:
1295
+ parseGitHubCredentialGeneration(frame.generation) ?? undefined,
1136
1296
  userId: frame.userId,
1137
1297
  installationId: frame.installationId,
1138
1298
  githubLogin: frame.githubLogin,
@@ -1147,8 +1307,51 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
1147
1307
  : undefined,
1148
1308
  };
1149
1309
  }
1150
- if (frame.kind === "gh.connect.clear" && typeof frame.userId === "string") {
1151
- return { kind: "gh.connect.clear", userId: frame.userId };
1310
+ if (
1311
+ frame.kind === "gh.connect.clear" &&
1312
+ typeof frame.userId === "string" &&
1313
+ (frame.opId === undefined || typeof frame.opId === "string") &&
1314
+ parseGitHubCredentialGeneration(frame.generation) !== null
1315
+ ) {
1316
+ return {
1317
+ kind: "gh.connect.clear",
1318
+ opId: typeof frame.opId === "string" ? frame.opId : undefined,
1319
+ generation:
1320
+ parseGitHubCredentialGeneration(frame.generation) ?? undefined,
1321
+ userId: frame.userId,
1322
+ };
1323
+ }
1324
+ if (
1325
+ frame.kind === "gh.installations.list" &&
1326
+ typeof frame.opId === "string" &&
1327
+ typeof frame.userId === "string" &&
1328
+ parseGitHubCredentialGeneration(frame.generation) !== null
1329
+ ) {
1330
+ return {
1331
+ kind: "gh.installations.list",
1332
+ opId: frame.opId,
1333
+ generation:
1334
+ parseGitHubCredentialGeneration(frame.generation) ?? undefined,
1335
+ userId: frame.userId,
1336
+ };
1337
+ }
1338
+ if (
1339
+ frame.kind === "gh.repositories.list" &&
1340
+ typeof frame.opId === "string" &&
1341
+ typeof frame.userId === "string" &&
1342
+ typeof frame.installationId === "number" &&
1343
+ Number.isSafeInteger(frame.installationId) &&
1344
+ frame.installationId > 0 &&
1345
+ parseGitHubCredentialGeneration(frame.generation) !== null
1346
+ ) {
1347
+ return {
1348
+ kind: "gh.repositories.list",
1349
+ opId: frame.opId,
1350
+ generation:
1351
+ parseGitHubCredentialGeneration(frame.generation) ?? undefined,
1352
+ userId: frame.userId,
1353
+ installationId: frame.installationId,
1354
+ };
1152
1355
  }
1153
1356
  if (
1154
1357
  (frame.kind === "ssh.key.get" ||
package/src/protocol.ts CHANGED
@@ -35,11 +35,23 @@ export type HostCommandResult<T> =
35
35
  export const TRANSCRIPT_TARGETS_PROTOCOL_FEATURE = "transcript-targets-v1";
36
36
  export const SECRETARY_TYPED_DISPATCH_PROTOCOL_FEATURE =
37
37
  "secretary-typed-dispatch-v1";
38
+ export const GITHUB_INSTALLATION_VERIFICATION_PROTOCOL_FEATURE =
39
+ "github-installation-verification-v1";
40
+ export const GITHUB_REPOSITORY_ACCESS_PROTOCOL_FEATURE =
41
+ "github-repository-access-v1";
42
+ export const GITHUB_CREDENTIAL_GENERATION_PROTOCOL_FEATURE =
43
+ "github-credential-generation-v1";
38
44
  export const COMMUNICATOR_EXECUTION_PROFILE = "communicator";
39
45
  export const MAX_AGENT_ID_CHARS = 128;
40
46
  export const MAX_SECRETARY_DISPATCH_RECIPIENTS = 16;
41
47
  export const MAX_SECRETARY_DISPATCH_INSTRUCTION_CHARS = 8_000;
42
48
  export const MAX_SECRETARY_DISPATCH_ID_CHARS = 256;
49
+ // Wire and GitHub-call bounds for the host-assisted authorization snapshot.
50
+ // Exceeding either bound fails the whole verification; a partial installation
51
+ // list could cause destructive pruning, while a partial repository list is an
52
+ // explicit false-negative allowlist and therefore carries `truncated: true`.
53
+ export const MAX_GITHUB_INSTALLATIONS = 100;
54
+ export const MAX_GITHUB_REPOSITORIES_PER_INSTALLATION = 1_000;
43
55
 
44
56
  export interface CommandContext {
45
57
  commandId: string;
@@ -133,6 +145,19 @@ export function parseChannelMode(
133
145
  throw new Error("invalid command args: expected channel mode");
134
146
  }
135
147
 
148
+ /** Optional during rolling upgrades; `null` is the explicit invalid sentinel
149
+ * so generation zero remains a valid migrated credential generation. */
150
+ export function parseGitHubCredentialGeneration(
151
+ value: unknown,
152
+ ): number | undefined | null {
153
+ if (value === undefined) return undefined;
154
+ return typeof value === "number" &&
155
+ Number.isSafeInteger(value) &&
156
+ value >= 0
157
+ ? value
158
+ : null;
159
+ }
160
+
136
161
  /** Parse the appendTranscript wire argument. `undefined` alone is the legacy
137
162
  * three-argument command and maps to chat.md during a rolling deploy. */
138
163
  export function parseTranscriptTargets(value: unknown): TranscriptTarget[] {
@@ -468,6 +493,13 @@ export interface TunnelReqLine {
468
493
 
469
494
  export type TunnelTarget = "editor" | "preview";
470
495
 
496
+ export type GitHubRepositoryAccessErrorCode =
497
+ | "token_missing"
498
+ | "reauth_required"
499
+ | "installation_inaccessible"
500
+ | "github_unavailable"
501
+ | "invalid_response";
502
+
471
503
  export type CloudToHost =
472
504
  | {
473
505
  kind: "command";
@@ -506,6 +538,12 @@ export type CloudToHost =
506
538
  // for short-lived access tokens (host→cloud HTTP POST).
507
539
  | {
508
540
  kind: "gh.connect.set";
541
+ /** Correlates the stored credential with the exact host acknowledgement.
542
+ * Optional only for rolling compatibility with pre-correlation clouds. */
543
+ opId?: string;
544
+ /** Monotonic cloud-issued mutation fence. Optional only for rolling
545
+ * compatibility; generation-aware clouds require the matching feature. */
546
+ generation?: number;
509
547
  userId: string;
510
548
  installationId: number;
511
549
  githubLogin: string;
@@ -516,7 +554,31 @@ export type CloudToHost =
516
554
  }
517
555
  // Delete the user's token on the host. ADR-033: the host first POSTs the
518
556
  // token to the cloud's /api/github/revoke (kill it at GitHub) before deleting.
519
- | { kind: "gh.connect.clear"; userId: string }
557
+ | {
558
+ kind: "gh.connect.clear";
559
+ opId?: string;
560
+ generation?: number;
561
+ userId: string;
562
+ }
563
+ // Ask the host which installations GitHub currently lists for a user, using
564
+ // the token the host already holds. Used to prune historical owner
565
+ // misbindings before any legacy row is trusted.
566
+ | {
567
+ kind: "gh.installations.list";
568
+ opId: string;
569
+ generation?: number;
570
+ userId: string;
571
+ }
572
+ // Renew one installation's App ∩ user repository allowlist without sending
573
+ // the user's token to the cloud. Kept separate from installation listing so
574
+ // ordinary one-row lease renewal is bounded and independently retryable.
575
+ | {
576
+ kind: "gh.repositories.list";
577
+ opId: string;
578
+ generation?: number;
579
+ userId: string;
580
+ installationId: number;
581
+ }
520
582
  // Per-user SSH key lifecycle (ADR-029). Keys are generated + stored on the
521
583
  // host; only the public key crosses the bridge (ssh.key.ack). `get` fetches
522
584
  // without creating, `ensure` creates-if-absent, `delete` removes.
@@ -594,8 +656,45 @@ export type HostToCloud =
594
656
  | { kind: "tunnel.data"; tunnelId: string }
595
657
  | { kind: "tunnel.close"; tunnelId: string; reason?: string }
596
658
  // Ack for gh.connect.set / gh.connect.clear (ADR-027).
597
- | { kind: "gh.connect.ack"; userId: string; ok: true }
598
- | { kind: "gh.connect.ack"; userId: string; ok: false; error: string }
659
+ | { kind: "gh.connect.ack"; opId?: string; userId: string; ok: true }
660
+ | {
661
+ kind: "gh.connect.ack";
662
+ opId?: string;
663
+ userId: string;
664
+ ok: false;
665
+ code?: "stale_generation";
666
+ error: string;
667
+ }
668
+ // Ack for gh.installations.list. `installationIds` is GitHub's current
669
+ // verdict and is authoritative ONLY on ok:true. An error must never be read
670
+ // as "installed nowhere", which would delete every binding the user has.
671
+ | {
672
+ kind: "gh.installations.ack";
673
+ opId: string;
674
+ ok: true;
675
+ installationIds: number[];
676
+ }
677
+ | {
678
+ kind: "gh.installations.ack";
679
+ opId: string;
680
+ ok: false;
681
+ code: GitHubRepositoryAccessErrorCode;
682
+ error: string;
683
+ }
684
+ | {
685
+ kind: "gh.repositories.ack";
686
+ opId: string;
687
+ ok: true;
688
+ repositoryIds: number[];
689
+ truncated: boolean;
690
+ }
691
+ | {
692
+ kind: "gh.repositories.ack";
693
+ opId: string;
694
+ ok: false;
695
+ code: GitHubRepositoryAccessErrorCode;
696
+ error: string;
697
+ }
599
698
  // Ack for ssh.key.get / ssh.key.ensure / ssh.key.delete (ADR-029). publicKey
600
699
  // is null when the user has no key (after delete, or a get-miss).
601
700
  | { kind: "ssh.key.ack"; userId: string; ok: true; publicKey: string | null }