@runuai/host 0.9.138 → 0.9.140

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { and, eq } from "drizzle-orm";
11
11
 
12
- import { queueStoreWrite, userParameterName } from "./credential-store";
12
+ import { configuredCredentialStore, queueStoreWrite, userParameterName } from "./credential-store";
13
13
  import { getDb, schema } from "./db";
14
14
  import { sealAesGcm, openAesGcm } from "./secrets";
15
15
  import { dockerCli } from "./docker-exec";
@@ -34,6 +34,7 @@ import {
34
34
  } from "./git-identity";
35
35
  import type { TaskEnvironmentHandle } from "./task-environment/types";
36
36
  import { sidecarMode } from "./sidecar";
37
+ import { signInBoxMode } from "./signin-box";
37
38
  import {
38
39
  MAX_GITHUB_INSTALLATIONS,
39
40
  MAX_GITHUB_REPOSITORIES_PER_INSTALLATION,
@@ -403,7 +404,22 @@ export async function onConnectSet(
403
404
  // Invalidate and drain them before publishing the replacement grant.
404
405
  await deps.invalidateCredentials?.(frame.userId);
405
406
 
406
- (deps.storeLocal ?? storeTokenRow)(frame.userId, fields);
407
+ if (deps.storeLocal) deps.storeLocal(frame.userId, fields);
408
+ else if (signInBoxMode()) {
409
+ // ADR-136 sign-in box: the store first, awaited; a row with no
410
+ // material; the token in the bounded cache only.
411
+ await storeGithubTokenStoreFirst(
412
+ frame.userId,
413
+ {
414
+ installationId: fields.installationId,
415
+ generation: fields.generation,
416
+ refreshTokenExpiresAt: fields.refreshTokenExpiresAt,
417
+ kind,
418
+ updatedAt: now,
419
+ },
420
+ token,
421
+ );
422
+ } else storeTokenRow(frame.userId, fields);
407
423
  replacementStored = true;
408
424
  // Advance again only after the replacement is durable. This detaches work
409
425
  // started during the no-grant fence and lets reconciliation capture B.
@@ -448,6 +464,7 @@ export async function onConnectSet(
448
464
  }
449
465
 
450
466
  function deleteStoredTokenRow(userId: string): void {
467
+ forgetGithubToken(userId);
451
468
  const result = getDb()
452
469
  .delete(schema.githubTokens)
453
470
  .where(eq(schema.githubTokens.userId, userId))
@@ -515,7 +532,9 @@ export function githubTokenParameterName(userId: string): string {
515
532
  return userParameterName(userId, "github");
516
533
  }
517
534
 
518
- /** The row as it is mirrored (token decrypted). Never logged. */
535
+ /** The row as it is mirrored (token decrypted). Never logged. On the
536
+ * sign-in box a store-held row yields the cached token, or null when the
537
+ * cache is cold — there is nothing here to mirror then. */
519
538
  export function githubTokenStoreValue(userId: string): StoredGithubToken | null {
520
539
  const row = getDb()
521
540
  .select()
@@ -523,6 +542,13 @@ export function githubTokenStoreValue(userId: string): StoredGithubToken | null
523
542
  .where(eq(schema.githubTokens.userId, userId))
524
543
  .get();
525
544
  if (!row) return null;
545
+ const token = isStoreHeldGithubRow(row)
546
+ ? cachedGithubToken(userId)
547
+ : openAesGcm(
548
+ Buffer.from(row.refreshTokenCt as Uint8Array),
549
+ Buffer.from(row.refreshTokenNonce as Uint8Array),
550
+ );
551
+ if (token === null) return null;
526
552
  return {
527
553
  v: 1,
528
554
  kind: "github",
@@ -530,10 +556,7 @@ export function githubTokenStoreValue(userId: string): StoredGithubToken | null
530
556
  installationId: row.installationId,
531
557
  generation: row.generation,
532
558
  tokenKind: row.kind,
533
- token: openAesGcm(
534
- Buffer.from(row.refreshTokenCt as Uint8Array),
535
- Buffer.from(row.refreshTokenNonce as Uint8Array),
536
- ),
559
+ token,
537
560
  expiresAt: row.refreshTokenExpiresAt ?? null,
538
561
  updatedAt: row.updatedAt,
539
562
  };
@@ -555,6 +578,8 @@ export function listGithubTokenStoreRows(): Array<{ userId: string; storeRef: st
555
578
  }
556
579
 
557
580
  export function mirrorGithubTokenToStore(userId: string): void {
581
+ // ADR-136 sign-in box: rows hold no material; the store was written first.
582
+ if (signInBoxMode()) return;
558
583
  queueStoreWrite(`mirror github token ${userId}`, async (store) => {
559
584
  const value = githubTokenStoreValue(userId);
560
585
  if (!value) return;
@@ -754,10 +779,15 @@ function readStoredToken(
754
779
  .where(eq(schema.githubTokens.userId, userId))
755
780
  .get();
756
781
  if (!row) return null;
757
- const token = openAesGcm(
758
- Buffer.from(row.refreshTokenCt as Uint8Array),
759
- Buffer.from(row.refreshTokenNonce as Uint8Array),
760
- );
782
+ // ADR-136 sign-in box: a store-held row answers from the bounded cache
783
+ // (filled by `loadGithubTokenIntoCache` on the async paths) or not at all.
784
+ const token = isStoreHeldGithubRow(row)
785
+ ? cachedGithubToken(userId)
786
+ : openAesGcm(
787
+ Buffer.from(row.refreshTokenCt as Uint8Array),
788
+ Buffer.from(row.refreshTokenNonce as Uint8Array),
789
+ );
790
+ if (token === null) return null;
761
791
  return {
762
792
  token,
763
793
  kind: row.kind,
@@ -775,6 +805,7 @@ function readCurrentStoredToken(userId: string): PersistedGitHubGrant | null {
775
805
  }
776
806
 
777
807
  export function deleteToken(userId: string): void {
808
+ forgetGithubToken(userId);
778
809
  getDb()
779
810
  .delete(schema.githubTokens)
780
811
  .where(eq(schema.githubTokens.userId, userId))
@@ -785,7 +816,14 @@ export function deleteToken(userId: string): void {
785
816
  }
786
817
 
787
818
  export function hasToken(userId: string): boolean {
788
- return readCurrentStoredToken(userId) !== null;
819
+ // Metadata only: a store-held row (sign-in box) counts as connected whether
820
+ // or not its token is in the cache right now.
821
+ const row = getDb()
822
+ .select({ generation: schema.githubTokens.generation })
823
+ .from(schema.githubTokens)
824
+ .where(eq(schema.githubTokens.userId, userId))
825
+ .get();
826
+ return row !== undefined && githubCredentialGenerationMatches(userId, row.generation);
789
827
  }
790
828
 
791
829
  /** All cloud user ids with a GitHub token on this host (for capabilities). */
@@ -1299,9 +1337,20 @@ export function clearAllAccessCache(): void {
1299
1337
  githubAuthGenerations.clear();
1300
1338
  }
1301
1339
 
1302
- export function requestAccessToken(
1340
+ export async function requestAccessToken(
1303
1341
  userId: string,
1304
1342
  ): Promise<{ accessToken: string; expiresAt: number | null } | null> {
1343
+ // ADR-136 sign-in box: the token is read from the store on demand for
1344
+ // the installation listing and a legacy exchange, never kept on disk.
1345
+ if (signInBoxMode()) {
1346
+ try {
1347
+ await loadGithubTokenIntoCache(userId);
1348
+ } catch (err) {
1349
+ console.warn(
1350
+ `[github] user ${userId}: could not read the token from the credential store: ${err instanceof Error ? err.message : String(err)}`,
1351
+ );
1352
+ }
1353
+ }
1305
1354
  const generation = githubAuthGeneration(userId);
1306
1355
  // ADR-033 non-expiring path: the stored token IS the access token — return it
1307
1356
  // directly, no exchange/cache/rotation. `expiresAt: null` ⇒ no refresh.
@@ -1361,10 +1410,13 @@ async function doRequestAccessToken(
1361
1410
  });
1362
1411
  if (!row) return null;
1363
1412
 
1364
- const refreshToken = openAesGcm(
1365
- Buffer.from(row.refreshTokenCt as Uint8Array),
1366
- Buffer.from(row.refreshTokenNonce as Uint8Array),
1367
- );
1413
+ const refreshToken = isStoreHeldGithubRow(row)
1414
+ ? cachedGithubToken(userId)
1415
+ : openAesGcm(
1416
+ Buffer.from(row.refreshTokenCt as Uint8Array),
1417
+ Buffer.from(row.refreshTokenNonce as Uint8Array),
1418
+ );
1419
+ if (refreshToken === null) return null;
1368
1420
  const res = await fetch(exchangeUrl(), {
1369
1421
  method: "POST",
1370
1422
  headers: {
@@ -1394,6 +1446,28 @@ async function doRequestAccessToken(
1394
1446
  // exchange was in flight. Never overwrite that newer grant with the rotated
1395
1447
  // refresh token from the superseded exchange.
1396
1448
  if (
1449
+ data.refreshToken &&
1450
+ githubAuthGeneration(userId) === generation &&
1451
+ isStoreHeldGithubRow(row)
1452
+ ) {
1453
+ // ADR-136 sign-in box: the rotated token goes to the cache and the store,
1454
+ // never onto the row.
1455
+ rememberGithubToken(userId, data.refreshToken);
1456
+ const rotated: StoredGithubToken = {
1457
+ v: 1,
1458
+ kind: "github",
1459
+ userId,
1460
+ installationId: row.installationId,
1461
+ generation: row.generation,
1462
+ tokenKind: row.kind,
1463
+ token: data.refreshToken,
1464
+ expiresAt: data.refreshTokenExpiresAt ?? row.refreshTokenExpiresAt ?? null,
1465
+ updatedAt: Date.now(),
1466
+ };
1467
+ queueStoreWrite(`rotate github token ${userId}`, (store) =>
1468
+ store.put(githubTokenParameterName(userId), JSON.stringify(rotated)),
1469
+ );
1470
+ } else if (
1397
1471
  data.refreshToken &&
1398
1472
  githubAuthGeneration(userId) === generation
1399
1473
  ) {
@@ -1433,6 +1507,196 @@ async function doRequestAccessToken(
1433
1507
  return { accessToken: data.accessToken, expiresAt: data.expiresAt };
1434
1508
  }
1435
1509
 
1510
+ // ---------------------------------------------------------------------------
1511
+ // ADR-136 sign-in box: a token row here holds NO ciphertext — `refresh_token_ct`
1512
+ // is empty, the row is "store-held" — the token lives in the store and, only
1513
+ // while the box needs it (the installation listing, a legacy exchange, the
1514
+ // revoke of a superseded grant), in this bounded in-memory cache. Never on
1515
+ // disk. The row's metadata still drives `hasToken` / `connectedUserIds` and
1516
+ // the generation fence exactly as on any host.
1517
+ // ---------------------------------------------------------------------------
1518
+
1519
+ const BOX_TOKEN_CACHE_MS = 10 * 60_000;
1520
+ const boxTokens = new Map<string, { token: string; until: number }>();
1521
+
1522
+ export const GITHUB_TOKEN_TAGS = {
1523
+ installationId: "uai_installation_id",
1524
+ generation: "uai_generation",
1525
+ tokenKind: "uai_token_kind",
1526
+ expiresAt: "uai_expires_at",
1527
+ updatedAt: "uai_updated_at",
1528
+ } as const;
1529
+
1530
+ export function isStoreHeldGithubRow(row: { refreshTokenCt: unknown }): boolean {
1531
+ const ct = row.refreshTokenCt;
1532
+ if (ct === null || ct === undefined) return true;
1533
+ if (ct instanceof Uint8Array) return ct.byteLength === 0;
1534
+ return false;
1535
+ }
1536
+
1537
+ export function rememberGithubToken(userId: string, token: string): void {
1538
+ boxTokens.set(userId, { token, until: Date.now() + BOX_TOKEN_CACHE_MS });
1539
+ }
1540
+
1541
+ export function cachedGithubToken(userId: string): string | null {
1542
+ const entry = boxTokens.get(userId);
1543
+ if (!entry) return null;
1544
+ if (entry.until <= Date.now()) {
1545
+ boxTokens.delete(userId);
1546
+ return null;
1547
+ }
1548
+ return entry.token;
1549
+ }
1550
+
1551
+ export function forgetGithubToken(userId: string): void {
1552
+ boxTokens.delete(userId);
1553
+ }
1554
+
1555
+ /** Test seam. */
1556
+ export function clearGithubTokenCacheForTesting(): void {
1557
+ boxTokens.clear();
1558
+ }
1559
+
1560
+ /** The secret-free metadata a token parameter carries as tags, so a
1561
+ * replacement box lists the user as connected without reading the value. */
1562
+ export function githubTokenMetadataTags(value: {
1563
+ installationId: number;
1564
+ generation: number;
1565
+ tokenKind: string;
1566
+ expiresAt: number | null;
1567
+ updatedAt: number;
1568
+ }): Record<string, string> {
1569
+ return {
1570
+ [GITHUB_TOKEN_TAGS.installationId]: String(value.installationId),
1571
+ [GITHUB_TOKEN_TAGS.generation]: String(value.generation),
1572
+ [GITHUB_TOKEN_TAGS.tokenKind]: value.tokenKind,
1573
+ [GITHUB_TOKEN_TAGS.expiresAt]: value.expiresAt === null ? "" : String(value.expiresAt),
1574
+ [GITHUB_TOKEN_TAGS.updatedAt]: String(value.updatedAt),
1575
+ };
1576
+ }
1577
+
1578
+ /** Fill the cache from the store for a store-held row. True when a token is
1579
+ * cached afterwards (or the row is not store-held); false when there is no
1580
+ * row, no store, or no parameter. Throws on a store failure. */
1581
+ export async function loadGithubTokenIntoCache(userId: string): Promise<boolean> {
1582
+ if (cachedGithubToken(userId) !== null) return true;
1583
+ const row = getDb()
1584
+ .select({
1585
+ refreshTokenCt: schema.githubTokens.refreshTokenCt,
1586
+ generation: schema.githubTokens.generation,
1587
+ })
1588
+ .from(schema.githubTokens)
1589
+ .where(eq(schema.githubTokens.userId, userId))
1590
+ .get();
1591
+ if (!row) return false;
1592
+ if (!isStoreHeldGithubRow(row)) return true;
1593
+ const store = configuredCredentialStore();
1594
+ if (!store) return false;
1595
+ const raw = await store.get(githubTokenParameterName(userId));
1596
+ if (!raw) return false;
1597
+ const parsed: unknown = JSON.parse(raw);
1598
+ const value =
1599
+ parsed && typeof parsed === "object" && !Array.isArray(parsed)
1600
+ ? (parsed as { token?: unknown; generation?: unknown })
1601
+ : {};
1602
+ if (typeof value.token !== "string" || value.token.length === 0) {
1603
+ throw new Error("the stored GitHub token is malformed");
1604
+ }
1605
+ if (value.generation !== row.generation) {
1606
+ console.warn(
1607
+ `[github] user ${userId}: the credential store holds generation ${String(value.generation)}, this box recorded ${row.generation}`,
1608
+ );
1609
+ }
1610
+ rememberGithubToken(userId, value.token);
1611
+ return true;
1612
+ }
1613
+
1614
+ /** A token row with no material (sign-in box). Same fence as `storeTokenRow`. */
1615
+ export function insertStoreHeldGithubTokenRow(
1616
+ userId: string,
1617
+ fields: {
1618
+ installationId: number;
1619
+ generation: number;
1620
+ refreshTokenExpiresAt: number | null;
1621
+ kind: string;
1622
+ updatedAt: number;
1623
+ storeRef: string;
1624
+ },
1625
+ ): void {
1626
+ const values = {
1627
+ ...fields,
1628
+ refreshTokenCt: Buffer.alloc(0),
1629
+ refreshTokenNonce: Buffer.alloc(0),
1630
+ };
1631
+ getDb().transaction((tx) => {
1632
+ const fence = tx
1633
+ .select({
1634
+ generation: schema.githubCredentialGenerations.generation,
1635
+ mutation: schema.githubCredentialGenerations.mutation,
1636
+ })
1637
+ .from(schema.githubCredentialGenerations)
1638
+ .where(eq(schema.githubCredentialGenerations.userId, userId))
1639
+ .get();
1640
+ if (fence?.mutation !== "set" || fence.generation !== fields.generation) {
1641
+ throw new Error("GitHub credential generation changed before storage");
1642
+ }
1643
+ tx.insert(schema.githubTokens)
1644
+ .values({ userId, ...values })
1645
+ .onConflictDoUpdate({ target: schema.githubTokens.userId, set: values })
1646
+ .run();
1647
+ });
1648
+ }
1649
+
1650
+ /** Sign-in box (ADR-136): the store first, awaited — a refused put is the
1651
+ * connect's failure — then tags, a store-held row, and the cache. */
1652
+ export async function storeGithubTokenStoreFirst(
1653
+ userId: string,
1654
+ fields: {
1655
+ installationId: number;
1656
+ generation: number;
1657
+ refreshTokenExpiresAt: number | null;
1658
+ kind: string;
1659
+ updatedAt: number;
1660
+ },
1661
+ token: string,
1662
+ ): Promise<void> {
1663
+ const store = configuredCredentialStore();
1664
+ if (!store) throw new Error("the sign-in box has no credential store to hold the token");
1665
+ const name = githubTokenParameterName(userId);
1666
+ const value: StoredGithubToken = {
1667
+ v: 1,
1668
+ kind: "github",
1669
+ userId,
1670
+ installationId: fields.installationId,
1671
+ generation: fields.generation,
1672
+ tokenKind: fields.kind,
1673
+ token,
1674
+ expiresAt: fields.refreshTokenExpiresAt,
1675
+ updatedAt: fields.updatedAt,
1676
+ };
1677
+ await store.put(name, JSON.stringify(value));
1678
+ try {
1679
+ await store.tag(name, githubTokenMetadataTags(value));
1680
+ } catch (err) {
1681
+ console.warn(
1682
+ `[github] user ${userId}: tagging ${name} failed (${err instanceof Error ? err.message : String(err)}) — a replacement box reads the value once to list this user`,
1683
+ );
1684
+ }
1685
+ insertStoreHeldGithubTokenRow(userId, { ...fields, storeRef: name });
1686
+ rememberGithubToken(userId, token);
1687
+ }
1688
+
1689
+ /** Delete the row only (no store call) — the start-time reconcile's "gone
1690
+ * elsewhere" move on the sign-in box. */
1691
+ export function deleteGithubTokenRowLocal(userId: string): void {
1692
+ forgetGithubToken(userId);
1693
+ getDb()
1694
+ .delete(schema.githubTokens)
1695
+ .where(eq(schema.githubTokens.userId, userId))
1696
+ .run();
1697
+ notifyGithubChange();
1698
+ }
1699
+
1436
1700
  // --- container injection ----------------------------------------------------
1437
1701
 
1438
1702
  /** Injectable docker-exec seam (mocked in tests). Async so the docker call
package/lib/host-env.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  } from "./credential-store";
26
26
  import { getDb, schema } from "./db";
27
27
  import { sealAesGcm, openAesGcm } from "./secrets";
28
+ import { signInBoxMode } from "./signin-box";
28
29
 
29
30
  /** Pack a sealed value (ct + nonce) into the single TEXT column. */
30
31
  function packValue(value: string): string {
@@ -227,6 +228,9 @@ export function listProjectEnvStoreRows(): Array<{ projectId: string; key: strin
227
228
  }
228
229
 
229
230
  export function mirrorProjectEnvToStore(projectId: string, key: string): void {
231
+ // ADR-136 sign-in box: no local rows; env.var.* is a store passthrough
232
+ // (lib/signin-box-credentials.ts).
233
+ if (signInBoxMode()) return;
230
234
  const name = projectEnvParameterName(projectId, key);
231
235
  if (!name) return;
232
236
  queueStoreWrite(`mirror project env ${projectId}/${key}`, async (store) => {
@@ -14,9 +14,10 @@
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
+ import { configuredCredentialStore, queueStoreWrite, userParameterName } from "./credential-store";
18
18
  import { getDb, schema } from "./db";
19
19
  import { sealAesGcm, openAesGcm } from "./secrets";
20
+ import { signInBoxMode } from "./signin-box";
20
21
  import type { McpOp } from "../src/protocol";
21
22
 
22
23
  const FETCH_TIMEOUT_MS = 10_000;
@@ -311,6 +312,27 @@ async function probe(op: Extract<McpOp, { kind: "probe" }>): Promise<McpAck> {
311
312
  const lead = `unexpected response (${res.status})`;
312
313
  throw new Error(why ? `${lead}: ${why}` : lead);
313
314
  }
315
+ if (signInBoxMode()) {
316
+ // ADR-136 sign-in box: the store first, a row with no secret.
317
+ await putMcpConnectionStoreFirst({
318
+ v: 1,
319
+ kind: "mcp",
320
+ id: op.connectionId,
321
+ userId: op.userId,
322
+ url: op.url,
323
+ authKind: "token",
324
+ tokenEndpoint: null,
325
+ redirectUri: null,
326
+ clientId: null,
327
+ clientSecret: null,
328
+ pkceVerifier: null,
329
+ secret: JSON.stringify(header),
330
+ status: "connected",
331
+ scopes: null,
332
+ updatedAt: now,
333
+ });
334
+ return { status: "connected" };
335
+ }
314
336
  db.insert(schema.mcpConnections)
315
337
  .values({
316
338
  id: op.connectionId,
@@ -339,6 +361,26 @@ async function probe(op: Extract<McpOp, { kind: "probe" }>): Promise<McpAck> {
339
361
  // Bare probe.
340
362
  const res = await mcpInitialize(op.url);
341
363
  if (res.ok) {
364
+ if (signInBoxMode()) {
365
+ await putMcpConnectionStoreFirst({
366
+ v: 1,
367
+ kind: "mcp",
368
+ id: op.connectionId,
369
+ userId: op.userId,
370
+ url: op.url,
371
+ authKind: "none",
372
+ tokenEndpoint: null,
373
+ redirectUri: null,
374
+ clientId: null,
375
+ clientSecret: null,
376
+ pkceVerifier: null,
377
+ secret: null,
378
+ status: "connected",
379
+ scopes: null,
380
+ updatedAt: now,
381
+ });
382
+ return { status: "connected" };
383
+ }
342
384
  db.insert(schema.mcpConnections)
343
385
  .values({
344
386
  id: op.connectionId,
@@ -396,6 +438,28 @@ async function probe(op: Extract<McpOp, { kind: "probe" }>): Promise<McpAck> {
396
438
  authorize.searchParams.set("scope", found.scopesSupported.join(" "));
397
439
  }
398
440
 
441
+ if (signInBoxMode()) {
442
+ // ADR-136 sign-in box: the pending half-flow (verifier, client secret)
443
+ // lives in memory only until oauth.complete; nothing pending reaches the
444
+ // store or the row. A box restart mid-flow means "probe again".
445
+ boxPendingOauth.set(op.connectionId, { verifier, clientSecret: clientSecret ?? null });
446
+ upsertStoreHeldMcpConnectionRow(
447
+ {
448
+ id: op.connectionId,
449
+ userId: op.userId,
450
+ url: op.url,
451
+ authKind: "oauth",
452
+ tokenEndpoint: found.tokenEndpoint,
453
+ redirectUri: op.redirectUri,
454
+ clientId,
455
+ status: "pending",
456
+ scopes: found.scopesSupported?.join(" ") ?? null,
457
+ updatedAt: now,
458
+ },
459
+ null,
460
+ );
461
+ return { status: "auth_required", authorizeUrl: authorize.toString() };
462
+ }
399
463
  const fields = {
400
464
  userId: op.userId,
401
465
  url: op.url,
@@ -426,9 +490,25 @@ async function oauthComplete(connectionId: string, code: string): Promise<McpAck
426
490
  .where(eq(schema.mcpConnections.id, connectionId))
427
491
  .get();
428
492
  if (!row) throw new Error("unknown connection — probe first");
429
- if (!row.tokenEndpoint || !row.clientId || !row.pkceVerifierEnc || !row.redirectUri) {
430
- throw new Error("connection has no pending OAuth flow");
493
+ const pending = signInBoxMode() ? (boxPendingOauth.get(connectionId) ?? null) : null;
494
+ if (
495
+ !row.tokenEndpoint ||
496
+ !row.clientId ||
497
+ !row.redirectUri ||
498
+ (!pending && !row.pkceVerifierEnc)
499
+ ) {
500
+ throw new Error(
501
+ signInBoxMode() && !pending
502
+ ? "the sign-in box restarted during this OAuth flow — probe again"
503
+ : "connection has no pending OAuth flow",
504
+ );
431
505
  }
506
+ const verifier = pending ? pending.verifier : unpack(row.pkceVerifierEnc!);
507
+ const clientSecret = pending
508
+ ? (pending.clientSecret ?? undefined)
509
+ : row.clientSecretEnc
510
+ ? unpack(row.clientSecretEnc)
511
+ : undefined;
432
512
 
433
513
  const { ok, status, json } = await postTokenEndpoint(
434
514
  row.tokenEndpoint,
@@ -437,12 +517,12 @@ async function oauthComplete(connectionId: string, code: string): Promise<McpAck
437
517
  code,
438
518
  redirect_uri: row.redirectUri,
439
519
  client_id: row.clientId,
440
- code_verifier: unpack(row.pkceVerifierEnc),
520
+ code_verifier: verifier,
441
521
  // RFC 8707, mirrored from the authorize request.
442
522
  resource: row.url,
443
523
  },
444
524
  row.clientId,
445
- row.clientSecretEnc ? unpack(row.clientSecretEnc) : undefined,
525
+ clientSecret,
446
526
  );
447
527
  if (!ok || typeof json.access_token !== "string") {
448
528
  const detail =
@@ -463,6 +543,30 @@ async function oauthComplete(connectionId: string, code: string): Promise<McpAck
463
543
  typeof json.scope === "string" && json.scope.length > 0
464
544
  ? json.scope
465
545
  : row.scopes;
546
+ if (pending) {
547
+ await putMcpConnectionStoreFirst({
548
+ v: 1,
549
+ kind: "mcp",
550
+ id: connectionId,
551
+ userId: row.userId,
552
+ url: row.url,
553
+ authKind: "oauth",
554
+ tokenEndpoint: row.tokenEndpoint,
555
+ redirectUri: row.redirectUri,
556
+ clientId: row.clientId,
557
+ clientSecret: pending.clientSecret,
558
+ pkceVerifier: null,
559
+ secret: JSON.stringify(secret),
560
+ status: "connected",
561
+ scopes,
562
+ updatedAt: Date.now(),
563
+ });
564
+ boxPendingOauth.delete(connectionId);
565
+ return {
566
+ status: "connected",
567
+ scopes: scopes ? scopes.split(" ").filter(Boolean) : undefined,
568
+ };
569
+ }
466
570
  db.update(schema.mcpConnections)
467
571
  .set({
468
572
  secretEnc: pack(JSON.stringify(secret)),
@@ -698,6 +802,9 @@ export function listMcpConnectionStoreRows(): Array<{
698
802
  }
699
803
 
700
804
  export function mirrorMcpConnectionToStore(id: string): void {
805
+ // ADR-136 sign-in box: rows hold no secret; the store was written first,
806
+ // and a mirror of a secret-less row would overwrite the only copy.
807
+ if (signInBoxMode()) return;
701
808
  queueStoreWrite(`mirror mcp connection ${id}`, async (store) => {
702
809
  const value = mcpConnectionStoreValue(id);
703
810
  if (!value) return;
@@ -720,6 +827,102 @@ export function deleteMcpConnectionLocal(id: string): void {
720
827
  queueStoreWrite(`delete mcp connection ${id}`, (store) => store.delete(name));
721
828
  }
722
829
 
830
+ // ---------------------------------------------------------------------------
831
+ // ADR-136 sign-in box: connection rows here hold NO secret — the header or
832
+ // OAuth tokens, the client secret and the PKCE verifier live in the store (or,
833
+ // for a half-finished OAuth flow, in memory only). What the box lists (url,
834
+ // auth kind, status, scopes) also rides as tags so a replacement box needs no
835
+ // value read.
836
+ // ---------------------------------------------------------------------------
837
+
838
+ export const MCP_CONNECTION_TAGS = {
839
+ urlB64: "uai_url_b64",
840
+ authKind: "uai_auth_kind",
841
+ status: "uai_status",
842
+ scopesB64: "uai_scopes_b64",
843
+ updatedAt: "uai_updated_at",
844
+ } as const;
845
+
846
+ const boxPendingOauth = new Map<string, { verifier: string; clientSecret: string | null }>();
847
+
848
+ /** Test seam. */
849
+ export function pendingOauthForTesting(connectionId: string): boolean {
850
+ return boxPendingOauth.has(connectionId);
851
+ }
852
+
853
+ export type StoreHeldMcpConnectionRow = Pick<
854
+ StoredMcpConnection,
855
+ "id" | "userId" | "url" | "authKind" | "tokenEndpoint" | "redirectUri" | "clientId" | "status" | "scopes" | "updatedAt"
856
+ >;
857
+
858
+ export function mcpConnectionMetadataTags(value: {
859
+ url: string;
860
+ authKind: string;
861
+ status: string;
862
+ scopes: string | null;
863
+ updatedAt: number;
864
+ }): Record<string, string> {
865
+ return {
866
+ [MCP_CONNECTION_TAGS.urlB64]: Buffer.from(value.url, "utf8").toString("base64url").slice(0, 256),
867
+ [MCP_CONNECTION_TAGS.authKind]: value.authKind,
868
+ [MCP_CONNECTION_TAGS.status]: value.status,
869
+ [MCP_CONNECTION_TAGS.scopesB64]: value.scopes
870
+ ? Buffer.from(value.scopes, "utf8").toString("base64url").slice(0, 256)
871
+ : "",
872
+ [MCP_CONNECTION_TAGS.updatedAt]: String(value.updatedAt),
873
+ };
874
+ }
875
+
876
+ /** Upsert a row with every secret column null. */
877
+ export function upsertStoreHeldMcpConnectionRow(
878
+ value: StoreHeldMcpConnectionRow,
879
+ storeRef: string | null,
880
+ ): void {
881
+ const fields = {
882
+ userId: value.userId,
883
+ url: value.url,
884
+ authKind: value.authKind,
885
+ tokenEndpoint: value.tokenEndpoint,
886
+ redirectUri: value.redirectUri,
887
+ clientId: value.clientId,
888
+ clientSecretEnc: null,
889
+ pkceVerifierEnc: null,
890
+ secretEnc: null,
891
+ status: value.status,
892
+ scopes: value.scopes,
893
+ updatedAt: value.updatedAt,
894
+ storeRef,
895
+ };
896
+ getDb()
897
+ .insert(schema.mcpConnections)
898
+ .values({ id: value.id, ...fields })
899
+ .onConflictDoUpdate({ target: schema.mcpConnections.id, set: fields })
900
+ .run();
901
+ }
902
+
903
+ /** The store first, awaited (a refused put is the op's failure), then tags
904
+ * and a secret-less row. */
905
+ export async function putMcpConnectionStoreFirst(value: StoredMcpConnection): Promise<void> {
906
+ const store = configuredCredentialStore();
907
+ if (!store) throw new Error("the sign-in box has no credential store to hold the connection");
908
+ const name = mcpConnectionParameterName(value.userId, value.id);
909
+ await store.put(name, JSON.stringify(value));
910
+ try {
911
+ await store.tag(name, mcpConnectionMetadataTags(value));
912
+ } catch (err) {
913
+ console.warn(
914
+ `[mcp] connection ${value.id}: tagging ${name} failed (${err instanceof Error ? err.message : String(err)}) — a replacement box needs the transitional read to list it`,
915
+ );
916
+ }
917
+ upsertStoreHeldMcpConnectionRow(value, name);
918
+ }
919
+
920
+ /** Delete the row only (no store call). */
921
+ export function deleteMcpConnectionRowLocal(id: string): void {
922
+ boxPendingOauth.delete(id);
923
+ getDb().delete(schema.mcpConnections).where(eq(schema.mcpConnections.id, id)).run();
924
+ }
925
+
723
926
  /** Reconcile import: a connection this host lacks. Existing rows win. */
724
927
  export function importMcpConnectionFromStore(
725
928
  value: StoredMcpConnection,