@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.
- package/lib/browser-testing.ts +6 -1
- package/lib/credential-store.ts +34 -2
- package/lib/engine-accounts.ts +438 -133
- package/lib/engine-connect.ts +40 -9
- package/lib/engine-login.ts +64 -15
- package/lib/env-file.ts +30 -0
- 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/signin-box.ts +114 -0
- package/lib/ssh.ts +109 -6
- package/package.json +1 -1
- package/src/cli.ts +39 -6
- package/src/main.ts +210 -3
- package/src/protocol.ts +11 -5
package/lib/github-tokens.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
-
|
|
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 =
|
|
1365
|
-
|
|
1366
|
-
|
|
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) => {
|
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,
|