@runuai/host 0.9.139 → 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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.139",
3
+ "version": "0.9.140",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
package/src/cli.ts CHANGED
@@ -1585,12 +1585,21 @@ async function cmdSetup(rest: string[]): Promise<void> {
1585
1585
  process.exitCode = 1;
1586
1586
  return;
1587
1587
  }
1588
+ // ADR-136 step 4: a replace-machine enrollment answers with the EXISTING
1589
+ // row's id — this machine adopts it instead of its own proposal, so the
1590
+ // runner keeps its identity (and every task created against it).
1591
+ const outcome =
1592
+ success && typeof success === "object" && !Array.isArray(success)
1593
+ ? (success as Record<string, unknown>)
1594
+ : null;
1595
+ const replacedHostId =
1596
+ outcome?.replaced === true && typeof outcome.hostId === "string" && outcome.hostId.length > 0
1597
+ ? outcome.hostId
1598
+ : null;
1588
1599
  if (
1589
- !success ||
1590
- typeof success !== "object" ||
1591
- Array.isArray(success) ||
1592
- (success as Record<string, unknown>).ok !== true ||
1593
- (success as Record<string, unknown>).hostId !== pending.hostId
1600
+ !outcome ||
1601
+ outcome.ok !== true ||
1602
+ (replacedHostId === null && outcome.hostId !== pending.hostId)
1594
1603
  ) {
1595
1604
  console.error(red("enrollment outcome did not match the proposed host identity"));
1596
1605
  console.error(dim("the proposed identity is saved locally; retry this exact command."));
@@ -1614,7 +1623,27 @@ async function cmdSetup(rest: string[]): Promise<void> {
1614
1623
  process.exitCode = 1;
1615
1624
  return;
1616
1625
  }
1617
- console.log(green("✓ enrolled") + dim(` — config written to ${envLocalPath()}`));
1626
+ if (replacedHostId !== null) {
1627
+ // The pending commit wrote the proposed id; the row this machine now
1628
+ // answers as is the replaced one. Same secret, so the bridge admits it.
1629
+ try {
1630
+ writeEnvValuesAtomic(envLocalPath(), { UAI_HOST_ID: replacedHostId });
1631
+ } catch (err) {
1632
+ console.error(
1633
+ red(
1634
+ `replaced, but could not record the runner id in ${envLocalPath()}: ${err instanceof Error ? err.message : String(err)}`,
1635
+ ),
1636
+ );
1637
+ console.error(dim(`set UAI_HOST_ID=${replacedHostId} there by hand before starting.`));
1638
+ process.exitCode = 1;
1639
+ return;
1640
+ }
1641
+ console.log(
1642
+ green("✓ replaced") + dim(` — this machine now answers as runner ${replacedHostId}; config written to ${envLocalPath()}`),
1643
+ );
1644
+ } else {
1645
+ console.log(green("✓ enrolled") + dim(` — config written to ${envLocalPath()}`));
1646
+ }
1618
1647
  printTelemetryNotice();
1619
1648
  console.log("");
1620
1649
  console.log("Start the host:");