@runuai/host 0.9.111 → 0.9.113

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,6 @@
1
+ -- ADR-134 slice 2 (host 0.9.113): per-user credentials mirror to the
2
+ -- credential store on Uai-operated hosts. Null on owned hosts and before the
3
+ -- first successful mirror.
4
+ ALTER TABLE `host_github_tokens` ADD `store_ref` text;--> statement-breakpoint
5
+ ALTER TABLE `host_ssh_keys` ADD `store_ref` text;--> statement-breakpoint
6
+ ALTER TABLE `host_mcp_connections` ADD `store_ref` text;
@@ -141,6 +141,13 @@
141
141
  "when": 1788900000001,
142
142
  "tag": "0019_engine_account_store_ref",
143
143
  "breakpoints": true
144
+ },
145
+ {
146
+ "idx": 20,
147
+ "version": "6",
148
+ "when": 1788900000002,
149
+ "tag": "0020_user_credential_store_ref",
150
+ "breakpoints": true
144
151
  }
145
152
  ]
146
153
  }
package/db/schema.ts CHANGED
@@ -129,6 +129,8 @@ export const githubTokens = sqliteTable("host_github_tokens", {
129
129
  // "access" (non-expiring, ADR-033) | "refresh" (expiring, ADR-027).
130
130
  kind: text("kind").notNull().default("refresh"),
131
131
  updatedAt: integer("updated_at").notNull(),
132
+ // ADR-134 slice 2: the credential-store parameter this row mirrors to.
133
+ storeRef: text("store_ref"),
132
134
  });
133
135
 
134
136
  export type GithubToken = typeof githubTokens.$inferSelect;
@@ -158,6 +160,8 @@ export const sshKeys = sqliteTable("host_ssh_keys", {
158
160
  privateKeyNonce: blob("private_key_nonce").notNull(),
159
161
  createdAt: integer("created_at").notNull(),
160
162
  updatedAt: integer("updated_at").notNull(),
163
+ // ADR-134 slice 2: the credential-store parameter this row mirrors to.
164
+ storeRef: text("store_ref"),
161
165
  });
162
166
 
163
167
  export type SshKey = typeof sshKeys.$inferSelect;
@@ -253,6 +257,8 @@ export const mcpConnections = sqliteTable("host_mcp_connections", {
253
257
  status: text("status").notNull().default("pending"), // pending|connected
254
258
  scopes: text("scopes"),
255
259
  updatedAt: integer("updated_at", { mode: "number" }).notNull(),
260
+ // ADR-134 slice 2: the credential-store parameter this row mirrors to.
261
+ storeRef: text("store_ref"),
256
262
  });
257
263
 
258
264
  export type McpConnection = typeof mcpConnections.$inferSelect;
@@ -237,6 +237,47 @@ export class AwsSsmCredentialStore implements CredentialStore {
237
237
  }
238
238
  }
239
239
 
240
+ // ---------------------------------------------------------------------------
241
+ // Write queue shared by every mirrored table (engine accounts, GitHub tokens,
242
+ // SSH keys, MCP connections). Mirrors are async and best effort: a store
243
+ // outage delays a mirror, never a task-up or a UI action.
244
+ // ---------------------------------------------------------------------------
245
+
246
+ const inflight = new Set<Promise<void>>();
247
+
248
+ /** Run `work` against the configured store off the caller's path; a no-op on
249
+ * owned hosts. Failures are logged (never the value) and never thrown. */
250
+ export function queueStoreWrite(
251
+ what: string,
252
+ work: (store: CredentialStore) => Promise<void>,
253
+ ): void {
254
+ const store = configuredCredentialStore();
255
+ if (!store) return;
256
+ const p: Promise<void> = work(store)
257
+ .catch((err: unknown) => {
258
+ console.warn(
259
+ `[credential-store] ${what} failed: ${
260
+ err instanceof Error ? err.message : String(err)
261
+ }`,
262
+ );
263
+ })
264
+ .finally(() => {
265
+ inflight.delete(p);
266
+ });
267
+ inflight.add(p);
268
+ }
269
+
270
+ /** Tests / shutdown: wait for every queued mirror or delete to settle. */
271
+ export async function settleStoreWrites(): Promise<void> {
272
+ while (inflight.size > 0) await Promise.allSettled([...inflight]);
273
+ }
274
+
275
+ /** `<prefix>/users/<userId>/<…>` — the per-user subtree (GitHub token, SSH
276
+ * key, MCP connections). The STS `user` session tag scopes it later. */
277
+ export function userParameterName(userId: string, ...segments: string[]): string {
278
+ return parameterName(credentialStorePrefix(), "users", userId, ...segments);
279
+ }
280
+
240
281
  // ---------------------------------------------------------------------------
241
282
  // Process-wide configuration.
242
283
  // ---------------------------------------------------------------------------
@@ -46,6 +46,8 @@ import {
46
46
  credentialStorePrefix,
47
47
  isParameterPathSegment,
48
48
  parameterName,
49
+ queueStoreWrite,
50
+ settleStoreWrites,
49
51
  type CredentialStore,
50
52
  } from "./credential-store";
51
53
  import { getDb, schema } from "./db";
@@ -1127,7 +1129,6 @@ type StoreRow = {
1127
1129
 
1128
1130
  let storeHostId: string | null = null;
1129
1131
  let warnedNoHostId = false;
1130
- const inflight = new Set<Promise<void>>();
1131
1132
 
1132
1133
  /** main.ts: the host id that names `host`-scope parameters. */
1133
1134
  export function configureEngineAccountStore(opts: { hostId: string | null }): void {
@@ -1136,7 +1137,7 @@ export function configureEngineAccountStore(opts: { hostId: string | null }): vo
1136
1137
 
1137
1138
  /** Tests: wait for every queued mirror/delete to settle. */
1138
1139
  export async function settleEngineAccountStoreWrites(): Promise<void> {
1139
- while (inflight.size > 0) await Promise.allSettled([...inflight]);
1140
+ await settleStoreWrites();
1140
1141
  }
1141
1142
 
1142
1143
  /**
@@ -1240,22 +1241,6 @@ function parseStoredAccount(value: string): StoredEngineAccount {
1240
1241
  };
1241
1242
  }
1242
1243
 
1243
- function queueStoreWrite(what: string, work: (store: CredentialStore) => Promise<void>): void {
1244
- const store = configuredCredentialStore();
1245
- if (!store) return;
1246
- const p: Promise<void> = work(store)
1247
- .catch((err: unknown) => {
1248
- console.warn(
1249
- `[engine-accounts] credential store ${what} failed: ${
1250
- err instanceof Error ? err.message : String(err)
1251
- }`,
1252
- );
1253
- })
1254
- .finally(() => {
1255
- inflight.delete(p);
1256
- });
1257
- inflight.add(p);
1258
- }
1259
1244
 
1260
1245
  function selectRow(id: string): StoreRow | undefined {
1261
1246
  return getDb()
@@ -27,6 +27,7 @@ import { homedir, tmpdir } from "node:os";
27
27
  import { basename, dirname, join, resolve } from "node:path";
28
28
 
29
29
  import {
30
+ ENGINE_LOGIN_KINDS,
30
31
  HOST_ACCOUNT_SCOPE,
31
32
  isEngineAccountLabel,
32
33
  isEngineAccountScope,
@@ -808,7 +809,14 @@ export class EngineLoginManager {
808
809
  "The login container stopped unexpectedly.",
809
810
  ),
810
811
  );
811
- } catch {
812
+ } catch (error) {
813
+ // The operator sees a one-line failure; the journal must say WHY (the
814
+ // 2026-09-09 sweep refusal was silent for six hours).
815
+ console.warn(
816
+ `[engine-login] ${operation.engine} login could not start: ${
817
+ error instanceof Error ? error.message : String(error)
818
+ }`,
819
+ );
812
820
  if (processHandle && !operation.process) {
813
821
  await this.stopDetachedProcess(processHandle);
814
822
  }
@@ -2260,8 +2268,15 @@ export function codexCallbackReplayDockerRequest(
2260
2268
  };
2261
2269
  }
2262
2270
 
2263
- function isEngineLoginContainerName(value: string): boolean {
2264
- return /^uai-login-(?:claude|codex)-[0-9a-f]{16}$/.test(value);
2271
+ const ENGINE_LOGIN_CONTAINER_NAME = new RegExp(
2272
+ `^uai-login-(?:${ENGINE_LOGIN_KINDS.join("|")})-[0-9a-f]{16}$`,
2273
+ );
2274
+
2275
+ /** `uai-login-<kind>-<16 hex>` for every login kind. The sweep, the log
2276
+ * follower, the remover, and the codex callback replay all gate on this, so
2277
+ * a kind missing here strands its containers AND blocks every later login. */
2278
+ export function isEngineLoginContainerName(value: string): boolean {
2279
+ return ENGINE_LOGIN_CONTAINER_NAME.test(value);
2265
2280
  }
2266
2281
 
2267
2282
  /** The CLI that owns login containers (ADR-106): the ambient docker client,
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { and, eq } from "drizzle-orm";
11
11
 
12
+ import { queueStoreWrite, userParameterName } from "./credential-store";
12
13
  import { getDb, schema } from "./db";
13
14
  import { sealAesGcm, openAesGcm } from "./secrets";
14
15
  import { dockerCli } from "./docker-exec";
@@ -446,10 +447,13 @@ export async function onConnectSet(
446
447
  }
447
448
 
448
449
  function deleteStoredTokenRow(userId: string): void {
449
- getDb()
450
+ const result = getDb()
450
451
  .delete(schema.githubTokens)
451
452
  .where(eq(schema.githubTokens.userId, userId))
452
453
  .run();
454
+ // A connect clears any prior grant first; with none stored there is no
455
+ // parameter to delete either.
456
+ if (result.changes > 0) deleteGithubTokenFromStore(userId);
453
457
  }
454
458
 
455
459
  function storeTokenRow(
@@ -484,6 +488,117 @@ function storeTokenRow(
484
488
  .onConflictDoUpdate({ target: schema.githubTokens.userId, set: fields })
485
489
  .run();
486
490
  });
491
+ mirrorGithubTokenToStore(userId);
492
+ }
493
+
494
+ // ---------------------------------------------------------------------------
495
+ // Credential store mirror (ADR-134 slice 2): `<prefix>/users/<userId>/github`.
496
+ // The generation rides along so a replacement host imports the row behind the
497
+ // same durable fence (a later disconnect this host never saw still wins).
498
+ // Owned hosts: no-ops.
499
+ // ---------------------------------------------------------------------------
500
+
501
+ export interface StoredGithubToken {
502
+ v: 1;
503
+ kind: "github";
504
+ userId: string;
505
+ installationId: number;
506
+ generation: number;
507
+ tokenKind: string;
508
+ token: string;
509
+ expiresAt: number | null;
510
+ updatedAt: number;
511
+ }
512
+
513
+ export function githubTokenParameterName(userId: string): string {
514
+ return userParameterName(userId, "github");
515
+ }
516
+
517
+ /** The row as it is mirrored (token decrypted). Never logged. */
518
+ export function githubTokenStoreValue(userId: string): StoredGithubToken | null {
519
+ const row = getDb()
520
+ .select()
521
+ .from(schema.githubTokens)
522
+ .where(eq(schema.githubTokens.userId, userId))
523
+ .get();
524
+ if (!row) return null;
525
+ return {
526
+ v: 1,
527
+ kind: "github",
528
+ userId,
529
+ installationId: row.installationId,
530
+ generation: row.generation,
531
+ tokenKind: row.kind,
532
+ token: openAesGcm(
533
+ Buffer.from(row.refreshTokenCt as Uint8Array),
534
+ Buffer.from(row.refreshTokenNonce as Uint8Array),
535
+ ),
536
+ expiresAt: row.refreshTokenExpiresAt ?? null,
537
+ updatedAt: row.updatedAt,
538
+ };
539
+ }
540
+
541
+ export function setGithubTokenStoreRef(userId: string, storeRef: string): void {
542
+ getDb()
543
+ .update(schema.githubTokens)
544
+ .set({ storeRef })
545
+ .where(eq(schema.githubTokens.userId, userId))
546
+ .run();
547
+ }
548
+
549
+ export function listGithubTokenStoreRows(): Array<{ userId: string; storeRef: string | null }> {
550
+ return getDb()
551
+ .select({ userId: schema.githubTokens.userId, storeRef: schema.githubTokens.storeRef })
552
+ .from(schema.githubTokens)
553
+ .all();
554
+ }
555
+
556
+ export function mirrorGithubTokenToStore(userId: string): void {
557
+ queueStoreWrite(`mirror github token ${userId}`, async (store) => {
558
+ const value = githubTokenStoreValue(userId);
559
+ if (!value) return;
560
+ const name = githubTokenParameterName(userId);
561
+ await store.put(name, JSON.stringify(value));
562
+ setGithubTokenStoreRef(userId, name);
563
+ });
564
+ }
565
+
566
+ export function deleteGithubTokenFromStore(userId: string): void {
567
+ queueStoreWrite(`delete github token ${userId}`, (store) =>
568
+ store.delete(githubTokenParameterName(userId)),
569
+ );
570
+ }
571
+
572
+ /**
573
+ * Reconcile import: a token this host lacks. Claims the stored generation as
574
+ * a `set` first — refused (false) when this host's fence already moved past
575
+ * it, so a disconnect that outran the store's delete can never resurrect a
576
+ * token here.
577
+ */
578
+ export function importGithubTokenFromStore(
579
+ value: StoredGithubToken,
580
+ storeRef: string,
581
+ ): boolean {
582
+ const claim = claimGithubCredentialGeneration(value.userId, value.generation, "set");
583
+ if (!claim) return false;
584
+ const sealed = sealAesGcm(value.token);
585
+ const fields = {
586
+ installationId: value.installationId,
587
+ generation: value.generation,
588
+ refreshTokenCt: sealed.ct,
589
+ refreshTokenNonce: sealed.nonce,
590
+ refreshTokenExpiresAt: value.expiresAt,
591
+ kind: value.tokenKind,
592
+ updatedAt: value.updatedAt,
593
+ storeRef,
594
+ };
595
+ getDb()
596
+ .insert(schema.githubTokens)
597
+ .values({ userId: value.userId, ...fields })
598
+ .onConflictDoUpdate({ target: schema.githubTokens.userId, set: fields })
599
+ .run();
600
+ notifyGithubChange();
601
+ return true;
487
602
  }
488
603
 
489
604
  async function bestEffortRevoke(
@@ -663,6 +778,7 @@ export function deleteToken(userId: string): void {
663
778
  .delete(schema.githubTokens)
664
779
  .where(eq(schema.githubTokens.userId, userId))
665
780
  .run();
781
+ deleteGithubTokenFromStore(userId);
666
782
  advanceGithubAuthGeneration(userId);
667
783
  notifyGithubChange();
668
784
  }
@@ -14,6 +14,7 @@
14
14
  import { createHash, randomBytes } from "node:crypto";
15
15
  import { eq } from "drizzle-orm";
16
16
 
17
+ import { queueStoreWrite, userParameterName } from "./credential-store";
17
18
  import { getDb, schema } from "./db";
18
19
  import { sealAesGcm, openAesGcm } from "./secrets";
19
20
  import type { McpOp } from "../src/protocol";
@@ -276,10 +277,7 @@ export async function handleMcpOp(op: McpOp): Promise<McpAck> {
276
277
  case "oauth.complete":
277
278
  return oauthComplete(op.connectionId, op.code);
278
279
  case "disconnect": {
279
- getDb()
280
- .delete(schema.mcpConnections)
281
- .where(eq(schema.mcpConnections.id, op.connectionId))
282
- .run();
280
+ deleteMcpConnectionLocal(op.connectionId);
283
281
  return { status: "disconnected" };
284
282
  }
285
283
  }
@@ -334,6 +332,7 @@ async function probe(op: Extract<McpOp, { kind: "probe" }>): Promise<McpAck> {
334
332
  },
335
333
  })
336
334
  .run();
335
+ mirrorMcpConnectionToStore(op.connectionId);
337
336
  return { status: "connected" };
338
337
  }
339
338
 
@@ -354,6 +353,7 @@ async function probe(op: Extract<McpOp, { kind: "probe" }>): Promise<McpAck> {
354
353
  set: { url: op.url, authKind: "none", status: "connected", updatedAt: now },
355
354
  })
356
355
  .run();
356
+ mirrorMcpConnectionToStore(op.connectionId);
357
357
  return { status: "connected" };
358
358
  }
359
359
  if (res.status !== 401 && res.status !== 403) {
@@ -414,6 +414,7 @@ async function probe(op: Extract<McpOp, { kind: "probe" }>): Promise<McpAck> {
414
414
  .values({ id: op.connectionId, ...fields })
415
415
  .onConflictDoUpdate({ target: schema.mcpConnections.id, set: fields })
416
416
  .run();
417
+ mirrorMcpConnectionToStore(op.connectionId);
417
418
  return { status: "auth_required", authorizeUrl: authorize.toString() };
418
419
  }
419
420
 
@@ -472,6 +473,7 @@ async function oauthComplete(connectionId: string, code: string): Promise<McpAck
472
473
  })
473
474
  .where(eq(schema.mcpConnections.id, connectionId))
474
475
  .run();
476
+ mirrorMcpConnectionToStore(connectionId);
475
477
  return {
476
478
  status: "connected",
477
479
  scopes: scopes ? scopes.split(" ").filter(Boolean) : undefined,
@@ -611,5 +613,137 @@ async function refreshTokens(
611
613
  .set({ secretEnc: pack(JSON.stringify(next)), updatedAt: Date.now() })
612
614
  .where(eq(schema.mcpConnections.id, row.id))
613
615
  .run();
616
+ mirrorMcpConnectionToStore(row.id);
614
617
  return next;
615
618
  }
619
+
620
+ // ---------------------------------------------------------------------------
621
+ // Credential store mirror (ADR-134 slice 2):
622
+ // `<prefix>/users/<userId>/mcp/<connectionId>`. The connection id is the
623
+ // cloud's, which is org-bound there; on the host the owner is the user.
624
+ // Owned hosts: no-ops.
625
+ // ---------------------------------------------------------------------------
626
+
627
+ export interface StoredMcpConnection {
628
+ v: 1;
629
+ kind: "mcp";
630
+ id: string;
631
+ userId: string;
632
+ url: string;
633
+ authKind: string;
634
+ tokenEndpoint: string | null;
635
+ redirectUri: string | null;
636
+ clientId: string | null;
637
+ clientSecret: string | null;
638
+ pkceVerifier: string | null;
639
+ /** The sealed JSON, decrypted: header or OAuth tokens. */
640
+ secret: string | null;
641
+ status: string;
642
+ scopes: string | null;
643
+ updatedAt: number;
644
+ }
645
+
646
+ export function mcpConnectionParameterName(userId: string, id: string): string {
647
+ return userParameterName(userId, "mcp", id);
648
+ }
649
+
650
+ /** The row as it is mirrored (secrets decrypted). Never logged. */
651
+ export function mcpConnectionStoreValue(id: string): StoredMcpConnection | null {
652
+ const row = getDb()
653
+ .select()
654
+ .from(schema.mcpConnections)
655
+ .where(eq(schema.mcpConnections.id, id))
656
+ .get();
657
+ if (!row) return null;
658
+ return {
659
+ v: 1,
660
+ kind: "mcp",
661
+ id: row.id,
662
+ userId: row.userId,
663
+ url: row.url,
664
+ authKind: row.authKind,
665
+ tokenEndpoint: row.tokenEndpoint,
666
+ redirectUri: row.redirectUri,
667
+ clientId: row.clientId,
668
+ clientSecret: row.clientSecretEnc ? unpack(row.clientSecretEnc) : null,
669
+ pkceVerifier: row.pkceVerifierEnc ? unpack(row.pkceVerifierEnc) : null,
670
+ secret: row.secretEnc ? unpack(row.secretEnc) : null,
671
+ status: row.status,
672
+ scopes: row.scopes,
673
+ updatedAt: row.updatedAt,
674
+ };
675
+ }
676
+
677
+ export function setMcpConnectionStoreRef(id: string, storeRef: string): void {
678
+ getDb()
679
+ .update(schema.mcpConnections)
680
+ .set({ storeRef })
681
+ .where(eq(schema.mcpConnections.id, id))
682
+ .run();
683
+ }
684
+
685
+ export function listMcpConnectionStoreRows(): Array<{
686
+ id: string;
687
+ userId: string;
688
+ storeRef: string | null;
689
+ }> {
690
+ return getDb()
691
+ .select({
692
+ id: schema.mcpConnections.id,
693
+ userId: schema.mcpConnections.userId,
694
+ storeRef: schema.mcpConnections.storeRef,
695
+ })
696
+ .from(schema.mcpConnections)
697
+ .all();
698
+ }
699
+
700
+ export function mirrorMcpConnectionToStore(id: string): void {
701
+ queueStoreWrite(`mirror mcp connection ${id}`, async (store) => {
702
+ const value = mcpConnectionStoreValue(id);
703
+ if (!value) return;
704
+ const name = mcpConnectionParameterName(value.userId, id);
705
+ await store.put(name, JSON.stringify(value));
706
+ setMcpConnectionStoreRef(id, name);
707
+ });
708
+ }
709
+
710
+ /** Delete the row and its parameter (the disconnect op and reconcile). */
711
+ export function deleteMcpConnectionLocal(id: string): void {
712
+ const row = getDb()
713
+ .select({ userId: schema.mcpConnections.userId, storeRef: schema.mcpConnections.storeRef })
714
+ .from(schema.mcpConnections)
715
+ .where(eq(schema.mcpConnections.id, id))
716
+ .get();
717
+ getDb().delete(schema.mcpConnections).where(eq(schema.mcpConnections.id, id)).run();
718
+ if (!row) return;
719
+ const name = row.storeRef ?? mcpConnectionParameterName(row.userId, id);
720
+ queueStoreWrite(`delete mcp connection ${id}`, (store) => store.delete(name));
721
+ }
722
+
723
+ /** Reconcile import: a connection this host lacks. Existing rows win. */
724
+ export function importMcpConnectionFromStore(
725
+ value: StoredMcpConnection,
726
+ storeRef: string,
727
+ ): boolean {
728
+ const result = getDb()
729
+ .insert(schema.mcpConnections)
730
+ .values({
731
+ id: value.id,
732
+ userId: value.userId,
733
+ url: value.url,
734
+ authKind: value.authKind,
735
+ tokenEndpoint: value.tokenEndpoint,
736
+ redirectUri: value.redirectUri,
737
+ clientId: value.clientId,
738
+ clientSecretEnc: value.clientSecret === null ? null : pack(value.clientSecret),
739
+ pkceVerifierEnc: value.pkceVerifier === null ? null : pack(value.pkceVerifier),
740
+ secretEnc: value.secret === null ? null : pack(value.secret),
741
+ status: value.status,
742
+ scopes: value.scopes,
743
+ updatedAt: value.updatedAt,
744
+ storeRef,
745
+ })
746
+ .onConflictDoNothing({ target: schema.mcpConnections.id })
747
+ .run();
748
+ return result.changes > 0;
749
+ }
package/lib/ssh.ts CHANGED
@@ -23,6 +23,7 @@ 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
27
  import { getDb, schema } from "./db";
27
28
  import { env } from "./env";
28
29
  import { sealAesGcm, openAesGcm } from "./secrets";
@@ -104,12 +105,102 @@ export function ensureKeyForUser(userId: string): string {
104
105
  })
105
106
  .onConflictDoNothing({ target: schema.sshKeys.userId })
106
107
  .run();
108
+ mirrorSshKeyToStore(userId);
107
109
  // Re-read in case a concurrent task-up won the insert race.
108
110
  return getPublicKey(userId) ?? publicKey;
109
111
  }
110
112
 
111
113
  export function deleteKey(userId: string): void {
112
114
  getDb().delete(schema.sshKeys).where(eq(schema.sshKeys.userId, userId)).run();
115
+ queueStoreWrite(`delete ssh key ${userId}`, (store) =>
116
+ store.delete(sshKeyParameterName(userId)),
117
+ );
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Credential store mirror (ADR-134 slice 2): `<prefix>/users/<userId>/ssh`.
122
+ // A replacement Uai-operated host imports the SAME keypair, so the public key
123
+ // the user pasted into GitHub keeps working. Owned hosts: no-ops.
124
+ // ---------------------------------------------------------------------------
125
+
126
+ export interface StoredSshKey {
127
+ v: 1;
128
+ kind: "ssh";
129
+ userId: string;
130
+ publicKey: string;
131
+ privateKey: string;
132
+ createdAt: number;
133
+ updatedAt: number;
134
+ }
135
+
136
+ export function sshKeyParameterName(userId: string): string {
137
+ return userParameterName(userId, "ssh");
138
+ }
139
+
140
+ /** The row as it is mirrored (private key decrypted). Never logged. */
141
+ export function sshKeyStoreValue(userId: string): StoredSshKey | null {
142
+ const row = getDb()
143
+ .select()
144
+ .from(schema.sshKeys)
145
+ .where(eq(schema.sshKeys.userId, userId))
146
+ .get();
147
+ if (!row) return null;
148
+ return {
149
+ v: 1,
150
+ kind: "ssh",
151
+ userId,
152
+ publicKey: row.publicKey,
153
+ privateKey: openAesGcm(
154
+ Buffer.from(row.privateKeyCt as Uint8Array),
155
+ Buffer.from(row.privateKeyNonce as Uint8Array),
156
+ ),
157
+ createdAt: row.createdAt,
158
+ updatedAt: row.updatedAt,
159
+ };
160
+ }
161
+
162
+ export function setSshKeyStoreRef(userId: string, storeRef: string): void {
163
+ getDb()
164
+ .update(schema.sshKeys)
165
+ .set({ storeRef })
166
+ .where(eq(schema.sshKeys.userId, userId))
167
+ .run();
168
+ }
169
+
170
+ export function listSshKeyStoreRows(): Array<{ userId: string; storeRef: string | null }> {
171
+ return getDb()
172
+ .select({ userId: schema.sshKeys.userId, storeRef: schema.sshKeys.storeRef })
173
+ .from(schema.sshKeys)
174
+ .all();
175
+ }
176
+
177
+ export function mirrorSshKeyToStore(userId: string): void {
178
+ queueStoreWrite(`mirror ssh key ${userId}`, async (store) => {
179
+ const value = sshKeyStoreValue(userId);
180
+ if (!value) return;
181
+ const name = sshKeyParameterName(userId);
182
+ await store.put(name, JSON.stringify(value));
183
+ setSshKeyStoreRef(userId, name);
184
+ });
185
+ }
186
+
187
+ /** Reconcile import: a keypair this host lacks. Existing rows win. */
188
+ export function importSshKeyFromStore(value: StoredSshKey, storeRef: string): boolean {
189
+ const sealed = sealAesGcm(value.privateKey);
190
+ const result = getDb()
191
+ .insert(schema.sshKeys)
192
+ .values({
193
+ userId: value.userId,
194
+ publicKey: value.publicKey,
195
+ privateKeyCt: sealed.ct,
196
+ privateKeyNonce: sealed.nonce,
197
+ createdAt: value.createdAt,
198
+ updatedAt: value.updatedAt,
199
+ storeRef,
200
+ })
201
+ .onConflictDoNothing({ target: schema.sshKeys.userId })
202
+ .run();
203
+ return result.changes > 0;
113
204
  }
114
205
 
115
206
  /** Decrypt + return the user's private key, or null if they have none. */
@@ -0,0 +1,310 @@
1
+ /**
2
+ * ADR-134 slice 2 — per-user credentials on a Uai-operated host reconcile
3
+ * with the credential store at start, the same way engine accounts do
4
+ * (`reconcileEngineAccountsFromStore`):
5
+ *
6
+ * <prefix>/users/<userId>/github GitHub App user token
7
+ * <prefix>/users/<userId>/ssh the user's host-minted keypair
8
+ * <prefix>/users/<userId>/mcp/<connId> an MCP connection
9
+ *
10
+ * - a parameter with no local row is imported (a replacement host inherits
11
+ * every user's credentials without anyone reconnecting);
12
+ * - a local row with no `store_ref` is uploaded (first start with the flag);
13
+ * - a local row whose `store_ref` is under the listed path but absent from
14
+ * it was removed elsewhere — it goes locally too.
15
+ * The write-through mirrors live next to each table's writes (ssh.ts,
16
+ * github-tokens.ts, mcp-connections.ts). Owned hosts: `configuredCredentialStore()`
17
+ * is null and nothing here runs.
18
+ */
19
+
20
+ import {
21
+ configuredCredentialStore,
22
+ credentialStorePrefix,
23
+ isParameterPathSegment,
24
+ type CredentialStore,
25
+ } from "./credential-store";
26
+ import {
27
+ deleteToken as deleteGithubTokenLocal,
28
+ githubTokenParameterName,
29
+ githubTokenStoreValue,
30
+ importGithubTokenFromStore,
31
+ listGithubTokenStoreRows,
32
+ setGithubTokenStoreRef,
33
+ type StoredGithubToken,
34
+ } from "./github-tokens";
35
+ import {
36
+ deleteMcpConnectionLocal,
37
+ importMcpConnectionFromStore,
38
+ listMcpConnectionStoreRows,
39
+ mcpConnectionParameterName,
40
+ mcpConnectionStoreValue,
41
+ setMcpConnectionStoreRef,
42
+ type StoredMcpConnection,
43
+ } from "./mcp-connections";
44
+ import {
45
+ deleteKey as deleteSshKeyLocal,
46
+ importSshKeyFromStore,
47
+ listSshKeyStoreRows,
48
+ setSshKeyStoreRef,
49
+ sshKeyParameterName,
50
+ sshKeyStoreValue,
51
+ type StoredSshKey,
52
+ } from "./ssh";
53
+
54
+ export interface ReconcileUserCredentialsResult {
55
+ skipped: boolean;
56
+ imported: number;
57
+ uploaded: number;
58
+ removed: number;
59
+ errors: number;
60
+ }
61
+
62
+ type ParsedName =
63
+ | { kind: "github"; userId: string }
64
+ | { kind: "ssh"; userId: string }
65
+ | { kind: "mcp"; userId: string; id: string };
66
+
67
+ /** `<prefix>/users/<u>/github|ssh|mcp/<id>`; null for anything else. */
68
+ export function parseUserParameterName(
69
+ name: string,
70
+ prefix: string = credentialStorePrefix(),
71
+ ): ParsedName | null {
72
+ if (!name.startsWith(`${prefix}/users/`)) return null;
73
+ const parts = name.slice(prefix.length + 1).split("/");
74
+ if (!parts.every((p) => isParameterPathSegment(p))) return null;
75
+ const [, userId, what, id] = parts;
76
+ if (!userId || !what) return null;
77
+ if (parts.length === 3 && what === "github") return { kind: "github", userId };
78
+ if (parts.length === 3 && what === "ssh") return { kind: "ssh", userId };
79
+ if (parts.length === 4 && what === "mcp" && id) return { kind: "mcp", userId, id };
80
+ return null;
81
+ }
82
+
83
+ function obj(value: string): Record<string, unknown> {
84
+ const parsed: unknown = JSON.parse(value);
85
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
86
+ throw new Error("not an object");
87
+ }
88
+ const o = parsed as Record<string, unknown>;
89
+ if (o.v !== 1) throw new Error(`unsupported version ${String(o.v)}`);
90
+ return o;
91
+ }
92
+ function str(o: Record<string, unknown>, key: string): string {
93
+ const v = o[key];
94
+ if (typeof v !== "string") throw new Error(`bad ${key}`);
95
+ return v;
96
+ }
97
+ function strOrNull(o: Record<string, unknown>, key: string): string | null {
98
+ const v = o[key];
99
+ if (v === null || v === undefined) return null;
100
+ if (typeof v !== "string") throw new Error(`bad ${key}`);
101
+ return v;
102
+ }
103
+ function num(o: Record<string, unknown>, key: string): number {
104
+ const v = o[key];
105
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`bad ${key}`);
106
+ return v;
107
+ }
108
+ function numOrNull(o: Record<string, unknown>, key: string): number | null {
109
+ const v = o[key];
110
+ if (v === null || v === undefined) return null;
111
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`bad ${key}`);
112
+ return v;
113
+ }
114
+
115
+ export function parseStoredGithubToken(value: string): StoredGithubToken {
116
+ const o = obj(value);
117
+ if (o.kind !== "github") throw new Error("not a github token");
118
+ const generation = num(o, "generation");
119
+ if (!Number.isSafeInteger(generation) || generation < 0) throw new Error("bad generation");
120
+ return {
121
+ v: 1,
122
+ kind: "github",
123
+ userId: str(o, "userId"),
124
+ installationId: num(o, "installationId"),
125
+ generation,
126
+ tokenKind: str(o, "tokenKind"),
127
+ token: str(o, "token"),
128
+ expiresAt: numOrNull(o, "expiresAt"),
129
+ updatedAt: num(o, "updatedAt"),
130
+ };
131
+ }
132
+
133
+ export function parseStoredSshKey(value: string): StoredSshKey {
134
+ const o = obj(value);
135
+ if (o.kind !== "ssh") throw new Error("not an ssh key");
136
+ return {
137
+ v: 1,
138
+ kind: "ssh",
139
+ userId: str(o, "userId"),
140
+ publicKey: str(o, "publicKey"),
141
+ privateKey: str(o, "privateKey"),
142
+ createdAt: num(o, "createdAt"),
143
+ updatedAt: num(o, "updatedAt"),
144
+ };
145
+ }
146
+
147
+ export function parseStoredMcpConnection(value: string): StoredMcpConnection {
148
+ const o = obj(value);
149
+ if (o.kind !== "mcp") throw new Error("not an mcp connection");
150
+ return {
151
+ v: 1,
152
+ kind: "mcp",
153
+ id: str(o, "id"),
154
+ userId: str(o, "userId"),
155
+ url: str(o, "url"),
156
+ authKind: str(o, "authKind"),
157
+ tokenEndpoint: strOrNull(o, "tokenEndpoint"),
158
+ redirectUri: strOrNull(o, "redirectUri"),
159
+ clientId: strOrNull(o, "clientId"),
160
+ clientSecret: strOrNull(o, "clientSecret"),
161
+ pkceVerifier: strOrNull(o, "pkceVerifier"),
162
+ secret: strOrNull(o, "secret"),
163
+ status: str(o, "status"),
164
+ scopes: strOrNull(o, "scopes"),
165
+ updatedAt: num(o, "updatedAt"),
166
+ };
167
+ }
168
+
169
+ async function upload(
170
+ store: CredentialStore,
171
+ name: string,
172
+ value: unknown,
173
+ setRef: (ref: string) => void,
174
+ ): Promise<void> {
175
+ await store.put(name, JSON.stringify(value));
176
+ setRef(name);
177
+ }
178
+
179
+ export async function reconcileUserCredentialsFromStore(): Promise<ReconcileUserCredentialsResult> {
180
+ const result: ReconcileUserCredentialsResult = {
181
+ skipped: false,
182
+ imported: 0,
183
+ uploaded: 0,
184
+ removed: 0,
185
+ errors: 0,
186
+ };
187
+ const store = configuredCredentialStore();
188
+ if (!store) return { ...result, skipped: true };
189
+ const prefix = credentialStorePrefix();
190
+ const path = `${prefix}/users`;
191
+ const warn = (line: string): void =>
192
+ console.warn(`[user-credentials] credential store: ${line}`);
193
+
194
+ let listed: Array<{ name: string; value: string }>;
195
+ try {
196
+ listed = await store.list(path);
197
+ } catch (err) {
198
+ result.errors++;
199
+ warn(`listing ${path} failed: ${err instanceof Error ? err.message : String(err)}`);
200
+ // Nothing else is safe to decide without the inventory.
201
+ return result;
202
+ }
203
+
204
+ const github = new Map(listGithubTokenStoreRows().map((r) => [r.userId, r.storeRef]));
205
+ const ssh = new Map(listSshKeyStoreRows().map((r) => [r.userId, r.storeRef]));
206
+ const mcp = new Map(listMcpConnectionStoreRows().map((r) => [r.id, r]));
207
+ const seen = new Set<string>();
208
+
209
+ for (const param of listed) {
210
+ const at = parseUserParameterName(param.name, prefix);
211
+ if (!at) continue; // a neighbour from a later slice
212
+ seen.add(param.name);
213
+ try {
214
+ if (at.kind === "github") {
215
+ const value = parseStoredGithubToken(param.value);
216
+ if (value.userId !== at.userId) throw new Error("payload names another user");
217
+ if (github.has(at.userId)) {
218
+ if (github.get(at.userId) !== param.name) setGithubTokenStoreRef(at.userId, param.name);
219
+ } else if (importGithubTokenFromStore(value, param.name)) {
220
+ result.imported++;
221
+ }
222
+ } else if (at.kind === "ssh") {
223
+ const value = parseStoredSshKey(param.value);
224
+ if (value.userId !== at.userId) throw new Error("payload names another user");
225
+ if (ssh.has(at.userId)) {
226
+ if (ssh.get(at.userId) !== param.name) setSshKeyStoreRef(at.userId, param.name);
227
+ } else if (importSshKeyFromStore(value, param.name)) {
228
+ result.imported++;
229
+ }
230
+ } else {
231
+ const value = parseStoredMcpConnection(param.value);
232
+ if (value.userId !== at.userId || value.id !== at.id) {
233
+ throw new Error("payload does not describe the connection its path names");
234
+ }
235
+ const local = mcp.get(at.id);
236
+ if (local) {
237
+ if (local.storeRef !== param.name) setMcpConnectionStoreRef(at.id, param.name);
238
+ } else if (importMcpConnectionFromStore(value, param.name)) {
239
+ result.imported++;
240
+ }
241
+ }
242
+ } catch (err) {
243
+ result.errors++;
244
+ warn(`${param.name}: ${err instanceof Error ? err.message : String(err)}`);
245
+ }
246
+ }
247
+
248
+ const under = (ref: string): boolean => ref.startsWith(`${path}/`);
249
+ for (const [userId, storeRef] of github) {
250
+ try {
251
+ if (storeRef) {
252
+ if (under(storeRef) && !seen.has(storeRef)) {
253
+ deleteGithubTokenLocal(userId);
254
+ result.removed++;
255
+ }
256
+ continue;
257
+ }
258
+ const value = githubTokenStoreValue(userId);
259
+ if (!value) continue;
260
+ await upload(store, githubTokenParameterName(userId), value, (ref) =>
261
+ setGithubTokenStoreRef(userId, ref),
262
+ );
263
+ result.uploaded++;
264
+ } catch (err) {
265
+ result.errors++;
266
+ warn(`github token ${userId}: ${err instanceof Error ? err.message : String(err)}`);
267
+ }
268
+ }
269
+ for (const [userId, storeRef] of ssh) {
270
+ try {
271
+ if (storeRef) {
272
+ if (under(storeRef) && !seen.has(storeRef)) {
273
+ deleteSshKeyLocal(userId);
274
+ result.removed++;
275
+ }
276
+ continue;
277
+ }
278
+ const value = sshKeyStoreValue(userId);
279
+ if (!value) continue;
280
+ await upload(store, sshKeyParameterName(userId), value, (ref) =>
281
+ setSshKeyStoreRef(userId, ref),
282
+ );
283
+ result.uploaded++;
284
+ } catch (err) {
285
+ result.errors++;
286
+ warn(`ssh key ${userId}: ${err instanceof Error ? err.message : String(err)}`);
287
+ }
288
+ }
289
+ for (const [id, row] of mcp) {
290
+ try {
291
+ if (row.storeRef) {
292
+ if (under(row.storeRef) && !seen.has(row.storeRef)) {
293
+ deleteMcpConnectionLocal(id);
294
+ result.removed++;
295
+ }
296
+ continue;
297
+ }
298
+ const value = mcpConnectionStoreValue(id);
299
+ if (!value) continue;
300
+ await upload(store, mcpConnectionParameterName(row.userId, id), value, (ref) =>
301
+ setMcpConnectionStoreRef(id, ref),
302
+ );
303
+ result.uploaded++;
304
+ } catch (err) {
305
+ result.errors++;
306
+ warn(`mcp connection ${id}: ${err instanceof Error ? err.message : String(err)}`);
307
+ }
308
+ }
309
+ return result;
310
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.111",
3
+ "version": "0.9.113",
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/main.ts CHANGED
@@ -157,6 +157,7 @@ import {
157
157
  listEngineAccountsAllScopes,
158
158
  reconcileEngineAccountsFromStore,
159
159
  } from "../lib/engine-accounts";
160
+ import { reconcileUserCredentialsFromStore } from "../lib/user-credentials-store";
160
161
  import { createEngineConnectManager } from "../lib/engine-connect";
161
162
  import { createEngineLoginManager } from "../lib/engine-login";
162
163
  import {
@@ -458,6 +459,18 @@ void reconcileEngineAccountsFromStore({ hostId })
458
459
  `[engine-accounts] credential store reconcile failed: ${error instanceof Error ? error.message : String(error)}`,
459
460
  );
460
461
  });
462
+ void reconcileUserCredentialsFromStore()
463
+ .then((r) => {
464
+ if (r.skipped) return;
465
+ console.log(
466
+ `[user-credentials] credential store reconciled: ${r.imported} imported, ${r.uploaded} uploaded, ${r.removed} removed, ${r.errors} errors`,
467
+ );
468
+ })
469
+ .catch((error: unknown) => {
470
+ console.warn(
471
+ `[user-credentials] credential store reconcile failed: ${error instanceof Error ? error.message : String(error)}`,
472
+ );
473
+ });
461
474
  // Reconcile a durable retention-off setting before recovery/connect can make
462
475
  // crash-left owner-facing terminal history observable again. Correctness
463
476
  // tombstones remain intact inside applyTerminalHistoryRetention.
package/src/protocol.ts CHANGED
@@ -332,7 +332,12 @@ export type HostLogsEndFrame =
332
332
  /** Engines with a REMOTE sign-in (login container driven from the cloud).
333
333
  * grok since host 0.9.105: `grok login --device-auth`, same device-code shape
334
334
  * as codex. Kimi/OpenCode remain host-terminal (or key) for now. */
335
- export type EngineLoginKind = "claude" | "codex" | "grok";
335
+ /** Every engine with a remote sign-in flow. ONE list: the login container
336
+ * name pattern, the wire guard, and the cloud validator all derive from it
337
+ * (a kind added to the type alone left grok containers unrecognized by the
338
+ * stale-container sweep, which then refused EVERY login — live 2026-09-09). */
339
+ export const ENGINE_LOGIN_KINDS = ["claude", "codex", "grok"] as const;
340
+ export type EngineLoginKind = (typeof ENGINE_LOGIN_KINDS)[number];
336
341
  export type EngineLoginPhase =
337
342
  | "starting"
338
343
  | "authorize"
@@ -674,7 +679,7 @@ function isBoundedMessage(value: unknown): value is string {
674
679
  }
675
680
 
676
681
  function isEngineLoginKind(value: unknown): value is EngineLoginKind {
677
- return value === "claude" || value === "codex" || value === "grok";
682
+ return (ENGINE_LOGIN_KINDS as readonly unknown[]).includes(value);
678
683
  }
679
684
 
680
685
  function isEngineLoginErrorCode(value: unknown): value is EngineLoginErrorCode {