@runuai/host 0.9.112 → 0.9.114
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/db/migrations/0020_user_credential_store_ref.sql +6 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +6 -0
- package/lib/codex-auth.ts +79 -3
- package/lib/credential-store-pickup.ts +135 -0
- package/lib/credential-store.ts +77 -0
- package/lib/engine-accounts.ts +137 -20
- package/lib/env.ts +3 -0
- package/lib/github-tokens.ts +117 -1
- package/lib/mcp-connections.ts +138 -4
- package/lib/orchestrator.ts +52 -0
- package/lib/ssh.ts +91 -0
- package/lib/token-broker-lambda.ts +51 -0
- package/lib/token-broker-local.ts +368 -0
- package/lib/token-broker-ssm.ts +185 -0
- package/lib/token-broker.ts +218 -0
- package/lib/token-lanes/openai-oauth.ts +119 -0
- package/lib/token-lanes/registry.ts +45 -0
- package/lib/token-lanes/types.ts +163 -0
- package/lib/token-lanes/xai-oauth.ts +161 -0
- package/lib/user-credentials-store.ts +310 -0
- package/package.json +3 -1
- package/scripts/agent/task-up.sh +31 -1
- package/src/main.ts +31 -1
package/lib/github-tokens.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { and, eq } from "drizzle-orm";
|
|
11
11
|
|
|
12
|
+
import { queueStoreWrite, userParameterName } from "./credential-store";
|
|
12
13
|
import { getDb, schema } from "./db";
|
|
13
14
|
import { sealAesGcm, openAesGcm } from "./secrets";
|
|
14
15
|
import { dockerCli } from "./docker-exec";
|
|
@@ -446,10 +447,13 @@ export async function onConnectSet(
|
|
|
446
447
|
}
|
|
447
448
|
|
|
448
449
|
function deleteStoredTokenRow(userId: string): void {
|
|
449
|
-
getDb()
|
|
450
|
+
const result = getDb()
|
|
450
451
|
.delete(schema.githubTokens)
|
|
451
452
|
.where(eq(schema.githubTokens.userId, userId))
|
|
452
453
|
.run();
|
|
454
|
+
// A connect clears any prior grant first; with none stored there is no
|
|
455
|
+
// parameter to delete either.
|
|
456
|
+
if (result.changes > 0) deleteGithubTokenFromStore(userId);
|
|
453
457
|
}
|
|
454
458
|
|
|
455
459
|
function storeTokenRow(
|
|
@@ -484,6 +488,117 @@ function storeTokenRow(
|
|
|
484
488
|
.onConflictDoUpdate({ target: schema.githubTokens.userId, set: fields })
|
|
485
489
|
.run();
|
|
486
490
|
});
|
|
491
|
+
mirrorGithubTokenToStore(userId);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
// Credential store mirror (ADR-134 slice 2): `<prefix>/users/<userId>/github`.
|
|
496
|
+
// The generation rides along so a replacement host imports the row behind the
|
|
497
|
+
// same durable fence (a later disconnect this host never saw still wins).
|
|
498
|
+
// Owned hosts: no-ops.
|
|
499
|
+
// ---------------------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
export interface StoredGithubToken {
|
|
502
|
+
v: 1;
|
|
503
|
+
kind: "github";
|
|
504
|
+
userId: string;
|
|
505
|
+
installationId: number;
|
|
506
|
+
generation: number;
|
|
507
|
+
tokenKind: string;
|
|
508
|
+
token: string;
|
|
509
|
+
expiresAt: number | null;
|
|
510
|
+
updatedAt: number;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export function githubTokenParameterName(userId: string): string {
|
|
514
|
+
return userParameterName(userId, "github");
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** The row as it is mirrored (token decrypted). Never logged. */
|
|
518
|
+
export function githubTokenStoreValue(userId: string): StoredGithubToken | null {
|
|
519
|
+
const row = getDb()
|
|
520
|
+
.select()
|
|
521
|
+
.from(schema.githubTokens)
|
|
522
|
+
.where(eq(schema.githubTokens.userId, userId))
|
|
523
|
+
.get();
|
|
524
|
+
if (!row) return null;
|
|
525
|
+
return {
|
|
526
|
+
v: 1,
|
|
527
|
+
kind: "github",
|
|
528
|
+
userId,
|
|
529
|
+
installationId: row.installationId,
|
|
530
|
+
generation: row.generation,
|
|
531
|
+
tokenKind: row.kind,
|
|
532
|
+
token: openAesGcm(
|
|
533
|
+
Buffer.from(row.refreshTokenCt as Uint8Array),
|
|
534
|
+
Buffer.from(row.refreshTokenNonce as Uint8Array),
|
|
535
|
+
),
|
|
536
|
+
expiresAt: row.refreshTokenExpiresAt ?? null,
|
|
537
|
+
updatedAt: row.updatedAt,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export function setGithubTokenStoreRef(userId: string, storeRef: string): void {
|
|
542
|
+
getDb()
|
|
543
|
+
.update(schema.githubTokens)
|
|
544
|
+
.set({ storeRef })
|
|
545
|
+
.where(eq(schema.githubTokens.userId, userId))
|
|
546
|
+
.run();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export function listGithubTokenStoreRows(): Array<{ userId: string; storeRef: string | null }> {
|
|
550
|
+
return getDb()
|
|
551
|
+
.select({ userId: schema.githubTokens.userId, storeRef: schema.githubTokens.storeRef })
|
|
552
|
+
.from(schema.githubTokens)
|
|
553
|
+
.all();
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export function mirrorGithubTokenToStore(userId: string): void {
|
|
557
|
+
queueStoreWrite(`mirror github token ${userId}`, async (store) => {
|
|
558
|
+
const value = githubTokenStoreValue(userId);
|
|
559
|
+
if (!value) return;
|
|
560
|
+
const name = githubTokenParameterName(userId);
|
|
561
|
+
await store.put(name, JSON.stringify(value));
|
|
562
|
+
setGithubTokenStoreRef(userId, name);
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export function deleteGithubTokenFromStore(userId: string): void {
|
|
567
|
+
queueStoreWrite(`delete github token ${userId}`, (store) =>
|
|
568
|
+
store.delete(githubTokenParameterName(userId)),
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Reconcile import: a token this host lacks. Claims the stored generation as
|
|
574
|
+
* a `set` first — refused (false) when this host's fence already moved past
|
|
575
|
+
* it, so a disconnect that outran the store's delete can never resurrect a
|
|
576
|
+
* token here.
|
|
577
|
+
*/
|
|
578
|
+
export function importGithubTokenFromStore(
|
|
579
|
+
value: StoredGithubToken,
|
|
580
|
+
storeRef: string,
|
|
581
|
+
): boolean {
|
|
582
|
+
const claim = claimGithubCredentialGeneration(value.userId, value.generation, "set");
|
|
583
|
+
if (!claim) return false;
|
|
584
|
+
const sealed = sealAesGcm(value.token);
|
|
585
|
+
const fields = {
|
|
586
|
+
installationId: value.installationId,
|
|
587
|
+
generation: value.generation,
|
|
588
|
+
refreshTokenCt: sealed.ct,
|
|
589
|
+
refreshTokenNonce: sealed.nonce,
|
|
590
|
+
refreshTokenExpiresAt: value.expiresAt,
|
|
591
|
+
kind: value.tokenKind,
|
|
592
|
+
updatedAt: value.updatedAt,
|
|
593
|
+
storeRef,
|
|
594
|
+
};
|
|
595
|
+
getDb()
|
|
596
|
+
.insert(schema.githubTokens)
|
|
597
|
+
.values({ userId: value.userId, ...fields })
|
|
598
|
+
.onConflictDoUpdate({ target: schema.githubTokens.userId, set: fields })
|
|
599
|
+
.run();
|
|
600
|
+
notifyGithubChange();
|
|
601
|
+
return true;
|
|
487
602
|
}
|
|
488
603
|
|
|
489
604
|
async function bestEffortRevoke(
|
|
@@ -663,6 +778,7 @@ export function deleteToken(userId: string): void {
|
|
|
663
778
|
.delete(schema.githubTokens)
|
|
664
779
|
.where(eq(schema.githubTokens.userId, userId))
|
|
665
780
|
.run();
|
|
781
|
+
deleteGithubTokenFromStore(userId);
|
|
666
782
|
advanceGithubAuthGeneration(userId);
|
|
667
783
|
notifyGithubChange();
|
|
668
784
|
}
|
package/lib/mcp-connections.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
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
18
|
import { getDb, schema } from "./db";
|
|
18
19
|
import { sealAesGcm, openAesGcm } from "./secrets";
|
|
19
20
|
import type { McpOp } from "../src/protocol";
|
|
@@ -276,10 +277,7 @@ export async function handleMcpOp(op: McpOp): Promise<McpAck> {
|
|
|
276
277
|
case "oauth.complete":
|
|
277
278
|
return oauthComplete(op.connectionId, op.code);
|
|
278
279
|
case "disconnect": {
|
|
279
|
-
|
|
280
|
-
.delete(schema.mcpConnections)
|
|
281
|
-
.where(eq(schema.mcpConnections.id, op.connectionId))
|
|
282
|
-
.run();
|
|
280
|
+
deleteMcpConnectionLocal(op.connectionId);
|
|
283
281
|
return { status: "disconnected" };
|
|
284
282
|
}
|
|
285
283
|
}
|
|
@@ -334,6 +332,7 @@ async function probe(op: Extract<McpOp, { kind: "probe" }>): Promise<McpAck> {
|
|
|
334
332
|
},
|
|
335
333
|
})
|
|
336
334
|
.run();
|
|
335
|
+
mirrorMcpConnectionToStore(op.connectionId);
|
|
337
336
|
return { status: "connected" };
|
|
338
337
|
}
|
|
339
338
|
|
|
@@ -354,6 +353,7 @@ async function probe(op: Extract<McpOp, { kind: "probe" }>): Promise<McpAck> {
|
|
|
354
353
|
set: { url: op.url, authKind: "none", status: "connected", updatedAt: now },
|
|
355
354
|
})
|
|
356
355
|
.run();
|
|
356
|
+
mirrorMcpConnectionToStore(op.connectionId);
|
|
357
357
|
return { status: "connected" };
|
|
358
358
|
}
|
|
359
359
|
if (res.status !== 401 && res.status !== 403) {
|
|
@@ -414,6 +414,7 @@ async function probe(op: Extract<McpOp, { kind: "probe" }>): Promise<McpAck> {
|
|
|
414
414
|
.values({ id: op.connectionId, ...fields })
|
|
415
415
|
.onConflictDoUpdate({ target: schema.mcpConnections.id, set: fields })
|
|
416
416
|
.run();
|
|
417
|
+
mirrorMcpConnectionToStore(op.connectionId);
|
|
417
418
|
return { status: "auth_required", authorizeUrl: authorize.toString() };
|
|
418
419
|
}
|
|
419
420
|
|
|
@@ -472,6 +473,7 @@ async function oauthComplete(connectionId: string, code: string): Promise<McpAck
|
|
|
472
473
|
})
|
|
473
474
|
.where(eq(schema.mcpConnections.id, connectionId))
|
|
474
475
|
.run();
|
|
476
|
+
mirrorMcpConnectionToStore(connectionId);
|
|
475
477
|
return {
|
|
476
478
|
status: "connected",
|
|
477
479
|
scopes: scopes ? scopes.split(" ").filter(Boolean) : undefined,
|
|
@@ -611,5 +613,137 @@ async function refreshTokens(
|
|
|
611
613
|
.set({ secretEnc: pack(JSON.stringify(next)), updatedAt: Date.now() })
|
|
612
614
|
.where(eq(schema.mcpConnections.id, row.id))
|
|
613
615
|
.run();
|
|
616
|
+
mirrorMcpConnectionToStore(row.id);
|
|
614
617
|
return next;
|
|
615
618
|
}
|
|
619
|
+
|
|
620
|
+
// ---------------------------------------------------------------------------
|
|
621
|
+
// Credential store mirror (ADR-134 slice 2):
|
|
622
|
+
// `<prefix>/users/<userId>/mcp/<connectionId>`. The connection id is the
|
|
623
|
+
// cloud's, which is org-bound there; on the host the owner is the user.
|
|
624
|
+
// Owned hosts: no-ops.
|
|
625
|
+
// ---------------------------------------------------------------------------
|
|
626
|
+
|
|
627
|
+
export interface StoredMcpConnection {
|
|
628
|
+
v: 1;
|
|
629
|
+
kind: "mcp";
|
|
630
|
+
id: string;
|
|
631
|
+
userId: string;
|
|
632
|
+
url: string;
|
|
633
|
+
authKind: string;
|
|
634
|
+
tokenEndpoint: string | null;
|
|
635
|
+
redirectUri: string | null;
|
|
636
|
+
clientId: string | null;
|
|
637
|
+
clientSecret: string | null;
|
|
638
|
+
pkceVerifier: string | null;
|
|
639
|
+
/** The sealed JSON, decrypted: header or OAuth tokens. */
|
|
640
|
+
secret: string | null;
|
|
641
|
+
status: string;
|
|
642
|
+
scopes: string | null;
|
|
643
|
+
updatedAt: number;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
export function mcpConnectionParameterName(userId: string, id: string): string {
|
|
647
|
+
return userParameterName(userId, "mcp", id);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** The row as it is mirrored (secrets decrypted). Never logged. */
|
|
651
|
+
export function mcpConnectionStoreValue(id: string): StoredMcpConnection | null {
|
|
652
|
+
const row = getDb()
|
|
653
|
+
.select()
|
|
654
|
+
.from(schema.mcpConnections)
|
|
655
|
+
.where(eq(schema.mcpConnections.id, id))
|
|
656
|
+
.get();
|
|
657
|
+
if (!row) return null;
|
|
658
|
+
return {
|
|
659
|
+
v: 1,
|
|
660
|
+
kind: "mcp",
|
|
661
|
+
id: row.id,
|
|
662
|
+
userId: row.userId,
|
|
663
|
+
url: row.url,
|
|
664
|
+
authKind: row.authKind,
|
|
665
|
+
tokenEndpoint: row.tokenEndpoint,
|
|
666
|
+
redirectUri: row.redirectUri,
|
|
667
|
+
clientId: row.clientId,
|
|
668
|
+
clientSecret: row.clientSecretEnc ? unpack(row.clientSecretEnc) : null,
|
|
669
|
+
pkceVerifier: row.pkceVerifierEnc ? unpack(row.pkceVerifierEnc) : null,
|
|
670
|
+
secret: row.secretEnc ? unpack(row.secretEnc) : null,
|
|
671
|
+
status: row.status,
|
|
672
|
+
scopes: row.scopes,
|
|
673
|
+
updatedAt: row.updatedAt,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export function setMcpConnectionStoreRef(id: string, storeRef: string): void {
|
|
678
|
+
getDb()
|
|
679
|
+
.update(schema.mcpConnections)
|
|
680
|
+
.set({ storeRef })
|
|
681
|
+
.where(eq(schema.mcpConnections.id, id))
|
|
682
|
+
.run();
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
export function listMcpConnectionStoreRows(): Array<{
|
|
686
|
+
id: string;
|
|
687
|
+
userId: string;
|
|
688
|
+
storeRef: string | null;
|
|
689
|
+
}> {
|
|
690
|
+
return getDb()
|
|
691
|
+
.select({
|
|
692
|
+
id: schema.mcpConnections.id,
|
|
693
|
+
userId: schema.mcpConnections.userId,
|
|
694
|
+
storeRef: schema.mcpConnections.storeRef,
|
|
695
|
+
})
|
|
696
|
+
.from(schema.mcpConnections)
|
|
697
|
+
.all();
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
export function mirrorMcpConnectionToStore(id: string): void {
|
|
701
|
+
queueStoreWrite(`mirror mcp connection ${id}`, async (store) => {
|
|
702
|
+
const value = mcpConnectionStoreValue(id);
|
|
703
|
+
if (!value) return;
|
|
704
|
+
const name = mcpConnectionParameterName(value.userId, id);
|
|
705
|
+
await store.put(name, JSON.stringify(value));
|
|
706
|
+
setMcpConnectionStoreRef(id, name);
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** Delete the row and its parameter (the disconnect op and reconcile). */
|
|
711
|
+
export function deleteMcpConnectionLocal(id: string): void {
|
|
712
|
+
const row = getDb()
|
|
713
|
+
.select({ userId: schema.mcpConnections.userId, storeRef: schema.mcpConnections.storeRef })
|
|
714
|
+
.from(schema.mcpConnections)
|
|
715
|
+
.where(eq(schema.mcpConnections.id, id))
|
|
716
|
+
.get();
|
|
717
|
+
getDb().delete(schema.mcpConnections).where(eq(schema.mcpConnections.id, id)).run();
|
|
718
|
+
if (!row) return;
|
|
719
|
+
const name = row.storeRef ?? mcpConnectionParameterName(row.userId, id);
|
|
720
|
+
queueStoreWrite(`delete mcp connection ${id}`, (store) => store.delete(name));
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** Reconcile import: a connection this host lacks. Existing rows win. */
|
|
724
|
+
export function importMcpConnectionFromStore(
|
|
725
|
+
value: StoredMcpConnection,
|
|
726
|
+
storeRef: string,
|
|
727
|
+
): boolean {
|
|
728
|
+
const result = getDb()
|
|
729
|
+
.insert(schema.mcpConnections)
|
|
730
|
+
.values({
|
|
731
|
+
id: value.id,
|
|
732
|
+
userId: value.userId,
|
|
733
|
+
url: value.url,
|
|
734
|
+
authKind: value.authKind,
|
|
735
|
+
tokenEndpoint: value.tokenEndpoint,
|
|
736
|
+
redirectUri: value.redirectUri,
|
|
737
|
+
clientId: value.clientId,
|
|
738
|
+
clientSecretEnc: value.clientSecret === null ? null : pack(value.clientSecret),
|
|
739
|
+
pkceVerifierEnc: value.pkceVerifier === null ? null : pack(value.pkceVerifier),
|
|
740
|
+
secretEnc: value.secret === null ? null : pack(value.secret),
|
|
741
|
+
status: value.status,
|
|
742
|
+
scopes: value.scopes,
|
|
743
|
+
updatedAt: value.updatedAt,
|
|
744
|
+
storeRef,
|
|
745
|
+
})
|
|
746
|
+
.onConflictDoNothing({ target: schema.mcpConnections.id })
|
|
747
|
+
.run();
|
|
748
|
+
return result.changes > 0;
|
|
749
|
+
}
|
package/lib/orchestrator.ts
CHANGED
|
@@ -106,6 +106,7 @@ import {
|
|
|
106
106
|
type BrowserSetup,
|
|
107
107
|
} from "./browser-testing";
|
|
108
108
|
import { injectCodexIntoContainer } from "./codex-auth";
|
|
109
|
+
import { isCredentialInvalidSignal, requestCredentialRefresh } from "./token-broker-local";
|
|
109
110
|
import {
|
|
110
111
|
codexHomeIsolatable,
|
|
111
112
|
codexHomeSeedCommand,
|
|
@@ -2424,6 +2425,29 @@ export class Orchestrator {
|
|
|
2424
2425
|
) {
|
|
2425
2426
|
break;
|
|
2426
2427
|
}
|
|
2428
|
+
// ADR-135 reactive path: an engine whose brokered copy just died asks
|
|
2429
|
+
// the one refresher (this host, or the broker Lambda) for a fresh
|
|
2430
|
+
// token, which re-pushes the copy; then the agent respawns on it.
|
|
2431
|
+
// Rate-limited per credential inside requestCredentialRefresh.
|
|
2432
|
+
if (
|
|
2433
|
+
agent &&
|
|
2434
|
+
agent.kind !== "claude" &&
|
|
2435
|
+
isCredentialInvalidSignal(agent.kind, event.message)
|
|
2436
|
+
) {
|
|
2437
|
+
console.warn(
|
|
2438
|
+
`[orchestrator] ${channel.taskId}/${agentId}: ${agent.kind} credential-invalid pattern matched: ${event.message.slice(0, 300)}`,
|
|
2439
|
+
);
|
|
2440
|
+
const accountId = channel.accountByAgent.get(agentId) ?? null;
|
|
2441
|
+
const requested = await requestCredentialRefresh({
|
|
2442
|
+
kind: agent.kind,
|
|
2443
|
+
accountId: accountId && !accountId.startsWith("default:") ? accountId : null,
|
|
2444
|
+
scope: channel.engineAccountScope,
|
|
2445
|
+
});
|
|
2446
|
+
if (requested) {
|
|
2447
|
+
await this.refreshAgentToken(channel, agent);
|
|
2448
|
+
break;
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2427
2451
|
const isClaude = agent?.kind === "claude";
|
|
2428
2452
|
if (isClaude && agent && isClaudeAuthRevoked(event.message)) {
|
|
2429
2453
|
// Token revoked/expired: the env-injected credential only changes on
|
|
@@ -2985,6 +3009,34 @@ export class Orchestrator {
|
|
|
2985
3009
|
* changes (a re-login). Resets each agent's respawn budget first so an agent
|
|
2986
3010
|
* that exhausted it during the outage gets a clean chance on the new token.
|
|
2987
3011
|
*/
|
|
3012
|
+
/**
|
|
3013
|
+
* ADR-135: after the broker refreshed `kind`'s material, copy the brokered
|
|
3014
|
+
* accounts into every running task that has an agent of that kind. The
|
|
3015
|
+
* copy is what the agent reads on its next spawn; a live engine process
|
|
3016
|
+
* keeps its access token until it respawns or the reactive path fires.
|
|
3017
|
+
*/
|
|
3018
|
+
async refreshEngineAccountCopies(kind: string): Promise<void> {
|
|
3019
|
+
for (const channel of this.channels.values()) {
|
|
3020
|
+
if (!this.isActiveChannel(channel)) continue;
|
|
3021
|
+
if (!channel.roster.some((agent) => agent.kind === kind)) continue;
|
|
3022
|
+
try {
|
|
3023
|
+
await provisionEngineAccounts(
|
|
3024
|
+
channel.containerName,
|
|
3025
|
+
[kind],
|
|
3026
|
+
{},
|
|
3027
|
+
channel.agentHandle ?? undefined,
|
|
3028
|
+
channel.engineAccountScope,
|
|
3029
|
+
);
|
|
3030
|
+
} catch (err) {
|
|
3031
|
+
console.warn(
|
|
3032
|
+
`[orchestrator] ${channel.taskId}: re-push of ${kind} accounts failed: ${
|
|
3033
|
+
err instanceof Error ? err.message : String(err)
|
|
3034
|
+
}`,
|
|
3035
|
+
);
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
3039
|
+
|
|
2988
3040
|
async refreshEngineAccountAgents(kind: string): Promise<void> {
|
|
2989
3041
|
for (const channel of this.channels.values()) {
|
|
2990
3042
|
if (!this.isActiveChannel(channel)) continue;
|
package/lib/ssh.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { join, resolve } from "node:path";
|
|
|
23
23
|
|
|
24
24
|
import { eq } from "drizzle-orm";
|
|
25
25
|
|
|
26
|
+
import { queueStoreWrite, userParameterName } from "./credential-store";
|
|
26
27
|
import { getDb, schema } from "./db";
|
|
27
28
|
import { env } from "./env";
|
|
28
29
|
import { sealAesGcm, openAesGcm } from "./secrets";
|
|
@@ -104,12 +105,102 @@ export function ensureKeyForUser(userId: string): string {
|
|
|
104
105
|
})
|
|
105
106
|
.onConflictDoNothing({ target: schema.sshKeys.userId })
|
|
106
107
|
.run();
|
|
108
|
+
mirrorSshKeyToStore(userId);
|
|
107
109
|
// Re-read in case a concurrent task-up won the insert race.
|
|
108
110
|
return getPublicKey(userId) ?? publicKey;
|
|
109
111
|
}
|
|
110
112
|
|
|
111
113
|
export function deleteKey(userId: string): void {
|
|
112
114
|
getDb().delete(schema.sshKeys).where(eq(schema.sshKeys.userId, userId)).run();
|
|
115
|
+
queueStoreWrite(`delete ssh key ${userId}`, (store) =>
|
|
116
|
+
store.delete(sshKeyParameterName(userId)),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Credential store mirror (ADR-134 slice 2): `<prefix>/users/<userId>/ssh`.
|
|
122
|
+
// A replacement Uai-operated host imports the SAME keypair, so the public key
|
|
123
|
+
// the user pasted into GitHub keeps working. Owned hosts: no-ops.
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
export interface StoredSshKey {
|
|
127
|
+
v: 1;
|
|
128
|
+
kind: "ssh";
|
|
129
|
+
userId: string;
|
|
130
|
+
publicKey: string;
|
|
131
|
+
privateKey: string;
|
|
132
|
+
createdAt: number;
|
|
133
|
+
updatedAt: number;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function sshKeyParameterName(userId: string): string {
|
|
137
|
+
return userParameterName(userId, "ssh");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The row as it is mirrored (private key decrypted). Never logged. */
|
|
141
|
+
export function sshKeyStoreValue(userId: string): StoredSshKey | null {
|
|
142
|
+
const row = getDb()
|
|
143
|
+
.select()
|
|
144
|
+
.from(schema.sshKeys)
|
|
145
|
+
.where(eq(schema.sshKeys.userId, userId))
|
|
146
|
+
.get();
|
|
147
|
+
if (!row) return null;
|
|
148
|
+
return {
|
|
149
|
+
v: 1,
|
|
150
|
+
kind: "ssh",
|
|
151
|
+
userId,
|
|
152
|
+
publicKey: row.publicKey,
|
|
153
|
+
privateKey: openAesGcm(
|
|
154
|
+
Buffer.from(row.privateKeyCt as Uint8Array),
|
|
155
|
+
Buffer.from(row.privateKeyNonce as Uint8Array),
|
|
156
|
+
),
|
|
157
|
+
createdAt: row.createdAt,
|
|
158
|
+
updatedAt: row.updatedAt,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function setSshKeyStoreRef(userId: string, storeRef: string): void {
|
|
163
|
+
getDb()
|
|
164
|
+
.update(schema.sshKeys)
|
|
165
|
+
.set({ storeRef })
|
|
166
|
+
.where(eq(schema.sshKeys.userId, userId))
|
|
167
|
+
.run();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function listSshKeyStoreRows(): Array<{ userId: string; storeRef: string | null }> {
|
|
171
|
+
return getDb()
|
|
172
|
+
.select({ userId: schema.sshKeys.userId, storeRef: schema.sshKeys.storeRef })
|
|
173
|
+
.from(schema.sshKeys)
|
|
174
|
+
.all();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function mirrorSshKeyToStore(userId: string): void {
|
|
178
|
+
queueStoreWrite(`mirror ssh key ${userId}`, async (store) => {
|
|
179
|
+
const value = sshKeyStoreValue(userId);
|
|
180
|
+
if (!value) return;
|
|
181
|
+
const name = sshKeyParameterName(userId);
|
|
182
|
+
await store.put(name, JSON.stringify(value));
|
|
183
|
+
setSshKeyStoreRef(userId, name);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Reconcile import: a keypair this host lacks. Existing rows win. */
|
|
188
|
+
export function importSshKeyFromStore(value: StoredSshKey, storeRef: string): boolean {
|
|
189
|
+
const sealed = sealAesGcm(value.privateKey);
|
|
190
|
+
const result = getDb()
|
|
191
|
+
.insert(schema.sshKeys)
|
|
192
|
+
.values({
|
|
193
|
+
userId: value.userId,
|
|
194
|
+
publicKey: value.publicKey,
|
|
195
|
+
privateKeyCt: sealed.ct,
|
|
196
|
+
privateKeyNonce: sealed.nonce,
|
|
197
|
+
createdAt: value.createdAt,
|
|
198
|
+
updatedAt: value.updatedAt,
|
|
199
|
+
storeRef,
|
|
200
|
+
})
|
|
201
|
+
.onConflictDoNothing({ target: schema.sshKeys.userId })
|
|
202
|
+
.run();
|
|
203
|
+
return result.changes > 0;
|
|
113
204
|
}
|
|
114
205
|
|
|
115
206
|
/** Decrypt + return the user's private key, or null if they have none. */
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Broker Lambda entrypoint (ADR-135). Runs one broker cycle against the
|
|
3
|
+
* credential store. Invoked by the six-hourly EventBridge rule, and
|
|
4
|
+
* synchronously by hosts for the reactive path with `{ "names": [...] }`
|
|
5
|
+
* (parameter names to refresh now). Returns the cycle summary; logs one
|
|
6
|
+
* audit line per decision and never a value.
|
|
7
|
+
*
|
|
8
|
+
* Lives in lib/ (the package import boundary) and is bundled by
|
|
9
|
+
* host-agent/scripts/token-broker/build.sh (esbuild, AWS SDK left external —
|
|
10
|
+
* the Node runtime provides it). Nothing in the host imports it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { SSMClient } from "@aws-sdk/client-ssm";
|
|
14
|
+
|
|
15
|
+
import { runBrokerCycle } from "./token-broker";
|
|
16
|
+
import { acquireBrokerLock, SsmBrokerStorage } from "./token-broker-ssm";
|
|
17
|
+
|
|
18
|
+
interface BrokerEvent {
|
|
19
|
+
names?: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const MAX_FORCED_NAMES = 50;
|
|
23
|
+
|
|
24
|
+
export async function handler(event: BrokerEvent = {}): Promise<Record<string, unknown>> {
|
|
25
|
+
const prefix = process.env.UAI_CREDENTIAL_STORE_PREFIX || "/uai";
|
|
26
|
+
const kmsKeyId = process.env.UAI_CREDENTIAL_STORE_KMS_KEY || "alias/uai-credentials";
|
|
27
|
+
const names = Array.isArray(event.names)
|
|
28
|
+
? event.names.filter((n): n is string => typeof n === "string" && n.startsWith(`${prefix}/`)).slice(0, MAX_FORCED_NAMES)
|
|
29
|
+
: [];
|
|
30
|
+
const started = Date.now();
|
|
31
|
+
const client = new SSMClient({});
|
|
32
|
+
// One run at a time, whatever the account's concurrency settings allow:
|
|
33
|
+
// two refreshers for the same credential is the bug this exists to end.
|
|
34
|
+
const release = await acquireBrokerLock({ client, prefix });
|
|
35
|
+
if (!release) {
|
|
36
|
+
console.log("[token-broker] another run holds the lock; skipped");
|
|
37
|
+
return { skipped: "locked", forced: names.length, ms: Date.now() - started };
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const summary = await runBrokerCycle({
|
|
41
|
+
storage: new SsmBrokerStorage({ prefix, kmsKeyId, client }),
|
|
42
|
+
force: new Set(names),
|
|
43
|
+
log: (line) => console.log(`[token-broker] ${line}`),
|
|
44
|
+
});
|
|
45
|
+
const result = { ...summary, forced: names.length, ms: Date.now() - started };
|
|
46
|
+
console.log(`[token-broker] cycle ${JSON.stringify(result)}`);
|
|
47
|
+
return result;
|
|
48
|
+
} finally {
|
|
49
|
+
await release();
|
|
50
|
+
}
|
|
51
|
+
}
|