@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.
@@ -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,