@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.
@@ -0,0 +1,6 @@
1
+ -- ADR-134 slice 2 (host 0.9.113): per-user credentials mirror to the
2
+ -- credential store on Uai-operated hosts. Null on owned hosts and before the
3
+ -- first successful mirror.
4
+ ALTER TABLE `host_github_tokens` ADD `store_ref` text;--> statement-breakpoint
5
+ ALTER TABLE `host_ssh_keys` ADD `store_ref` text;--> statement-breakpoint
6
+ ALTER TABLE `host_mcp_connections` ADD `store_ref` text;
@@ -141,6 +141,13 @@
141
141
  "when": 1788900000001,
142
142
  "tag": "0019_engine_account_store_ref",
143
143
  "breakpoints": true
144
+ },
145
+ {
146
+ "idx": 20,
147
+ "version": "6",
148
+ "when": 1788900000002,
149
+ "tag": "0020_user_credential_store_ref",
150
+ "breakpoints": true
144
151
  }
145
152
  ]
146
153
  }
package/db/schema.ts CHANGED
@@ -129,6 +129,8 @@ export const githubTokens = sqliteTable("host_github_tokens", {
129
129
  // "access" (non-expiring, ADR-033) | "refresh" (expiring, ADR-027).
130
130
  kind: text("kind").notNull().default("refresh"),
131
131
  updatedAt: integer("updated_at").notNull(),
132
+ // ADR-134 slice 2: the credential-store parameter this row mirrors to.
133
+ storeRef: text("store_ref"),
132
134
  });
133
135
 
134
136
  export type GithubToken = typeof githubTokens.$inferSelect;
@@ -158,6 +160,8 @@ export const sshKeys = sqliteTable("host_ssh_keys", {
158
160
  privateKeyNonce: blob("private_key_nonce").notNull(),
159
161
  createdAt: integer("created_at").notNull(),
160
162
  updatedAt: integer("updated_at").notNull(),
163
+ // ADR-134 slice 2: the credential-store parameter this row mirrors to.
164
+ storeRef: text("store_ref"),
161
165
  });
162
166
 
163
167
  export type SshKey = typeof sshKeys.$inferSelect;
@@ -253,6 +257,8 @@ export const mcpConnections = sqliteTable("host_mcp_connections", {
253
257
  status: text("status").notNull().default("pending"), // pending|connected
254
258
  scopes: text("scopes"),
255
259
  updatedAt: integer("updated_at", { mode: "number" }).notNull(),
260
+ // ADR-134 slice 2: the credential-store parameter this row mirrors to.
261
+ storeRef: text("store_ref"),
256
262
  });
257
263
 
258
264
  export type McpConnection = typeof mcpConnections.$inferSelect;
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,6 +238,80 @@ 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
+ }
272
+ }
273
+
274
+ // (listVersions lives on the class below.)
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Write queue shared by every mirrored table (engine accounts, GitHub tokens,
278
+ // SSH keys, MCP connections). Mirrors are async and best effort: a store
279
+ // outage delays a mirror, never a task-up or a UI action.
280
+ // ---------------------------------------------------------------------------
281
+
282
+ const inflight = new Set<Promise<void>>();
283
+
284
+ /** Run `work` against the configured store off the caller's path; a no-op on
285
+ * owned hosts. Failures are logged (never the value) and never thrown. */
286
+ export function queueStoreWrite(
287
+ what: string,
288
+ work: (store: CredentialStore) => Promise<void>,
289
+ ): void {
290
+ const store = configuredCredentialStore();
291
+ if (!store) return;
292
+ const p: Promise<void> = work(store)
293
+ .catch((err: unknown) => {
294
+ console.warn(
295
+ `[credential-store] ${what} failed: ${
296
+ err instanceof Error ? err.message : String(err)
297
+ }`,
298
+ );
299
+ })
300
+ .finally(() => {
301
+ inflight.delete(p);
302
+ });
303
+ inflight.add(p);
304
+ }
305
+
306
+ /** Tests / shutdown: wait for every queued mirror or delete to settle. */
307
+ export async function settleStoreWrites(): Promise<void> {
308
+ while (inflight.size > 0) await Promise.allSettled([...inflight]);
309
+ }
310
+
311
+ /** `<prefix>/users/<userId>/<…>` — the per-user subtree (GitHub token, SSH
312
+ * key, MCP connections). The STS `user` session tag scopes it later. */
313
+ export function userParameterName(userId: string, ...segments: string[]): string {
314
+ return parameterName(credentialStorePrefix(), "users", userId, ...segments);
238
315
  }
239
316
 
240
317
  // ---------------------------------------------------------------------------
@@ -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";
@@ -46,9 +49,12 @@ import {
46
49
  credentialStorePrefix,
47
50
  isParameterPathSegment,
48
51
  parameterName,
52
+ queueStoreWrite,
53
+ settleStoreWrites,
49
54
  type CredentialStore,
50
55
  } from "./credential-store";
51
56
  import { getDb, schema } from "./db";
57
+ import { brokeredFilesFor } from "./token-broker";
52
58
  import {
53
59
  taskContainerBackend,
54
60
  taskContainerCli,
@@ -548,11 +554,88 @@ export function resetRotationState(): void {
548
554
  // dirs at session start, since task-up.sh (bash) can't read the sealed DB.
549
555
  // ---------------------------------------------------------------------------
550
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
+
551
611
  async function copyDirInto(
552
612
  hostDir: string,
553
613
  containerDir: string,
554
614
  container: string,
555
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">,
556
639
  ): Promise<void> {
557
640
  if (environment) {
558
641
  const run = async (argv: [string, ...string[]], step: string) => {
@@ -680,6 +763,7 @@ export async function provisionEngineAccounts(
680
763
  account.configDir.containerDir,
681
764
  container,
682
765
  environment,
766
+ kind,
683
767
  );
684
768
  copied += 1;
685
769
  } catch (err) {
@@ -1127,16 +1211,19 @@ type StoreRow = {
1127
1211
 
1128
1212
  let storeHostId: string | null = null;
1129
1213
  let warnedNoHostId = false;
1130
- const inflight = new Set<Promise<void>>();
1131
1214
 
1132
1215
  /** main.ts: the host id that names `host`-scope parameters. */
1133
1216
  export function configureEngineAccountStore(opts: { hostId: string | null }): void {
1134
1217
  storeHostId = opts.hostId;
1135
1218
  }
1136
1219
 
1220
+ export function engineAccountHostId(): string | null {
1221
+ return storeHostId;
1222
+ }
1223
+
1137
1224
  /** Tests: wait for every queued mirror/delete to settle. */
1138
1225
  export async function settleEngineAccountStoreWrites(): Promise<void> {
1139
- while (inflight.size > 0) await Promise.allSettled([...inflight]);
1226
+ await settleStoreWrites();
1140
1227
  }
1141
1228
 
1142
1229
  /**
@@ -1196,7 +1283,7 @@ function storedAccountFromRow(row: StoreRow): StoredEngineAccount {
1196
1283
  }
1197
1284
  const files: Record<string, string> = {};
1198
1285
  for (const entry of readdirSync(row.configDir, { withFileTypes: true })) {
1199
- if (!entry.isFile()) continue;
1286
+ if (!entry.isFile() || entry.name === BROKER_SIDECAR_FILE) continue;
1200
1287
  const content = readFileSync(join(row.configDir, entry.name));
1201
1288
  if (content.byteLength > MAX_STORED_FILE_BYTES) continue;
1202
1289
  files[entry.name] = content.toString("utf8");
@@ -1240,22 +1327,6 @@ function parseStoredAccount(value: string): StoredEngineAccount {
1240
1327
  };
1241
1328
  }
1242
1329
 
1243
- function queueStoreWrite(what: string, work: (store: CredentialStore) => Promise<void>): void {
1244
- const store = configuredCredentialStore();
1245
- if (!store) return;
1246
- const p: Promise<void> = work(store)
1247
- .catch((err: unknown) => {
1248
- console.warn(
1249
- `[engine-accounts] credential store ${what} failed: ${
1250
- err instanceof Error ? err.message : String(err)
1251
- }`,
1252
- );
1253
- })
1254
- .finally(() => {
1255
- inflight.delete(p);
1256
- });
1257
- inflight.add(p);
1258
- }
1259
1330
 
1260
1331
  function selectRow(id: string): StoreRow | undefined {
1261
1332
  return getDb()
@@ -1448,3 +1519,49 @@ export async function reconcileEngineAccountsFromStore(
1448
1519
  }
1449
1520
  return result;
1450
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"),