@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.
@@ -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
  }
@@ -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
- getDb()
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
+ }
@@ -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
+ }