@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.
@@ -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;
@@ -50,6 +50,13 @@
50
50
  "when": 1779900007000,
51
51
  "tag": "0006_host_project_env",
52
52
  "breakpoints": true
53
+ },
54
+ {
55
+ "idx": 7,
56
+ "version": "6",
57
+ "when": 1779900008000,
58
+ "tag": "0007_host_github_token_kind",
59
+ "breakpoints": true
53
60
  }
54
61
  ]
55
62
  }
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
 
@@ -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
  });
@@ -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
- const EXCHANGE_TIMEOUT_MS = 15_000; // cap the cloud token-exchange round-trip
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
- /** Encrypt + persist the user's refresh token (gh.connect.set handler). */
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
- const sealed = sealAesGcm(frame.refreshToken);
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
- userId: frame.userId,
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
- /** Delete a user's refresh token + clear their active-task refresh timers. */
58
- export function onConnectClear(userId: string): void {
59
- deleteToken(userId);
60
- for (const taskId of activeTaskIdsForUser(userId)) {
61
- clearRefresh(taskId);
62
- authExpiredHandler?.(taskId, userId, "GitHub disconnected");
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 exchangeUrl(): string {
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}/api/github/oauth/exchange`;
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
- export async function requestAccessToken(
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
- throw new Error(`gh token exchange failed: HTTP ${res.status}`);
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
- scheduleRefresh(taskId, userId, tok.expiresAt);
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 (/\b40[13]\b|revoked|invalid_grant|bad_refresh/i.test(reason)) {
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
- void setupTaskGithub(taskId, userId, {}, attempt + 1);
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
- _schedule(taskId, userId, tok.expiresAt);
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
- authExpiredHandler?.(taskId, userId, reason);
367
- // Self-heal: a transient exchange/inject failure shouldn't strand the task
368
- // on a stale token until a manual /retry-gh. Retry with bounded backoff.
369
- scheduleGithubRetry(taskId, userId, attempt);
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);
@@ -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
- /** True once agent sessions have been spawned (lazily, on first send). */
60
- sessionsStarted: boolean;
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
- sessionsStarted: false,
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 async ensureSessions(channel: Channel): Promise<boolean> {
171
- if (channel.sessionsStarted) return true;
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 session = this.channels.get(taskId)?.sessions.get(agentId);
271
- if (!session) return { ok: false, error: "no active session" };
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 mean.",
700
- "There is NO `peer` command and no shared tmux session; hand-offs are",
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
- async function dockerListContainersByLabel(label: string): Promise<DockerPs[]> {
821
- const res = await dockerCli([
822
- "ps",
823
- "--all",
824
- "--filter",
825
- `label=${label}`,
826
- "--format",
827
- "{{json .}}",
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
- const res = await dockerCli(["start", containerName]);
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(containerName: string, cmd: string[]): Promise<boolean> {
870
- const res = await dockerCli(["exec", containerName, ...cmd]);
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
- if (!(await dockerExec(containerName, ["/usr/local/bin/uai-init"]))) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.2.4",
3
+ "version": "0.2.8",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
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
- onConnectClear(frame.userId);
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
- typeof frame.refreshToken === "string"
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
- refreshToken: frame.refreshToken,
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 user's
309
- // refresh token to the host, which encrypts + persists it; the per-task
310
- // access-token exchange is a host→cloud HTTP POST, not a frame.
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
- refreshToken: string;
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";