@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/browser-testing.ts
CHANGED
|
@@ -734,7 +734,12 @@ function runCodex(home, args) {
|
|
|
734
734
|
encoding: "utf8",
|
|
735
735
|
env: Object.assign({}, process.env, { CODEX_HOME: home }),
|
|
736
736
|
maxBuffer: 1024 * 1024,
|
|
737
|
-
|
|
737
|
+
// Codex's FIRST run on a fresh machine initialises its state (sqlite)
|
|
738
|
+
// before answering; 5 s timed out on every hostless task machine
|
|
739
|
+
// (smoke 10, 2026-09-10: ETIMEDOUT for both homes) and the direct
|
|
740
|
+
// config.toml fallback did the work with an error in the log. A minute
|
|
741
|
+
// is generous for a probe that normally answers in well under a second.
|
|
742
|
+
timeout: 60000,
|
|
738
743
|
killSignal: "SIGKILL",
|
|
739
744
|
});
|
|
740
745
|
}
|
package/lib/engine-connect.ts
CHANGED
|
@@ -23,7 +23,10 @@ import { addEngineAccount } from "./engine-accounts";
|
|
|
23
23
|
import { connectEngine, type EngineKind } from "./engines";
|
|
24
24
|
|
|
25
25
|
const CONNECT_TIMEOUT_MS = 2 * 60_000;
|
|
26
|
-
|
|
26
|
+
// Connects are one store write each; labeled (workspace) connects never touch
|
|
27
|
+
// a shared slot, so they run side by side — a user is never told to wait for
|
|
28
|
+
// someone else's connect (ADR-136 sign-in box). Bounded, not serialised.
|
|
29
|
+
const MAX_CONCURRENT_CONNECTS = 16;
|
|
27
30
|
|
|
28
31
|
export type EmitEngineConnectEvent = (frame: EngineConnectEventFrame) => void;
|
|
29
32
|
|
|
@@ -94,7 +97,7 @@ export class EngineConnectManager {
|
|
|
94
97
|
engine,
|
|
95
98
|
phase: "done",
|
|
96
99
|
ok: false,
|
|
97
|
-
message: "
|
|
100
|
+
message: "Too many engine connects are running on this runner right now; try again in a minute.",
|
|
98
101
|
});
|
|
99
102
|
return true;
|
|
100
103
|
}
|
package/lib/engine-login.ts
CHANGED
|
@@ -64,7 +64,13 @@ export const ENGINE_LOGIN_TIMEOUT_MS = 10 * 60_000;
|
|
|
64
64
|
export const SUBMIT_KEYSTROKE_DELAY_MS = 300;
|
|
65
65
|
// Global serialization keeps a failed per-operation stale-resource sweep from
|
|
66
66
|
// ever deleting another live login's labeled container or marked temp dir.
|
|
67
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Concurrent login containers per runner. Workspace sign-ins run side by
|
|
69
|
+
* side (ADR-136 sign-in box: a user is never told to wait for someone else's
|
|
70
|
+
* login); only the host's DEFAULT slot is exclusive, per engine. Bounded so
|
|
71
|
+
* a burst cannot fork containers without limit.
|
|
72
|
+
*/
|
|
73
|
+
export const MAX_CONCURRENT_ENGINE_LOGINS = 16;
|
|
68
74
|
|
|
69
75
|
const LOGIN_MOUNT = "/run/uai-engine-login";
|
|
70
76
|
const LOGIN_HOME = `${LOGIN_MOUNT}/home`;
|
|
@@ -402,7 +408,14 @@ export class EngineLoginManager {
|
|
|
402
408
|
private readonly seams: EngineLoginSeams;
|
|
403
409
|
private readonly workspaceOnly: boolean;
|
|
404
410
|
private readonly operations = new Map<string, LoginOperation>();
|
|
405
|
-
|
|
411
|
+
/**
|
|
412
|
+
* The DEFAULT-SLOT lock: one unlabeled capture per engine at a time, since
|
|
413
|
+
* two of them would race on the host's own `~/.codex`-style slot. Labeled
|
|
414
|
+
* (workspace) captures land in isolated accounts and never take it — on
|
|
415
|
+
* the ADR-136 sign-in box every workspace's sign-in runs alongside every
|
|
416
|
+
* other's; a user is never told to wait for someone else's login.
|
|
417
|
+
*/
|
|
418
|
+
private readonly defaultSlotOperations = new Map<EngineLoginKind, LoginOperation>();
|
|
406
419
|
private reconciliation: Promise<boolean>;
|
|
407
420
|
|
|
408
421
|
constructor(options: EngineLoginManagerOptions) {
|
|
@@ -499,7 +512,7 @@ export class EngineLoginManager {
|
|
|
499
512
|
safeEmit(emit, failedEvent(opId, engine, "unavailable", "Login operation already exists."));
|
|
500
513
|
return false;
|
|
501
514
|
}
|
|
502
|
-
if (this.
|
|
515
|
+
if (label === undefined && this.defaultSlotOperations.has(engine)) {
|
|
503
516
|
safeEmit(
|
|
504
517
|
emit,
|
|
505
518
|
failedEvent(
|
|
@@ -511,10 +524,16 @@ export class EngineLoginManager {
|
|
|
511
524
|
);
|
|
512
525
|
return false;
|
|
513
526
|
}
|
|
527
|
+
// Every login is a container; bound the fan-out without serialising it.
|
|
514
528
|
if (this.operations.size >= MAX_CONCURRENT_ENGINE_LOGINS) {
|
|
515
529
|
safeEmit(
|
|
516
530
|
emit,
|
|
517
|
-
failedEvent(
|
|
531
|
+
failedEvent(
|
|
532
|
+
opId,
|
|
533
|
+
engine,
|
|
534
|
+
"unavailable",
|
|
535
|
+
"Too many sign-ins are running on this runner right now; try again in a minute.",
|
|
536
|
+
),
|
|
518
537
|
);
|
|
519
538
|
return false;
|
|
520
539
|
}
|
|
@@ -546,7 +565,7 @@ export class EngineLoginManager {
|
|
|
546
565
|
postInputOutputSeen: false,
|
|
547
566
|
};
|
|
548
567
|
this.operations.set(opId, operation);
|
|
549
|
-
this.
|
|
568
|
+
if (label === undefined) this.defaultSlotOperations.set(engine, operation);
|
|
550
569
|
operation.timer = this.seams.timers.setTimeout(() => {
|
|
551
570
|
void this.timeout(operation);
|
|
552
571
|
}, ENGINE_LOGIN_TIMEOUT_MS);
|
|
@@ -1309,8 +1328,8 @@ export class EngineLoginManager {
|
|
|
1309
1328
|
if (this.operations.get(operation.opId) === operation) {
|
|
1310
1329
|
this.operations.delete(operation.opId);
|
|
1311
1330
|
}
|
|
1312
|
-
if (this.
|
|
1313
|
-
this.
|
|
1331
|
+
if (this.defaultSlotOperations.get(operation.engine) === operation) {
|
|
1332
|
+
this.defaultSlotOperations.delete(operation.engine);
|
|
1314
1333
|
}
|
|
1315
1334
|
});
|
|
1316
1335
|
return operation.settlementPromise;
|
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) => {
|