@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/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/main.ts +225 -22
- package/src/protocol.ts +102 -3
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
? {
|
|
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
|
-
//
|
|
392
|
-
// remote
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
ok
|
|
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 (
|
|
1151
|
-
|
|
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
|
-
| {
|
|
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
|
-
| {
|
|
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 }
|