@runuai/host 0.9.113 → 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/lib/codex-auth.ts CHANGED
@@ -53,14 +53,15 @@
53
53
  * this targets ALL running tasks — matching task-up, which copies it into every
54
54
  * container regardless of roster.
55
55
  */
56
- import { existsSync, watch } from "node:fs";
57
- import { homedir } from "node:os";
58
- import { join } from "node:path";
56
+ import { existsSync, mkdtempSync, readFileSync, rmSync, watch, writeFileSync } from "node:fs";
57
+ import { homedir, tmpdir } from "node:os";
58
+ import { dirname, join } from "node:path";
59
59
 
60
60
  import { isNull } from "drizzle-orm";
61
61
 
62
62
  import { getDb, schema } from "./db";
63
63
  import { dockerCli } from "./docker-exec";
64
+ import { brokeredFilesFor } from "./token-broker";
64
65
  import {
65
66
  listRunningTaskAppContainerNames,
66
67
  taskAppContainerName,
@@ -90,6 +91,8 @@ export interface CodexDeps {
90
91
  /** DB-active task containers — may include stopped ones; docker decides. */
91
92
  activeTaskContainers?: () => string[];
92
93
  fileExists?: (path: string) => boolean;
94
+ /** Reads the host's auth.json for brokering (tests inject; never the real one). */
95
+ readFile?: (path: string) => string;
93
96
  }
94
97
 
95
98
  function ownerCodexDir(): string {
@@ -176,6 +179,32 @@ async function copyCodexInto(
176
179
  for (const item of items) {
177
180
  const src = join(dir, item);
178
181
  if (!exists(src)) continue;
182
+ // ADR-135: the task's copy never carries the rotating refresh token — N
183
+ // copies racing to refresh it revoke the whole family. cp needs a real
184
+ // file, so write the brokered variant to a private temp file and cp that.
185
+ if (item === "auth.json") {
186
+ let brokered: string | null = null;
187
+ try {
188
+ const raw = (deps.readFile ?? ((p: string) => readFileSync(p, "utf8")))(src);
189
+ const out = brokeredFilesFor(
190
+ { kind: "codex", authKind: "config-dir", id: "default", scope: "host" },
191
+ { "auth.json": raw },
192
+ );
193
+ brokered = out["auth.json"] !== raw ? out["auth.json"]! : null;
194
+ } catch {
195
+ brokered = null;
196
+ }
197
+ if (brokered !== null) {
198
+ const tmp = join(mkdtempSync(join(tmpdir(), "uai-codex-auth-")), "auth.json");
199
+ writeFileSync(tmp, brokered, { mode: 0o600 });
200
+ try {
201
+ await must(["cp", tmp, `${container}:/home/node/.codex/auth.json`], "docker cp auth.json (brokered)");
202
+ } finally {
203
+ rmSync(dirname(tmp), { recursive: true, force: true });
204
+ }
205
+ continue;
206
+ }
207
+ }
179
208
  await must(["cp", src, `${container}:/home/node/.codex/`], `docker cp ${item}`);
180
209
  }
181
210
  await must(
@@ -356,3 +385,50 @@ export function stopWatchingCodexAuth(): void {
356
385
  debounceTimer = null;
357
386
  }
358
387
  }
388
+
389
+ /**
390
+ * ADR-135: re-push the host's default Grok sign-in (`~/.grok/auth.json`,
391
+ * brokered) into every running task, after the owned-host loop refreshed it.
392
+ * Same sweep shape as the Codex reinject; only auth.json travels.
393
+ */
394
+ export async function reinjectGrokRunningTasks(deps: CodexDeps = {}): Promise<void> {
395
+ const exists = deps.fileExists ?? existsSync;
396
+ const src = join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".grok", "auth.json");
397
+ if (!exists(src)) return;
398
+ const active = (deps.activeTaskContainers ?? defaultActiveTaskContainers)();
399
+ if (active.length === 0) return;
400
+ const running = await dockerRunningNames(deps.exec ?? defaultExec);
401
+ if (running === null) return;
402
+ const exec = deps.exec ?? defaultExec;
403
+ let brokered: string;
404
+ try {
405
+ const raw = readFileSync(src, "utf8");
406
+ brokered = brokeredFilesFor(
407
+ { kind: "grok", authKind: "config-dir", id: "default", scope: "host" },
408
+ { "auth.json": raw },
409
+ )["auth.json"]!;
410
+ } catch {
411
+ return;
412
+ }
413
+ const dir = mkdtempSync(join(tmpdir(), "uai-grok-auth-"));
414
+ const tmp = join(dir, "auth.json");
415
+ try {
416
+ writeFileSync(tmp, brokered, { mode: 0o600 });
417
+ for (const container of active.filter((c) => running.has(c))) {
418
+ const steps: Array<[string[], string]> = [
419
+ [["exec", "-u", "root", ...runtimeAuthorityDockerExecArgs(), container, "/bin/mkdir", "-p", "/home/node/.grok"], "mkdir"],
420
+ [["cp", tmp, `${container}:/home/node/.grok/auth.json`], "docker cp auth.json (brokered)"],
421
+ [["exec", "-u", "root", ...runtimeAuthorityDockerExecArgs(), container, "/bin/chown", "-R", "node:node", "/home/node/.grok"], "chown"],
422
+ ];
423
+ for (const [args, step] of steps) {
424
+ const res = await exec(args);
425
+ if (res.status !== 0) {
426
+ console.error(`[grok] reinject into ${container}: ${step} failed (exit ${res.status ?? "none"})`);
427
+ break;
428
+ }
429
+ }
430
+ }
431
+ } finally {
432
+ rmSync(dir, { recursive: true, force: true });
433
+ }
434
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Uai-operated hosts: pick up credentials the broker Lambda refreshed
3
+ * (ADR-135). Every 15 minutes list the engine parameters under this host's
4
+ * scopes — names and versions only, no decryption — and re-import whatever
5
+ * moved, then re-push brokered copies of that kind into running tasks. The
6
+ * reactive path calls `pickupNow` right after a forced refresh.
7
+ *
8
+ * Never refreshes anything: on a Uai-operated host the Lambda is the one
9
+ * refresher. Owned hosts never run this (no store).
10
+ */
11
+
12
+ import { configuredCredentialStore, credentialStorePrefix } from "./credential-store";
13
+ import {
14
+ applyStoredEngineAccount,
15
+ engineAccountHostId,
16
+ parseEngineAccountParameterName,
17
+ } from "./engine-accounts";
18
+ import { setPickupNow } from "./token-broker-local";
19
+
20
+ const PICKUP_INTERVAL_MS = 15 * 60_000;
21
+
22
+ let timer: ReturnType<typeof setInterval> | null = null;
23
+ let known: Map<string, number> | null = null;
24
+ let inflight: Promise<PickupSummary> | null = null;
25
+ let repushHook: ((kind: string) => Promise<void>) | null = null;
26
+
27
+ export interface PickupSummary {
28
+ skipped: boolean;
29
+ listed: number;
30
+ changed: number;
31
+ applied: number;
32
+ errors: number;
33
+ }
34
+
35
+ function log(line: string): void {
36
+ console.log(`[credential-pickup] ${line}`);
37
+ }
38
+
39
+ /**
40
+ * One pass. The first pass records the baseline (the start-up reconcile
41
+ * already imported current values); later passes apply changed versions.
42
+ */
43
+ export async function pickupChangedCredentials(): Promise<PickupSummary> {
44
+ const store = configuredCredentialStore();
45
+ const summary: PickupSummary = { skipped: false, listed: 0, changed: 0, applied: 0, errors: 0 };
46
+ if (!store) return { ...summary, skipped: true };
47
+ const prefix = credentialStorePrefix();
48
+ const hostId = engineAccountHostId();
49
+ const paths = [`${prefix}/orgs`, ...(hostId ? [`${prefix}/hosts/${hostId}`] : [])];
50
+ const current = new Map<string, number>();
51
+ for (const path of paths) {
52
+ try {
53
+ for (const entry of await store.listVersions(path)) {
54
+ if (parseEngineAccountParameterName(entry.name, prefix)) current.set(entry.name, entry.version);
55
+ }
56
+ } catch (err) {
57
+ summary.errors++;
58
+ log(`listing ${path} failed: ${err instanceof Error ? err.message : String(err)}`);
59
+ // An incomplete listing must not look like deletions; keep the baseline.
60
+ return summary;
61
+ }
62
+ }
63
+ summary.listed = current.size;
64
+ if (known === null) {
65
+ known = current;
66
+ return summary;
67
+ }
68
+ const kinds = new Set<string>();
69
+ for (const [name, version] of current) {
70
+ if (known.get(name) === version) continue;
71
+ summary.changed++;
72
+ try {
73
+ const value = await store.get(name);
74
+ if (value === null) continue;
75
+ const applied = applyStoredEngineAccount(name, value);
76
+ if (applied) {
77
+ summary.applied++;
78
+ kinds.add(applied.kind);
79
+ }
80
+ } catch (err) {
81
+ summary.errors++;
82
+ log(`applying ${name} failed: ${err instanceof Error ? err.message : String(err)}`);
83
+ continue;
84
+ }
85
+ known.set(name, version);
86
+ }
87
+ for (const name of [...known.keys()]) if (!current.has(name)) known.delete(name);
88
+ for (const kind of kinds) {
89
+ try {
90
+ await repushHook?.(kind);
91
+ } catch (err) {
92
+ log(`re-push of ${kind} copies failed: ${err instanceof Error ? err.message : String(err)}`);
93
+ }
94
+ }
95
+ if (summary.changed > 0) {
96
+ log(`${summary.changed} parameter(s) changed, ${summary.applied} applied, ${summary.errors} errors`);
97
+ }
98
+ return summary;
99
+ }
100
+
101
+ async function pickupSerialized(): Promise<PickupSummary> {
102
+ if (inflight) return inflight;
103
+ inflight = pickupChangedCredentials().finally(() => {
104
+ inflight = null;
105
+ });
106
+ return inflight;
107
+ }
108
+
109
+ /** Uai-operated hosts only: baseline now, then poll every 15 minutes. */
110
+ export function startCredentialStorePickup(hooks: { repush: (kind: string) => Promise<void> }): void {
111
+ if (timer || !configuredCredentialStore()) return;
112
+ repushHook = hooks.repush;
113
+ setPickupNow(async () => {
114
+ await pickupSerialized();
115
+ });
116
+ void pickupSerialized().catch((err) =>
117
+ log(`baseline failed: ${err instanceof Error ? err.message : String(err)}`),
118
+ );
119
+ timer = setInterval(() => {
120
+ void pickupSerialized().catch((err) =>
121
+ log(`poll failed: ${err instanceof Error ? err.message : String(err)}`),
122
+ );
123
+ }, PICKUP_INTERVAL_MS);
124
+ timer.unref?.();
125
+ }
126
+
127
+ /** Tests. */
128
+ export function resetCredentialStorePickup(): void {
129
+ if (timer) clearInterval(timer);
130
+ timer = null;
131
+ known = null;
132
+ inflight = null;
133
+ repushHook = null;
134
+ setPickupNow(null);
135
+ }
@@ -45,6 +45,9 @@ export interface CredentialStore {
45
45
  delete(name: string): Promise<void>;
46
46
  /** Every credential under `path` (recursive), decrypted. Throws on failure. */
47
47
  list(path: string): Promise<StoredParameter[]>;
48
+ /** Names and versions under `path` (recursive), NO decryption — the cheap
49
+ * poll a Uai-operated host runs to notice a broker refresh. */
50
+ listVersions(path: string): Promise<Array<{ name: string; version: number }>>;
48
51
  }
49
52
 
50
53
  export interface CliResult {
@@ -235,8 +238,41 @@ export class AwsSsmCredentialStore implements CredentialStore {
235
238
  }
236
239
  return out;
237
240
  }
241
+
242
+ async listVersions(path: string): Promise<Array<{ name: string; version: number }>> {
243
+ if (!isParameterName(path)) throw new Error(`invalid parameter path: ${path}`);
244
+ const out: Array<{ name: string; version: number }> = [];
245
+ let nextToken: string | undefined;
246
+ for (let page = 0; page < 100; page++) {
247
+ const result = await this.run([
248
+ "ssm",
249
+ "get-parameters-by-path",
250
+ "--path",
251
+ path,
252
+ "--recursive",
253
+ "--query",
254
+ "{Parameters: Parameters[].{Name: Name, Version: Version}, NextToken: NextToken}",
255
+ ...(nextToken ? ["--next-token", nextToken] : []),
256
+ ]);
257
+ if (result.status !== 0) throw failure("get-parameters-by-path", result);
258
+ const parsed = JSON.parse(result.stdout) as {
259
+ Parameters?: Array<{ Name?: unknown; Version?: unknown }> | null;
260
+ NextToken?: unknown;
261
+ };
262
+ for (const p of parsed.Parameters ?? []) {
263
+ if (typeof p.Name === "string" && typeof p.Version === "number") {
264
+ out.push({ name: p.Name, version: p.Version });
265
+ }
266
+ }
267
+ if (typeof parsed.NextToken === "string" && parsed.NextToken) nextToken = parsed.NextToken;
268
+ else break;
269
+ }
270
+ return out;
271
+ }
238
272
  }
239
273
 
274
+ // (listVersions lives on the class below.)
275
+
240
276
  // ---------------------------------------------------------------------------
241
277
  // Write queue shared by every mirrored table (engine accounts, GitHub tokens,
242
278
  // SSH keys, MCP connections). Mirrors are async and best effort: a store
@@ -31,12 +31,15 @@ import {
31
31
  chmodSync,
32
32
  existsSync,
33
33
  mkdirSync,
34
+ mkdtempSync,
34
35
  readdirSync,
35
36
  readFileSync,
37
+ renameSync,
36
38
  rmSync,
39
+ statSync,
37
40
  writeFileSync,
38
41
  } from "node:fs";
39
- import { homedir } from "node:os";
42
+ import { homedir, tmpdir } from "node:os";
40
43
  import { join } from "node:path";
41
44
 
42
45
  import { eq } from "drizzle-orm";
@@ -51,6 +54,7 @@ import {
51
54
  type CredentialStore,
52
55
  } from "./credential-store";
53
56
  import { getDb, schema } from "./db";
57
+ import { brokeredFilesFor } from "./token-broker";
54
58
  import {
55
59
  taskContainerBackend,
56
60
  taskContainerCli,
@@ -550,11 +554,88 @@ export function resetRotationState(): void {
550
554
  // dirs at session start, since task-up.sh (bash) can't read the sealed DB.
551
555
  // ---------------------------------------------------------------------------
552
556
 
557
+ /**
558
+ * ADR-135: what a task receives for a config-dir account. The host dir is
559
+ * copied verbatim EXCEPT that a lane-owned `auth.json` gets its rotating
560
+ * refresh token replaced by the broker placeholder, and the broker's own
561
+ * sidecar never travels. Returns a private temp dir (0700) the caller copies
562
+ * from and must remove; null means "copy the host dir as it is".
563
+ */
564
+ export function stageBrokeredAccountDir(
565
+ kind: string,
566
+ hostDir: string,
567
+ ): { dir: string; cleanup: () => void } | null {
568
+ let entries: string[];
569
+ try {
570
+ entries = readdirSync(hostDir);
571
+ } catch {
572
+ return null;
573
+ }
574
+ const files: Record<string, string> = {};
575
+ const binaries = new Map<string, Buffer>();
576
+ for (const name of entries) {
577
+ if (name === BROKER_SIDECAR_FILE) continue;
578
+ const path = join(hostDir, name);
579
+ let content: Buffer;
580
+ try {
581
+ if (!statSync(path).isFile()) continue;
582
+ content = readFileSync(path);
583
+ } catch {
584
+ continue;
585
+ }
586
+ if (name === "auth.json") files[name] = content.toString("utf8");
587
+ else binaries.set(name, content);
588
+ }
589
+ if (!("auth.json" in files)) return null;
590
+ const brokered = brokeredFilesFor(
591
+ { kind, authKind: "config-dir", id: "staging", scope: HOST_ACCOUNT_SCOPE },
592
+ files,
593
+ );
594
+ if (brokered === files) return null;
595
+ const dir = mkdtempSync(join(tmpdir(), "uai-brokered-"));
596
+ try {
597
+ chmodSync(dir, 0o700);
598
+ for (const [name, content] of Object.entries(brokered)) {
599
+ writeFileSync(join(dir, name), content, { mode: 0o600 });
600
+ }
601
+ for (const [name, content] of binaries) {
602
+ writeFileSync(join(dir, name), content, { mode: 0o600 });
603
+ }
604
+ } catch (err) {
605
+ rmSync(dir, { recursive: true, force: true });
606
+ throw err;
607
+ }
608
+ return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
609
+ }
610
+
553
611
  async function copyDirInto(
554
612
  hostDir: string,
555
613
  containerDir: string,
556
614
  container: string,
557
615
  environment?: Pick<TaskEnvironmentHandle, "exec" | "copy">,
616
+ kind?: string,
617
+ ): Promise<void> {
618
+ // ADR-135: never hand a task the rotating refresh token.
619
+ const staged = kind ? stageBrokeredAccountDir(kind, hostDir) : null;
620
+ if (staged) {
621
+ try {
622
+ await copyDirIntoRaw(staged.dir, containerDir, container, environment);
623
+ } finally {
624
+ staged.cleanup();
625
+ }
626
+ return;
627
+ }
628
+ await copyDirIntoRaw(hostDir, containerDir, container, environment);
629
+ }
630
+
631
+ /** The broker's per-dir metadata (docs/token-broker.md); never copied. */
632
+ export const BROKER_SIDECAR_FILE = "uai-broker.json";
633
+
634
+ async function copyDirIntoRaw(
635
+ hostDir: string,
636
+ containerDir: string,
637
+ container: string,
638
+ environment?: Pick<TaskEnvironmentHandle, "exec" | "copy">,
558
639
  ): Promise<void> {
559
640
  if (environment) {
560
641
  const run = async (argv: [string, ...string[]], step: string) => {
@@ -682,6 +763,7 @@ export async function provisionEngineAccounts(
682
763
  account.configDir.containerDir,
683
764
  container,
684
765
  environment,
766
+ kind,
685
767
  );
686
768
  copied += 1;
687
769
  } catch (err) {
@@ -1135,6 +1217,10 @@ export function configureEngineAccountStore(opts: { hostId: string | null }): vo
1135
1217
  storeHostId = opts.hostId;
1136
1218
  }
1137
1219
 
1220
+ export function engineAccountHostId(): string | null {
1221
+ return storeHostId;
1222
+ }
1223
+
1138
1224
  /** Tests: wait for every queued mirror/delete to settle. */
1139
1225
  export async function settleEngineAccountStoreWrites(): Promise<void> {
1140
1226
  await settleStoreWrites();
@@ -1197,7 +1283,7 @@ function storedAccountFromRow(row: StoreRow): StoredEngineAccount {
1197
1283
  }
1198
1284
  const files: Record<string, string> = {};
1199
1285
  for (const entry of readdirSync(row.configDir, { withFileTypes: true })) {
1200
- if (!entry.isFile()) continue;
1286
+ if (!entry.isFile() || entry.name === BROKER_SIDECAR_FILE) continue;
1201
1287
  const content = readFileSync(join(row.configDir, entry.name));
1202
1288
  if (content.byteLength > MAX_STORED_FILE_BYTES) continue;
1203
1289
  files[entry.name] = content.toString("utf8");
@@ -1433,3 +1519,49 @@ export async function reconcileEngineAccountsFromStore(
1433
1519
  }
1434
1520
  return result;
1435
1521
  }
1522
+
1523
+ /**
1524
+ * ADR-135 pickup: a parameter changed in the store (the broker refreshed it,
1525
+ * or a sibling host re-mirrored it). Update the local row's material in
1526
+ * place — config-dir files rewritten atomically, env secret re-sealed — or
1527
+ * import the row when this host lacks it. Returns the kind touched, or null
1528
+ * when the value is not an engine account this host should hold.
1529
+ */
1530
+ export function applyStoredEngineAccount(
1531
+ name: string,
1532
+ value: string,
1533
+ seams: Partial<AccountSeams> = {},
1534
+ ): { kind: string; id: string; imported: boolean } | null {
1535
+ const at = parseEngineAccountParameterName(name);
1536
+ if (!at) return null;
1537
+ if (at.scope === HOST_ACCOUNT_SCOPE && at.hostId !== storeHostId) return null;
1538
+ const stored = parseStoredAccount(value);
1539
+ if (stored.id !== at.id || stored.kind !== at.kind || stored.scope !== at.scope) {
1540
+ throw new Error("payload does not describe the account its path names");
1541
+ }
1542
+ const row = selectRow(stored.id);
1543
+ if (!row) {
1544
+ importStoredAccount(stored, name, withDefaults(seams));
1545
+ return { kind: stored.kind, id: stored.id, imported: true };
1546
+ }
1547
+ if (row.authKind === "env" && stored.authKind === "env") {
1548
+ getDb()
1549
+ .update(schema.engineAccounts)
1550
+ .set({ secretEnc: packSecret(JSON.stringify(stored.env ?? {})), storeRef: name })
1551
+ .where(eq(schema.engineAccounts.id, row.id))
1552
+ .run();
1553
+ } else if (row.authKind === "config-dir" && stored.authKind === "config-dir" && row.configDir) {
1554
+ mkdirSync(row.configDir, { recursive: true, mode: 0o700 });
1555
+ for (const [basename, content] of Object.entries(stored.files ?? {})) {
1556
+ if (basename.includes("/") || basename.includes("\\") || basename === "..") continue;
1557
+ const file = join(row.configDir, basename);
1558
+ const tmp = `${file}.${process.pid}.tmp`;
1559
+ writeFileSync(tmp, content, { mode: 0o600 });
1560
+ renameSync(tmp, file);
1561
+ }
1562
+ if (row.storeRef !== name) setStoreRef(row.id, name);
1563
+ } else {
1564
+ throw new Error("stored auth kind does not match the local row");
1565
+ }
1566
+ return { kind: stored.kind, id: stored.id, imported: false };
1567
+ }
package/lib/env.ts CHANGED
@@ -54,6 +54,9 @@ const schema = z.object({
54
54
  // Shared with the ADR-121 machine provider (read raw there); the store
55
55
  // needs it too, so surface it here for one validated read.
56
56
  UAI_AWS_REGION: z.string().min(1).optional(),
57
+ // ADR-135: the broker Lambda a Uai-operated host invokes for a reactive
58
+ // refresh (name or ARN). Unset = scheduled refreshes only.
59
+ UAI_TOKEN_BROKER_FUNCTION: z.string().min(1).optional(),
57
60
  NODE_ENV: z
58
61
  .enum(["development", "production", "test"])
59
62
  .default("development"),
@@ -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;
@@ -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
+ }