@runuai/host 0.9.0 → 0.9.2

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.
@@ -7,11 +7,15 @@
7
7
  * exchange is a host→cloud HTTP POST to /api/github/oauth/exchange.
8
8
  */
9
9
 
10
- import { and, eq, isNull } from "drizzle-orm";
10
+ import { eq } from "drizzle-orm";
11
11
 
12
12
  import { getDb, schema } from "./db";
13
13
  import { sealAesGcm, openAesGcm } from "./secrets";
14
14
  import { dockerCli } from "./docker-exec";
15
+ import {
16
+ configureTaskSshTransport,
17
+ ensureTaskSshIdentity,
18
+ } from "./git-identity";
15
19
  import type { CloudToHost } from "../src/protocol";
16
20
 
17
21
  const REFRESH_LEAD_MS = 5 * 60 * 1000; // refresh 5 min before expiry
@@ -21,9 +25,126 @@ const EXEC_TIMEOUT_MS = 10_000;
21
25
  // (we never see the rotated replacement), so a slow success beats a fast
22
26
  // ambiguous abort.
23
27
  const EXCHANGE_TIMEOUT_MS = 30_000;
28
+ const MANAGED_GH_CONFIG_DIR = "/home/node/.config/gh";
29
+ const SAFE_CONTAINER_PATH = "/usr/bin:/bin";
30
+
31
+ // `docker exec` inherits the task container's project environment. Override
32
+ // loader/config variables before the first executable starts, then use env -i
33
+ // so arbitrary GIT_CONFIG_KEY_* and similar project keys cannot influence the
34
+ // managed gh/git mutation. Commands themselves use absolute paths.
35
+ const SANITIZED_DOCKER_EXEC_ENV = [
36
+ "-e",
37
+ "HOME=/home/node",
38
+ "-e",
39
+ `GH_CONFIG_DIR=${MANAGED_GH_CONFIG_DIR}`,
40
+ "-e",
41
+ `PATH=${SAFE_CONTAINER_PATH}`,
42
+ "-e",
43
+ "XDG_CONFIG_HOME=",
44
+ "-e",
45
+ "LD_PRELOAD=",
46
+ "-e",
47
+ "LD_LIBRARY_PATH=",
48
+ "-e",
49
+ "DYLD_INSERT_LIBRARIES=",
50
+ "-e",
51
+ "DYLD_LIBRARY_PATH=",
52
+ "-e",
53
+ "GIT_CONFIG=",
54
+ "-e",
55
+ "GIT_CONFIG_GLOBAL=",
56
+ "-e",
57
+ "GIT_CONFIG_SYSTEM=",
58
+ "-e",
59
+ "GIT_CONFIG_NOSYSTEM=",
60
+ "-e",
61
+ "GIT_CONFIG_COUNT=0",
62
+ "-e",
63
+ "GIT_EXEC_PATH=",
64
+ "-e",
65
+ "GIT_SSH=",
66
+ "-e",
67
+ "GIT_SSH_COMMAND=",
68
+ "-e",
69
+ "GIT_ASKPASS=",
70
+ "-e",
71
+ "SSH_ASKPASS=",
72
+ ] as const;
73
+
74
+ const CLEAN_CONTAINER_ENV = [
75
+ "/usr/bin/env",
76
+ "-i",
77
+ "HOME=/home/node",
78
+ `GH_CONFIG_DIR=${MANAGED_GH_CONFIG_DIR}`,
79
+ `PATH=${SAFE_CONTAINER_PATH}`,
80
+ ] as const;
81
+
82
+ function managedContainerExecArgs(
83
+ container: string,
84
+ command: string[],
85
+ stdin = false,
86
+ ): string[] {
87
+ return [
88
+ "exec",
89
+ ...(stdin ? ["-i"] : []),
90
+ "-u",
91
+ "node",
92
+ ...SANITIZED_DOCKER_EXEC_ENV,
93
+ container,
94
+ ...CLEAN_CONTAINER_ENV,
95
+ ...command,
96
+ ];
97
+ }
24
98
 
25
99
  type GhConnectSet = Extract<CloudToHost, { kind: "gh.connect.set" }>;
26
100
 
101
+ // Every local grant replacement/removal advances the user's generation. Slow
102
+ // token exchanges and container mutations capture the generation they began
103
+ // under and must not publish their result after a newer connect/disconnect.
104
+ const githubAuthGenerations = new Map<string, number>();
105
+ const githubConnectionTransitionQueues = new Map<string, Promise<void>>();
106
+
107
+ function githubAuthGeneration(userId: string): number {
108
+ return githubAuthGenerations.get(userId) ?? 0;
109
+ }
110
+
111
+ function advanceGithubAuthGeneration(userId: string): number {
112
+ const next = githubAuthGeneration(userId) + 1;
113
+ githubAuthGenerations.set(userId, next);
114
+ accessCache.delete(userId);
115
+ // The request itself cannot be cancelled safely (the refresh token may have
116
+ // rotated at GitHub), but a new grant must never join that stale promise.
117
+ inflightExchanges.delete(userId);
118
+ return next;
119
+ }
120
+
121
+ /** Serialize connect/set/clear for one user. Without this outer fence, a clear
122
+ * that lands while set is awaiting task-up invalidation can be overwritten by
123
+ * the older set storing its token afterward. */
124
+ export function runGithubConnectionTransition<T>(
125
+ userId: string,
126
+ transition: () => Promise<T>,
127
+ ): Promise<T> {
128
+ const previous =
129
+ githubConnectionTransitionQueues.get(userId) ?? Promise.resolve();
130
+ let result!: Promise<T>;
131
+ let tail!: Promise<void>;
132
+ result = previous
133
+ .catch(() => {})
134
+ .then(transition)
135
+ .finally(() => {
136
+ if (githubConnectionTransitionQueues.get(userId) === tail) {
137
+ githubConnectionTransitionQueues.delete(userId);
138
+ }
139
+ });
140
+ tail = result.then(
141
+ () => {},
142
+ () => {},
143
+ );
144
+ githubConnectionTransitionQueues.set(userId, tail);
145
+ return result;
146
+ }
147
+
27
148
  // --- token store ------------------------------------------------------------
28
149
 
29
150
  /**
@@ -32,15 +153,42 @@ type GhConnectSet = Extract<CloudToHost, { kind: "gh.connect.set" }>;
32
153
  * directly); a legacy expiring grant carries `refreshToken` (kind="refresh",
33
154
  * exchanged per task). Exactly one is present.
34
155
  */
35
- export function onConnectSet(frame: GhConnectSet): { ok: boolean; error?: string } {
156
+ export interface ConnectSetDeps {
157
+ invalidateCredentials?: (userId: string) => Promise<void>;
158
+ readStored?: (
159
+ userId: string,
160
+ ) => { token: string; kind: string } | null;
161
+ deleteLocal?: (userId: string) => void;
162
+ storeLocal?: (
163
+ userId: string,
164
+ fields: {
165
+ installationId: number;
166
+ refreshTokenCt: Buffer;
167
+ refreshTokenNonce: Buffer;
168
+ refreshTokenExpiresAt: number | null;
169
+ kind: string;
170
+ updatedAt: number;
171
+ },
172
+ ) => void;
173
+ taskIds?: (userId: string) => string[];
174
+ reconcile?: (taskId: string, userId: string) => Promise<boolean>;
175
+ revoke?: (token: string) => Promise<void>;
176
+ seal?: typeof sealAesGcm;
177
+ notifyChange?: () => void;
178
+ }
179
+
180
+ export async function onConnectSet(
181
+ frame: GhConnectSet,
182
+ deps: ConnectSetDeps = {},
183
+ ): Promise<{ ok: boolean; error?: string }> {
184
+ let oldGrantRemoved = false;
185
+ let replacementStored = false;
186
+ let priorGrant: { token: string; kind: string } | null = null;
36
187
  try {
37
- // Fresh grant — a cached access token from the previous grant may be
38
- // revoked; drop it so the next mint exchanges against the new token.
39
- clearAccessCache(frame.userId);
40
188
  const token = frame.accessToken ?? frame.refreshToken;
41
189
  if (!token) return { ok: false, error: "gh.connect.set carried no token" };
42
190
  const kind = frame.accessToken ? "access" : "refresh";
43
- const sealed = sealAesGcm(token);
191
+ const sealed = (deps.seal ?? sealAesGcm)(token);
44
192
  const now = Date.now();
45
193
  const fields = {
46
194
  installationId: frame.installationId,
@@ -50,29 +198,142 @@ export function onConnectSet(frame: GhConnectSet): { ok: boolean; error?: string
50
198
  kind,
51
199
  updatedAt: now,
52
200
  };
53
- getDb()
54
- .insert(schema.githubTokens)
55
- .values({ userId: frame.userId, ...fields })
56
- .onConflictDoUpdate({ target: schema.githubTokens.userId, set: fields })
57
- .run();
58
- notifyGithubChange();
59
- // A reconnect must reach ALREADY-RUNNING containers, not just new tasks:
60
- // each container's gh config is written once, at task setup, so a fresh
61
- // grant otherwise sits unused until /retry-gh. Re-inject into the user's
62
- // live tasks now. Fire-and-forget — never blocks the token store.
63
- reinjectRunningTasks(frame.userId);
201
+
202
+ try {
203
+ priorGrant = (deps.readStored ?? readStoredToken)(frame.userId);
204
+ } catch (err) {
205
+ // A corrupt superseded row must not prevent replacement. It can still be
206
+ // deleted locally and every container is authoritatively scrubbed below.
207
+ console.warn(
208
+ `[github] user ${frame.userId}: could not read token before replacement: ${
209
+ err instanceof Error ? err.message : err
210
+ }`,
211
+ );
212
+ }
213
+
214
+ // Remove the old grant before fencing host-side Git. A task that begins
215
+ // while account switching is in progress must not mint or seed the old
216
+ // user's credential. The replacement is already sealed above, so failures
217
+ // before this point leave the existing connection untouched.
218
+ (deps.deleteLocal ?? deleteStoredTokenRow)(frame.userId);
219
+ oldGrantRemoved = true;
220
+ advanceGithubAuthGeneration(frame.userId);
221
+
222
+ // Account switching must also retire A remotely. Skip when GitHub sent the
223
+ // same access token again, which would otherwise revoke replacement B.
224
+ if (
225
+ priorGrant?.kind === "access" &&
226
+ priorGrant.token !== token
227
+ ) {
228
+ startBestEffortRevoke(
229
+ frame.userId,
230
+ priorGrant.token,
231
+ deps.revoke ?? revokeAtGitHub,
232
+ );
233
+ }
234
+
235
+ // Existing task-up operations may still be using the old ephemeral cache.
236
+ // Invalidate and drain them before publishing the replacement grant.
237
+ await deps.invalidateCredentials?.(frame.userId);
238
+
239
+ (deps.storeLocal ?? storeTokenRow)(frame.userId, fields);
240
+ replacementStored = true;
241
+ // Advance again only after the replacement is durable. This detaches work
242
+ // started during the no-grant fence and lets reconciliation capture B.
243
+ advanceGithubAuthGeneration(frame.userId);
244
+ (deps.notifyChange ?? notifyGithubChange)();
245
+
246
+ // Account-switch acknowledgement is a credential boundary: wait until each
247
+ // container that could retain A has been removed or scrubbed and configured
248
+ // with B. Main injects a task-lifecycle wrapper so a concurrent task-up is
249
+ // completed before this per-task credential transition runs.
250
+ const taskIds = (deps.taskIds ?? credentialCleanupTaskIdsForUser)(
251
+ frame.userId,
252
+ );
253
+ const reconcile = deps.reconcile ?? reconcileTaskGitAuth;
254
+ const settled = await Promise.allSettled(
255
+ taskIds.map((taskId) => reconcile(taskId, frame.userId)),
256
+ );
257
+ const rejected = settled.find(
258
+ (result): result is PromiseRejectedResult => result.status === "rejected",
259
+ );
260
+ if (rejected) throw rejected.reason;
261
+ if (settled.some((result) => result.status === "fulfilled" && !result.value)) {
262
+ throw new Error("GitHub credential replacement did not reach every task");
263
+ }
64
264
  return { ok: true };
65
265
  } catch (err) {
266
+ // If the old grant was already removed, advertise the authoritative local
267
+ // state and scrub live containers through the normal no-token reconciler.
268
+ // Never leave a failed account switch looking connected to the old user.
269
+ if (oldGrantRemoved && !replacementStored) {
270
+ (deps.notifyChange ?? notifyGithubChange)();
271
+ const taskIds = (deps.taskIds ?? credentialCleanupTaskIdsForUser)(
272
+ frame.userId,
273
+ );
274
+ const reconcile = deps.reconcile ?? reconcileTaskGitAuth;
275
+ await Promise.allSettled(
276
+ taskIds.map((taskId) => reconcile(taskId, frame.userId)),
277
+ );
278
+ }
66
279
  return { ok: false, error: err instanceof Error ? err.message : "store failed" };
67
280
  }
68
281
  }
69
282
 
283
+ function deleteStoredTokenRow(userId: string): void {
284
+ getDb()
285
+ .delete(schema.githubTokens)
286
+ .where(eq(schema.githubTokens.userId, userId))
287
+ .run();
288
+ }
289
+
290
+ function storeTokenRow(
291
+ userId: string,
292
+ fields: {
293
+ installationId: number;
294
+ refreshTokenCt: Buffer;
295
+ refreshTokenNonce: Buffer;
296
+ refreshTokenExpiresAt: number | null;
297
+ kind: string;
298
+ updatedAt: number;
299
+ },
300
+ ): void {
301
+ getDb()
302
+ .insert(schema.githubTokens)
303
+ .values({ userId, ...fields })
304
+ .onConflictDoUpdate({ target: schema.githubTokens.userId, set: fields })
305
+ .run();
306
+ }
307
+
308
+ function startBestEffortRevoke(
309
+ userId: string,
310
+ token: string,
311
+ revoke: (token: string) => Promise<void>,
312
+ ): void {
313
+ try {
314
+ void revoke(token).catch((err) =>
315
+ console.warn(
316
+ `[github] user ${userId}: token revoke failed after local removal: ${
317
+ err instanceof Error ? err.message : err
318
+ }`,
319
+ ),
320
+ );
321
+ } catch (err) {
322
+ console.warn(
323
+ `[github] user ${userId}: token revoke failed after local removal: ${
324
+ err instanceof Error ? err.message : err
325
+ }`,
326
+ );
327
+ }
328
+ }
329
+
70
330
  /**
71
331
  * Disconnect GitHub on this host (gh.connect.clear handler). Deletes the token
72
332
  * locally FIRST (so capabilities re-advertise immediately and the UI reflects
73
- * removal without waiting on the network), then best-effort revokes it at
74
- * GitHub. Whole body is guarded a fired-and-forgotten failure must never
75
- * crash the host (no global unhandledRejection handler).
333
+ * removal without waiting on the network), starts best-effort revocation, then
334
+ * waits only for every live container to drop the credential and select its
335
+ * disconnected transport. The bridge ack is therefore truthful without being
336
+ * gated on GitHub's revoke round-trip.
76
337
  *
77
338
  * ADR-033: only a non-expiring ACCESS token can be revoked by token
78
339
  * (`DELETE /applications/{client_id}/token` matches access tokens only — a
@@ -80,26 +341,74 @@ export function onConnectSet(frame: GhConnectSet): { ok: boolean; error?: string
80
341
  * Legacy refresh tokens aren't single-token revocable; the short-lived access
81
342
  * tokens they mint expire on their own.
82
343
  */
83
- export async function onConnectClear(userId: string): Promise<void> {
344
+ export interface ConnectClearDeps {
345
+ readStored?: (
346
+ userId: string,
347
+ ) => { token: string; kind: string } | null;
348
+ deleteLocal?: (userId: string) => void;
349
+ activeTaskIds?: (userId: string) => string[];
350
+ reconcile?: (taskId: string, userId: string) => Promise<boolean>;
351
+ revoke?: (token: string) => Promise<void>;
352
+ invalidateCredentials?: (userId: string) => Promise<void>;
353
+ }
354
+
355
+ export async function onConnectClear(
356
+ userId: string,
357
+ deps: ConnectClearDeps = {},
358
+ ): Promise<void> {
359
+ let stored: { token: string; kind: string } | null = null;
84
360
  try {
85
- const stored = readStoredToken(userId);
86
- deleteToken(userId); // fires onGithubChange → re-advertise capabilities
87
- for (const taskId of activeTaskIdsForUser(userId)) {
88
- clearRefresh(taskId);
89
- authExpiredHandler?.(taskId, userId, "GitHub disconnected");
90
- }
91
- if (stored && stored.kind === "access") {
92
- await revokeAtGitHub(stored.token);
93
- } else if (stored) {
94
- console.warn(
95
- `[github] user ${userId}: legacy refresh token cleared locally; cannot single-token revoke at GitHub`,
96
- );
97
- }
361
+ stored = (deps.readStored ?? readStoredToken)(userId);
98
362
  } catch (err) {
363
+ // A corrupt legacy row must not make Disconnect impossible. We can still
364
+ // delete it locally and clean every live container; only remote revocation
365
+ // is unavailable because the token could not be decrypted.
99
366
  console.warn(
100
- `[github] disconnect failed: ${err instanceof Error ? err.message : err}`,
367
+ `[github] user ${userId}: could not read token before disconnect: ${
368
+ err instanceof Error ? err.message : err
369
+ }`,
101
370
  );
102
371
  }
372
+
373
+ // Local capability removal is synchronous and authoritative. It must happen
374
+ // even when token decryption or GitHub's revoke endpoint is unavailable.
375
+ (deps.deleteLocal ?? deleteToken)(userId);
376
+
377
+ // Start remote revocation immediately but do not put it on the ack path.
378
+ // revokeAtGitHub catches its own failures; the catch also makes injected
379
+ // test/alternate implementations safely fire-and-forget.
380
+ if (stored?.kind === "access") {
381
+ startBestEffortRevoke(
382
+ userId,
383
+ stored.token,
384
+ deps.revoke ?? revokeAtGitHub,
385
+ );
386
+ } else if (stored) {
387
+ console.warn(
388
+ `[github] user ${userId}: legacy refresh token cleared locally; cannot single-token revoke at GitHub`,
389
+ );
390
+ }
391
+
392
+ // Incrementing the host-Git generation prevents any not-yet-started use of
393
+ // an old prepared handle. Await already-started task-up operations before
394
+ // discovering and scrubbing containers: a task may create its app container
395
+ // while Disconnect is in flight.
396
+ await deps.invalidateCredentials?.(userId);
397
+
398
+ const activeTasks = (deps.activeTaskIds ?? credentialCleanupTaskIdsForUser)(
399
+ userId,
400
+ );
401
+ for (const taskId of activeTasks) {
402
+ clearRefresh(taskId);
403
+ }
404
+ const reconcile = deps.reconcile ?? reconcileTaskGitAuth;
405
+ const settled = await Promise.allSettled(
406
+ activeTasks.map((taskId) => reconcile(taskId, userId)),
407
+ );
408
+ const failed = settled.find(
409
+ (result): result is PromiseRejectedResult => result.status === "rejected",
410
+ );
411
+ if (failed) throw failed.reason;
103
412
  }
104
413
 
105
414
  /** Decrypt the user's stored token + its kind, or null if none. */
@@ -120,11 +429,11 @@ function readStoredToken(
120
429
  }
121
430
 
122
431
  export function deleteToken(userId: string): void {
123
- clearAccessCache(userId);
124
432
  getDb()
125
433
  .delete(schema.githubTokens)
126
434
  .where(eq(schema.githubTokens.userId, userId))
127
435
  .run();
436
+ advanceGithubAuthGeneration(userId);
128
437
  notifyGithubChange();
129
438
  }
130
439
 
@@ -161,20 +470,48 @@ function notifyGithubChange(): void {
161
470
  }
162
471
  }
163
472
 
164
- function activeTaskIdsForUser(userId: string): string[] {
473
+ export function taskMayRetainGithubCredential(task: {
474
+ endedAt: number | null;
475
+ composeProject: string | null;
476
+ statusMirror: string | null;
477
+ }): boolean {
478
+ // Active/starting rows may gain a container while Disconnect is running.
479
+ // Ended rows need cleanup only when their compose identity is preserved;
480
+ // task-down clears composeProject after destroying the stack. A failed
481
+ // task-up can leave a conventional app container before compose metadata was
482
+ // persisted, so error rows are probed by task id as well.
483
+ return (
484
+ task.endedAt == null ||
485
+ task.composeProject != null ||
486
+ task.statusMirror === "error"
487
+ );
488
+ }
489
+
490
+ function taskCredentialRowsForUser(userId: string) {
165
491
  return getDb()
166
- .select({ taskId: schema.hostTasks.taskId })
492
+ .select({
493
+ taskId: schema.hostTasks.taskId,
494
+ endedAt: schema.hostTasks.endedAt,
495
+ composeProject: schema.hostTasks.composeProject,
496
+ statusMirror: schema.hostTasks.statusMirror,
497
+ })
167
498
  .from(schema.hostTasks)
168
- .where(
169
- and(
170
- eq(schema.hostTasks.ownerUserId, userId),
171
- isNull(schema.hostTasks.endedAt),
172
- ),
173
- )
174
- .all()
499
+ .where(eq(schema.hostTasks.ownerUserId, userId))
500
+ .all();
501
+ }
502
+
503
+ function credentialCleanupTaskIdsForUser(userId: string): string[] {
504
+ return taskCredentialRowsForUser(userId)
505
+ .filter(taskMayRetainGithubCredential)
175
506
  .map((r) => r.taskId);
176
507
  }
177
508
 
509
+ function runningTaskIdsForUser(userId: string): string[] {
510
+ return taskCredentialRowsForUser(userId)
511
+ .filter((task) => task.endedAt == null)
512
+ .map((task) => task.taskId);
513
+ }
514
+
178
515
  /**
179
516
  * Re-mint + inject a (re)connected token into the user's live tasks — so a
180
517
  * reconnect on Account applies to running containers, not only new tasks. Clear
@@ -183,9 +520,15 @@ function activeTaskIdsForUser(userId: string): string[] {
183
520
  * run setup fresh. Fire-and-forget per task; best-effort by construction.
184
521
  */
185
522
  export function reinjectRunningTasks(userId: string): void {
186
- for (const taskId of activeTaskIdsForUser(userId)) {
523
+ for (const taskId of runningTaskIdsForUser(userId)) {
187
524
  clearRefresh(taskId);
188
- void setupTaskGithub(taskId, userId);
525
+ void reconcileTaskGitAuth(taskId, userId).catch((err) =>
526
+ console.warn(
527
+ `[github] task ${taskId}: credential reconciliation failed: ${
528
+ err instanceof Error ? err.message : err
529
+ }`,
530
+ ),
531
+ );
189
532
  }
190
533
  }
191
534
 
@@ -252,7 +595,10 @@ interface ExchangeResult {
252
595
  */
253
596
  const inflightExchanges = new Map<
254
597
  string,
255
- Promise<{ accessToken: string; expiresAt: number } | null>
598
+ {
599
+ generation: number;
600
+ promise: Promise<{ accessToken: string; expiresAt: number } | null>;
601
+ }
256
602
  >();
257
603
 
258
604
  /**
@@ -271,19 +617,17 @@ const accessCache = new Map<
271
617
  { accessToken: string; expiresAt: number }
272
618
  >();
273
619
 
274
- /** Drop a user's cached access token (token deleted / re-granted). */
275
- function clearAccessCache(userId: string): void {
276
- accessCache.delete(userId);
277
- }
278
-
279
620
  /** Test hook: reset the access-token cache. */
280
621
  export function clearAllAccessCache(): void {
281
622
  accessCache.clear();
623
+ inflightExchanges.clear();
624
+ githubAuthGenerations.clear();
282
625
  }
283
626
 
284
627
  export function requestAccessToken(
285
628
  userId: string,
286
629
  ): Promise<{ accessToken: string; expiresAt: number | null } | null> {
630
+ const generation = githubAuthGeneration(userId);
287
631
  // ADR-033 non-expiring path: the stored token IS the access token — return it
288
632
  // directly, no exchange/cache/rotation. `expiresAt: null` ⇒ no refresh.
289
633
  const row = getDb()
@@ -305,21 +649,27 @@ export function requestAccessToken(
305
649
  return Promise.resolve(cached);
306
650
  }
307
651
  const existing = inflightExchanges.get(userId);
308
- if (existing) return existing;
309
- const run = doRequestAccessToken(userId)
652
+ if (existing?.generation === generation) return existing.promise;
653
+ let run!: Promise<{ accessToken: string; expiresAt: number } | null>;
654
+ run = doRequestAccessToken(userId, generation)
310
655
  .then((tok) => {
311
- if (tok) accessCache.set(userId, tok);
656
+ if (tok && githubAuthGeneration(userId) === generation) {
657
+ accessCache.set(userId, tok);
658
+ }
312
659
  return tok;
313
660
  })
314
661
  .finally(() => {
315
- inflightExchanges.delete(userId);
662
+ if (inflightExchanges.get(userId)?.promise === run) {
663
+ inflightExchanges.delete(userId);
664
+ }
316
665
  });
317
- inflightExchanges.set(userId, run);
666
+ inflightExchanges.set(userId, { generation, promise: run });
318
667
  return run;
319
668
  }
320
669
 
321
670
  async function doRequestAccessToken(
322
671
  userId: string,
672
+ generation: number,
323
673
  ): Promise<{ accessToken: string; expiresAt: number } | null> {
324
674
  const row = getDb()
325
675
  .select()
@@ -357,7 +707,13 @@ async function doRequestAccessToken(
357
707
  throw new Error(`gh token exchange failed: HTTP ${res.status}${detail}`);
358
708
  }
359
709
  const data = (await res.json()) as ExchangeResult;
360
- if (data.refreshToken) {
710
+ // A reconnect/disconnect may have replaced the DB row while the network
711
+ // exchange was in flight. Never overwrite that newer grant with the rotated
712
+ // refresh token from the superseded exchange.
713
+ if (
714
+ data.refreshToken &&
715
+ githubAuthGeneration(userId) === generation
716
+ ) {
361
717
  const sealed = sealAesGcm(data.refreshToken);
362
718
  getDb()
363
719
  .update(schema.githubTokens)
@@ -388,30 +744,68 @@ const defaultExec: DockerExec = async (args, input) => {
388
744
  return { status: res.status, stderr: res.stderr };
389
745
  };
390
746
 
391
- /** Write the access token into the container's gh config via `gh auth login`. */
747
+ /**
748
+ * Write the access token into the container's gh config and make gh Git's
749
+ * HTTPS credential helper. Also removes the legacy global HTTPS→SSH rewrite so
750
+ * reconnecting heals already-running containers without recreation.
751
+ */
392
752
  export async function injectIntoContainer(
393
753
  taskId: string,
394
754
  accessToken: string,
395
755
  exec: DockerExec = defaultExec,
396
756
  ): Promise<void> {
397
757
  const container = `task-${taskId}-app-1`;
398
- const args = [
399
- "exec",
400
- "-i",
401
- "-u",
402
- "node",
758
+ const args = managedContainerExecArgs(
403
759
  container,
404
- "gh",
405
- "auth",
406
- "login",
407
- "--with-token",
408
- "--hostname",
409
- "github.com",
410
- ];
760
+ [
761
+ "/usr/bin/gh",
762
+ "auth",
763
+ "login",
764
+ "--with-token",
765
+ "--hostname",
766
+ "github.com",
767
+ "--git-protocol",
768
+ "https",
769
+ ],
770
+ true,
771
+ );
411
772
  const res = await exec(args, `${accessToken}\n`);
412
773
  if (res.status !== 0) {
413
774
  throw new Error(`gh auth login failed in ${container}: ${res.stderr.trim()}`);
414
775
  }
776
+
777
+ const setup = await exec(
778
+ managedContainerExecArgs(container, [
779
+ "/usr/bin/gh",
780
+ "auth",
781
+ "setup-git",
782
+ "--hostname",
783
+ "github.com",
784
+ ]),
785
+ "",
786
+ );
787
+ if (setup.status !== 0) {
788
+ throw new Error(
789
+ `gh git credential setup failed in ${container}: ${setup.stderr.trim()}`,
790
+ );
791
+ }
792
+
793
+ const transport = await exec(
794
+ managedContainerExecArgs(container, [
795
+ "/bin/sh",
796
+ "-c",
797
+ "/usr/bin/git config --global --unset-all 'url.git@github.com:.insteadOf' >/dev/null 2>&1 || true; " +
798
+ "/usr/bin/git config --global --unset-all core.sshCommand >/dev/null 2>&1 || true; " +
799
+ "/usr/bin/git config --global --replace-all 'url.https://github.com/.insteadOf' 'git@github.com:'; " +
800
+ "/usr/bin/git config --global --add 'url.https://github.com/.insteadOf' 'ssh://git@github.com/'",
801
+ ]),
802
+ "",
803
+ );
804
+ if (transport.status !== 0) {
805
+ throw new Error(
806
+ `Git HTTPS transport setup failed in ${container}: ${transport.stderr.trim()}`,
807
+ );
808
+ }
415
809
  }
416
810
 
417
811
  // --- refresh schedule -------------------------------------------------------
@@ -430,11 +824,26 @@ export function scheduleRefresh(
430
824
  taskId: string,
431
825
  userId: string,
432
826
  expiresAt: number,
827
+ generation = githubAuthGeneration(userId),
433
828
  ): void {
434
829
  clearRefresh(taskId);
435
830
  const delay = Math.max(0, expiresAt - Date.now() - REFRESH_LEAD_MS);
436
831
  const timer = setTimeout(() => {
437
- void runRefresh(taskId, userId);
832
+ timers.delete(taskId);
833
+ if (githubAuthGeneration(userId) !== generation) return;
834
+ void enqueueTaskGitAuth(taskId, userId, {}, {
835
+ generation,
836
+ getGeneration: () => githubAuthGeneration(userId),
837
+ attempt: 0,
838
+ }).catch((err) => {
839
+ if (githubAuthGeneration(userId) !== generation) return;
840
+ console.warn(
841
+ `[github] task ${taskId}: refresh reconciliation failed: ${
842
+ err instanceof Error ? err.message : err
843
+ }`,
844
+ );
845
+ scheduleGithubRetry(taskId, userId, 0, {}, generation);
846
+ });
438
847
  }, delay);
439
848
  timer.unref?.();
440
849
  timers.set(taskId, timer);
@@ -484,30 +893,6 @@ export function isTransientGithubError(reason: string): boolean {
484
893
  );
485
894
  }
486
895
 
487
- async function runRefresh(taskId: string, userId: string): Promise<void> {
488
- try {
489
- const tok = await requestAccessToken(userId);
490
- if (!tok) {
491
- authExpiredHandler?.(taskId, userId, "no GitHub token on host");
492
- return;
493
- }
494
- await injectIntoContainer(taskId, tok.accessToken);
495
- // Only re-schedule for an expiring token; non-expiring needs no refresh.
496
- if (tok.expiresAt !== null) scheduleRefresh(taskId, userId, tok.expiresAt);
497
- } catch (err) {
498
- const reason = err instanceof Error ? err.message : String(err);
499
- // A revoked / expired refresh token can't recover — drop it to force a
500
- // clean re-grant through the OAuth flow. Anything else is likely transient
501
- // (network / cloud blip) — self-heal with a bounded retry.
502
- if (isRevokedTokenError(reason)) {
503
- deleteToken(userId);
504
- } else {
505
- scheduleGithubRetry(taskId, userId, 0);
506
- }
507
- authExpiredHandler?.(taskId, userId, reason);
508
- }
509
- }
510
-
511
896
  export function clearRefresh(taskId: string): void {
512
897
  clearGithubRetry(taskId);
513
898
  const t = timers.get(taskId);
@@ -551,6 +936,7 @@ function scheduleGithubRetry(
551
936
  userId: string,
552
937
  attempt: number,
553
938
  deps: SetupTaskDeps = {},
939
+ generation = githubAuthGeneration(userId),
554
940
  ): void {
555
941
  clearGithubRetry(taskId);
556
942
  if (attempt >= RETRY_BACKOFF_MS.length) {
@@ -568,9 +954,23 @@ function scheduleGithubRetry(
568
954
  const delay = RETRY_BACKOFF_MS[attempt] ?? 300_000;
569
955
  const timer = setTimeout(() => {
570
956
  retryTimers.delete(taskId);
957
+ if (githubAuthGeneration(userId) !== generation) return;
571
958
  const isActive = deps.taskIsActive ?? taskIsActive;
572
959
  if (!isActive(taskId)) return; // task ended while we backed off
573
- void setupTaskGithub(taskId, userId, deps, attempt + 1);
960
+ void enqueueTaskGitAuth(taskId, userId, {}, {
961
+ generation,
962
+ getGeneration: () => githubAuthGeneration(userId),
963
+ setupDeps: deps,
964
+ attempt: attempt + 1,
965
+ }).catch((err) => {
966
+ if (githubAuthGeneration(userId) !== generation) return;
967
+ console.warn(
968
+ `[github] task ${taskId}: retry reconciliation failed: ${
969
+ err instanceof Error ? err.message : err
970
+ }`,
971
+ );
972
+ scheduleGithubRetry(taskId, userId, attempt + 1, deps, generation);
973
+ });
574
974
  }, delay);
575
975
  timer.unref?.();
576
976
  retryTimers.set(taskId, timer);
@@ -594,6 +994,7 @@ export interface SetupTaskDeps {
594
994
  schedule?: (taskId: string, userId: string, expiresAt: number) => void;
595
995
  deleteToken?: (userId: string) => void;
596
996
  taskIsActive?: (taskId: string) => boolean;
997
+ scrub?: (taskId: string) => Promise<boolean | void>;
597
998
  }
598
999
 
599
1000
  /**
@@ -603,36 +1004,55 @@ export interface SetupTaskDeps {
603
1004
  * a failure emits the auth-expired note but never aborts task-up. Returns true
604
1005
  * iff a token was injected.
605
1006
  */
606
- /** Tasks whose gh setup is currently in flight. task-up and the first
607
- * channel-ensure both kick this off and can overlap; a second concurrent
608
- * `gh auth login` would race the container's gh config, so we dedupe. */
609
- const setupInFlight = new Set<string>();
1007
+ interface SetupExecutionGuard {
1008
+ generation: number;
1009
+ isCurrent: () => boolean;
1010
+ }
610
1011
 
611
1012
  export async function setupTaskGithub(
612
1013
  taskId: string,
613
1014
  userId: string,
614
1015
  deps: SetupTaskDeps = {},
615
1016
  attempt = 0,
1017
+ guard?: SetupExecutionGuard,
616
1018
  ): Promise<boolean> {
1019
+ const capturedGeneration =
1020
+ guard?.generation ?? githubAuthGeneration(userId);
1021
+ const isCurrent =
1022
+ guard?.isCurrent ??
1023
+ (() => githubAuthGeneration(userId) === capturedGeneration);
617
1024
  const _hasToken = deps.hasToken ?? hasToken;
618
1025
  const _request = deps.requestAccessToken ?? requestAccessToken;
619
1026
  const _inject =
620
1027
  deps.inject ?? ((t: string, tok: string) => injectIntoContainer(t, tok));
621
- const _schedule = deps.schedule ?? scheduleRefresh;
622
- if (setupInFlight.has(taskId)) return false;
623
- setupInFlight.add(taskId);
1028
+ if (!isCurrent()) return false;
624
1029
  try {
625
1030
  if (_hasToken(userId)) {
626
1031
  const tok = await _request(userId);
627
- if (tok) {
1032
+ if (tok && isCurrent()) {
628
1033
  await _inject(taskId, tok.accessToken);
1034
+ // The mutation itself cannot be interrupted, but a newer transition is
1035
+ // already queued behind us. Do not let this stale run arm more work.
1036
+ if (!isCurrent()) return false;
629
1037
  // Non-expiring (ADR-033) tokens (expiresAt null) need no refresh timer.
630
- if (tok.expiresAt !== null) _schedule(taskId, userId, tok.expiresAt);
1038
+ if (tok.expiresAt !== null) {
1039
+ if (deps.schedule) {
1040
+ deps.schedule(taskId, userId, tok.expiresAt);
1041
+ } else {
1042
+ scheduleRefresh(
1043
+ taskId,
1044
+ userId,
1045
+ tok.expiresAt,
1046
+ capturedGeneration,
1047
+ );
1048
+ }
1049
+ }
631
1050
  clearGithubRetry(taskId);
632
1051
  console.log(`[github] task ${taskId}: injected user access token`);
633
1052
  return true;
634
1053
  }
635
1054
  }
1055
+ if (!isCurrent()) return false;
636
1056
  const pat = process.env.UAI_GH_PAT_FALLBACK;
637
1057
  if (pat) {
638
1058
  await _inject(taskId, pat);
@@ -643,6 +1063,9 @@ export async function setupTaskGithub(
643
1063
  console.log(`[github] task ${taskId}: gh not configured for user ${userId}`);
644
1064
  return false;
645
1065
  } catch (err) {
1066
+ // A newer connect/disconnect owns the final state and is queued behind this
1067
+ // operation. The superseded run must neither delete its grant nor retry.
1068
+ if (!isCurrent()) return false;
646
1069
  const reason = err instanceof Error ? err.message : String(err);
647
1070
  console.warn(`[github] task ${taskId}: gh setup failed: ${reason}`);
648
1071
  // A revoked refresh token can't recover by retrying — drop it so the user
@@ -663,11 +1086,234 @@ export async function setupTaskGithub(
663
1086
  // (in scheduleGithubRetry). The handler classifies the reason (transient
664
1087
  // 5xx blip vs. genuine expiry) and words the note accordingly.
665
1088
  if (attempt === 0) authExpiredHandler?.(taskId, userId, reason);
666
- scheduleGithubRetry(taskId, userId, attempt, deps);
1089
+ scheduleGithubRetry(
1090
+ taskId,
1091
+ userId,
1092
+ attempt,
1093
+ deps,
1094
+ capturedGeneration,
1095
+ );
1096
+ return false;
1097
+ }
1098
+ }
1099
+
1100
+ // One task-local queue owns transport transitions. Connect/disconnect frames,
1101
+ // channel recovery, and /retry-gh can otherwise overlap: a slow old-token
1102
+ // injection must never win after a newer disconnect (or vice versa).
1103
+ const gitAuthReconcileQueues = new Map<string, Promise<boolean>>();
1104
+
1105
+ const REMOVE_MANAGED_GITHUB_AUTH = [
1106
+ "set -eu",
1107
+ "/usr/bin/rm -f -- \"$GH_CONFIG_DIR/hosts.yml\"",
1108
+ "test ! -e \"$GH_CONFIG_DIR/hosts.yml\"",
1109
+ "/usr/bin/git config --global --unset-all 'credential.https://github.com.helper' >/dev/null 2>&1 || true",
1110
+ "/usr/bin/git config --global --unset-all 'credential.https://gist.github.com.helper' >/dev/null 2>&1 || true",
1111
+ "/usr/bin/git config --global --unset-all 'url.git@github.com:.insteadOf' >/dev/null 2>&1 || true",
1112
+ "/usr/bin/git config --global --unset-all core.sshCommand >/dev/null 2>&1 || true",
1113
+ "/usr/bin/git config --global --replace-all 'url.https://github.com/.insteadOf' 'git@github.com:'",
1114
+ "/usr/bin/git config --global --add 'url.https://github.com/.insteadOf' 'ssh://git@github.com/'",
1115
+ "test -z \"$(/usr/bin/git config --global --get-all 'credential.https://github.com.helper' 2>/dev/null || true)\"",
1116
+ "test -z \"$(/usr/bin/git config --global --get-all 'credential.https://gist.github.com.helper' 2>/dev/null || true)\"",
1117
+ ].join("; ");
1118
+
1119
+ type DockerCommand = typeof dockerCli;
1120
+
1121
+ async function checkedDockerCommand(
1122
+ run: DockerCommand,
1123
+ args: string[],
1124
+ label: string,
1125
+ timeoutMs = EXEC_TIMEOUT_MS,
1126
+ ): Promise<Awaited<ReturnType<DockerCommand>>> {
1127
+ const result = await run(args, { timeoutMs });
1128
+ if (result.status !== 0) {
1129
+ throw new Error(
1130
+ `${label} failed: ${result.stderr.trim() || `exit ${result.status}`}`,
1131
+ );
1132
+ }
1133
+ return result;
1134
+ }
1135
+
1136
+ export async function removeGithubFromContainer(
1137
+ taskId: string,
1138
+ run: DockerCommand = dockerCli,
1139
+ ): Promise<boolean> {
1140
+ const container = `task-${taskId}-app-1`;
1141
+ const listed = await checkedDockerCommand(
1142
+ run,
1143
+ [
1144
+ "ps",
1145
+ "--all",
1146
+ "--filter",
1147
+ `name=^/${container}$`,
1148
+ "--format",
1149
+ "{{.State}}",
1150
+ ],
1151
+ `inspect ${container}`,
1152
+ );
1153
+ const states = listed.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1154
+ if (states.length === 0) return false; // no container means no credential to retain
1155
+ if (states.length !== 1) {
1156
+ throw new Error(`inspect ${container} returned ${states.length} containers`);
1157
+ }
1158
+
1159
+ const originalState = states[0];
1160
+ if (originalState !== "running") {
1161
+ // Never start or unpause untrusted task code while its old credential is
1162
+ // still on disk. The app container is disposable; workspace/repository
1163
+ // state lives outside it and Compose recreates it on Resume.
1164
+ await checkedDockerCommand(
1165
+ run,
1166
+ ["rm", "--force", container],
1167
+ `remove credential-bearing ${container}`,
1168
+ );
1169
+ const remaining = await checkedDockerCommand(
1170
+ run,
1171
+ [
1172
+ "ps",
1173
+ "--all",
1174
+ "--filter",
1175
+ `name=^/${container}$`,
1176
+ "--format",
1177
+ "{{.State}}",
1178
+ ],
1179
+ `verify removal of ${container}`,
1180
+ );
1181
+ if (remaining.stdout.trim()) {
1182
+ throw new Error(`remove ${container} was not verified`);
1183
+ }
667
1184
  return false;
668
- } finally {
669
- setupInFlight.delete(taskId);
670
1185
  }
1186
+
1187
+ await checkedDockerCommand(
1188
+ run,
1189
+ managedContainerExecArgs(container, [
1190
+ "/bin/sh",
1191
+ "-c",
1192
+ REMOVE_MANAGED_GITHUB_AUTH,
1193
+ ]),
1194
+ `remove managed GitHub auth from ${container}`,
1195
+ );
1196
+ return true;
1197
+ }
1198
+
1199
+ export interface ReconcileTaskGitAuthDeps {
1200
+ hasToken?: (userId: string) => boolean;
1201
+ setupGithub?: (taskId: string, userId: string) => Promise<boolean>;
1202
+ setupDeps?: SetupTaskDeps;
1203
+ authGeneration?: (userId: string) => number;
1204
+ logoutContainer?: (taskId: string) => Promise<boolean | void>;
1205
+ ensureSsh?: (taskId: string, userId: string) => Promise<boolean>;
1206
+ selectSsh?: (taskId: string) => Promise<boolean>;
1207
+ }
1208
+
1209
+ interface ReconcileInvocation {
1210
+ generation: number;
1211
+ getGeneration: () => number;
1212
+ setupDeps?: SetupTaskDeps;
1213
+ attempt: number;
1214
+ }
1215
+
1216
+ /**
1217
+ * Re-read the current host credential state and select exactly one Git
1218
+ * transport for a live task: connected user token over HTTPS, otherwise the
1219
+ * task owner's SSH identity. Serialized per task for reconnect safety.
1220
+ */
1221
+ export function reconcileTaskGitAuth(
1222
+ taskId: string,
1223
+ userId: string,
1224
+ deps: ReconcileTaskGitAuthDeps = {},
1225
+ ): Promise<boolean> {
1226
+ const getGeneration = () =>
1227
+ (deps.authGeneration ?? githubAuthGeneration)(userId);
1228
+ return enqueueTaskGitAuth(taskId, userId, deps, {
1229
+ generation: getGeneration(),
1230
+ getGeneration,
1231
+ setupDeps: deps.setupDeps,
1232
+ attempt: 0,
1233
+ });
1234
+ }
1235
+
1236
+ function enqueueTaskGitAuth(
1237
+ taskId: string,
1238
+ userId: string,
1239
+ deps: ReconcileTaskGitAuthDeps,
1240
+ invocation: ReconcileInvocation,
1241
+ ): Promise<boolean> {
1242
+ const previous = gitAuthReconcileQueues.get(taskId) ?? Promise.resolve(false);
1243
+ let queued: Promise<boolean>;
1244
+ queued = previous
1245
+ .catch(() => false)
1246
+ .then(async () => {
1247
+ const isCurrent = () =>
1248
+ invocation.getGeneration() === invocation.generation;
1249
+ if (!isCurrent()) return false;
1250
+
1251
+ const connected =
1252
+ deps.hasToken ?? invocation.setupDeps?.hasToken ?? hasToken;
1253
+ const shouldConnect = connected(userId);
1254
+ if (!shouldConnect) clearRefresh(taskId);
1255
+
1256
+ // Authoritative replacement, not gh's multi-account add/switch behavior:
1257
+ // remove every managed account/helper first, in the same per-task queue,
1258
+ // then install only the current user's credential. Non-running containers
1259
+ // are removed rather than executed with the old credential on disk.
1260
+ const canConfigureLiveTransport = await (
1261
+ deps.logoutContainer ??
1262
+ invocation.setupDeps?.scrub ??
1263
+ removeGithubFromContainer
1264
+ )(taskId);
1265
+ if (!isCurrent()) return false;
1266
+ if (canConfigureLiveTransport === false) {
1267
+ // No credential-bearing container remains. A connected credential will
1268
+ // be installed when Compose recreates it; disconnected state is already
1269
+ // fully scrubbed.
1270
+ return shouldConnect;
1271
+ }
1272
+
1273
+ if (shouldConnect) {
1274
+ if (deps.setupGithub) {
1275
+ const result = await deps.setupGithub(taskId, userId);
1276
+ return isCurrent() ? result : false;
1277
+ }
1278
+ return setupTaskGithub(
1279
+ taskId,
1280
+ userId,
1281
+ invocation.setupDeps,
1282
+ invocation.attempt,
1283
+ { generation: invocation.generation, isCurrent },
1284
+ );
1285
+ }
1286
+
1287
+ // SSH remains an opt-in fallback for users who did not connect GitHub;
1288
+ // ensuring the identity also keeps commit/tag signing available. Never
1289
+ // select SSH transport unless a usable identity was actually asserted.
1290
+ const hasSshIdentity = await (
1291
+ deps.ensureSsh ?? ensureTaskSshIdentity
1292
+ )(taskId, userId);
1293
+ if (!isCurrent()) return false;
1294
+ if (hasSshIdentity) {
1295
+ const selected = await (
1296
+ deps.selectSsh ?? configureTaskSshTransport
1297
+ )(taskId);
1298
+ if (!selected) {
1299
+ throw new Error(
1300
+ `task ${taskId}: SSH fallback transport could not be configured`,
1301
+ );
1302
+ }
1303
+ } else {
1304
+ console.log(
1305
+ `[github] task ${taskId}: managed GitHub credential removed; using credential-free HTTPS`,
1306
+ );
1307
+ }
1308
+ return false;
1309
+ })
1310
+ .finally(() => {
1311
+ if (gitAuthReconcileQueues.get(taskId) === queued) {
1312
+ gitAuthReconcileQueues.delete(taskId);
1313
+ }
1314
+ });
1315
+ gitAuthReconcileQueues.set(taskId, queued);
1316
+ return queued;
671
1317
  }
672
1318
 
673
1319
  /** Test/maintenance hook: drop all scheduled refresh + retry timers. */