@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.
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Local storage adapter + the owned-host refresh loop (ADR-135).
3
+ *
4
+ * On a host the operator owns, the credentials live on disk: the default
5
+ * slots (`~/.codex`, `~/.grok`) and every config-dir engine account
6
+ * (`~/.<kind>-acct-<id>`). This adapter lists them for the broker core,
7
+ * writes refreshed material back atomically, and keeps the broker's meta in
8
+ * a sidecar file (`uai-broker.json`) that never travels to a task and never
9
+ * mirrors to the store.
10
+ *
11
+ * The loop runs ONLY on owned hosts (`configuredCredentialStore() === null`).
12
+ * A Uai-operated host must not refresh — the broker Lambda does — it picks
13
+ * up refreshed parameters instead (credential-store-pickup.ts).
14
+ */
15
+
16
+ import {
17
+ existsSync,
18
+ mkdtempSync,
19
+ readdirSync,
20
+ readFileSync,
21
+ renameSync,
22
+ rmSync,
23
+ statSync,
24
+ writeFileSync,
25
+ } from "node:fs";
26
+ import { homedir, tmpdir } from "node:os";
27
+ import { join } from "node:path";
28
+ import { spawn } from "node:child_process";
29
+
30
+ import { configuredCredentialStore, credentialStorePrefix } from "./credential-store";
31
+ import { getDb, schema } from "./db";
32
+ import {
33
+ BROKER_SIDECAR_FILE,
34
+ engineAccountParameterName,
35
+ mirrorEngineAccount,
36
+ } from "./engine-accounts";
37
+ import { runBrokerCycle, type BrokerCredential, type BrokerStorage } from "./token-broker";
38
+ import { lanesForKind } from "./token-lanes/registry";
39
+ import { refreshMetaFrom, type CredentialFiles, type RefreshMeta } from "./token-lanes/types";
40
+ import { env } from "./env";
41
+
42
+ /** Hourly: Grok tokens live six hours (observed 2026-09-09); the lanes'
43
+ * leads decide what is actually refreshed, so most runs do nothing. */
44
+ const CYCLE_INTERVAL_MS = 60 * 60 * 1000;
45
+ /** Kinds whose default slot lives in a home dir the broker may rewrite. */
46
+ const DEFAULT_SLOTS: Record<string, (home: string) => string> = {
47
+ codex: (home) => join(home, ".codex"),
48
+ grok: (home) => join(home, ".grok"),
49
+ };
50
+
51
+ export interface LocalBrokerSeams {
52
+ ownerHome?: () => string;
53
+ /** Rows to consider (tests inject); defaults to every stored account. */
54
+ accounts?: () => Array<{ id: string; kind: string; scope: string; configDir: string | null; authKind: string }>;
55
+ now?: () => number;
56
+ }
57
+
58
+ function ownerHome(seams: LocalBrokerSeams): string {
59
+ return (seams.ownerHome ?? (() => process.env.UAI_OWNER_HOME?.trim() || homedir()))();
60
+ }
61
+
62
+ function readDirFiles(dir: string): CredentialFiles | null {
63
+ let names: string[];
64
+ try {
65
+ names = readdirSync(dir);
66
+ } catch {
67
+ return null;
68
+ }
69
+ const files: CredentialFiles = {};
70
+ for (const name of names) {
71
+ if (name === BROKER_SIDECAR_FILE || name !== "auth.json") continue;
72
+ try {
73
+ const path = join(dir, name);
74
+ if (!statSync(path).isFile()) continue;
75
+ files[name] = readFileSync(path, "utf8");
76
+ } catch {
77
+ /* unreadable file: the lane will not match */
78
+ }
79
+ }
80
+ return files;
81
+ }
82
+
83
+ function readSidecar(dir: string): RefreshMeta | null {
84
+ try {
85
+ return refreshMetaFrom(JSON.parse(readFileSync(join(dir, BROKER_SIDECAR_FILE), "utf8")));
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /** temp + rename in the same dir, 0600: a crash never leaves a torn file. */
92
+ export function writeFileAtomic(path: string, content: string): void {
93
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
94
+ writeFileSync(tmp, content, { mode: 0o600 });
95
+ renameSync(tmp, path);
96
+ }
97
+
98
+ /** A local credential name the reactive path and tests can address. */
99
+ export function localCredentialName(kind: string, accountId: string | null): string {
100
+ return accountId ? `account:${kind}:${accountId}` : `default:${kind}`;
101
+ }
102
+
103
+ export class LocalBrokerStorage implements BrokerStorage {
104
+ private readonly dirs = new Map<string, { dir: string; accountId: string | null; kind: string }>();
105
+
106
+ constructor(private readonly seams: LocalBrokerSeams = {}) {}
107
+
108
+ async list(): Promise<BrokerCredential[]> {
109
+ const out: BrokerCredential[] = [];
110
+ const home = ownerHome(this.seams);
111
+ for (const [kind, dirOf] of Object.entries(DEFAULT_SLOTS)) {
112
+ const dir = dirOf(home);
113
+ if (!existsSync(join(dir, "auth.json"))) continue;
114
+ const files = readDirFiles(dir);
115
+ if (!files) continue;
116
+ const name = localCredentialName(kind, null);
117
+ this.dirs.set(name, { dir, accountId: null, kind });
118
+ out.push({
119
+ name,
120
+ header: { kind, authKind: "config-dir", id: "default", scope: "host" },
121
+ files,
122
+ refresh: readSidecar(dir),
123
+ });
124
+ }
125
+ const rows =
126
+ this.seams.accounts?.() ??
127
+ getDb()
128
+ .select()
129
+ .from(schema.engineAccounts)
130
+ .all()
131
+ .map((r) => ({
132
+ id: r.id,
133
+ kind: r.kind,
134
+ scope: r.scope ?? "host",
135
+ configDir: r.configDir,
136
+ authKind: r.authKind,
137
+ }));
138
+ for (const row of rows) {
139
+ if (row.authKind !== "config-dir" || !row.configDir) continue;
140
+ const files = readDirFiles(row.configDir);
141
+ if (!files) continue;
142
+ const name = localCredentialName(row.kind, row.id);
143
+ this.dirs.set(name, { dir: row.configDir, accountId: row.id, kind: row.kind });
144
+ out.push({
145
+ name,
146
+ header: { kind: row.kind, authKind: "config-dir", id: row.id, scope: row.scope },
147
+ files,
148
+ refresh: readSidecar(row.configDir),
149
+ });
150
+ }
151
+ return out;
152
+ }
153
+
154
+ async save(credential: BrokerCredential, files: CredentialFiles | null, refresh: RefreshMeta): Promise<void> {
155
+ const target = this.dirs.get(credential.name);
156
+ if (!target) throw new Error(`unknown local credential ${credential.name}`);
157
+ if (files) {
158
+ for (const [name, content] of Object.entries(files)) {
159
+ if (name.includes("/") || name === BROKER_SIDECAR_FILE) continue;
160
+ writeFileAtomic(join(target.dir, name), content);
161
+ }
162
+ }
163
+ writeFileAtomic(join(target.dir, BROKER_SIDECAR_FILE), `${JSON.stringify(refresh, null, 2)}\n`);
164
+ // Mirrors are no-ops without a store; on a Uai-operated host this
165
+ // adapter does not run at all, so this only matters in tests.
166
+ if (target.accountId && files) mirrorEngineAccount(target.accountId);
167
+ }
168
+
169
+ kindsRefreshed(names: Iterable<string>): Set<string> {
170
+ const kinds = new Set<string>();
171
+ for (const name of names) {
172
+ const t = this.dirs.get(name);
173
+ if (t) kinds.add(t.kind);
174
+ }
175
+ return kinds;
176
+ }
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Loop + reactive entry points.
181
+ // ---------------------------------------------------------------------------
182
+
183
+ export interface TokenBrokerHooks {
184
+ /** Re-push brokered copies of `kind` into running tasks. */
185
+ repush: (kind: string) => Promise<void>;
186
+ log?: (line: string) => void;
187
+ }
188
+
189
+ let hooks: TokenBrokerHooks | null = null;
190
+ let timer: ReturnType<typeof setInterval> | null = null;
191
+ let running: Promise<void> | null = null;
192
+ const reactiveLastAt = new Map<string, number>();
193
+ const REACTIVE_MIN_INTERVAL_MS = 10 * 60_000;
194
+
195
+ export function configureTokenBroker(next: TokenBrokerHooks): void {
196
+ hooks = next;
197
+ }
198
+
199
+ /** Whether this host is the refresher for its own credentials. */
200
+ export function ownedHostRefreshes(): boolean {
201
+ return configuredCredentialStore() === null;
202
+ }
203
+
204
+ function log(line: string): void {
205
+ (hooks?.log ?? ((l: string) => console.log(`[token-broker] ${l}`)))(line);
206
+ }
207
+
208
+ /**
209
+ * One local cycle: refresh what is due (or forced) and re-push the kinds
210
+ * that changed into running tasks. Serialized — a reactive request during a
211
+ * scheduled run waits for it.
212
+ */
213
+ export async function runLocalBrokerCycle(opts: { force?: ReadonlySet<string>; seams?: LocalBrokerSeams } = {}): Promise<void> {
214
+ const work = async (): Promise<void> => {
215
+ const storage = new LocalBrokerStorage(opts.seams);
216
+ const refreshedNames = new Set<string>();
217
+ const summary = await runBrokerCycle({
218
+ storage,
219
+ force: opts.force,
220
+ now: opts.seams?.now,
221
+ log: (line) => {
222
+ if (line.startsWith("refreshed ")) refreshedNames.add(line.split(" ")[1]!);
223
+ log(line);
224
+ },
225
+ });
226
+ if (summary.checked > 0) {
227
+ log(`local cycle: ${summary.checked} checked, ${summary.due} due, ${summary.refreshed} refreshed, ${summary.failed} failed, ${summary.terminal} need sign-in`);
228
+ }
229
+ for (const kind of storage.kindsRefreshed(refreshedNames)) {
230
+ try {
231
+ await hooks?.repush(kind);
232
+ } catch (err) {
233
+ log(`re-push of ${kind} copies failed: ${err instanceof Error ? err.message : String(err)}`);
234
+ }
235
+ }
236
+ };
237
+ const previous = running ?? Promise.resolve();
238
+ const current = previous.then(work, work);
239
+ running = current;
240
+ try {
241
+ await current;
242
+ } finally {
243
+ if (running === current) running = null;
244
+ }
245
+ }
246
+
247
+ /** Owned hosts only: refresh now, then hourly. Idempotent. */
248
+ export function startTokenBrokerLoop(): void {
249
+ if (timer || !ownedHostRefreshes()) return;
250
+ void runLocalBrokerCycle().catch((err) => log(`cycle failed: ${err instanceof Error ? err.message : String(err)}`));
251
+ timer = setInterval(() => {
252
+ void runLocalBrokerCycle().catch((err) => log(`cycle failed: ${err instanceof Error ? err.message : String(err)}`));
253
+ }, CYCLE_INTERVAL_MS);
254
+ timer.unref?.();
255
+ }
256
+
257
+ export function stopTokenBrokerLoop(): void {
258
+ if (timer) clearInterval(timer);
259
+ timer = null;
260
+ }
261
+
262
+ /** Whether an engine's error output means its access token is dead now. */
263
+ export function isCredentialInvalidSignal(kind: string, message: string): boolean {
264
+ return lanesForKind(kind).some((lane) => lane.reactiveSignals.some((re) => re.test(message)));
265
+ }
266
+
267
+ /**
268
+ * Reactive path: a task reported a dead token for `kind` (account `accountId`,
269
+ * or the default slot). Owned host: one forced local refresh + re-push.
270
+ * Uai-operated host: invoke the broker Lambda for that parameter, then pick
271
+ * up the new version. Rate-limited per credential; returns whether a refresh
272
+ * was requested.
273
+ */
274
+ export async function requestCredentialRefresh(input: {
275
+ kind: string;
276
+ accountId: string | null;
277
+ scope: string;
278
+ }): Promise<boolean> {
279
+ const key = `${input.kind}:${input.accountId ?? "default"}`;
280
+ const now = Date.now();
281
+ const last = reactiveLastAt.get(key) ?? 0;
282
+ if (now - last < REACTIVE_MIN_INTERVAL_MS) return false;
283
+ reactiveLastAt.set(key, now);
284
+ if (ownedHostRefreshes()) {
285
+ await runLocalBrokerCycle({ force: new Set([localCredentialName(input.kind, input.accountId)]) });
286
+ return true;
287
+ }
288
+ if (!input.accountId) return false;
289
+ const name = engineAccountParameterName({ id: input.accountId, kind: input.kind, scope: input.scope });
290
+ if (!name) return false;
291
+ const invoked = await invokeBrokerLambda([name]);
292
+ if (invoked) {
293
+ try {
294
+ await pickupNow?.();
295
+ } catch (err) {
296
+ log(`pickup after reactive refresh failed: ${err instanceof Error ? err.message : String(err)}`);
297
+ }
298
+ }
299
+ return invoked;
300
+ }
301
+
302
+ let pickupNow: (() => Promise<void>) | null = null;
303
+ /** credential-store-pickup.ts registers its immediate poll here. */
304
+ export function setPickupNow(fn: (() => Promise<void>) | null): void {
305
+ pickupNow = fn;
306
+ }
307
+
308
+ /** `aws lambda invoke` on the broker function named by UAI_TOKEN_BROKER_FUNCTION. */
309
+ export async function invokeBrokerLambda(names: string[]): Promise<boolean> {
310
+ const fn = env.UAI_TOKEN_BROKER_FUNCTION;
311
+ const region = env.UAI_AWS_REGION;
312
+ if (!fn || !region) {
313
+ log("reactive refresh requested but UAI_TOKEN_BROKER_FUNCTION/UAI_AWS_REGION are not set");
314
+ return false;
315
+ }
316
+ const prefix = credentialStorePrefix();
317
+ const safe = names.filter((n) => n.startsWith(`${prefix}/`));
318
+ if (safe.length === 0) return false;
319
+ const outDir = mkdtempSync(join(tmpdir(), "uai-broker-invoke-"));
320
+ const outFile = join(outDir, "out.json");
321
+ try {
322
+ const result = await new Promise<{ status: number | null; stderr: string }>((resolve) => {
323
+ const child = spawn(
324
+ "aws",
325
+ [
326
+ "--region",
327
+ region,
328
+ "--output",
329
+ "json",
330
+ "lambda",
331
+ "invoke",
332
+ "--function-name",
333
+ fn,
334
+ "--cli-binary-format",
335
+ "raw-in-base64-out",
336
+ "--payload",
337
+ JSON.stringify({ names: safe }),
338
+ outFile,
339
+ ],
340
+ { stdio: ["ignore", "ignore", "pipe"] },
341
+ );
342
+ let stderr = "";
343
+ const timer = setTimeout(() => child.kill("SIGKILL"), 150_000);
344
+ timer.unref?.();
345
+ child.stderr?.setEncoding("utf8");
346
+ child.stderr?.on("data", (c: string) => (stderr += c));
347
+ child.once("error", () => resolve({ status: null, stderr }));
348
+ child.once("close", (code) => {
349
+ clearTimeout(timer);
350
+ resolve({ status: code, stderr });
351
+ });
352
+ });
353
+ if (result.status !== 0) {
354
+ log(`lambda invoke failed (exit ${result.status ?? "none"}): ${result.stderr.split("\n")[0] ?? ""}`);
355
+ return false;
356
+ }
357
+ let summary = "";
358
+ try {
359
+ summary = readFileSync(outFile, "utf8").trim().slice(0, 300);
360
+ } catch {
361
+ /* no body */
362
+ }
363
+ log(`lambda refreshed on request: ${summary}`);
364
+ return true;
365
+ } finally {
366
+ rmSync(outDir, { recursive: true, force: true });
367
+ }
368
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Parameter Store adapter for the broker core (ADR-135) — the storage the
3
+ * broker Lambda runs against. Reads the ADR-134 engine-account parameters
4
+ * under `<prefix>/orgs` and `<prefix>/hosts`, hands each to the core as a
5
+ * credential, and writes refreshed material + the `refresh` block back with
6
+ * `Overwrite`. Only the Lambda imports this module: it depends on the AWS
7
+ * SDK the Lambda runtime provides, which the host package does not ship.
8
+ */
9
+
10
+ import {
11
+ DeleteParameterCommand,
12
+ GetParameterCommand,
13
+ GetParametersByPathCommand,
14
+ PutParameterCommand,
15
+ SSMClient,
16
+ } from "@aws-sdk/client-ssm";
17
+
18
+ import type { BrokerCredential, BrokerStorage } from "./token-broker";
19
+ import { refreshMetaFrom, type CredentialFiles, type RefreshMeta } from "./token-lanes/types";
20
+
21
+ export interface SsmBrokerStorageOptions {
22
+ prefix: string;
23
+ kmsKeyId: string;
24
+ client?: SSMClient;
25
+ region?: string;
26
+ }
27
+
28
+ interface StoredValue {
29
+ raw: Record<string, unknown>;
30
+ version: number | undefined;
31
+ }
32
+
33
+ /** `/uai/orgs/<o>/engines/<kind>/<id>` and `/uai/hosts/<h>/engines/<kind>/<id>`. */
34
+ export function engineParameterHeader(
35
+ name: string,
36
+ prefix: string,
37
+ ): { kind: string; id: string; scope: string } | null {
38
+ if (!name.startsWith(`${prefix}/`)) return null;
39
+ const parts = name.slice(prefix.length + 1).split("/");
40
+ if (parts.length !== 5 || parts[2] !== "engines") return null;
41
+ const [owner, ownerId, , kind, id] = parts as [string, string, string, string, string];
42
+ if (owner === "orgs") return { kind, id, scope: `org:${ownerId}` };
43
+ if (owner === "hosts") return { kind, id, scope: "host" };
44
+ return null;
45
+ }
46
+
47
+ export class SsmBrokerStorage implements BrokerStorage {
48
+ private readonly client: SSMClient;
49
+ private readonly values = new Map<string, StoredValue>();
50
+
51
+ constructor(private readonly opts: SsmBrokerStorageOptions) {
52
+ this.client = opts.client ?? new SSMClient(opts.region ? { region: opts.region } : {});
53
+ }
54
+
55
+ async list(): Promise<BrokerCredential[]> {
56
+ const out: BrokerCredential[] = [];
57
+ for (const path of [`${this.opts.prefix}/orgs`, `${this.opts.prefix}/hosts`]) {
58
+ let nextToken: string | undefined;
59
+ do {
60
+ const page = await this.client.send(
61
+ new GetParametersByPathCommand({
62
+ Path: path,
63
+ Recursive: true,
64
+ WithDecryption: true,
65
+ NextToken: nextToken,
66
+ }),
67
+ );
68
+ for (const p of page.Parameters ?? []) {
69
+ if (!p.Name || typeof p.Value !== "string") continue;
70
+ const header = engineParameterHeader(p.Name, this.opts.prefix);
71
+ if (!header) continue;
72
+ let raw: unknown;
73
+ try {
74
+ raw = JSON.parse(p.Value);
75
+ } catch {
76
+ continue;
77
+ }
78
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
79
+ const value = raw as Record<string, unknown>;
80
+ if (value.v !== 1 || value.authKind !== "config-dir") continue;
81
+ const files = value.files;
82
+ if (!files || typeof files !== "object" || Array.isArray(files)) continue;
83
+ const materials: CredentialFiles = {};
84
+ for (const [k, v] of Object.entries(files as Record<string, unknown>)) {
85
+ if (typeof v === "string") materials[k] = v;
86
+ }
87
+ this.values.set(p.Name, { raw: value, version: p.Version });
88
+ out.push({
89
+ name: p.Name,
90
+ header: { kind: header.kind, authKind: "config-dir", id: header.id, scope: header.scope },
91
+ files: materials,
92
+ refresh: refreshMetaFrom(value.refresh),
93
+ });
94
+ }
95
+ nextToken = page.NextToken;
96
+ } while (nextToken);
97
+ }
98
+ return out;
99
+ }
100
+
101
+ async save(
102
+ credential: BrokerCredential,
103
+ files: CredentialFiles | null,
104
+ refresh: RefreshMeta,
105
+ ): Promise<void> {
106
+ const stored = this.values.get(credential.name);
107
+ const raw = stored?.raw ?? { v: 1 };
108
+ const next = { ...raw, refresh, ...(files ? { files } : {}) };
109
+ await this.client.send(
110
+ new PutParameterCommand({
111
+ Name: credential.name,
112
+ Value: JSON.stringify(next),
113
+ Type: "SecureString",
114
+ KeyId: this.opts.kmsKeyId,
115
+ Tier: "Advanced",
116
+ Overwrite: true,
117
+ }),
118
+ );
119
+ this.values.set(credential.name, { raw: next, version: undefined });
120
+ }
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Mutual exclusion between runs. Reserved concurrency 1 is the first line
125
+ // (deploy-aws.sh sets it when the account's limit allows); this lock is the
126
+ // one that always holds: a `PutParameter` WITHOUT `Overwrite` is atomic, so
127
+ // two runs can never both create `<prefix>/locks/token-broker`. A holder
128
+ // that died keeps the lock at most LOCK_STALE_MS; a later run takes it over.
129
+ // ---------------------------------------------------------------------------
130
+
131
+ export const LOCK_STALE_MS = 5 * 60_000;
132
+
133
+ export function brokerLockName(prefix: string): string {
134
+ return `${prefix}/locks/token-broker`;
135
+ }
136
+
137
+ export interface BrokerLockOptions {
138
+ client: SSMClient;
139
+ prefix: string;
140
+ now?: () => number;
141
+ staleMs?: number;
142
+ }
143
+
144
+ /** Try to take the lock. Returns a release function, or null when another
145
+ * run holds a fresh one. */
146
+ export async function acquireBrokerLock(opts: BrokerLockOptions): Promise<(() => Promise<void>) | null> {
147
+ const name = brokerLockName(opts.prefix);
148
+ const now = opts.now ?? Date.now;
149
+ const staleMs = opts.staleMs ?? LOCK_STALE_MS;
150
+ const token = `${now()}:${Math.random().toString(36).slice(2)}`;
151
+ const tryCreate = async (overwrite: boolean): Promise<boolean> => {
152
+ try {
153
+ await opts.client.send(
154
+ new PutParameterCommand({ Name: name, Value: token, Type: "String", Overwrite: overwrite }),
155
+ );
156
+ return true;
157
+ } catch (err) {
158
+ if (!overwrite && (err as { name?: string }).name === "ParameterAlreadyExists") return false;
159
+ throw err;
160
+ }
161
+ };
162
+ if (!(await tryCreate(false))) {
163
+ // Someone holds it. Stale?
164
+ let heldAt = 0;
165
+ try {
166
+ const current = await opts.client.send(new GetParameterCommand({ Name: name }));
167
+ heldAt = Number(current.Parameter?.Value?.split(":")[0] ?? 0) || 0;
168
+ } catch {
169
+ heldAt = 0;
170
+ }
171
+ if (heldAt && now() - heldAt < staleMs) return null;
172
+ await tryCreate(true);
173
+ }
174
+ return async () => {
175
+ try {
176
+ // Only release our own token: a takeover after a stall must not be
177
+ // undone by the stalled run finishing late.
178
+ const current = await opts.client.send(new GetParameterCommand({ Name: name }));
179
+ if (current.Parameter?.Value !== token) return;
180
+ await opts.client.send(new DeleteParameterCommand({ Name: name }));
181
+ } catch {
182
+ /* the stale window covers a failed release */
183
+ }
184
+ };
185
+ }