@runuai/host 0.9.139 → 0.9.141
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/engine-connect.ts +5 -2
- package/lib/engine-login.ts +26 -7
- 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/ssh.ts +109 -6
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +37 -1
- package/src/cli.ts +35 -6
- package/src/main.ts +93 -8
- 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
|
+
}
|
package/lib/ssh.ts
CHANGED
|
@@ -23,10 +23,11 @@ import { join, resolve } from "node:path";
|
|
|
23
23
|
|
|
24
24
|
import { eq } from "drizzle-orm";
|
|
25
25
|
|
|
26
|
-
import { queueStoreWrite, userParameterName } from "./credential-store";
|
|
26
|
+
import { configuredCredentialStore, queueStoreWrite, userParameterName } from "./credential-store";
|
|
27
27
|
import { getDb, schema } from "./db";
|
|
28
28
|
import { env } from "./env";
|
|
29
29
|
import { sealAesGcm, openAesGcm } from "./secrets";
|
|
30
|
+
import { signInBoxMode } from "./signin-box";
|
|
30
31
|
import { assertSafeHostTaskId } from "./task-identity";
|
|
31
32
|
|
|
32
33
|
const withNewline = (s: string): string => (s.endsWith("\n") ? s : `${s}\n`);
|
|
@@ -137,14 +138,15 @@ export function sshKeyParameterName(userId: string): string {
|
|
|
137
138
|
return userParameterName(userId, "ssh");
|
|
138
139
|
}
|
|
139
140
|
|
|
140
|
-
/** The row as it is mirrored (private key decrypted). Never logged.
|
|
141
|
+
/** The row as it is mirrored (private key decrypted). Never logged. Null
|
|
142
|
+
* for a store-held row (sign-in box): nothing here to mirror. */
|
|
141
143
|
export function sshKeyStoreValue(userId: string): StoredSshKey | null {
|
|
142
144
|
const row = getDb()
|
|
143
145
|
.select()
|
|
144
146
|
.from(schema.sshKeys)
|
|
145
147
|
.where(eq(schema.sshKeys.userId, userId))
|
|
146
148
|
.get();
|
|
147
|
-
if (!row) return null;
|
|
149
|
+
if (!row || isStoreHeldSshRow(row)) return null;
|
|
148
150
|
return {
|
|
149
151
|
v: 1,
|
|
150
152
|
kind: "ssh",
|
|
@@ -175,6 +177,8 @@ export function listSshKeyStoreRows(): Array<{ userId: string; storeRef: string
|
|
|
175
177
|
}
|
|
176
178
|
|
|
177
179
|
export function mirrorSshKeyToStore(userId: string): void {
|
|
180
|
+
// ADR-136 sign-in box: rows hold no material; the store was written first.
|
|
181
|
+
if (signInBoxMode()) return;
|
|
178
182
|
queueStoreWrite(`mirror ssh key ${userId}`, async (store) => {
|
|
179
183
|
const value = sshKeyStoreValue(userId);
|
|
180
184
|
if (!value) return;
|
|
@@ -203,14 +207,15 @@ export function importSshKeyFromStore(value: StoredSshKey, storeRef: string): bo
|
|
|
203
207
|
return result.changes > 0;
|
|
204
208
|
}
|
|
205
209
|
|
|
206
|
-
/** Decrypt + return the user's private key, or null if they have none
|
|
210
|
+
/** Decrypt + return the user's private key, or null if they have none (or
|
|
211
|
+
* the row is store-held — the sign-in box never holds one). */
|
|
207
212
|
export function privateKeyForUser(userId: string): string | null {
|
|
208
213
|
const row = getDb()
|
|
209
214
|
.select()
|
|
210
215
|
.from(schema.sshKeys)
|
|
211
216
|
.where(eq(schema.sshKeys.userId, userId))
|
|
212
217
|
.get();
|
|
213
|
-
if (!row) return null;
|
|
218
|
+
if (!row || isStoreHeldSshRow(row)) return null;
|
|
214
219
|
return openAesGcm(
|
|
215
220
|
Buffer.from(row.privateKeyCt as Uint8Array),
|
|
216
221
|
Buffer.from(row.privateKeyNonce as Uint8Array),
|
|
@@ -226,7 +231,7 @@ export function keyPairForUser(
|
|
|
226
231
|
.from(schema.sshKeys)
|
|
227
232
|
.where(eq(schema.sshKeys.userId, userId))
|
|
228
233
|
.get();
|
|
229
|
-
if (!row) return null;
|
|
234
|
+
if (!row || isStoreHeldSshRow(row)) return null;
|
|
230
235
|
return {
|
|
231
236
|
privateKey: openAesGcm(
|
|
232
237
|
Buffer.from(row.privateKeyCt as Uint8Array),
|
|
@@ -236,6 +241,104 @@ export function keyPairForUser(
|
|
|
236
241
|
};
|
|
237
242
|
}
|
|
238
243
|
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// ADR-136 sign-in box: a key row here holds NO private key (`private_key_ct`
|
|
246
|
+
// empty — "store-held"); the keypair goes to the store first and only the
|
|
247
|
+
// public half stays, which is all the box ever answers (`ssh.key.get`). The
|
|
248
|
+
// public key also rides as a tag so a replacement box lists it without
|
|
249
|
+
// reading the value.
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
export const SSH_KEY_TAGS = { publicKey: "uai_public_key", createdAt: "uai_created_at" } as const;
|
|
253
|
+
|
|
254
|
+
export function isStoreHeldSshRow(row: { privateKeyCt: unknown }): boolean {
|
|
255
|
+
const ct = row.privateKeyCt;
|
|
256
|
+
if (ct === null || ct === undefined) return true;
|
|
257
|
+
if (ct instanceof Uint8Array) return ct.byteLength === 0;
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function sshKeyMetadataTags(value: { publicKey: string; createdAt: number }): Record<string, string> {
|
|
262
|
+
return {
|
|
263
|
+
[SSH_KEY_TAGS.publicKey]: value.publicKey,
|
|
264
|
+
[SSH_KEY_TAGS.createdAt]: String(value.createdAt),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** A key row with no private half (sign-in box). Existing rows win. */
|
|
269
|
+
export function insertStoreHeldSshKeyRow(args: {
|
|
270
|
+
userId: string;
|
|
271
|
+
publicKey: string;
|
|
272
|
+
createdAt: number;
|
|
273
|
+
updatedAt: number;
|
|
274
|
+
storeRef: string;
|
|
275
|
+
}): boolean {
|
|
276
|
+
const result = getDb()
|
|
277
|
+
.insert(schema.sshKeys)
|
|
278
|
+
.values({
|
|
279
|
+
userId: args.userId,
|
|
280
|
+
publicKey: args.publicKey,
|
|
281
|
+
privateKeyCt: Buffer.alloc(0),
|
|
282
|
+
privateKeyNonce: Buffer.alloc(0),
|
|
283
|
+
createdAt: args.createdAt,
|
|
284
|
+
updatedAt: args.updatedAt,
|
|
285
|
+
storeRef: args.storeRef,
|
|
286
|
+
})
|
|
287
|
+
.onConflictDoNothing({ target: schema.sshKeys.userId })
|
|
288
|
+
.run();
|
|
289
|
+
return result.changes > 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Delete the row only (no store call). */
|
|
293
|
+
export function deleteSshKeyRowLocal(userId: string): void {
|
|
294
|
+
getDb().delete(schema.sshKeys).where(eq(schema.sshKeys.userId, userId)).run();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* `ensureKeyForUser` for the sign-in box: generate, write the keypair to the
|
|
299
|
+
* store FIRST (awaited — a refused put is the operation's failure and leaves
|
|
300
|
+
* nothing), tag the public key, then keep a store-held row. A row whose
|
|
301
|
+
* public key this box could not learn (a legacy parameter it may not read)
|
|
302
|
+
* is refused rather than silently regenerated over the user's real key.
|
|
303
|
+
*/
|
|
304
|
+
export async function ensureSshKeyStoreFirst(userId: string): Promise<string> {
|
|
305
|
+
const existing = getDb()
|
|
306
|
+
.select({ publicKey: schema.sshKeys.publicKey })
|
|
307
|
+
.from(schema.sshKeys)
|
|
308
|
+
.where(eq(schema.sshKeys.userId, userId))
|
|
309
|
+
.get();
|
|
310
|
+
if (existing) {
|
|
311
|
+
if (existing.publicKey) return existing.publicKey;
|
|
312
|
+
throw new Error(
|
|
313
|
+
"this user's SSH key exists in the credential store but its public half is not tagged yet; re-run the sign-in box bootstrap with the transitional read",
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
const store = configuredCredentialStore();
|
|
317
|
+
if (!store) throw new Error("the sign-in box has no credential store to hold the key");
|
|
318
|
+
const { publicKey, privateKey } = generateEd25519(`uai-${userId}`);
|
|
319
|
+
const now = Date.now();
|
|
320
|
+
const name = sshKeyParameterName(userId);
|
|
321
|
+
const value: StoredSshKey = {
|
|
322
|
+
v: 1,
|
|
323
|
+
kind: "ssh",
|
|
324
|
+
userId,
|
|
325
|
+
publicKey,
|
|
326
|
+
privateKey,
|
|
327
|
+
createdAt: now,
|
|
328
|
+
updatedAt: now,
|
|
329
|
+
};
|
|
330
|
+
await store.put(name, JSON.stringify(value));
|
|
331
|
+
try {
|
|
332
|
+
await store.tag(name, sshKeyMetadataTags(value));
|
|
333
|
+
} catch (err) {
|
|
334
|
+
console.warn(
|
|
335
|
+
`[ssh] user ${userId}: tagging ${name} failed (${err instanceof Error ? err.message : String(err)}) — a replacement box needs the transitional read to list this key`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
insertStoreHeldSshKeyRow({ userId, publicKey, createdAt: now, updatedAt: now, storeRef: name });
|
|
339
|
+
return getPublicKey(userId) ?? publicKey;
|
|
340
|
+
}
|
|
341
|
+
|
|
239
342
|
// --- per-task on-disk identity (optional SSH fallback + signing, ADR-029) -----
|
|
240
343
|
//
|
|
241
344
|
// task-up.sh uses the creator's key for host Git only when GitHub is not
|
package/package.json
CHANGED
package/scripts/agent/task-up.sh
CHANGED
|
@@ -141,6 +141,42 @@ reject_workspace_runtime_shadow() {
|
|
|
141
141
|
fi
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
# The first check of a task-up used to be the refusal above. A fresh task
|
|
145
|
+
# never trips it (the workspace is empty), but a RESUMED one can: the
|
|
146
|
+
# workspace persists across an idle park, agents' shells start in /workspace,
|
|
147
|
+
# and `asdf local nodejs 22` from there writes exactly this file — after
|
|
148
|
+
# which the task could never wake again without someone deleting the file on
|
|
149
|
+
# the host (client runner, 2026-09-14). The entry is the task's own
|
|
150
|
+
# container-writable content, so move it aside instead: a rename is
|
|
151
|
+
# metadata-only (never opens a FIFO/device/socket), the runtime proof is
|
|
152
|
+
# unchanged because nothing is left at the root, and the content survives
|
|
153
|
+
# under .uai/refused/ where the agent can re-pin it in a project. The
|
|
154
|
+
# post-materialization recheck below keeps refusing — by then the root must
|
|
155
|
+
# be empty by construction, so an entry there is a bug, not a user file.
|
|
156
|
+
quarantine_workspace_runtime_shadow() {
|
|
157
|
+
local path="$task_workspace/.tool-versions" aside_dir aside
|
|
158
|
+
if [ -L "$path" ] || [ -e "$path" ]; then
|
|
159
|
+
aside_dir="$task_workspace/.uai/refused"
|
|
160
|
+
reject_unsafe_host_directory "$task_workspace/.uai" "the task control directory"
|
|
161
|
+
reject_unsafe_host_directory "$aside_dir" "the refused-files directory"
|
|
162
|
+
mkdir -p "$aside_dir"
|
|
163
|
+
aside="$aside_dir/tool-versions.$(date +%s)"
|
|
164
|
+
if ! mv -f "$path" "$aside" 2>/dev/null || [ -L "$path" ] || [ -e "$path" ]; then
|
|
165
|
+
reject_workspace_runtime_shadow
|
|
166
|
+
fi
|
|
167
|
+
cat > "$aside_dir/README.txt" <<'NOTE'
|
|
168
|
+
Uai moved a /workspace/.tool-versions aside before starting this task.
|
|
169
|
+
|
|
170
|
+
A .tool-versions at the workspace root is outside the runtime proof and would
|
|
171
|
+
override the runtimes proved for every project in the task, so the host refuses
|
|
172
|
+
to start a container while one exists. The file is kept here, untouched, under
|
|
173
|
+
its original content. Pin runtimes in a project's own .tool-versions instead
|
|
174
|
+
(/workspace/<project>/.tool-versions), which the materializer proves.
|
|
175
|
+
NOTE
|
|
176
|
+
log "moved a workspace-root .tool-versions aside to ${aside#"$task_workspace"/} (it would override the runtimes proved for this task; pin per project instead)"
|
|
177
|
+
fi
|
|
178
|
+
}
|
|
179
|
+
|
|
144
180
|
mapped_host_port() {
|
|
145
181
|
local container="$1" container_port="$2"
|
|
146
182
|
docker port "$container" "$container_port" 2>/dev/null \
|
|
@@ -555,7 +591,7 @@ else
|
|
|
555
591
|
fi
|
|
556
592
|
reject_unsafe_host_directory "$task_workspace" "the task workspace"
|
|
557
593
|
mkdir -p "$task_workspace"
|
|
558
|
-
|
|
594
|
+
quarantine_workspace_runtime_shadow
|
|
559
595
|
|
|
560
596
|
# Git resolves symlinked ancestors when it writes a linked worktree's absolute
|
|
561
597
|
# `.git` pointer. Keep the configured (lexical) task paths as Uai's stable host
|