@runuai/host 0.2.6 → 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
 
@@ -26,56 +26,101 @@ type GhConnectSet = Extract<CloudToHost, { kind: "gh.connect.set" }>;
26
26
 
27
27
  // --- token store ------------------------------------------------------------
28
28
 
29
- /** 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
+ */
30
35
  export function onConnectSet(frame: GhConnectSet): { ok: boolean; error?: string } {
31
36
  try {
32
37
  // Fresh grant — a cached access token from the previous grant may be
33
38
  // revoked; drop it so the next mint exchanges against the new token.
34
39
  clearAccessCache(frame.userId);
35
- const sealed = sealAesGcm(frame.refreshToken);
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);
36
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
+ };
37
53
  getDb()
38
54
  .insert(schema.githubTokens)
39
- .values({
40
- userId: frame.userId,
41
- installationId: frame.installationId,
42
- refreshTokenCt: sealed.ct,
43
- refreshTokenNonce: sealed.nonce,
44
- refreshTokenExpiresAt: frame.refreshTokenExpiresAt ?? null,
45
- updatedAt: now,
46
- })
47
- .onConflictDoUpdate({
48
- target: schema.githubTokens.userId,
49
- set: {
50
- installationId: frame.installationId,
51
- refreshTokenCt: sealed.ct,
52
- refreshTokenNonce: sealed.nonce,
53
- refreshTokenExpiresAt: frame.refreshTokenExpiresAt ?? null,
54
- updatedAt: now,
55
- },
56
- })
55
+ .values({ userId: frame.userId, ...fields })
56
+ .onConflictDoUpdate({ target: schema.githubTokens.userId, set: fields })
57
57
  .run();
58
+ notifyGithubChange();
58
59
  return { ok: true };
59
60
  } catch (err) {
60
61
  return { ok: false, error: err instanceof Error ? err.message : "store failed" };
61
62
  }
62
63
  }
63
64
 
64
- /** Delete a user's refresh token + clear their active-task refresh timers. */
65
- export function onConnectClear(userId: string): void {
66
- deleteToken(userId);
67
- for (const taskId of activeTaskIdsForUser(userId)) {
68
- clearRefresh(taskId);
69
- 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
+ );
70
97
  }
71
98
  }
72
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
+
73
117
  export function deleteToken(userId: string): void {
74
118
  clearAccessCache(userId);
75
119
  getDb()
76
120
  .delete(schema.githubTokens)
77
121
  .where(eq(schema.githubTokens.userId, userId))
78
122
  .run();
123
+ notifyGithubChange();
79
124
  }
80
125
 
81
126
  export function hasToken(userId: string): boolean {
@@ -88,6 +133,29 @@ export function hasToken(userId: string): boolean {
88
133
  );
89
134
  }
90
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
+
91
159
  function activeTaskIdsForUser(userId: string): string[] {
92
160
  return getDb()
93
161
  .select({ taskId: schema.hostTasks.taskId })
@@ -104,12 +172,43 @@ function activeTaskIdsForUser(userId: string): string[] {
104
172
 
105
173
  // --- access-token exchange --------------------------------------------------
106
174
 
107
- function exchangeUrl(): string {
175
+ function cloudHttpBase(): string {
108
176
  const cloud = process.env.UAI_CLOUD_URL ?? "ws://127.0.0.1:8789/host";
109
177
  const u = new URL(cloud);
110
178
  const proto =
111
179
  u.protocol === "wss:" ? "https:" : u.protocol === "ws:" ? "http:" : u.protocol;
112
- 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
+ }
113
212
  }
114
213
 
115
214
  interface ExchangeResult {
@@ -165,7 +264,23 @@ export function clearAllAccessCache(): void {
165
264
 
166
265
  export function requestAccessToken(
167
266
  userId: string,
168
- ): Promise<{ accessToken: string; expiresAt: number } | null> {
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.
169
284
  const cached = accessCache.get(userId);
170
285
  if (cached && cached.expiresAt - Date.now() > ACCESS_CACHE_LEAD_MS) {
171
286
  return Promise.resolve(cached);
@@ -327,7 +442,8 @@ async function runRefresh(taskId: string, userId: string): Promise<void> {
327
442
  return;
328
443
  }
329
444
  await injectIntoContainer(taskId, tok.accessToken);
330
- 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);
331
447
  } catch (err) {
332
448
  const reason = err instanceof Error ? err.message : String(err);
333
449
  // A revoked / expired refresh token can't recover — drop it to force a
@@ -423,7 +539,7 @@ export interface SetupTaskDeps {
423
539
  hasToken?: (userId: string) => boolean;
424
540
  requestAccessToken?: (
425
541
  userId: string,
426
- ) => Promise<{ accessToken: string; expiresAt: number } | null>;
542
+ ) => Promise<{ accessToken: string; expiresAt: number | null } | null>;
427
543
  inject?: (taskId: string, token: string) => void | Promise<void>;
428
544
  schedule?: (taskId: string, userId: string, expiresAt: number) => void;
429
545
  deleteToken?: (userId: string) => void;
@@ -460,7 +576,8 @@ export async function setupTaskGithub(
460
576
  const tok = await _request(userId);
461
577
  if (tok) {
462
578
  await _inject(taskId, tok.accessToken);
463
- _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);
464
581
  clearGithubRetry(taskId);
465
582
  console.log(`[github] task ${taskId}: injected user access token`);
466
583
  return true;
@@ -741,10 +741,16 @@ export function buildSystemPreamble(
741
741
  "",
742
742
  `Agents in this channel: ${others}.`,
743
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
+ "",
744
750
  "An agent only receives a message when it is explicitly @-mentioned",
745
- "(or addressed by the human) — so always @-mention the agent you mean.",
746
- "There is NO `peer` command and no shared tmux session; hand-offs are",
747
- "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.",
748
754
  "",
749
755
  "Because your input is only what you're addressed, you may be missing",
750
756
  "context from messages between the human and the other agents. The full",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.2.6",
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