@runuai/host 0.9.139 → 0.9.141
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/browser-testing.ts +6 -1
- package/lib/engine-connect.ts +5 -2
- package/lib/engine-login.ts +26 -7
- package/lib/github-tokens.ts +281 -17
- package/lib/host-env.ts +4 -0
- package/lib/mcp-connections.ts +208 -5
- package/lib/signin-box-credentials.ts +465 -0
- package/lib/ssh.ts +109 -6
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +37 -1
- package/src/cli.ts +35 -6
- package/src/main.ts +93 -8
- package/src/protocol.ts +11 -5
package/lib/mcp-connections.ts
CHANGED
|
@@ -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
|
-
|
|
430
|
-
|
|
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:
|
|
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
|
-
|
|
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,
|