@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.
- 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/github-tokens.ts +149 -32
- package/lib/orchestrator.ts +9 -3
- package/package.json +1 -1
- package/src/main.ts +22 -3
- package/src/protocol.ts +15 -4
|
@@ -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/github-tokens.ts
CHANGED
|
@@ -26,56 +26,101 @@ type GhConnectSet = Extract<CloudToHost, { kind: "gh.connect.set" }>;
|
|
|
26
26
|
|
|
27
27
|
// --- token store ------------------------------------------------------------
|
|
28
28
|
|
|
29
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/lib/orchestrator.ts
CHANGED
|
@@ -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
|
|
746
|
-
"There is NO `peer` command and no shared tmux session;
|
|
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
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
|