@runuai/host 0.9.148 → 0.9.149

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.
Files changed (93) hide show
  1. package/db/migrations/0024_task_env_mode.sql +9 -0
  2. package/db/migrations/0025_project_env_authority.sql +147 -0
  3. package/db/migrations/0026_project_env_replica.sql +150 -0
  4. package/db/migrations/meta/_journal.json +21 -0
  5. package/db/schema.ts +312 -2
  6. package/images/standard/Dockerfile +44 -6
  7. package/images/standard/container/uai-init +85 -2
  8. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  9. package/lib/agent-cli.ts +18 -1
  10. package/lib/agent.ts +99 -34
  11. package/lib/agents/codex.ts +9 -1
  12. package/lib/agents/cursor.ts +15 -1
  13. package/lib/agents/grok.ts +8 -0
  14. package/lib/agents/kimi.ts +1 -0
  15. package/lib/agents/machine-durable.ts +2 -0
  16. package/lib/agents/opencode.ts +1 -0
  17. package/lib/agents/proc.ts +6 -1
  18. package/lib/agents/transport.ts +9 -5
  19. package/lib/browser-testing.ts +6 -0
  20. package/lib/codex-auth.ts +2 -0
  21. package/lib/credential-store.ts +141 -6
  22. package/lib/engine-accounts.ts +1 -0
  23. package/lib/env-file.ts +7 -0
  24. package/lib/env-schema.ts +841 -51
  25. package/lib/git-diff.ts +1 -0
  26. package/lib/git-identity.ts +2 -0
  27. package/lib/github-tokens.ts +1 -0
  28. package/lib/host-env.ts +98 -18
  29. package/lib/lease.ts +11 -3
  30. package/lib/machine-gateway-tunnel.ts +195 -17
  31. package/lib/machine-project-env-loader.ts +152 -0
  32. package/lib/managed-runtime.ts +270 -51
  33. package/lib/mcp-gateway.ts +777 -26
  34. package/lib/orchestrator.ts +391 -69
  35. package/lib/project-env-authority.ts +1319 -0
  36. package/lib/project-env-feature.ts +74 -0
  37. package/lib/project-env-loader.ts +237 -0
  38. package/lib/project-env-replica.ts +2094 -0
  39. package/lib/project-env-schema-capture.ts +342 -0
  40. package/lib/project-env-staging.ts +148 -0
  41. package/lib/project-env-task-policy.ts +176 -0
  42. package/lib/runtime-archive.ts +79 -16
  43. package/lib/runtime-authority.ts +44 -0
  44. package/lib/runtime-state.ts +175 -10
  45. package/lib/secrets-proxy/address.ts +72 -0
  46. package/lib/secrets-proxy/audit.ts +30 -0
  47. package/lib/secrets-proxy/ca-store.ts +200 -0
  48. package/lib/secrets-proxy/ca.ts +470 -0
  49. package/lib/secrets-proxy/container-steps.ts +138 -0
  50. package/lib/secrets-proxy/der.ts +273 -0
  51. package/lib/secrets-proxy/engine.ts +1633 -0
  52. package/lib/secrets-proxy/host.ts +93 -0
  53. package/lib/secrets-proxy/hostname.ts +60 -0
  54. package/lib/secrets-proxy/placeholders.ts +196 -0
  55. package/lib/secrets-proxy/policy.ts +141 -0
  56. package/lib/secrets-proxy/readiness.ts +148 -0
  57. package/lib/secrets-proxy/runtime.ts +582 -0
  58. package/lib/secrets-proxy/scrub.ts +559 -0
  59. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  60. package/lib/secrets-proxy/task-scripts.ts +262 -0
  61. package/lib/secrets-proxy/task-trust.ts +574 -0
  62. package/lib/secrets-proxy/trust-watch.ts +80 -0
  63. package/lib/shared-files-s3.ts +3 -0
  64. package/lib/sidecar-guard.ts +58 -0
  65. package/lib/sidecar-resume.ts +51 -3
  66. package/lib/standard-image.ts +50 -12
  67. package/lib/task-env-forwarder.ts +228 -0
  68. package/lib/task-env-source.ts +193 -0
  69. package/lib/task-environment/apple-container.ts +45 -11
  70. package/lib/task-environment/docker.ts +51 -12
  71. package/lib/task-environment/index.ts +6 -2
  72. package/lib/task-environment/machine-task-up.ts +258 -25
  73. package/lib/task-environment/machine.ts +80 -22
  74. package/lib/task-environment/registry.ts +3 -2
  75. package/lib/task-environment/self-identity.ts +20 -0
  76. package/lib/task-environment/self.ts +92 -16
  77. package/lib/task-environment/types.ts +63 -0
  78. package/lib/task-gateway-port.ts +22 -0
  79. package/lib/task-gateway-protocol.ts +190 -0
  80. package/lib/task-gateway-recovery.ts +44 -0
  81. package/lib/task-gateway-state.ts +497 -0
  82. package/lib/transcript.ts +1 -0
  83. package/package.json +13 -1
  84. package/scripts/agent/env-hook.sh +626 -0
  85. package/scripts/agent/task-up.sh +476 -27
  86. package/scripts/agent/uai-env-exec.sh +26 -0
  87. package/src/cli.ts +26 -3
  88. package/src/index.ts +273 -19
  89. package/src/main.ts +939 -48
  90. package/src/project-env-latest.ts +130 -0
  91. package/src/project-env-policy.ts +281 -0
  92. package/src/project-env-wording.ts +47 -0
  93. package/src/protocol.ts +2219 -15
@@ -0,0 +1,582 @@
1
+ /**
2
+ * The secrets proxy as the runner runs it (ADR-153 §3): the persistent CA,
3
+ * the leaf cache, per-task placeholders, the engine and its audit log,
4
+ * composed once per runner process.
5
+ *
6
+ * What a task may use comes from outside, through TaskProxyPolicySource:
7
+ * the declarations work resolves each task's effective policy (binding ∩
8
+ * schema observation) and the current real values. This module only turns
9
+ * that into what the engine consults per request, so a policy change (a key
10
+ * withheld, a domain dropped) applies to the next request with nothing to
11
+ * invalidate here.
12
+ */
13
+
14
+ import { AsyncLocalStorage } from "node:async_hooks";
15
+ import { promises as dns } from "node:dns";
16
+ import { readFileSync } from "node:fs";
17
+ import { BlockList, isIP } from "node:net";
18
+ import { networkInterfaces } from "node:os";
19
+ import { join } from "node:path";
20
+
21
+ import { createAuditWriter } from "./audit";
22
+ import { createLeafCertificateCache, type LeafCertificateCache } from "./ca";
23
+ import { loadOrCreateCertificateAuthorities, type CertificateAuthorities, type SealedText } from "./ca-store";
24
+ import { createSecretsProxy, type ProxyKeyRef, type ProxyTaskState, type SecretsProxy } from "./engine";
25
+ import { isProxiableDomain } from "./hostname";
26
+ import type { TaskEnvHead } from "../project-env-task-policy";
27
+ import { createPlaceholderStore, type PlaceholderShape } from "./placeholders";
28
+
29
+ const HOUR_MS = 60 * 60 * 1_000;
30
+ /** How often the runner reviews its CA dates and retries a failed bundle
31
+ * delivery, so an idle runner mints the successor (and gets it to running
32
+ * tasks) when its window opens, not at the next handshake or task start,
33
+ * which it may not see for months. */
34
+ const TRUST_REVIEW_EVERY_MS = HOUR_MS;
35
+
36
+ /** One key a task currently has in `proxy` mode (effective policy). */
37
+ export interface ProxiedKeyPolicy {
38
+ projectId: string;
39
+ key: string;
40
+ domains: readonly string[];
41
+ /** The repo's `@placeholder` shape, if it declared one. */
42
+ placeholder?: PlaceholderShape | null;
43
+ /** The head the key's value must have (ADR-153 §4). A source that knows
44
+ * heads gives the value only at it, so a value read after the head moved
45
+ * is withheld until the engine learns the new one. */
46
+ head?: TaskEnvHead;
47
+ }
48
+
49
+ export interface TaskProxyPolicySource {
50
+ /** The task's keys in proxy mode right now, or null when the task is not
51
+ * (or no longer) served — an unknown, stopped or revoked generation. */
52
+ proxiedKeys(taskId: string): readonly ProxiedKeyPolicy[] | null;
53
+ /** The key's current real value, read per request; null withholds. */
54
+ realValue(taskId: string, ref: { projectId: string; key: string; head?: TaskEnvHead }): string | null;
55
+ /** Every name the task does not get as its value (proxied or withheld),
56
+ * when the source knows more than its proxied keys. */
57
+ notDelivered?(taskId: string): readonly string[] | null;
58
+ }
59
+
60
+ /** ADR-153's kill switch: `UAI_SECRETS_PROXY=off` stops the proxy, and the
61
+ * policy then withholds proxied keys (never delivers them). */
62
+ export function secretsProxyEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
63
+ return env.UAI_SECRETS_PROXY?.trim().toLowerCase() !== "off";
64
+ }
65
+
66
+ /** Upstream resolution for a runner whose /etc/hosts the task does NOT share
67
+ * (Docker and Apple hosts): the OS resolver, which also honours the
68
+ * platform's scoped and VPN resolvers. */
69
+ export async function resolveWithSystem(hostname: string): Promise<string[]> {
70
+ const answers = await dns.lookup(hostname, { all: true, verbatim: true });
71
+ return answers.map((answer) => answer.address);
72
+ }
73
+
74
+ /** Where a machine's real (uplink) DNS servers are listed: systemd-resolved's
75
+ * own upstream list first, then the classic file. */
76
+ export const UPLINK_RESOLV_FILES: readonly string[] = ["/run/systemd/resolve/resolv.conf", "/etc/resolv.conf"];
77
+
78
+ /** This host's own interface addresses: a resolver listening on one of them
79
+ * (dnsmasq on the private address) is as local as one on loopback. */
80
+ function interfaceAddresses(): string[] {
81
+ return Object.values(networkInterfaces())
82
+ .flat()
83
+ .flatMap((entry) => (entry === undefined ? [] : [entry.address]));
84
+ }
85
+
86
+ /** Matches a server on this host in any textual form — `0:0:0:0:0:0:0:1`,
87
+ * `::ffff:7f00:1` — since BlockList compares parsed addresses. IPv4 and
88
+ * IPv6 rules stay in separate lists (see docs/agent-notes.md). */
89
+ function localServerMatcher(localAddresses: readonly string[]): (address: string) => boolean {
90
+ const v4 = new BlockList();
91
+ v4.addSubnet("127.0.0.0", 8, "ipv4");
92
+ v4.addSubnet("0.0.0.0", 8, "ipv4");
93
+ const v6 = new BlockList();
94
+ // ::, ::1 and the IPv4-compatible forms of the above.
95
+ v6.addSubnet("::", 96, "ipv6");
96
+ v6.addSubnet("::ffff:127.0.0.0", 104, "ipv6");
97
+ v6.addSubnet("::ffff:0.0.0.0", 104, "ipv6");
98
+ for (const raw of localAddresses) {
99
+ const address = raw.split("%")[0] ?? "";
100
+ const family = isIP(address);
101
+ if (family === 4) {
102
+ v4.addAddress(address, "ipv4");
103
+ v6.addAddress(`::ffff:${address}`, "ipv6");
104
+ } else if (family === 6) {
105
+ v6.addAddress(address, "ipv6");
106
+ }
107
+ }
108
+ return (raw) => {
109
+ const address = raw.split("%")[0] ?? "";
110
+ const family = isIP(address);
111
+ // Anything unparsable counts as local: it is never queried.
112
+ if (family === 4) return v4.check(address, "ipv4");
113
+ if (family === 6) return v6.check(address, "ipv6");
114
+ return true;
115
+ };
116
+ }
117
+
118
+ /**
119
+ * The machine's uplink DNS servers: the `nameserver`s of the first file in
120
+ * `files` that lists any server not on this host. A server on this host never
121
+ * counts — systemd-resolved's 127.0.0.53 stub, or dnsmasq, answers from
122
+ * /etc/hosts, which on a machine carries the task's pins. Empty when there is
123
+ * no uplink server at all.
124
+ */
125
+ export function uplinkDnsServers(
126
+ files: readonly string[] = UPLINK_RESOLV_FILES,
127
+ localAddresses: readonly string[] = interfaceAddresses(),
128
+ ): string[] {
129
+ const isLocalServer = localServerMatcher(localAddresses);
130
+ for (const file of files) {
131
+ let text: string;
132
+ try {
133
+ text = readFileSync(file, "utf8");
134
+ } catch {
135
+ continue;
136
+ }
137
+ const servers: string[] = [];
138
+ for (const line of text.split("\n")) {
139
+ const [keyword, address] = line.trim().split(/\s+/);
140
+ if (keyword !== "nameserver" || address === undefined || isIP(address) === 0) continue;
141
+ if (!isLocalServer(address) && !servers.includes(address)) servers.push(address);
142
+ }
143
+ if (servers.length > 0) return servers;
144
+ }
145
+ return [];
146
+ }
147
+
148
+ /** Upstream resolution for a runner that shares /etc/hosts with the task (a
149
+ * machine's sidecar): DNS queried directly, so the task's pins — which
150
+ * point proxied names at loopback — can never answer for the upstream.
151
+ * The servers are re-read per lookup (DHCP can change them); none, which
152
+ * would leave only a stub that reads /etc/hosts, fails the lookup closed. */
153
+ export function resolverIgnoringHostsFile(
154
+ servers: () => readonly string[] = () => uplinkDnsServers(),
155
+ ): (hostname: string) => Promise<string[]> {
156
+ return async (hostname) => {
157
+ const list = servers();
158
+ if (list.length === 0) throw new Error("no uplink DNS server: refusing to resolve through a local stub");
159
+ const resolver = new dns.Resolver();
160
+ resolver.setServers([...list]);
161
+ const [v4, v6] = await Promise.allSettled([resolver.resolve4(hostname), resolver.resolve6(hostname)]);
162
+ const addresses = [
163
+ ...(v4.status === "fulfilled" ? v4.value : []),
164
+ ...(v6.status === "fulfilled" ? v6.value : []),
165
+ ];
166
+ if (addresses.length === 0) throw new Error("no A or AAAA records");
167
+ return addresses;
168
+ };
169
+ }
170
+
171
+ export interface SecretsProxyRuntimeDeps {
172
+ /** The runner's data dir: the CA and placeholders live under
173
+ * `secrets-proxy/`, the audit log under `logs/`. */
174
+ dataDir: string;
175
+ policy: TaskProxyPolicySource;
176
+ seal(plaintext: string): SealedText;
177
+ open(ct: Buffer, nonce: Buffer): string;
178
+ /** Required, because the right answer depends on where the runner sits:
179
+ * `resolveWithSystem` on a Docker or Apple host, `resolverIgnoringHostsFile()`
180
+ * in a machine's sidecar, whose /etc/hosts carries the task's pins. */
181
+ resolveHost: (hostname: string) => Promise<string[]>;
182
+ now?: () => number;
183
+ log?: Pick<Console, "warn">;
184
+ }
185
+
186
+ export interface SecretsProxyRuntime {
187
+ readonly proxy: SecretsProxy;
188
+ /** What every container or VM start installs into the task's trust store:
189
+ * the signing CA and, inside its last 90 days, its successor (PEM). */
190
+ caBundlePem(): string;
191
+ /** The runner's hook for keeping running tasks' trust current, level-
192
+ * triggered: the listener installs the bundle it is given into every
193
+ * running task and resolves once they all have it. It is called at
194
+ * subscription — so a successor minted while the runner was down, during
195
+ * its start, or just before a crash still reaches running tasks — and
196
+ * whenever the bundle changes; a delivery that throws or rejects is
197
+ * logged and retried at the next hourly review until one succeeds.
198
+ *
199
+ * Each delivery gets its own signal, aborted if the watcher is
200
+ * unsubscribed or the runtime closes while that delivery runs. A finished
201
+ * delivery's signal never aborts: nothing of it is left to stop, and a
202
+ * handler left on it must not run later, outside the delivery that
203
+ * registered it.
204
+ *
205
+ * Teardown is quiescent: the returned unsubscribe, and close(), abort the
206
+ * delivery under way and resolve only once it has ended, so an old
207
+ * runtime can never install its bundle over a replacement's. A listener
208
+ * must stop promptly when its signal aborts.
209
+ * Once close() has begun, watchTrust delivers nothing. Unsubscribe and
210
+ * close() are always quiescent, so neither may be called from inside a
211
+ * listener (or from work it started while its delivery lasts): there it
212
+ * would wait for itself, or for a delivery waiting on it. Such a call is
213
+ * rejected and changes nothing. */
214
+ watchTrust(listener: (bundlePem: string, signal: AbortSignal) => void | Promise<void>): () => Promise<void>;
215
+ /** Delivers the current bundle to every watcher again, though they all
216
+ * acknowledged it: a task's own install failed, or it was still starting,
217
+ * so the bundle is not everywhere after all. */
218
+ requestTrustDelivery(): void;
219
+ /** The placeholder the /env renderer hands a task for a key the policy
220
+ * serves it in proxy mode; throws for any other key or task. */
221
+ placeholderFor(taskId: string, key: ProxiedKeyPolicy): string;
222
+ /** The canonical domains a task's proxied keys may reach — what its
223
+ * /etc/hosts block and redirect chain are reconciled to. */
224
+ domainsFor(taskId: string): string[];
225
+ /** The task's proxy-mode keys, each with the canonical, proxiable domains
226
+ * it may reach (what its agents are told). */
227
+ proxiedKeysFor(taskId: string): { projectId: string; key: string; domains: string[] }[];
228
+ /** Stop (or any revocation): end the task's connections. Its placeholders
229
+ * stay — they are the task's for its whole life, across stops, resumes and
230
+ * runner restarts — and while the policy source does not serve the task,
231
+ * nothing is served or minted for it. */
232
+ stopTask(taskId: string): void;
233
+ /** Deletion: stopTask, then forget the task's placeholders. Call it once
234
+ * the policy source no longer serves the task, so nothing mints them
235
+ * again. */
236
+ dropTask(taskId: string): void;
237
+ close(): Promise<void>;
238
+ }
239
+
240
+ function proxiableDomains(keys: readonly ProxiedKeyPolicy[]): Set<string> {
241
+ const domains = new Set<string>();
242
+ for (const key of keys) {
243
+ // isProxiableDomain also requires the name to be canonical.
244
+ for (const domain of key.domains) if (isProxiableDomain(domain)) domains.add(domain);
245
+ }
246
+ return domains;
247
+ }
248
+
249
+ /** One string per policy snapshot, so derived state is rebuilt only when the
250
+ * policy actually changes. */
251
+ function policySignature(keys: readonly ProxiedKeyPolicy[]): string {
252
+ return JSON.stringify(keys.map((key) => [key.projectId, key.key, [...key.domains], key.placeholder ?? null, key.head ?? null]));
253
+ }
254
+
255
+ /** One trust listener call, while its delivery lasts, linked to the
256
+ * delivery it started inside, if any: a delivery begun from within another
257
+ * replaces the async context, and its end must not make its children look
258
+ * outside the one still running around it. */
259
+ interface DeliveryScope {
260
+ active: boolean;
261
+ readonly parent: DeliveryScope | null;
262
+ }
263
+
264
+ /** The async context of trust listener calls — of ANY runtime, so a listener
265
+ * of one runtime cannot tear down another whose listener is tearing down
266
+ * the first (during a replacement two are alive). It grants nothing: it only
267
+ * lets such a call be refused instead of deadlocking. Work a listener leaves
268
+ * behind that runs after every enclosing delivery ended is an ordinary
269
+ * caller again. */
270
+ const insideDelivery = new AsyncLocalStorage<DeliveryScope>();
271
+
272
+ function refuseInsideDelivery(call: string): void {
273
+ for (let scope = insideDelivery.getStore() ?? null; scope !== null; scope = scope.parent) {
274
+ if (scope.active) {
275
+ throw new Error(`${call} cannot be called from inside a trust listener: it would wait for a delivery`);
276
+ }
277
+ }
278
+ }
279
+
280
+ export function createSecretsProxyRuntime(deps: SecretsProxyRuntimeDeps): SecretsProxyRuntime {
281
+ const dir = join(deps.dataDir, "secrets-proxy");
282
+ const now = deps.now ?? Date.now;
283
+ const log = deps.log ?? console;
284
+ /** A logger that throws, or a reason that cannot be formatted, must never
285
+ * break a handshake or skip a delivery's cleanup. */
286
+ const warn = (message: () => string): void => {
287
+ try {
288
+ log.warn(message());
289
+ } catch {
290
+ // Nothing more can be done with a report that cannot be made.
291
+ }
292
+ };
293
+ const reasonOf = (error: unknown): string => {
294
+ try {
295
+ return error instanceof Error ? error.message : String(error);
296
+ } catch {
297
+ return "an error that could not be formatted";
298
+ }
299
+ };
300
+ const loadAuthorities = (): CertificateAuthorities =>
301
+ loadOrCreateCertificateAuthorities({
302
+ dir,
303
+ seal: deps.seal,
304
+ open: deps.open,
305
+ now,
306
+ log: { warn: (message: string) => warn(() => message) },
307
+ });
308
+ let authorities = loadAuthorities();
309
+ let leaves = createLeafCertificateCache({ ca: authorities.signer, now });
310
+ const bundleOf = (set: CertificateAuthorities): string =>
311
+ set.trusted.map(({ certificatePem }) => (certificatePem.endsWith("\n") ? certificatePem : `${certificatePem}\n`)).join("");
312
+
313
+ interface TrustWatcher {
314
+ readonly listener: (bundlePem: string, signal: AbortSignal) => void | Promise<void>;
315
+ /** Aborted at unsubscribe or close(); nothing starts after it. Internal:
316
+ * the listener never sees it, so nothing is registered on it. */
317
+ readonly stop: AbortController;
318
+ /** The last bundle the listener acknowledged. */
319
+ delivered: string | null;
320
+ /** A delivery was asked for (requestTrustDelivery) after the one under
321
+ * way began: that one may already be past the task that needs it, so
322
+ * another follows even if it is acknowledged. */
323
+ redeliver: boolean;
324
+ /** The delivery under way, if any. Never rejects. */
325
+ inFlight: Promise<void> | null;
326
+ /** That delivery's own signal and scope, so its abort is dispatched
327
+ * inside it. Cleared when it ends: its signal then never aborts. */
328
+ current: { readonly controller: AbortController; readonly scope: DeliveryScope } | null;
329
+ }
330
+ const watchers = new Set<TrustWatcher>();
331
+ /** Every delivery under way, including an unsubscribed watcher's, so
332
+ * close() can wait for all of them. */
333
+ const deliveries = new Set<Promise<void>>();
334
+ /** Set as close() begins: no delivery starts after it. */
335
+ let closed = false;
336
+
337
+ /** The engine's close, shared by every close() call. */
338
+ let engineClosed: Promise<void> | null = null;
339
+ /** Hands the current bundle to a watcher that does not have it yet, never
340
+ * inside a TLS callback and never twice at once. A success that turns out
341
+ * stale (the bundle changed meanwhile) delivers again at once; a failure
342
+ * waits for the next review. */
343
+ const deliver = (watcher: TrustWatcher): void => {
344
+ const bundlePem = bundleOf(authorities);
345
+ const { signal } = watcher.stop;
346
+ if (closed || signal.aborted || watcher.inFlight !== null) return;
347
+ if (watcher.delivered === bundlePem && !watcher.redeliver) return;
348
+ watcher.redeliver = false;
349
+ let acknowledged = false;
350
+ const scope: DeliveryScope = { active: true, parent: insideDelivery.getStore() ?? null };
351
+ const controller = new AbortController();
352
+ const delivery: Promise<void> = Promise.resolve()
353
+ // Checked again here: an unsubscribe or close() may have come between.
354
+ .then(() =>
355
+ signal.aborted ? undefined : insideDelivery.run(scope, () => watcher.listener(bundlePem, controller.signal)),
356
+ )
357
+ .then(
358
+ () => {
359
+ acknowledged = !signal.aborted;
360
+ },
361
+ (error: unknown) => {
362
+ if (!signal.aborted) {
363
+ warn(
364
+ () =>
365
+ "[secrets-proxy] the CA bundle did not reach every running task; retrying at the next review: " +
366
+ reasonOf(error),
367
+ );
368
+ }
369
+ },
370
+ )
371
+ // Unconditional, and the delivery never rejects: close() waits on it.
372
+ .finally(() => {
373
+ scope.active = false;
374
+ watcher.current = null;
375
+ watcher.inFlight = null;
376
+ deliveries.delete(delivery);
377
+ })
378
+ .then(() => {
379
+ if (!acknowledged) return;
380
+ watcher.delivered = bundlePem;
381
+ deliver(watcher);
382
+ })
383
+ .catch(() => undefined);
384
+ watcher.inFlight = delivery;
385
+ watcher.current = { controller, scope };
386
+ deliveries.add(delivery);
387
+ };
388
+ /** Stops a watcher, and aborts its delivery under way, if any. AbortSignal
389
+ * runs its handlers in the context of whoever calls abort(), not the one
390
+ * that registered them: the dispatch happens inside that delivery's scope
391
+ * (active, with its ancestry), so a handler is fenced like the listener
392
+ * that registered it. */
393
+ const abortWatcher = (watcher: TrustWatcher): void => {
394
+ watcher.stop.abort();
395
+ const current = watcher.current;
396
+ if (current !== null) insideDelivery.run(current.scope, () => current.controller.abort());
397
+ };
398
+
399
+ /** The CA set, reviewed when its dates call for it: a successor joins the
400
+ * bundle inside the signer's last 90 days, and the signer changes only on
401
+ * its last day, to a CA every task started since has installed. */
402
+ const currentAuthorities = (): CertificateAuthorities => {
403
+ // A closed runtime never writes the store again: its replacement, on the
404
+ // same data dir, may already be reviewing it.
405
+ if (closed || now() < authorities.reviewAt) return authorities;
406
+ let next: CertificateAuthorities;
407
+ try {
408
+ next = loadAuthorities();
409
+ } catch (error) {
410
+ // The signer has at least its last day left: keep it, and retry in an
411
+ // hour rather than failing every handshake on a store error.
412
+ warn(() => `[secrets-proxy] could not review the proxy CA: ${reasonOf(error)}`);
413
+ authorities = { ...authorities, reviewAt: now() + HOUR_MS };
414
+ return authorities;
415
+ }
416
+ const before = bundleOf(authorities);
417
+ if (next.signer.certificatePem !== authorities.signer.certificatePem) {
418
+ leaves = createLeafCertificateCache({ ca: next.signer, now });
419
+ }
420
+ authorities = next;
421
+ const after = bundleOf(next);
422
+ if (after !== before) for (const watcher of watchers) deliver(watcher);
423
+ return authorities;
424
+ };
425
+ const reviewTimer = setInterval(() => {
426
+ currentAuthorities();
427
+ // Also retries any delivery that failed.
428
+ for (const watcher of watchers) deliver(watcher);
429
+ }, TRUST_REVIEW_EVERY_MS);
430
+ reviewTimer.unref();
431
+ const signerLeaves: LeafCertificateCache = {
432
+ get authority() {
433
+ return currentAuthorities().signer;
434
+ },
435
+ leafFor(hostname) {
436
+ currentAuthorities();
437
+ return leaves.leafFor(hostname);
438
+ },
439
+ };
440
+ const placeholders = createPlaceholderStore({ dir });
441
+
442
+ const mint = (taskId: string, key: ProxiedKeyPolicy): string => {
443
+ // The placeholder store belongs to the replacement once close() began.
444
+ if (closed) throw new Error("the secrets proxy runtime is closed");
445
+ return placeholders.placeholderFor({
446
+ taskId,
447
+ projectId: key.projectId,
448
+ key: key.key,
449
+ shape: key.placeholder ?? null,
450
+ });
451
+ };
452
+
453
+ /** The engine asks for a task's state on every accept, server name and
454
+ * request; it is derived once per policy snapshot. */
455
+ const derived = new Map<string, { signature: string; state: ProxyTaskState }>();
456
+ const taskState = (taskId: string): ProxyTaskState | null => {
457
+ const keys = deps.policy.proxiedKeys(taskId);
458
+ if (keys === null) {
459
+ derived.delete(taskId);
460
+ return null;
461
+ }
462
+ const signature = policySignature(keys);
463
+ const cached = derived.get(taskId);
464
+ if (cached !== undefined && cached.signature === signature) return cached.state;
465
+ const byPlaceholder = new Map<string, ProxyKeyRef>();
466
+ for (const key of keys) {
467
+ byPlaceholder.set(mint(taskId, key), {
468
+ projectId: key.projectId,
469
+ key: key.key,
470
+ domains: [...key.domains],
471
+ ...(key.head !== undefined ? { head: key.head } : {}),
472
+ });
473
+ }
474
+ const domains = proxiableDomains(keys);
475
+ const placeholderList = [...byPlaceholder.keys()];
476
+ const state: ProxyTaskState = {
477
+ domains: () => domains,
478
+ lookup: (placeholder) => byPlaceholder.get(placeholder) ?? null,
479
+ placeholders: () => placeholderList,
480
+ // Values are never cached: a rotation applies to the next request.
481
+ realValue: (ref) =>
482
+ deps.policy.realValue(taskId, {
483
+ projectId: ref.projectId,
484
+ key: ref.key,
485
+ ...(ref.head !== undefined ? { head: ref.head } : {}),
486
+ }),
487
+ };
488
+ derived.set(taskId, { signature, state });
489
+ return state;
490
+ };
491
+
492
+ const proxy = createSecretsProxy({
493
+ getTaskState: taskState,
494
+ ca: signerLeaves,
495
+ resolveHost: deps.resolveHost,
496
+ audit: createAuditWriter(join(deps.dataDir, "logs")),
497
+ now,
498
+ });
499
+
500
+ const stopTask = (taskId: string): void => {
501
+ if (closed) return;
502
+ derived.delete(taskId);
503
+ proxy.closeTask(taskId);
504
+ };
505
+
506
+ return {
507
+ proxy,
508
+ caBundlePem: () => bundleOf(currentAuthorities()),
509
+ watchTrust(listener) {
510
+ // A closing or closed runtime has nothing left to deliver, and a
511
+ // delivery started now would outlive the close() that is waiting.
512
+ if (closed) return async () => undefined;
513
+ // Reviewed first, so a change this makes is delivered once, not twice.
514
+ currentAuthorities();
515
+ const watcher: TrustWatcher = {
516
+ listener,
517
+ stop: new AbortController(),
518
+ delivered: null,
519
+ redeliver: false,
520
+ inFlight: null,
521
+ current: null,
522
+ };
523
+ watchers.add(watcher);
524
+ deliver(watcher);
525
+ return async () => {
526
+ refuseInsideDelivery("unsubscribe");
527
+ abortWatcher(watcher);
528
+ watchers.delete(watcher);
529
+ if (watcher.inFlight !== null) await watcher.inFlight;
530
+ };
531
+ },
532
+ requestTrustDelivery() {
533
+ if (closed) return;
534
+ for (const watcher of watchers) {
535
+ // Starts one now, or right after the one under way.
536
+ watcher.redeliver = true;
537
+ deliver(watcher);
538
+ }
539
+ },
540
+ placeholderFor(taskId, key) {
541
+ // Only for a key the policy serves the task right now, checked in the
542
+ // same synchronous step as the mint: a request racing a stop or a
543
+ // deletion cannot persist placeholders for a task that is gone.
544
+ const served = deps.policy
545
+ .proxiedKeys(taskId)
546
+ ?.find((candidate) => candidate.projectId === key.projectId && candidate.key === key.key);
547
+ if (served === undefined) throw new Error("this key is not proxied for this task");
548
+ return mint(taskId, served);
549
+ },
550
+ domainsFor: (taskId) => [...proxiableDomains(deps.policy.proxiedKeys(taskId) ?? [])].sort(),
551
+ proxiedKeysFor: (taskId) =>
552
+ (deps.policy.proxiedKeys(taskId) ?? [])
553
+ .map((key) => ({
554
+ projectId: key.projectId,
555
+ key: key.key,
556
+ domains: [...proxiableDomains([key])].sort(),
557
+ }))
558
+ .filter((key) => key.domains.length > 0),
559
+ stopTask,
560
+ dropTask(taskId) {
561
+ // A closed runtime never deletes what its replacement may have written.
562
+ if (closed) return;
563
+ stopTask(taskId);
564
+ placeholders.dropTask(taskId);
565
+ },
566
+ close: async () => {
567
+ refuseInsideDelivery("close()");
568
+ // Synchronous, before any await: nothing is delivered, queued, retried
569
+ // or written from here on.
570
+ closed = true;
571
+ clearInterval(reviewTimer);
572
+ for (const watcher of watchers) abortWatcher(watcher);
573
+ watchers.clear();
574
+ engineClosed ??= proxy.close();
575
+ // Returns only once the deliveries under way have ended, so the caller
576
+ // can start a replacement safely. No delivery can start after the
577
+ // fence, so this snapshot is all of them.
578
+ await Promise.all([...deliveries]);
579
+ await engineClosed;
580
+ },
581
+ };
582
+ }