@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/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
? {
|
|
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
|
-
//
|
|
392
|
-
// remote
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
ok
|
|
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 (
|
|
1151
|
-
|
|
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" ||
|