@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.
- package/images/standard/container/uai-init +38 -19
- package/lib/agent.ts +42 -16
- package/lib/agents/claude.ts +86 -14
- package/lib/agents/factory.ts +9 -1
- package/lib/agents/registry.ts +27 -0
- package/lib/agents/types.ts +3 -0
- package/lib/git-identity.ts +349 -70
- package/lib/github-git-auth.ts +207 -0
- package/lib/github-tokens.ts +756 -110
- package/lib/orchestrator.ts +303 -91
- package/lib/repo-clone.ts +12 -101
- package/lib/ssh.ts +11 -8
- package/lib/transcript.ts +17 -2
- package/package.json +1 -1
- package/scripts/agent/_common.sh +214 -0
- package/scripts/agent/task-down.sh +35 -59
- package/scripts/agent/task-up.sh +746 -72
- package/src/index.ts +81 -7
- package/src/main.ts +112 -31
- package/src/protocol.ts +51 -0
package/lib/github-tokens.ts
CHANGED
|
@@ -7,11 +7,15 @@
|
|
|
7
7
|
* exchange is a host→cloud HTTP POST to /api/github/oauth/exchange.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
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
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
.
|
|
56
|
-
|
|
57
|
-
.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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),
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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
|
|
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
|
-
|
|
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]
|
|
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
|
|
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({
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
.
|
|
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
|
|
523
|
+
for (const taskId of runningTaskIdsForUser(userId)) {
|
|
187
524
|
clearRefresh(taskId);
|
|
188
|
-
void
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
656
|
+
if (tok && githubAuthGeneration(userId) === generation) {
|
|
657
|
+
accessCache.set(userId, tok);
|
|
658
|
+
}
|
|
312
659
|
return tok;
|
|
313
660
|
})
|
|
314
661
|
.finally(() => {
|
|
315
|
-
inflightExchanges.
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
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)
|
|
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(
|
|
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. */
|