@runuai/host 0.9.138 → 0.9.140
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/lib/browser-testing.ts +6 -1
- package/lib/credential-store.ts +34 -2
- package/lib/engine-accounts.ts +438 -133
- package/lib/engine-connect.ts +40 -9
- package/lib/engine-login.ts +64 -15
- package/lib/env-file.ts +30 -0
- package/lib/github-tokens.ts +281 -17
- package/lib/host-env.ts +4 -0
- package/lib/mcp-connections.ts +208 -5
- package/lib/signin-box-credentials.ts +465 -0
- package/lib/signin-box.ts +114 -0
- package/lib/ssh.ts +109 -6
- package/package.json +1 -1
- package/src/cli.ts +39 -6
- package/src/main.ts +210 -3
- package/src/protocol.ts +11 -5
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-136 sign-in box, the user and project credentials (host release 3b).
|
|
3
|
+
*
|
|
4
|
+
* The shared "Uai Cloud" row is where the cloud sends every workspace's
|
|
5
|
+
* GitHub connects, SSH key requests, MCP connection probes and project env
|
|
6
|
+
* writes for hostless tasks — the box is the writer for all of them. Same
|
|
7
|
+
* contract as engine accounts (lib/engine-accounts.ts, store-only accounts):
|
|
8
|
+
*
|
|
9
|
+
* - GitHub tokens: the store first, a row with no ciphertext, the token in
|
|
10
|
+
* a bounded in-memory cache only while the box needs it (the repo picker's
|
|
11
|
+
* installation listing reads it back under a policy scoped to
|
|
12
|
+
* `/uai/users/*\/github`). lib/github-tokens.ts.
|
|
13
|
+
* - SSH keys: the keypair to the store first, only the public half stays
|
|
14
|
+
* (tagged, so a replacement box lists it without a read). lib/ssh.ts.
|
|
15
|
+
* - MCP connections: header / OAuth tokens to the store first, a row with
|
|
16
|
+
* every secret column null; a half-finished OAuth flow lives in memory.
|
|
17
|
+
* lib/mcp-connections.ts.
|
|
18
|
+
* - Project env: no rows at all — set/delete/list are store calls
|
|
19
|
+
* (put + org tag, delete, describe names).
|
|
20
|
+
*
|
|
21
|
+
* This module owns what cuts across them: the start-time purge of whatever a
|
|
22
|
+
* previous release left, the names-and-tags reconcile that rebuilds the
|
|
23
|
+
* metadata rows (reading a value only where the policy allows it — GitHub
|
|
24
|
+
* always, SSH and MCP only during the transitional window that lets the box
|
|
25
|
+
* tag legacy parameters once), and the project env passthrough.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { eq } from "drizzle-orm";
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
configuredCredentialStore,
|
|
32
|
+
credentialStorePrefix,
|
|
33
|
+
isParameterPathSegment,
|
|
34
|
+
} from "./credential-store";
|
|
35
|
+
import { getDb, schema } from "./db";
|
|
36
|
+
import {
|
|
37
|
+
claimGithubCredentialGeneration,
|
|
38
|
+
deleteGithubTokenRowLocal,
|
|
39
|
+
GITHUB_TOKEN_TAGS,
|
|
40
|
+
githubTokenMetadataTags,
|
|
41
|
+
insertStoreHeldGithubTokenRow,
|
|
42
|
+
isStoreHeldGithubRow,
|
|
43
|
+
} from "./github-tokens";
|
|
44
|
+
import {
|
|
45
|
+
projectEnvParameterName,
|
|
46
|
+
projectEnvTags,
|
|
47
|
+
type StoredProjectEnv,
|
|
48
|
+
} from "./host-env";
|
|
49
|
+
import {
|
|
50
|
+
deleteMcpConnectionRowLocal,
|
|
51
|
+
MCP_CONNECTION_TAGS,
|
|
52
|
+
mcpConnectionMetadataTags,
|
|
53
|
+
upsertStoreHeldMcpConnectionRow,
|
|
54
|
+
} from "./mcp-connections";
|
|
55
|
+
import {
|
|
56
|
+
deleteSshKeyRowLocal,
|
|
57
|
+
insertStoreHeldSshKeyRow,
|
|
58
|
+
isStoreHeldSshRow,
|
|
59
|
+
SSH_KEY_TAGS,
|
|
60
|
+
sshKeyMetadataTags,
|
|
61
|
+
} from "./ssh";
|
|
62
|
+
import {
|
|
63
|
+
parseStoredGithubToken,
|
|
64
|
+
parseStoredMcpConnection,
|
|
65
|
+
parseStoredSshKey,
|
|
66
|
+
parseUserParameterName,
|
|
67
|
+
} from "./user-credentials-store";
|
|
68
|
+
|
|
69
|
+
function warn(line: string): void {
|
|
70
|
+
console.warn(`[signin-box] ${line}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function fromB64url(value: string | undefined): string | null {
|
|
74
|
+
if (!value) return null;
|
|
75
|
+
try {
|
|
76
|
+
return Buffer.from(value, "base64url").toString("utf8");
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function intTag(tags: Record<string, string>, key: string): number | null {
|
|
83
|
+
const raw = tags[key];
|
|
84
|
+
if (raw === undefined || raw === "") return null;
|
|
85
|
+
const n = Number.parseInt(raw, 10);
|
|
86
|
+
return Number.isFinite(n) ? n : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Project env: a store passthrough.
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
export async function setProjectEnvStoreOnly(
|
|
94
|
+
projectId: string,
|
|
95
|
+
key: string,
|
|
96
|
+
value: string,
|
|
97
|
+
orgId?: string,
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
const store = configuredCredentialStore();
|
|
100
|
+
if (!store) throw new Error("the sign-in box has no credential store to hold project env");
|
|
101
|
+
const name = projectEnvParameterName(projectId, key);
|
|
102
|
+
if (!name) throw new Error("this key cannot be stored (not a legal parameter name)");
|
|
103
|
+
const stored: StoredProjectEnv = {
|
|
104
|
+
v: 1,
|
|
105
|
+
kind: "project-env",
|
|
106
|
+
projectId,
|
|
107
|
+
key,
|
|
108
|
+
value,
|
|
109
|
+
updatedAt: Date.now(),
|
|
110
|
+
...(orgId ? { orgId } : {}),
|
|
111
|
+
};
|
|
112
|
+
await store.put(name, JSON.stringify(stored));
|
|
113
|
+
// The tag is what lets a task machine's lease read the value (ADR-133);
|
|
114
|
+
// without it the value is stored but unreachable, so this failure is the
|
|
115
|
+
// caller's.
|
|
116
|
+
if (orgId) await store.tag(name, projectEnvTags(projectId, orgId));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function deleteProjectEnvStoreOnly(projectId: string, key: string): Promise<void> {
|
|
120
|
+
const store = configuredCredentialStore();
|
|
121
|
+
if (!store) throw new Error("the sign-in box has no credential store to hold project env");
|
|
122
|
+
const name = projectEnvParameterName(projectId, key);
|
|
123
|
+
if (!name) return;
|
|
124
|
+
await store.delete(name);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The project's KEY NAMES from the store — names only, never a value. */
|
|
128
|
+
export async function listProjectEnvKeysStoreOnly(projectId: string): Promise<string[]> {
|
|
129
|
+
const store = configuredCredentialStore();
|
|
130
|
+
if (!store) throw new Error("the sign-in box has no credential store to hold project env");
|
|
131
|
+
if (!isParameterPathSegment(projectId)) return [];
|
|
132
|
+
const base = `${credentialStorePrefix()}/projects/${projectId}/env`;
|
|
133
|
+
const names = await store.describeNames(base);
|
|
134
|
+
return names
|
|
135
|
+
.map((name) => name.slice(base.length + 1))
|
|
136
|
+
.filter((key) => key.length > 0 && !key.includes("/"))
|
|
137
|
+
.sort();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Start-time purge.
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
export interface PurgeLocalUserCredentialSecretsResult {
|
|
145
|
+
githubStripped: number;
|
|
146
|
+
sshStripped: number;
|
|
147
|
+
mcpStripped: number;
|
|
148
|
+
projectEnvRemoved: number;
|
|
149
|
+
/** Rows deleted outright: never mirrored, so the store has no copy and a
|
|
150
|
+
* secret with no copy elsewhere still may not live here. */
|
|
151
|
+
unmirroredRemoved: number;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Nothing a previous release left on this machine may hold a credential.
|
|
156
|
+
* Rows keep their metadata and lose their material; rows the store never
|
|
157
|
+
* received are deleted; project env rows go entirely (the store is listed
|
|
158
|
+
* by name instead).
|
|
159
|
+
*/
|
|
160
|
+
export function purgeLocalUserCredentialSecrets(): PurgeLocalUserCredentialSecretsResult {
|
|
161
|
+
const result: PurgeLocalUserCredentialSecretsResult = {
|
|
162
|
+
githubStripped: 0,
|
|
163
|
+
sshStripped: 0,
|
|
164
|
+
mcpStripped: 0,
|
|
165
|
+
projectEnvRemoved: 0,
|
|
166
|
+
unmirroredRemoved: 0,
|
|
167
|
+
};
|
|
168
|
+
const db = getDb();
|
|
169
|
+
const empty = { ct: Buffer.alloc(0), nonce: Buffer.alloc(0) };
|
|
170
|
+
|
|
171
|
+
for (const row of db.select().from(schema.githubTokens).all()) {
|
|
172
|
+
if (!row.storeRef) {
|
|
173
|
+
deleteGithubTokenRowLocal(row.userId);
|
|
174
|
+
result.unmirroredRemoved++;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (isStoreHeldGithubRow(row)) continue;
|
|
178
|
+
db.update(schema.githubTokens)
|
|
179
|
+
.set({ refreshTokenCt: empty.ct, refreshTokenNonce: empty.nonce })
|
|
180
|
+
.where(eq(schema.githubTokens.userId, row.userId))
|
|
181
|
+
.run();
|
|
182
|
+
result.githubStripped++;
|
|
183
|
+
}
|
|
184
|
+
for (const row of db.select().from(schema.sshKeys).all()) {
|
|
185
|
+
if (!row.storeRef) {
|
|
186
|
+
deleteSshKeyRowLocal(row.userId);
|
|
187
|
+
result.unmirroredRemoved++;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (isStoreHeldSshRow(row)) continue;
|
|
191
|
+
db.update(schema.sshKeys)
|
|
192
|
+
.set({ privateKeyCt: empty.ct, privateKeyNonce: empty.nonce })
|
|
193
|
+
.where(eq(schema.sshKeys.userId, row.userId))
|
|
194
|
+
.run();
|
|
195
|
+
result.sshStripped++;
|
|
196
|
+
}
|
|
197
|
+
for (const row of db.select().from(schema.mcpConnections).all()) {
|
|
198
|
+
if (!row.storeRef) {
|
|
199
|
+
deleteMcpConnectionRowLocal(row.id);
|
|
200
|
+
result.unmirroredRemoved++;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (row.secretEnc === null && row.clientSecretEnc === null && row.pkceVerifierEnc === null) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
db.update(schema.mcpConnections)
|
|
207
|
+
.set({ secretEnc: null, clientSecretEnc: null, pkceVerifierEnc: null })
|
|
208
|
+
.where(eq(schema.mcpConnections.id, row.id))
|
|
209
|
+
.run();
|
|
210
|
+
result.mcpStripped++;
|
|
211
|
+
}
|
|
212
|
+
result.projectEnvRemoved = db.delete(schema.hostProjectEnv).run().changes;
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Start-time metadata reconcile: names + tags, a value only where allowed.
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
export interface ReconcileUserCredentialMetadataResult {
|
|
221
|
+
skipped: boolean;
|
|
222
|
+
imported: number;
|
|
223
|
+
removed: number;
|
|
224
|
+
/** Legacy parameters the box could read and has now tagged. */
|
|
225
|
+
tagged: number;
|
|
226
|
+
/** Parameters neither tagged nor readable — listed nowhere until an
|
|
227
|
+
* operator runs the bootstrap with the transitional read once. */
|
|
228
|
+
unreadable: number;
|
|
229
|
+
errors: number;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function reconcileUserCredentialMetadataFromStore(): Promise<ReconcileUserCredentialMetadataResult> {
|
|
233
|
+
const result: ReconcileUserCredentialMetadataResult = {
|
|
234
|
+
skipped: false,
|
|
235
|
+
imported: 0,
|
|
236
|
+
removed: 0,
|
|
237
|
+
tagged: 0,
|
|
238
|
+
unreadable: 0,
|
|
239
|
+
errors: 0,
|
|
240
|
+
};
|
|
241
|
+
const store = configuredCredentialStore();
|
|
242
|
+
if (!store) return { ...result, skipped: true };
|
|
243
|
+
const prefix = credentialStorePrefix();
|
|
244
|
+
let names: string[];
|
|
245
|
+
try {
|
|
246
|
+
names = await store.describeNames(`${prefix}/users`);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
warn(`listing ${prefix}/users failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
249
|
+
return { ...result, errors: 1 };
|
|
250
|
+
}
|
|
251
|
+
const db = getDb();
|
|
252
|
+
const github = new Map(db.select().from(schema.githubTokens).all().map((r) => [r.storeRef, r]));
|
|
253
|
+
const ssh = new Map(db.select().from(schema.sshKeys).all().map((r) => [r.storeRef, r]));
|
|
254
|
+
const mcp = new Map(db.select().from(schema.mcpConnections).all().map((r) => [r.storeRef, r]));
|
|
255
|
+
const seen = new Set<string>();
|
|
256
|
+
|
|
257
|
+
for (const name of names) {
|
|
258
|
+
const at = parseUserParameterName(name, prefix);
|
|
259
|
+
if (!at) continue;
|
|
260
|
+
seen.add(name);
|
|
261
|
+
const known =
|
|
262
|
+
at.kind === "github" ? github.has(name) : at.kind === "ssh" ? ssh.has(name) : mcp.has(name);
|
|
263
|
+
if (known) continue;
|
|
264
|
+
let tags: Record<string, string> = {};
|
|
265
|
+
try {
|
|
266
|
+
tags = await store.listTags(name);
|
|
267
|
+
} catch (err) {
|
|
268
|
+
result.errors++;
|
|
269
|
+
warn(`tags of ${name} unreadable: ${err instanceof Error ? err.message : String(err)}`);
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
if (at.kind === "github") {
|
|
273
|
+
await importGithubMetadata(store, name, at.userId, tags, result);
|
|
274
|
+
} else if (at.kind === "ssh") {
|
|
275
|
+
await importSshMetadata(store, name, at.userId, tags, result);
|
|
276
|
+
} else {
|
|
277
|
+
await importMcpMetadata(store, name, at.userId, at.id, tags, result);
|
|
278
|
+
}
|
|
279
|
+
} catch (err) {
|
|
280
|
+
result.errors++;
|
|
281
|
+
warn(`${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
for (const [ref, row] of github) {
|
|
286
|
+
if (ref && seen.has(ref)) continue;
|
|
287
|
+
deleteGithubTokenRowLocal(row.userId);
|
|
288
|
+
result.removed++;
|
|
289
|
+
}
|
|
290
|
+
for (const [ref, row] of ssh) {
|
|
291
|
+
if (ref && seen.has(ref)) continue;
|
|
292
|
+
deleteSshKeyRowLocal(row.userId);
|
|
293
|
+
result.removed++;
|
|
294
|
+
}
|
|
295
|
+
for (const [ref, row] of mcp) {
|
|
296
|
+
if (ref && seen.has(ref)) continue;
|
|
297
|
+
// A pending OAuth row this box created has no ref yet; keep it.
|
|
298
|
+
if (!ref && row.status === "pending") continue;
|
|
299
|
+
deleteMcpConnectionRowLocal(row.id);
|
|
300
|
+
result.removed++;
|
|
301
|
+
}
|
|
302
|
+
return result;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
type Store = NonNullable<ReturnType<typeof configuredCredentialStore>>;
|
|
306
|
+
|
|
307
|
+
/** Read a legacy (untagged) parameter where the policy allows it; null when
|
|
308
|
+
* it does not (AccessDenied or absent) — never thrown. */
|
|
309
|
+
async function readIfAllowed(store: Store, name: string): Promise<string | null> {
|
|
310
|
+
try {
|
|
311
|
+
return await store.get(name);
|
|
312
|
+
} catch (err) {
|
|
313
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
314
|
+
if (/AccessDenied/i.test(message)) return null;
|
|
315
|
+
throw err;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function importGithubMetadata(
|
|
320
|
+
store: Store,
|
|
321
|
+
name: string,
|
|
322
|
+
userId: string,
|
|
323
|
+
tags: Record<string, string>,
|
|
324
|
+
result: ReconcileUserCredentialMetadataResult,
|
|
325
|
+
): Promise<void> {
|
|
326
|
+
let meta: {
|
|
327
|
+
installationId: number;
|
|
328
|
+
generation: number;
|
|
329
|
+
tokenKind: string;
|
|
330
|
+
expiresAt: number | null;
|
|
331
|
+
updatedAt: number;
|
|
332
|
+
} | null = null;
|
|
333
|
+
const installationId = intTag(tags, GITHUB_TOKEN_TAGS.installationId);
|
|
334
|
+
const generation = intTag(tags, GITHUB_TOKEN_TAGS.generation);
|
|
335
|
+
const tokenKind = tags[GITHUB_TOKEN_TAGS.tokenKind];
|
|
336
|
+
if (installationId !== null && generation !== null && tokenKind) {
|
|
337
|
+
meta = {
|
|
338
|
+
installationId,
|
|
339
|
+
generation,
|
|
340
|
+
tokenKind,
|
|
341
|
+
expiresAt: intTag(tags, GITHUB_TOKEN_TAGS.expiresAt),
|
|
342
|
+
updatedAt: intTag(tags, GITHUB_TOKEN_TAGS.updatedAt) ?? Date.now(),
|
|
343
|
+
};
|
|
344
|
+
} else {
|
|
345
|
+
// The box may read GitHub tokens (the repo picker needs them anyway):
|
|
346
|
+
// read once, tag, and keep only the metadata.
|
|
347
|
+
const raw = await readIfAllowed(store, name);
|
|
348
|
+
if (raw === null) {
|
|
349
|
+
result.unreadable++;
|
|
350
|
+
warn(`${name}: no tags and not readable — the user shows as disconnected on this box`);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const value = parseStoredGithubToken(raw);
|
|
354
|
+
if (value.userId !== userId) throw new Error("payload names another user");
|
|
355
|
+
meta = {
|
|
356
|
+
installationId: value.installationId,
|
|
357
|
+
generation: value.generation,
|
|
358
|
+
tokenKind: value.tokenKind,
|
|
359
|
+
expiresAt: value.expiresAt,
|
|
360
|
+
updatedAt: value.updatedAt,
|
|
361
|
+
};
|
|
362
|
+
try {
|
|
363
|
+
await store.tag(name, githubTokenMetadataTags(meta));
|
|
364
|
+
result.tagged++;
|
|
365
|
+
} catch (err) {
|
|
366
|
+
warn(`${name}: tagging failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
// The same fence an import claims on any host: a generation this box has
|
|
370
|
+
// already moved past (a later clear) is not resurrected.
|
|
371
|
+
if (!claimGithubCredentialGeneration(userId, meta.generation, "set")) return;
|
|
372
|
+
insertStoreHeldGithubTokenRow(userId, {
|
|
373
|
+
installationId: meta.installationId,
|
|
374
|
+
generation: meta.generation,
|
|
375
|
+
refreshTokenExpiresAt: meta.expiresAt,
|
|
376
|
+
kind: meta.tokenKind,
|
|
377
|
+
updatedAt: meta.updatedAt,
|
|
378
|
+
storeRef: name,
|
|
379
|
+
});
|
|
380
|
+
result.imported++;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function importSshMetadata(
|
|
384
|
+
store: Store,
|
|
385
|
+
name: string,
|
|
386
|
+
userId: string,
|
|
387
|
+
tags: Record<string, string>,
|
|
388
|
+
result: ReconcileUserCredentialMetadataResult,
|
|
389
|
+
): Promise<void> {
|
|
390
|
+
let publicKey = tags[SSH_KEY_TAGS.publicKey] ?? "";
|
|
391
|
+
let createdAt = intTag(tags, SSH_KEY_TAGS.createdAt);
|
|
392
|
+
if (!publicKey) {
|
|
393
|
+
const raw = await readIfAllowed(store, name);
|
|
394
|
+
if (raw === null) {
|
|
395
|
+
result.unreadable++;
|
|
396
|
+
warn(`${name}: no public-key tag and not readable — kept as a row with an unknown public key`);
|
|
397
|
+
insertStoreHeldSshKeyRow({ userId, publicKey: "", createdAt: Date.now(), updatedAt: Date.now(), storeRef: name });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const value = parseStoredSshKey(raw);
|
|
401
|
+
if (value.userId !== userId) throw new Error("payload names another user");
|
|
402
|
+
publicKey = value.publicKey;
|
|
403
|
+
createdAt = value.createdAt;
|
|
404
|
+
try {
|
|
405
|
+
await store.tag(name, sshKeyMetadataTags({ publicKey, createdAt }));
|
|
406
|
+
result.tagged++;
|
|
407
|
+
} catch (err) {
|
|
408
|
+
warn(`${name}: tagging failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const now = Date.now();
|
|
412
|
+
if (insertStoreHeldSshKeyRow({ userId, publicKey, createdAt: createdAt ?? now, updatedAt: now, storeRef: name })) {
|
|
413
|
+
result.imported++;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function importMcpMetadata(
|
|
418
|
+
store: Store,
|
|
419
|
+
name: string,
|
|
420
|
+
userId: string,
|
|
421
|
+
id: string,
|
|
422
|
+
tags: Record<string, string>,
|
|
423
|
+
result: ReconcileUserCredentialMetadataResult,
|
|
424
|
+
): Promise<void> {
|
|
425
|
+
const url = fromB64url(tags[MCP_CONNECTION_TAGS.urlB64]);
|
|
426
|
+
const authKind = tags[MCP_CONNECTION_TAGS.authKind];
|
|
427
|
+
const status = tags[MCP_CONNECTION_TAGS.status];
|
|
428
|
+
if (url && authKind && status) {
|
|
429
|
+
upsertStoreHeldMcpConnectionRow(
|
|
430
|
+
{
|
|
431
|
+
id,
|
|
432
|
+
userId,
|
|
433
|
+
url,
|
|
434
|
+
authKind,
|
|
435
|
+
tokenEndpoint: null,
|
|
436
|
+
redirectUri: null,
|
|
437
|
+
clientId: null,
|
|
438
|
+
status,
|
|
439
|
+
scopes: fromB64url(tags[MCP_CONNECTION_TAGS.scopesB64]) || null,
|
|
440
|
+
updatedAt: intTag(tags, MCP_CONNECTION_TAGS.updatedAt) ?? Date.now(),
|
|
441
|
+
},
|
|
442
|
+
name,
|
|
443
|
+
);
|
|
444
|
+
result.imported++;
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const raw = await readIfAllowed(store, name);
|
|
448
|
+
if (raw === null) {
|
|
449
|
+
result.unreadable++;
|
|
450
|
+
warn(`${name}: no tags and not readable — not listed on this box`);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const value = parseStoredMcpConnection(raw);
|
|
454
|
+
if (value.userId !== userId || value.id !== id) {
|
|
455
|
+
throw new Error("payload does not describe the connection its path names");
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
await store.tag(name, mcpConnectionMetadataTags(value));
|
|
459
|
+
result.tagged++;
|
|
460
|
+
} catch (err) {
|
|
461
|
+
warn(`${name}: tagging failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
462
|
+
}
|
|
463
|
+
upsertStoreHeldMcpConnectionRow(value, name);
|
|
464
|
+
result.imported++;
|
|
465
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-136 sign-in box: the small always-on machine behind the "Uai Cloud"
|
|
3
|
+
* runner row (docs/shared-host-retirement.md). Selected by
|
|
4
|
+
* `uai-host run --signin-box` (env `UAI_SIGNIN_BOX=1`), which cli.ts sets
|
|
5
|
+
* before main.ts loads. Same package as every runner, one flag.
|
|
6
|
+
*
|
|
7
|
+
* The box has exactly one job — run engine sign-ins and connects for
|
|
8
|
+
* workspaces and write the result into the credential store — and holds no
|
|
9
|
+
* credential at rest: captures go to the store first and only a metadata row
|
|
10
|
+
* stays (lib/engine-accounts.ts, store-only accounts), nothing is imported
|
|
11
|
+
* from the store at start, the default engine slots are scrubbed on every
|
|
12
|
+
* start, and its store identity is write-only. Everything else a runner does
|
|
13
|
+
* is refused here: task commands and tunnels get a definite non-retryable
|
|
14
|
+
* answer, the local UI and the pickup/broker loops never start.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readdirSync, rmSync, type Dirent } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
import { removeEnvValuesAtomic } from "./env-file";
|
|
21
|
+
|
|
22
|
+
export function signInBoxMode(): boolean {
|
|
23
|
+
return process.env.UAI_SIGNIN_BOX === "1";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const SIGN_IN_BOX_TASK_REFUSAL =
|
|
27
|
+
"This runner is the Uai Cloud sign-in box; tasks run on their own machines.";
|
|
28
|
+
|
|
29
|
+
/** Host commands the box still serves: its own managed lifecycle and the
|
|
30
|
+
* account management the engines pane drives. Every task-shaped command
|
|
31
|
+
* (taskUp, channels, files, previews, attachments) is refused. */
|
|
32
|
+
const ALLOWED_COMMANDS: ReadonlySet<string> = new Set([
|
|
33
|
+
"hostUpdate",
|
|
34
|
+
"hostRestart",
|
|
35
|
+
"engineAccountRemove",
|
|
36
|
+
"engineAccountRename",
|
|
37
|
+
"engineAccountReorder",
|
|
38
|
+
"engineDisconnect",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
export function signInBoxAllowsCommand(command: string): boolean {
|
|
42
|
+
return ALLOWED_COMMANDS.has(command);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The env keys the adapters read as the host's OWN engine credential
|
|
46
|
+
* (lib/engines.ts `detect`). None may survive on the box. */
|
|
47
|
+
export const ENGINE_CREDENTIAL_ENV_KEYS = [
|
|
48
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
49
|
+
"ANTHROPIC_API_KEY",
|
|
50
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
51
|
+
"OPENAI_API_KEY",
|
|
52
|
+
"XAI_API_KEY",
|
|
53
|
+
"CURSOR_API_KEY",
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
/** Default-slot credential files under the owner's home (engine-accounts
|
|
57
|
+
* KINDS `defaultHostDir`), and the isolated per-account dirs
|
|
58
|
+
* (`extraHostDir`) — swept as strays in case no row references them. */
|
|
59
|
+
const DEFAULT_SLOT_FILES: readonly (readonly string[])[] = [
|
|
60
|
+
[".codex", "auth.json"],
|
|
61
|
+
[".grok", "auth.json"],
|
|
62
|
+
[".local", "share", "opencode", "auth.json"],
|
|
63
|
+
];
|
|
64
|
+
const ACCOUNT_DIR_PREFIXES: readonly { parent: readonly string[]; prefix: string }[] = [
|
|
65
|
+
{ parent: [], prefix: ".codex-acct-" },
|
|
66
|
+
{ parent: [], prefix: ".grok-acct-" },
|
|
67
|
+
{ parent: [".local", "share"], prefix: "opencode-acct-" },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
export interface PurgeDefaultEngineSlotsResult {
|
|
71
|
+
files: string[];
|
|
72
|
+
dirs: string[];
|
|
73
|
+
envKeys: string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Remove every host-level engine credential from this machine: the default
|
|
78
|
+
* slot files, stray isolated account dirs, and the credential keys in
|
|
79
|
+
* `.env.local` plus the live process env. Idempotent; a fresh box removes
|
|
80
|
+
* nothing.
|
|
81
|
+
*/
|
|
82
|
+
export function purgeDefaultEngineSlots(opts: {
|
|
83
|
+
ownerHome: string;
|
|
84
|
+
envFile: string;
|
|
85
|
+
procEnv?: NodeJS.ProcessEnv;
|
|
86
|
+
}): PurgeDefaultEngineSlotsResult {
|
|
87
|
+
const files: string[] = [];
|
|
88
|
+
const dirs: string[] = [];
|
|
89
|
+
for (const parts of DEFAULT_SLOT_FILES) {
|
|
90
|
+
const file = join(opts.ownerHome, ...parts);
|
|
91
|
+
if (!existsSync(file)) continue;
|
|
92
|
+
rmSync(file, { force: true });
|
|
93
|
+
files.push(file);
|
|
94
|
+
}
|
|
95
|
+
for (const { parent, prefix } of ACCOUNT_DIR_PREFIXES) {
|
|
96
|
+
const dir = join(opts.ownerHome, ...parent);
|
|
97
|
+
let entries: Dirent[];
|
|
98
|
+
try {
|
|
99
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
100
|
+
} catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue;
|
|
105
|
+
const path = join(dir, entry.name);
|
|
106
|
+
rmSync(path, { recursive: true, force: true });
|
|
107
|
+
dirs.push(path);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const envKeys = removeEnvValuesAtomic(opts.envFile, ENGINE_CREDENTIAL_ENV_KEYS);
|
|
111
|
+
const procEnv = opts.procEnv ?? process.env;
|
|
112
|
+
for (const key of ENGINE_CREDENTIAL_ENV_KEYS) delete procEnv[key];
|
|
113
|
+
return { files, dirs, envKeys };
|
|
114
|
+
}
|