@runuai/host 0.9.113 → 0.9.115

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,3 @@
1
+ -- ADR-134 slice 3 (host 0.9.115): project env values mirror to the credential
2
+ -- store on Uai-operated hosts. Null on owned hosts and before the first mirror.
3
+ ALTER TABLE `host_project_env` ADD `store_ref` text;
@@ -148,6 +148,13 @@
148
148
  "when": 1788900000002,
149
149
  "tag": "0020_user_credential_store_ref",
150
150
  "breakpoints": true
151
+ },
152
+ {
153
+ "idx": 21,
154
+ "version": "6",
155
+ "when": 1788900000003,
156
+ "tag": "0021_project_env_store_ref",
157
+ "breakpoints": true
151
158
  }
152
159
  ]
153
160
  }
package/db/schema.ts CHANGED
@@ -183,6 +183,8 @@ export const hostProjectEnv = sqliteTable(
183
183
  updatedAt: integer("updated_at", { mode: "number" })
184
184
  .notNull()
185
185
  .default(sql`(unixepoch() * 1000)`),
186
+ // ADR-134 slice 3: the credential-store parameter this row mirrors to.
187
+ storeRef: text("store_ref"),
186
188
  },
187
189
  (t) => ({
188
190
  pk: primaryKey({ columns: [t.projectId, t.key] }),
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"),
package/lib/host-env.ts CHANGED
@@ -16,6 +16,12 @@
16
16
 
17
17
  import { and, eq } from "drizzle-orm";
18
18
 
19
+ import {
20
+ credentialStorePrefix,
21
+ isParameterPathSegment,
22
+ parameterName,
23
+ queueStoreWrite,
24
+ } from "./credential-store";
19
25
  import { getDb, schema } from "./db";
20
26
  import { sealAesGcm, openAesGcm } from "./secrets";
21
27
 
@@ -48,11 +54,12 @@ export function setProjectEnvVar(
48
54
  set: { valueEnc: packValue(value), updatedAt: Date.now() },
49
55
  })
50
56
  .run();
57
+ mirrorProjectEnvToStore(projectId, key);
51
58
  }
52
59
 
53
60
  /** Remove a project env var (idempotent). */
54
61
  export function deleteProjectEnvVar(projectId: string, key: string): void {
55
- getDb()
62
+ const result = getDb()
56
63
  .delete(schema.hostProjectEnv)
57
64
  .where(
58
65
  and(
@@ -61,6 +68,104 @@ export function deleteProjectEnvVar(projectId: string, key: string): void {
61
68
  ),
62
69
  )
63
70
  .run();
71
+ if (result.changes > 0) {
72
+ const name = projectEnvParameterName(projectId, key);
73
+ if (name) queueStoreWrite(`delete project env ${projectId}/${key}`, (store) => store.delete(name));
74
+ }
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Credential store mirror (ADR-134 slice 3): `<prefix>/projects/<projectId>/env/<KEY>`.
79
+ // The set/delete frames carry no org, and a project id is org-bound in the
80
+ // cloud, so the project is the owner segment. Owned hosts: no-ops.
81
+ // ---------------------------------------------------------------------------
82
+
83
+ export interface StoredProjectEnv {
84
+ v: 1;
85
+ kind: "project-env";
86
+ projectId: string;
87
+ key: string;
88
+ value: string;
89
+ updatedAt: number;
90
+ }
91
+
92
+ /** Null when the project id or key cannot be a parameter path segment (a
93
+ * key with a character SSM refuses is kept local and logged once). */
94
+ export function projectEnvParameterName(projectId: string, key: string): string | null {
95
+ if (!isParameterPathSegment(projectId) || !isParameterPathSegment(key)) return null;
96
+ return parameterName(credentialStorePrefix(), "projects", projectId, "env", key);
97
+ }
98
+
99
+ /** The row as it is mirrored (value decrypted). Never logged. */
100
+ export function projectEnvStoreValue(projectId: string, key: string): StoredProjectEnv | null {
101
+ const row = getDb()
102
+ .select()
103
+ .from(schema.hostProjectEnv)
104
+ .where(and(eq(schema.hostProjectEnv.projectId, projectId), eq(schema.hostProjectEnv.key, key)))
105
+ .get();
106
+ if (!row) return null;
107
+ return {
108
+ v: 1,
109
+ kind: "project-env",
110
+ projectId,
111
+ key,
112
+ value: unpackValue(row.valueEnc),
113
+ updatedAt: row.updatedAt,
114
+ };
115
+ }
116
+
117
+ export function setProjectEnvStoreRef(projectId: string, key: string, storeRef: string): void {
118
+ getDb()
119
+ .update(schema.hostProjectEnv)
120
+ .set({ storeRef })
121
+ .where(and(eq(schema.hostProjectEnv.projectId, projectId), eq(schema.hostProjectEnv.key, key)))
122
+ .run();
123
+ }
124
+
125
+ export function listProjectEnvStoreRows(): Array<{ projectId: string; key: string; storeRef: string | null }> {
126
+ return getDb()
127
+ .select({
128
+ projectId: schema.hostProjectEnv.projectId,
129
+ key: schema.hostProjectEnv.key,
130
+ storeRef: schema.hostProjectEnv.storeRef,
131
+ })
132
+ .from(schema.hostProjectEnv)
133
+ .all();
134
+ }
135
+
136
+ export function mirrorProjectEnvToStore(projectId: string, key: string): void {
137
+ const name = projectEnvParameterName(projectId, key);
138
+ if (!name) return;
139
+ queueStoreWrite(`mirror project env ${projectId}/${key}`, async (store) => {
140
+ const value = projectEnvStoreValue(projectId, key);
141
+ if (!value) return;
142
+ await store.put(name, JSON.stringify(value));
143
+ setProjectEnvStoreRef(projectId, key, name);
144
+ });
145
+ }
146
+
147
+ /** Reconcile import: a value this host lacks. Existing rows win. */
148
+ export function importProjectEnvFromStore(value: StoredProjectEnv, storeRef: string): boolean {
149
+ const result = getDb()
150
+ .insert(schema.hostProjectEnv)
151
+ .values({
152
+ projectId: value.projectId,
153
+ key: value.key,
154
+ valueEnc: packValue(value.value),
155
+ updatedAt: value.updatedAt,
156
+ storeRef,
157
+ })
158
+ .onConflictDoNothing({ target: [schema.hostProjectEnv.projectId, schema.hostProjectEnv.key] })
159
+ .run();
160
+ return result.changes > 0;
161
+ }
162
+
163
+ /** Reconcile: the row's parameter vanished elsewhere; drop the row only. */
164
+ export function deleteProjectEnvLocal(projectId: string, key: string): void {
165
+ getDb()
166
+ .delete(schema.hostProjectEnv)
167
+ .where(and(eq(schema.hostProjectEnv.projectId, projectId), eq(schema.hostProjectEnv.key, key)))
168
+ .run();
64
169
  }
65
170
 
66
171
  /** Sorted list of a project's env var KEY NAMES (never the values). */