@runuai/host 0.2.4 → 0.2.8
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/0007_host_github_token_kind.sql +7 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +4 -0
- package/lib/docker-exec.ts +7 -0
- package/lib/github-tokens.ts +282 -41
- package/lib/orchestrator.ts +118 -27
- package/package.json +1 -1
- package/src/main.ts +22 -3
- package/src/protocol.ts +19 -5
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- ADR-033: non-expiring per-host tokens. `kind` distinguishes the stored token:
|
|
2
|
+
-- 'refresh' (legacy, ADR-027) → refresh_token_ct holds a refresh token; the
|
|
3
|
+
-- host exchanges it for short-lived access tokens.
|
|
4
|
+
-- 'access' (ADR-033) → refresh_token_ct holds a long-lived, non-
|
|
5
|
+
-- expiring access token; injected directly, no exchange/refresh.
|
|
6
|
+
-- Existing rows default to 'refresh' (their current semantics).
|
|
7
|
+
ALTER TABLE `host_github_tokens` ADD `kind` text DEFAULT 'refresh' NOT NULL;
|
package/db/schema.ts
CHANGED
|
@@ -62,9 +62,13 @@ export type NewHostEventRow = typeof hostEvents.$inferInsert;
|
|
|
62
62
|
export const githubTokens = sqliteTable("host_github_tokens", {
|
|
63
63
|
userId: text("user_id").primaryKey(),
|
|
64
64
|
installationId: integer("installation_id").notNull(),
|
|
65
|
+
// ADR-033: holds a long-lived ACCESS token when kind="access", or a refresh
|
|
66
|
+
// token when kind="refresh" (legacy ADR-027). Encrypted at rest either way.
|
|
65
67
|
refreshTokenCt: blob("refresh_token_ct").notNull(),
|
|
66
68
|
refreshTokenNonce: blob("refresh_token_nonce").notNull(),
|
|
67
69
|
refreshTokenExpiresAt: integer("refresh_token_expires_at"),
|
|
70
|
+
// "access" (non-expiring, ADR-033) | "refresh" (expiring, ADR-027).
|
|
71
|
+
kind: text("kind").notNull().default("refresh"),
|
|
68
72
|
updatedAt: integer("updated_at").notNull(),
|
|
69
73
|
});
|
|
70
74
|
|
package/lib/docker-exec.ts
CHANGED
|
@@ -59,6 +59,13 @@ export function dockerCli(
|
|
|
59
59
|
child.on("close", (code) => {
|
|
60
60
|
finish(code);
|
|
61
61
|
});
|
|
62
|
+
// EPIPE guard: if the child exits without draining stdin (or our own
|
|
63
|
+
// timeout SIGKILLs it mid-write), the failed write surfaces as an 'error'
|
|
64
|
+
// event on the stdin stream — unhandled, that's an uncaughtException that
|
|
65
|
+
// would take down the whole host service. Today's inputs are tiny (a gh
|
|
66
|
+
// token) and complete synchronously, but this keeps the helper safe for
|
|
67
|
+
// any future caller piping real data.
|
|
68
|
+
child.stdin?.on("error", () => {});
|
|
62
69
|
if (opts.input !== undefined) child.stdin?.write(opts.input);
|
|
63
70
|
child.stdin?.end();
|
|
64
71
|
});
|
package/lib/github-tokens.ts
CHANGED
|
@@ -16,58 +16,111 @@ import type { CloudToHost } from "../src/protocol";
|
|
|
16
16
|
|
|
17
17
|
const REFRESH_LEAD_MS = 5 * 60 * 1000; // refresh 5 min before expiry
|
|
18
18
|
const EXEC_TIMEOUT_MS = 10_000;
|
|
19
|
-
|
|
19
|
+
// Cap the cloud token-exchange round-trip. Generous on purpose: aborting an
|
|
20
|
+
// exchange that GitHub already completed burns the single-use refresh token
|
|
21
|
+
// (we never see the rotated replacement), so a slow success beats a fast
|
|
22
|
+
// ambiguous abort.
|
|
23
|
+
const EXCHANGE_TIMEOUT_MS = 30_000;
|
|
20
24
|
|
|
21
25
|
type GhConnectSet = Extract<CloudToHost, { kind: "gh.connect.set" }>;
|
|
22
26
|
|
|
23
27
|
// --- token store ------------------------------------------------------------
|
|
24
28
|
|
|
25
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Encrypt + persist the user's token (gh.connect.set handler). ADR-033: a
|
|
31
|
+
* non-expiring grant carries `accessToken` (stored kind="access", injected
|
|
32
|
+
* directly); a legacy expiring grant carries `refreshToken` (kind="refresh",
|
|
33
|
+
* exchanged per task). Exactly one is present.
|
|
34
|
+
*/
|
|
26
35
|
export function onConnectSet(frame: GhConnectSet): { ok: boolean; error?: string } {
|
|
27
36
|
try {
|
|
28
|
-
|
|
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
|
+
const token = frame.accessToken ?? frame.refreshToken;
|
|
41
|
+
if (!token) return { ok: false, error: "gh.connect.set carried no token" };
|
|
42
|
+
const kind = frame.accessToken ? "access" : "refresh";
|
|
43
|
+
const sealed = sealAesGcm(token);
|
|
29
44
|
const now = Date.now();
|
|
45
|
+
const fields = {
|
|
46
|
+
installationId: frame.installationId,
|
|
47
|
+
refreshTokenCt: sealed.ct,
|
|
48
|
+
refreshTokenNonce: sealed.nonce,
|
|
49
|
+
refreshTokenExpiresAt: frame.refreshTokenExpiresAt ?? null,
|
|
50
|
+
kind,
|
|
51
|
+
updatedAt: now,
|
|
52
|
+
};
|
|
30
53
|
getDb()
|
|
31
54
|
.insert(schema.githubTokens)
|
|
32
|
-
.values({
|
|
33
|
-
|
|
34
|
-
installationId: frame.installationId,
|
|
35
|
-
refreshTokenCt: sealed.ct,
|
|
36
|
-
refreshTokenNonce: sealed.nonce,
|
|
37
|
-
refreshTokenExpiresAt: frame.refreshTokenExpiresAt ?? null,
|
|
38
|
-
updatedAt: now,
|
|
39
|
-
})
|
|
40
|
-
.onConflictDoUpdate({
|
|
41
|
-
target: schema.githubTokens.userId,
|
|
42
|
-
set: {
|
|
43
|
-
installationId: frame.installationId,
|
|
44
|
-
refreshTokenCt: sealed.ct,
|
|
45
|
-
refreshTokenNonce: sealed.nonce,
|
|
46
|
-
refreshTokenExpiresAt: frame.refreshTokenExpiresAt ?? null,
|
|
47
|
-
updatedAt: now,
|
|
48
|
-
},
|
|
49
|
-
})
|
|
55
|
+
.values({ userId: frame.userId, ...fields })
|
|
56
|
+
.onConflictDoUpdate({ target: schema.githubTokens.userId, set: fields })
|
|
50
57
|
.run();
|
|
58
|
+
notifyGithubChange();
|
|
51
59
|
return { ok: true };
|
|
52
60
|
} catch (err) {
|
|
53
61
|
return { ok: false, error: err instanceof Error ? err.message : "store failed" };
|
|
54
62
|
}
|
|
55
63
|
}
|
|
56
64
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Disconnect GitHub on this host (gh.connect.clear handler). Deletes the token
|
|
67
|
+
* locally FIRST (so capabilities re-advertise immediately and the UI reflects
|
|
68
|
+
* removal without waiting on the network), then best-effort revokes it at
|
|
69
|
+
* GitHub. Whole body is guarded — a fired-and-forgotten failure must never
|
|
70
|
+
* crash the host (no global unhandledRejection handler).
|
|
71
|
+
*
|
|
72
|
+
* ADR-033: only a non-expiring ACCESS token can be revoked by token
|
|
73
|
+
* (`DELETE /applications/{client_id}/token` matches access tokens only — a
|
|
74
|
+
* refresh token 404s, which the cloud endpoint would mis-report as success).
|
|
75
|
+
* Legacy refresh tokens aren't single-token revocable; the short-lived access
|
|
76
|
+
* tokens they mint expire on their own.
|
|
77
|
+
*/
|
|
78
|
+
export async function onConnectClear(userId: string): Promise<void> {
|
|
79
|
+
try {
|
|
80
|
+
const stored = readStoredToken(userId);
|
|
81
|
+
deleteToken(userId); // fires onGithubChange → re-advertise capabilities
|
|
82
|
+
for (const taskId of activeTaskIdsForUser(userId)) {
|
|
83
|
+
clearRefresh(taskId);
|
|
84
|
+
authExpiredHandler?.(taskId, userId, "GitHub disconnected");
|
|
85
|
+
}
|
|
86
|
+
if (stored && stored.kind === "access") {
|
|
87
|
+
await revokeAtGitHub(stored.token);
|
|
88
|
+
} else if (stored) {
|
|
89
|
+
console.warn(
|
|
90
|
+
`[github] user ${userId}: legacy refresh token cleared locally; cannot single-token revoke at GitHub`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
} catch (err) {
|
|
94
|
+
console.warn(
|
|
95
|
+
`[github] disconnect failed: ${err instanceof Error ? err.message : err}`,
|
|
96
|
+
);
|
|
63
97
|
}
|
|
64
98
|
}
|
|
65
99
|
|
|
100
|
+
/** Decrypt the user's stored token + its kind, or null if none. */
|
|
101
|
+
function readStoredToken(
|
|
102
|
+
userId: string,
|
|
103
|
+
): { token: string; kind: string } | null {
|
|
104
|
+
const row = getDb()
|
|
105
|
+
.select()
|
|
106
|
+
.from(schema.githubTokens)
|
|
107
|
+
.where(eq(schema.githubTokens.userId, userId))
|
|
108
|
+
.get();
|
|
109
|
+
if (!row) return null;
|
|
110
|
+
const token = openAesGcm(
|
|
111
|
+
Buffer.from(row.refreshTokenCt as Uint8Array),
|
|
112
|
+
Buffer.from(row.refreshTokenNonce as Uint8Array),
|
|
113
|
+
);
|
|
114
|
+
return { token, kind: row.kind };
|
|
115
|
+
}
|
|
116
|
+
|
|
66
117
|
export function deleteToken(userId: string): void {
|
|
118
|
+
clearAccessCache(userId);
|
|
67
119
|
getDb()
|
|
68
120
|
.delete(schema.githubTokens)
|
|
69
121
|
.where(eq(schema.githubTokens.userId, userId))
|
|
70
122
|
.run();
|
|
123
|
+
notifyGithubChange();
|
|
71
124
|
}
|
|
72
125
|
|
|
73
126
|
export function hasToken(userId: string): boolean {
|
|
@@ -80,6 +133,29 @@ export function hasToken(userId: string): boolean {
|
|
|
80
133
|
);
|
|
81
134
|
}
|
|
82
135
|
|
|
136
|
+
/** All cloud user ids with a GitHub token on this host (for capabilities). */
|
|
137
|
+
export function connectedUserIds(): string[] {
|
|
138
|
+
return getDb()
|
|
139
|
+
.select({ userId: schema.githubTokens.userId })
|
|
140
|
+
.from(schema.githubTokens)
|
|
141
|
+
.all()
|
|
142
|
+
.map((r) => r.userId);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Fires whenever the set of stored tokens changes (add/remove), so the host
|
|
146
|
+
// can re-advertise capabilities and the UI reflects per-host gh state promptly.
|
|
147
|
+
let githubChangeHandler: (() => void) | null = null;
|
|
148
|
+
export function onGithubChange(fn: (() => void) | null): void {
|
|
149
|
+
githubChangeHandler = fn;
|
|
150
|
+
}
|
|
151
|
+
function notifyGithubChange(): void {
|
|
152
|
+
try {
|
|
153
|
+
githubChangeHandler?.();
|
|
154
|
+
} catch {
|
|
155
|
+
/* never let a listener break token handling */
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
83
159
|
function activeTaskIdsForUser(userId: string): string[] {
|
|
84
160
|
return getDb()
|
|
85
161
|
.select({ taskId: schema.hostTasks.taskId })
|
|
@@ -96,12 +172,43 @@ function activeTaskIdsForUser(userId: string): string[] {
|
|
|
96
172
|
|
|
97
173
|
// --- access-token exchange --------------------------------------------------
|
|
98
174
|
|
|
99
|
-
function
|
|
175
|
+
function cloudHttpBase(): string {
|
|
100
176
|
const cloud = process.env.UAI_CLOUD_URL ?? "ws://127.0.0.1:8789/host";
|
|
101
177
|
const u = new URL(cloud);
|
|
102
178
|
const proto =
|
|
103
179
|
u.protocol === "wss:" ? "https:" : u.protocol === "ws:" ? "http:" : u.protocol;
|
|
104
|
-
return `${proto}//${u.host}
|
|
180
|
+
return `${proto}//${u.host}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function exchangeUrl(): string {
|
|
184
|
+
return `${cloudHttpBase()}/api/github/oauth/exchange`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Revoke a token at GitHub via the cloud (which holds the App client secret —
|
|
189
|
+
* the host can't revoke alone). Same host→cloud HTTP pattern as the exchange;
|
|
190
|
+
* the token transits the cloud transiently, never stored (ADR-015/ADR-033).
|
|
191
|
+
* Best-effort — callers proceed to delete locally regardless.
|
|
192
|
+
*/
|
|
193
|
+
async function revokeAtGitHub(token: string): Promise<void> {
|
|
194
|
+
try {
|
|
195
|
+
const res = await fetch(`${cloudHttpBase()}/api/github/revoke`, {
|
|
196
|
+
method: "POST",
|
|
197
|
+
headers: {
|
|
198
|
+
"content-type": "application/json",
|
|
199
|
+
"x-uai-host-token": process.env.UAI_HOST_TOKEN ?? "",
|
|
200
|
+
},
|
|
201
|
+
body: JSON.stringify({ token }),
|
|
202
|
+
signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS),
|
|
203
|
+
});
|
|
204
|
+
if (!res.ok) {
|
|
205
|
+
console.warn(`[github] token revoke returned HTTP ${res.status}`);
|
|
206
|
+
}
|
|
207
|
+
} catch (err) {
|
|
208
|
+
console.warn(
|
|
209
|
+
`[github] token revoke failed (deleting locally anyway): ${err instanceof Error ? err.message : err}`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
105
212
|
}
|
|
106
213
|
|
|
107
214
|
interface ExchangeResult {
|
|
@@ -115,8 +222,84 @@ interface ExchangeResult {
|
|
|
115
222
|
* Mint a fresh access token for the user via the cloud exchange. Persists a
|
|
116
223
|
* rotated refresh token if GitHub returned one. Returns null when the user has
|
|
117
224
|
* no token on this host. Throws on exchange failure.
|
|
225
|
+
*
|
|
226
|
+
* Deduped PER USER: GitHub refresh tokens are single-use (each exchange
|
|
227
|
+
* rotates them), and GitHub treats reuse of a consumed token as a compromise
|
|
228
|
+
* signal that can kill the whole grant. Concurrent task setups for the same
|
|
229
|
+
* user (several task-ups at once, or every channel re-ensure after a host
|
|
230
|
+
* restart) used to race the rotation — the loser burned the grant and every
|
|
231
|
+
* exchange afterwards failed until a manual re-connect. The access token is
|
|
232
|
+
* per-user anyway, so concurrent callers share one in-flight exchange.
|
|
118
233
|
*/
|
|
119
|
-
|
|
234
|
+
const inflightExchanges = new Map<
|
|
235
|
+
string,
|
|
236
|
+
Promise<{ accessToken: string; expiresAt: number } | null>
|
|
237
|
+
>();
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Per-user ACCESS-token cache. Access tokens live ~8h, but every exchange
|
|
241
|
+
* ROTATES the single-use refresh token — and a rotation whose response is
|
|
242
|
+
* lost mid-flight (our timeout firing while GitHub already rotated) burns the
|
|
243
|
+
* grant, forcing a manual re-connect. So exchange as rarely as possible: all
|
|
244
|
+
* task setups and refresh timers for a user share one cached access token
|
|
245
|
+
* until it nears expiry. The refresh timers fire at expiry−5min, inside the
|
|
246
|
+
* 15min stale window, so exactly one of them performs the real exchange (the
|
|
247
|
+
* in-flight dedupe serializes any ties) and the rest re-inject from cache.
|
|
248
|
+
*/
|
|
249
|
+
const ACCESS_CACHE_LEAD_MS = 15 * 60 * 1000;
|
|
250
|
+
const accessCache = new Map<
|
|
251
|
+
string,
|
|
252
|
+
{ accessToken: string; expiresAt: number }
|
|
253
|
+
>();
|
|
254
|
+
|
|
255
|
+
/** Drop a user's cached access token (token deleted / re-granted). */
|
|
256
|
+
function clearAccessCache(userId: string): void {
|
|
257
|
+
accessCache.delete(userId);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Test hook: reset the access-token cache. */
|
|
261
|
+
export function clearAllAccessCache(): void {
|
|
262
|
+
accessCache.clear();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function requestAccessToken(
|
|
266
|
+
userId: string,
|
|
267
|
+
): Promise<{ accessToken: string; expiresAt: number | null } | null> {
|
|
268
|
+
// ADR-033 non-expiring path: the stored token IS the access token — return it
|
|
269
|
+
// directly, no exchange/cache/rotation. `expiresAt: null` ⇒ no refresh.
|
|
270
|
+
const row = getDb()
|
|
271
|
+
.select({ kind: schema.githubTokens.kind })
|
|
272
|
+
.from(schema.githubTokens)
|
|
273
|
+
.where(eq(schema.githubTokens.userId, userId))
|
|
274
|
+
.get();
|
|
275
|
+
if (!row) return Promise.resolve(null);
|
|
276
|
+
if (row.kind === "access") {
|
|
277
|
+
const stored = readStoredToken(userId);
|
|
278
|
+
return Promise.resolve(
|
|
279
|
+
stored ? { accessToken: stored.token, expiresAt: null } : null,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Legacy expiring path (ADR-027): cache + in-flight dedupe + exchange.
|
|
284
|
+
const cached = accessCache.get(userId);
|
|
285
|
+
if (cached && cached.expiresAt - Date.now() > ACCESS_CACHE_LEAD_MS) {
|
|
286
|
+
return Promise.resolve(cached);
|
|
287
|
+
}
|
|
288
|
+
const existing = inflightExchanges.get(userId);
|
|
289
|
+
if (existing) return existing;
|
|
290
|
+
const run = doRequestAccessToken(userId)
|
|
291
|
+
.then((tok) => {
|
|
292
|
+
if (tok) accessCache.set(userId, tok);
|
|
293
|
+
return tok;
|
|
294
|
+
})
|
|
295
|
+
.finally(() => {
|
|
296
|
+
inflightExchanges.delete(userId);
|
|
297
|
+
});
|
|
298
|
+
inflightExchanges.set(userId, run);
|
|
299
|
+
return run;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function doRequestAccessToken(
|
|
120
303
|
userId: string,
|
|
121
304
|
): Promise<{ accessToken: string; expiresAt: number } | null> {
|
|
122
305
|
const row = getDb()
|
|
@@ -140,7 +323,19 @@ export async function requestAccessToken(
|
|
|
140
323
|
signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS),
|
|
141
324
|
});
|
|
142
325
|
if (!res.ok) {
|
|
143
|
-
|
|
326
|
+
// Include the cloud's error body: it passes GitHub's OAuth error code
|
|
327
|
+
// through (e.g. "bad_refresh_token"/"invalid_grant" inside the 502
|
|
328
|
+
// message), which is the ONLY reliable revocation signal. The bare HTTP
|
|
329
|
+
// status is not one — the cloud returns 401 solely for a host-token
|
|
330
|
+
// mismatch and wraps every GitHub-side failure as 502.
|
|
331
|
+
let detail = "";
|
|
332
|
+
try {
|
|
333
|
+
const body = (await res.json()) as { message?: string };
|
|
334
|
+
if (body && typeof body.message === "string") detail = ` — ${body.message}`;
|
|
335
|
+
} catch {
|
|
336
|
+
// non-JSON body — status alone will have to do
|
|
337
|
+
}
|
|
338
|
+
throw new Error(`gh token exchange failed: HTTP ${res.status}${detail}`);
|
|
144
339
|
}
|
|
145
340
|
const data = (await res.json()) as ExchangeResult;
|
|
146
341
|
if (data.refreshToken) {
|
|
@@ -226,6 +421,19 @@ export function scheduleRefresh(
|
|
|
226
421
|
timers.set(taskId, timer);
|
|
227
422
|
}
|
|
228
423
|
|
|
424
|
+
/**
|
|
425
|
+
* True when the exchange error names a GitHub-side revocation — the refresh
|
|
426
|
+
* token itself is dead, so retrying is pointless and the user must re-grant.
|
|
427
|
+
* Deliberately keyed on GitHub's OAuth error strings (passed through the
|
|
428
|
+
* cloud's 502 body by requestAccessToken), NOT on bare HTTP status: the cloud
|
|
429
|
+
* returns 401 only for a host-token mismatch and wraps all GitHub failures as
|
|
430
|
+
* 502, so a status like "401"/"403" alone never means "revoked" — the old
|
|
431
|
+
* \b40[13]\b match here deleted VALID refresh tokens on host-auth/proxy noise.
|
|
432
|
+
*/
|
|
433
|
+
function isRevokedTokenError(reason: string): boolean {
|
|
434
|
+
return /revoked|invalid_grant|bad_refresh/i.test(reason);
|
|
435
|
+
}
|
|
436
|
+
|
|
229
437
|
async function runRefresh(taskId: string, userId: string): Promise<void> {
|
|
230
438
|
try {
|
|
231
439
|
const tok = await requestAccessToken(userId);
|
|
@@ -234,13 +442,14 @@ async function runRefresh(taskId: string, userId: string): Promise<void> {
|
|
|
234
442
|
return;
|
|
235
443
|
}
|
|
236
444
|
await injectIntoContainer(taskId, tok.accessToken);
|
|
237
|
-
|
|
445
|
+
// Only re-schedule for an expiring token; non-expiring needs no refresh.
|
|
446
|
+
if (tok.expiresAt !== null) scheduleRefresh(taskId, userId, tok.expiresAt);
|
|
238
447
|
} catch (err) {
|
|
239
448
|
const reason = err instanceof Error ? err.message : String(err);
|
|
240
449
|
// A revoked / expired refresh token can't recover — drop it to force a
|
|
241
450
|
// clean re-grant through the OAuth flow. Anything else is likely transient
|
|
242
451
|
// (network / cloud blip) — self-heal with a bounded retry.
|
|
243
|
-
if (
|
|
452
|
+
if (isRevokedTokenError(reason)) {
|
|
244
453
|
deleteToken(userId);
|
|
245
454
|
} else {
|
|
246
455
|
scheduleGithubRetry(taskId, userId, 0);
|
|
@@ -276,22 +485,42 @@ function clearGithubRetry(taskId: string): void {
|
|
|
276
485
|
}
|
|
277
486
|
}
|
|
278
487
|
|
|
488
|
+
/** Is the task still live on this host? Retries for ended tasks would docker
|
|
489
|
+
* exec against a removed container and post notes into a finished chat. */
|
|
490
|
+
function taskIsActive(taskId: string): boolean {
|
|
491
|
+
const row = getDb()
|
|
492
|
+
.select({ endedAt: schema.hostTasks.endedAt })
|
|
493
|
+
.from(schema.hostTasks)
|
|
494
|
+
.where(eq(schema.hostTasks.taskId, taskId))
|
|
495
|
+
.get();
|
|
496
|
+
return row != null && row.endedAt == null;
|
|
497
|
+
}
|
|
498
|
+
|
|
279
499
|
function scheduleGithubRetry(
|
|
280
500
|
taskId: string,
|
|
281
501
|
userId: string,
|
|
282
502
|
attempt: number,
|
|
503
|
+
deps: SetupTaskDeps = {},
|
|
283
504
|
): void {
|
|
284
505
|
clearGithubRetry(taskId);
|
|
285
506
|
if (attempt >= RETRY_BACKOFF_MS.length) {
|
|
507
|
+
// attempt counts retries fired so far; +1 for the initial try.
|
|
286
508
|
console.warn(
|
|
287
|
-
`[github] task ${taskId}: gh setup still failing after ${attempt} attempts — giving up until /retry-gh`,
|
|
509
|
+
`[github] task ${taskId}: gh setup still failing after ${attempt + 1} attempts — giving up until /retry-gh`,
|
|
510
|
+
);
|
|
511
|
+
authExpiredHandler?.(
|
|
512
|
+
taskId,
|
|
513
|
+
userId,
|
|
514
|
+
"gh setup kept failing — send /retry-gh once the connection recovers",
|
|
288
515
|
);
|
|
289
516
|
return;
|
|
290
517
|
}
|
|
291
518
|
const delay = RETRY_BACKOFF_MS[attempt] ?? 300_000;
|
|
292
519
|
const timer = setTimeout(() => {
|
|
293
520
|
retryTimers.delete(taskId);
|
|
294
|
-
|
|
521
|
+
const isActive = deps.taskIsActive ?? taskIsActive;
|
|
522
|
+
if (!isActive(taskId)) return; // task ended while we backed off
|
|
523
|
+
void setupTaskGithub(taskId, userId, deps, attempt + 1);
|
|
295
524
|
}, delay);
|
|
296
525
|
timer.unref?.();
|
|
297
526
|
retryTimers.set(taskId, timer);
|
|
@@ -310,9 +539,11 @@ export interface SetupTaskDeps {
|
|
|
310
539
|
hasToken?: (userId: string) => boolean;
|
|
311
540
|
requestAccessToken?: (
|
|
312
541
|
userId: string,
|
|
313
|
-
) => Promise<{ accessToken: string; expiresAt: number } | null>;
|
|
542
|
+
) => Promise<{ accessToken: string; expiresAt: number | null } | null>;
|
|
314
543
|
inject?: (taskId: string, token: string) => void | Promise<void>;
|
|
315
544
|
schedule?: (taskId: string, userId: string, expiresAt: number) => void;
|
|
545
|
+
deleteToken?: (userId: string) => void;
|
|
546
|
+
taskIsActive?: (taskId: string) => boolean;
|
|
316
547
|
}
|
|
317
548
|
|
|
318
549
|
/**
|
|
@@ -345,7 +576,8 @@ export async function setupTaskGithub(
|
|
|
345
576
|
const tok = await _request(userId);
|
|
346
577
|
if (tok) {
|
|
347
578
|
await _inject(taskId, tok.accessToken);
|
|
348
|
-
|
|
579
|
+
// Non-expiring (ADR-033) tokens (expiresAt null) need no refresh timer.
|
|
580
|
+
if (tok.expiresAt !== null) _schedule(taskId, userId, tok.expiresAt);
|
|
349
581
|
clearGithubRetry(taskId);
|
|
350
582
|
console.log(`[github] task ${taskId}: injected user access token`);
|
|
351
583
|
return true;
|
|
@@ -363,10 +595,19 @@ export async function setupTaskGithub(
|
|
|
363
595
|
} catch (err) {
|
|
364
596
|
const reason = err instanceof Error ? err.message : String(err);
|
|
365
597
|
console.warn(`[github] task ${taskId}: gh setup failed: ${reason}`);
|
|
366
|
-
|
|
367
|
-
//
|
|
368
|
-
|
|
369
|
-
|
|
598
|
+
// A revoked refresh token can't recover by retrying — drop it so the user
|
|
599
|
+
// gets a clean re-grant, and tell the chat once.
|
|
600
|
+
if (isRevokedTokenError(reason)) {
|
|
601
|
+
(deps.deleteToken ?? deleteToken)(userId);
|
|
602
|
+
authExpiredHandler?.(taskId, userId, reason);
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
// Transient: self-heal with bounded backoff. Post the chat note only on
|
|
606
|
+
// the FIRST failure of a chain — each retry re-enters this catch, and six
|
|
607
|
+
// "gh auth" notes for one outage is noise. Exhaustion posts its own note
|
|
608
|
+
// (in scheduleGithubRetry).
|
|
609
|
+
if (attempt === 0) authExpiredHandler?.(taskId, userId, reason);
|
|
610
|
+
scheduleGithubRetry(taskId, userId, attempt, deps);
|
|
370
611
|
return false;
|
|
371
612
|
} finally {
|
|
372
613
|
setupInFlight.delete(taskId);
|
package/lib/orchestrator.ts
CHANGED
|
@@ -56,8 +56,19 @@ interface Channel {
|
|
|
56
56
|
/** Per-agent first-turn message — only agents whose `initialPrompt` is
|
|
57
57
|
* non-empty have an entry. Delivered once at container-ready. */
|
|
58
58
|
firstTurns: Map<string, string>;
|
|
59
|
-
/**
|
|
60
|
-
|
|
59
|
+
/** In-flight (or completed) session start. A memoized promise — not a
|
|
60
|
+
* boolean flag — so a concurrent deliver() awaits actual readiness instead
|
|
61
|
+
* of observing "started" while the sessions map is still empty (the start
|
|
62
|
+
* awaits docker work before populating it). Reset to null on failure so the
|
|
63
|
+
* next ensure retries. */
|
|
64
|
+
sessionsReady: Promise<boolean> | null;
|
|
65
|
+
/** Agents with turn output already streamed for the current turn — i.e. the
|
|
66
|
+
* cloud has buffered content for them (see ChannelRouter.turnBuffer). */
|
|
67
|
+
openTurns: Set<string>;
|
|
68
|
+
/** Agents whose current turn was interrupted (ESC) — their next
|
|
69
|
+
* turn_complete is flagged `aborted` so the cloud DISCARDS the buffered
|
|
70
|
+
* half-turn instead of delivering it to @-mentioned peers. */
|
|
71
|
+
interrupted: Set<string>;
|
|
61
72
|
/** Per-agent respawn counter — bounded so a broken agent can't
|
|
62
73
|
* loop forever rewriting its config. */
|
|
63
74
|
respawns: Map<string, number>;
|
|
@@ -149,7 +160,9 @@ class Orchestrator {
|
|
|
149
160
|
containerName: `task-${taskId.toLowerCase()}-app-1`,
|
|
150
161
|
preambles,
|
|
151
162
|
firstTurns,
|
|
152
|
-
|
|
163
|
+
sessionsReady: null,
|
|
164
|
+
openTurns: new Set(),
|
|
165
|
+
interrupted: new Set(),
|
|
153
166
|
respawns: new Map(),
|
|
154
167
|
};
|
|
155
168
|
this.channels.set(taskId, channel);
|
|
@@ -167,16 +180,31 @@ class Orchestrator {
|
|
|
167
180
|
*
|
|
168
181
|
* Returns whether sessions are ready.
|
|
169
182
|
*/
|
|
170
|
-
private
|
|
171
|
-
|
|
183
|
+
private ensureSessions(channel: Channel): Promise<boolean> {
|
|
184
|
+
// Memoized: every caller awaits the SAME in-flight start, so a concurrent
|
|
185
|
+
// deliver() can't observe "ready" while the sessions map is still empty
|
|
186
|
+
// (startSessions awaits docker work before populating it — the old boolean
|
|
187
|
+
// flag flipped before that await and opened exactly that window).
|
|
188
|
+
if (!channel.sessionsReady) {
|
|
189
|
+
channel.sessionsReady = this.startSessions(channel);
|
|
190
|
+
// A failed/aborted start must not poison the channel — reset so the
|
|
191
|
+
// next ensure retries (e.g. the task wasn't `running` yet).
|
|
192
|
+
channel.sessionsReady.then(
|
|
193
|
+
(ok) => {
|
|
194
|
+
if (!ok) channel.sessionsReady = null;
|
|
195
|
+
},
|
|
196
|
+
() => {
|
|
197
|
+
channel.sessionsReady = null;
|
|
198
|
+
},
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
return channel.sessionsReady;
|
|
202
|
+
}
|
|
172
203
|
|
|
204
|
+
private async startSessions(channel: Channel): Promise<boolean> {
|
|
173
205
|
const task = getHostTask(channel.taskId);
|
|
174
206
|
if (!task || task.statusMirror !== "running") return false;
|
|
175
207
|
|
|
176
|
-
// Flip the flag before the await so a concurrent send can't
|
|
177
|
-
// double-spawn the sessions.
|
|
178
|
-
channel.sessionsStarted = true;
|
|
179
|
-
|
|
180
208
|
// Set the task creator's git author identity in the container (ADR-029).
|
|
181
209
|
// The SSH key itself is installed earlier by task-up.sh (host clone +
|
|
182
210
|
// container), using the creator's per-user key. Best-effort. Awaited but
|
|
@@ -267,8 +295,15 @@ class Orchestrator {
|
|
|
267
295
|
taskId: string,
|
|
268
296
|
agentId: string,
|
|
269
297
|
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
270
|
-
const
|
|
271
|
-
|
|
298
|
+
const channel = this.channels.get(taskId);
|
|
299
|
+
const session = channel?.sessions.get(agentId);
|
|
300
|
+
if (!channel || !session) return { ok: false, error: "no active session" };
|
|
301
|
+
// An interrupted turn is a half-turn — its eventual turn_complete must not
|
|
302
|
+
// hand the buffered fragment to @-mentioned peers. Flag it so the boundary
|
|
303
|
+
// goes out `aborted` and the cloud discards the buffer. Only when the turn
|
|
304
|
+
// actually streamed output (openTurns): an idle-agent ESC must not mark
|
|
305
|
+
// the NEXT legitimate turn as aborted.
|
|
306
|
+
if (channel.openTurns.has(agentId)) channel.interrupted.add(agentId);
|
|
272
307
|
void session.interrupt();
|
|
273
308
|
this.emitSystemNote(taskId, `Stopped @${agentId}.`);
|
|
274
309
|
return { ok: true };
|
|
@@ -300,6 +335,7 @@ class Orchestrator {
|
|
|
300
335
|
): Promise<void> {
|
|
301
336
|
switch (event.type) {
|
|
302
337
|
case "message_delta": {
|
|
338
|
+
channel.openTurns.add(agentId);
|
|
303
339
|
this.emitHost({
|
|
304
340
|
kind: "agent.message_delta",
|
|
305
341
|
taskId: channel.taskId,
|
|
@@ -309,6 +345,7 @@ class Orchestrator {
|
|
|
309
345
|
break;
|
|
310
346
|
}
|
|
311
347
|
case "message_complete": {
|
|
348
|
+
channel.openTurns.add(agentId);
|
|
312
349
|
this.emitHost({
|
|
313
350
|
kind: "agent.message_complete",
|
|
314
351
|
taskId: channel.taskId,
|
|
@@ -354,6 +391,10 @@ class Orchestrator {
|
|
|
354
391
|
break;
|
|
355
392
|
}
|
|
356
393
|
case "error": {
|
|
394
|
+
// The turn died with the session — drop its turn-state flags so a
|
|
395
|
+
// respawned session starts clean.
|
|
396
|
+
channel.openTurns.delete(agentId);
|
|
397
|
+
channel.interrupted.delete(agentId);
|
|
357
398
|
// Claude under load (especially Docker Desktop macOS) occasionally
|
|
358
399
|
// unlinks ~/.claude.json mid-write during atomic config rewrites,
|
|
359
400
|
// and a concurrent claude spawn lands during the gap and exits 0
|
|
@@ -381,10 +422,15 @@ class Orchestrator {
|
|
|
381
422
|
case "turn_complete": {
|
|
382
423
|
// Turn boundary — the cloud flushes any @-mentions buffered across this
|
|
383
424
|
// turn's messages and wakes the mentioned peers with the full turn.
|
|
425
|
+
// An interrupted (ESC'd) turn goes out `aborted`: it's a half-turn, so
|
|
426
|
+
// the cloud discards the buffer instead of handing it to peers.
|
|
427
|
+
const aborted = channel.interrupted.delete(agentId);
|
|
428
|
+
channel.openTurns.delete(agentId);
|
|
384
429
|
this.emitHost({
|
|
385
430
|
kind: "agent.turn_complete",
|
|
386
431
|
taskId: channel.taskId,
|
|
387
432
|
agentId,
|
|
433
|
+
aborted,
|
|
388
434
|
});
|
|
389
435
|
break;
|
|
390
436
|
}
|
|
@@ -695,10 +741,16 @@ export function buildSystemPreamble(
|
|
|
695
741
|
"",
|
|
696
742
|
`Agents in this channel: ${others}.`,
|
|
697
743
|
"",
|
|
744
|
+
"The human you're working with is **@you**. When you need their",
|
|
745
|
+
"input, a decision, or their attention — a question, a blocker, an",
|
|
746
|
+
"approval, or you've finished and are handing back to them — end your",
|
|
747
|
+
"message by @-mentioning **@you** (e.g. `@you which approach do you`",
|
|
748
|
+
"`prefer?` or `@you done — PR is up for review`). That notifies them.",
|
|
749
|
+
"",
|
|
698
750
|
"An agent only receives a message when it is explicitly @-mentioned",
|
|
699
|
-
"(or addressed by the human) — so always @-mention the agent you
|
|
700
|
-
"There is NO `peer` command and no shared tmux session;
|
|
701
|
-
"just @-mentions in your replies.",
|
|
751
|
+
"(or addressed by the human) — so always @-mention the agent (or @you)",
|
|
752
|
+
"you mean. There is NO `peer` command and no shared tmux session;",
|
|
753
|
+
"hand-offs are just @-mentions in your replies.",
|
|
702
754
|
"",
|
|
703
755
|
"Because your input is only what you're addressed, you may be missing",
|
|
704
756
|
"context from messages between the human and the other agents. The full",
|
|
@@ -817,15 +869,21 @@ interface DockerPs {
|
|
|
817
869
|
State: string; // "running" | "exited" | "created" | "paused"
|
|
818
870
|
}
|
|
819
871
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
872
|
+
/**
|
|
873
|
+
* Returns the task's containers, [] when docker reports none, or NULL when
|
|
874
|
+
* docker itself was unreachable/timed out (status null = spawn failure or our
|
|
875
|
+
* SIGKILL). The distinction matters: recovery treats [] as "container gone"
|
|
876
|
+
* and DOWNGRADES the task to stopped/error — doing that because a `docker ps`
|
|
877
|
+
* timed out under load would destroy a perfectly recoverable task.
|
|
878
|
+
*/
|
|
879
|
+
async function dockerListContainersByLabel(
|
|
880
|
+
label: string,
|
|
881
|
+
): Promise<DockerPs[] | null> {
|
|
882
|
+
const res = await dockerCli(
|
|
883
|
+
["ps", "--all", "--filter", `label=${label}`, "--format", "{{json .}}"],
|
|
884
|
+
{ timeoutMs: 60_000 },
|
|
885
|
+
);
|
|
886
|
+
if (res.status === null) return null; // docker state UNKNOWN — don't act on it
|
|
829
887
|
if (res.status !== 0) return [];
|
|
830
888
|
return res.stdout
|
|
831
889
|
.split("\n")
|
|
@@ -842,7 +900,9 @@ async function dockerListContainersByLabel(label: string): Promise<DockerPs[]> {
|
|
|
842
900
|
}
|
|
843
901
|
|
|
844
902
|
async function dockerStart(containerName: string): Promise<boolean> {
|
|
845
|
-
|
|
903
|
+
// Starting a big dev container can legitimately take a while — give it far
|
|
904
|
+
// more than dockerCli's 30s default before declaring the task stopped.
|
|
905
|
+
const res = await dockerCli(["start", containerName], { timeoutMs: 120_000 });
|
|
846
906
|
if (res.status !== 0) {
|
|
847
907
|
console.error(
|
|
848
908
|
`[orchestrator] docker start ${containerName} failed: ${res.stderr.trim()}`,
|
|
@@ -866,8 +926,15 @@ async function dockerPort(
|
|
|
866
926
|
return Number.isFinite(port) ? port : null;
|
|
867
927
|
}
|
|
868
928
|
|
|
869
|
-
async function dockerExec(
|
|
870
|
-
|
|
929
|
+
async function dockerExec(
|
|
930
|
+
containerName: string,
|
|
931
|
+
cmd: string[],
|
|
932
|
+
timeoutMs?: number,
|
|
933
|
+
): Promise<boolean> {
|
|
934
|
+
const res = await dockerCli(
|
|
935
|
+
["exec", containerName, ...cmd],
|
|
936
|
+
timeoutMs === undefined ? {} : { timeoutMs },
|
|
937
|
+
);
|
|
871
938
|
return res.status === 0;
|
|
872
939
|
}
|
|
873
940
|
|
|
@@ -915,6 +982,14 @@ async function recoverOneTask(
|
|
|
915
982
|
const containers = await dockerListContainersByLabel(
|
|
916
983
|
`com.docker.compose.project=${composeProject}`,
|
|
917
984
|
);
|
|
985
|
+
if (containers === null) {
|
|
986
|
+
// Docker unreachable / timed out — actual state unknown. Leave the row
|
|
987
|
+
// alone; the next recovery pass (or the next ensure) will see the truth.
|
|
988
|
+
console.warn(
|
|
989
|
+
`[orchestrator] recovery: ${task.taskId} skipped — docker did not answer`,
|
|
990
|
+
);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
918
993
|
|
|
919
994
|
if (containers.length === 0) {
|
|
920
995
|
// Container gone. Worktree state determines whether resume is
|
|
@@ -945,6 +1020,13 @@ async function recoverOneTask(
|
|
|
945
1020
|
});
|
|
946
1021
|
console.log(`[orchestrator] recovery: ${task.taskId} ports refreshed`);
|
|
947
1022
|
}
|
|
1023
|
+
// Re-establish gh proactively (ADR-027): the restart killed the in-memory
|
|
1024
|
+
// refresh timers, so an idle task's access token would silently expire
|
|
1025
|
+
// ~8h later. Cheap with the per-user access-token cache — one exchange
|
|
1026
|
+
// per user, every task re-injects from it. Best-effort.
|
|
1027
|
+
if (task.ownerUserId) {
|
|
1028
|
+
void setupTaskGithub(task.taskId, task.ownerUserId);
|
|
1029
|
+
}
|
|
948
1030
|
return;
|
|
949
1031
|
}
|
|
950
1032
|
|
|
@@ -960,7 +1042,11 @@ async function recoverOneTask(
|
|
|
960
1042
|
});
|
|
961
1043
|
return;
|
|
962
1044
|
}
|
|
963
|
-
|
|
1045
|
+
// uai-init reinstalls workspace deps (pnpm/npm install) — minutes on a big
|
|
1046
|
+
// repo. dockerCli's 30s default would SIGKILL it mid-install.
|
|
1047
|
+
if (
|
|
1048
|
+
!(await dockerExec(containerName, ["/usr/local/bin/uai-init"], 10 * 60_000))
|
|
1049
|
+
) {
|
|
964
1050
|
console.warn(
|
|
965
1051
|
`[orchestrator] recovery: ${task.taskId} uai-init failed; container is up but Editor may be down`,
|
|
966
1052
|
);
|
|
@@ -969,6 +1055,11 @@ async function recoverOneTask(
|
|
|
969
1055
|
db_setRuntime(task.taskId, {
|
|
970
1056
|
codeServerPort: port,
|
|
971
1057
|
});
|
|
1058
|
+
// Same gh re-establish as the running branch — the restarted container's gh
|
|
1059
|
+
// config is whatever it held when it exited.
|
|
1060
|
+
if (task.ownerUserId) {
|
|
1061
|
+
void setupTaskGithub(task.taskId, task.ownerUserId);
|
|
1062
|
+
}
|
|
972
1063
|
console.log(
|
|
973
1064
|
`[orchestrator] recovery: ${task.taskId} resumed (port ${port ?? "?"})`,
|
|
974
1065
|
);
|
package/package.json
CHANGED
package/src/main.ts
CHANGED
|
@@ -25,8 +25,10 @@ import {
|
|
|
25
25
|
import { getHostTask } from "../lib/runtime-state";
|
|
26
26
|
import { getOrchestrator } from "../lib/orchestrator";
|
|
27
27
|
import {
|
|
28
|
+
connectedUserIds,
|
|
28
29
|
onConnectClear,
|
|
29
30
|
onConnectSet,
|
|
31
|
+
onGithubChange,
|
|
30
32
|
setAuthExpiredHandler,
|
|
31
33
|
} from "../lib/github-tokens";
|
|
32
34
|
import {
|
|
@@ -166,6 +168,7 @@ function buildCapabilities(): HostCapabilities {
|
|
|
166
168
|
return {
|
|
167
169
|
agentKinds: agentKindCapabilities(),
|
|
168
170
|
runtimes: standardRuntimes(),
|
|
171
|
+
githubUsers: connectedUserIds(),
|
|
169
172
|
};
|
|
170
173
|
}
|
|
171
174
|
|
|
@@ -181,6 +184,9 @@ function sendCapabilities(): void {
|
|
|
181
184
|
}
|
|
182
185
|
|
|
183
186
|
onRegistryChange(() => sendCapabilities());
|
|
187
|
+
// Re-advertise when a gh token is added/removed (ADR-033) so the host page's
|
|
188
|
+
// per-host connected state updates promptly.
|
|
189
|
+
onGithubChange(() => sendCapabilities());
|
|
184
190
|
|
|
185
191
|
function connect(): void {
|
|
186
192
|
if (stopping || fatal) return;
|
|
@@ -284,7 +290,15 @@ function connect(): void {
|
|
|
284
290
|
break;
|
|
285
291
|
}
|
|
286
292
|
case "gh.connect.clear":
|
|
287
|
-
|
|
293
|
+
// Delete-then-revoke (ADR-033) is async + best-effort; ack immediately
|
|
294
|
+
// so the UI isn't gated on the GitHub revoke round-trip. The .catch is
|
|
295
|
+
// a belt over onConnectClear's own try/catch — a fire-and-forget
|
|
296
|
+
// rejection must never crash the host.
|
|
297
|
+
void onConnectClear(frame.userId).catch((err) =>
|
|
298
|
+
console.warn(
|
|
299
|
+
`[github] connect.clear failed: ${err instanceof Error ? err.message : err}`,
|
|
300
|
+
),
|
|
301
|
+
);
|
|
288
302
|
send(socket, { kind: "gh.connect.ack", userId: frame.userId, ok: true });
|
|
289
303
|
break;
|
|
290
304
|
case "ssh.key.get":
|
|
@@ -830,7 +844,9 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
|
|
|
830
844
|
typeof frame.installationId === "number" &&
|
|
831
845
|
typeof frame.githubLogin === "string" &&
|
|
832
846
|
(frame.targetType === "User" || frame.targetType === "Organization") &&
|
|
833
|
-
|
|
847
|
+
// Exactly one token kind: accessToken (ADR-033) or refreshToken (ADR-027).
|
|
848
|
+
(typeof frame.accessToken === "string" ||
|
|
849
|
+
typeof frame.refreshToken === "string")
|
|
834
850
|
) {
|
|
835
851
|
return {
|
|
836
852
|
kind: "gh.connect.set",
|
|
@@ -838,7 +854,10 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
|
|
|
838
854
|
installationId: frame.installationId,
|
|
839
855
|
githubLogin: frame.githubLogin,
|
|
840
856
|
targetType: frame.targetType,
|
|
841
|
-
|
|
857
|
+
accessToken:
|
|
858
|
+
typeof frame.accessToken === "string" ? frame.accessToken : undefined,
|
|
859
|
+
refreshToken:
|
|
860
|
+
typeof frame.refreshToken === "string" ? frame.refreshToken : undefined,
|
|
842
861
|
refreshTokenExpiresAt:
|
|
843
862
|
typeof frame.refreshTokenExpiresAt === "number"
|
|
844
863
|
? frame.refreshTokenExpiresAt
|
package/src/protocol.ts
CHANGED
|
@@ -68,6 +68,10 @@ export interface HostCapabilities {
|
|
|
68
68
|
kind: string;
|
|
69
69
|
availableVersions: string[];
|
|
70
70
|
}>;
|
|
71
|
+
// ADR-033: cloud user ids that currently have a GitHub token ON THIS HOST —
|
|
72
|
+
// the per-host gh-connected state shown on the host detail page. Re-advertised
|
|
73
|
+
// whenever a token is added/removed. Optional: older hosts omit it.
|
|
74
|
+
githubUsers?: string[];
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
export interface TaskUpResult {
|
|
@@ -305,18 +309,25 @@ export type CloudToHost =
|
|
|
305
309
|
// never responds, so the cloud never sends an ack. (HTTP tunnels only.)
|
|
306
310
|
| { kind: "tunnel.requestEnd"; tunnelId: string }
|
|
307
311
|
| { kind: "tunnel.close"; tunnelId: string; reason?: string }
|
|
308
|
-
// GitHub App connect lifecycle (ADR-027). The cloud forwards the
|
|
309
|
-
//
|
|
310
|
-
//
|
|
312
|
+
// GitHub App connect lifecycle (ADR-027 / ADR-033). The cloud forwards the
|
|
313
|
+
// user's token to the host, which encrypts + persists it. Exactly one token
|
|
314
|
+
// is present:
|
|
315
|
+
// - `accessToken` → non-expiring per-host token (ADR-033). Injected into
|
|
316
|
+
// containers directly; no exchange, no refresh.
|
|
317
|
+
// - `refreshToken` → legacy expiring token (ADR-027). The host exchanges it
|
|
318
|
+
// for short-lived access tokens (host→cloud HTTP POST).
|
|
311
319
|
| {
|
|
312
320
|
kind: "gh.connect.set";
|
|
313
321
|
userId: string;
|
|
314
322
|
installationId: number;
|
|
315
323
|
githubLogin: string;
|
|
316
324
|
targetType: "User" | "Organization";
|
|
317
|
-
|
|
325
|
+
accessToken?: string;
|
|
326
|
+
refreshToken?: string;
|
|
318
327
|
refreshTokenExpiresAt?: number;
|
|
319
328
|
}
|
|
329
|
+
// Delete the user's token on the host. ADR-033: the host first POSTs the
|
|
330
|
+
// token to the cloud's /api/github/revoke (kill it at GitHub) before deleting.
|
|
320
331
|
| { kind: "gh.connect.clear"; userId: string }
|
|
321
332
|
// Per-user SSH key lifecycle (ADR-029). Keys are generated + stored on the
|
|
322
333
|
// host; only the public key crosses the bridge (ssh.key.ack). `get` fetches
|
|
@@ -402,11 +413,14 @@ export type HostEvent =
|
|
|
402
413
|
// The agent finished a turn (it may have emitted several message_complete
|
|
403
414
|
// items within it). The cloud waits for this before waking @-mentioned peers,
|
|
404
415
|
// so a peer is handed the agent's COMPLETE turn rather than a mid-turn
|
|
405
|
-
// fragment that happened to contain the mention.
|
|
416
|
+
// fragment that happened to contain the mention. `aborted` marks a turn that
|
|
417
|
+
// was interrupted (ESC): the cloud discards the buffered half-turn instead
|
|
418
|
+
// of delivering it.
|
|
406
419
|
| {
|
|
407
420
|
kind: "agent.turn_complete";
|
|
408
421
|
taskId: string;
|
|
409
422
|
agentId: string;
|
|
423
|
+
aborted?: boolean;
|
|
410
424
|
}
|
|
411
425
|
| {
|
|
412
426
|
kind: "agent.tool_call";
|