@runuai/host 0.9.148 → 0.9.150

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 +408 -81
  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,574 @@
1
+ /**
2
+ * Putting a task behind the proxy (ADR-153 §3 "Order") and keeping it there:
3
+ * what task-up.sh runs inside the container before uai-init, and the same
4
+ * steps for a running task. Every root mutation of a task (its CA, its pins)
5
+ * goes through one queue per task, so no two ever interleave and the
6
+ * recorded readiness is always that of the last one to land; shutdown stops
7
+ * the queues and waits for them.
8
+ */
9
+
10
+ import { chmodSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+
13
+ import { isSafeHostTaskId } from "../task-identity";
14
+ import { SELF_TASK_ENVIRONMENT_PROVIDER, SELF_TASK_USER, TASK_HOSTS_VIEW_PATH } from "../task-environment/self-identity";
15
+ import type { TaskEnvironmentHandle } from "../task-environment/types";
16
+ import { MCP_GATEWAY_PORT } from "../task-gateway-port";
17
+ import { taskViewProven } from "./sidecar-view";
18
+ import { installTaskTrust, reconcileTaskPins, type TaskNetAdmin } from "./container-steps";
19
+ import {
20
+ beginTaskStart,
21
+ endTaskStart,
22
+ forgetTaskProxyReadiness,
23
+ isTaskStarting,
24
+ recordTaskProxyReadiness,
25
+ taskProxyReadiness,
26
+ type ProxyStepOutcome,
27
+ } from "./readiness";
28
+ import type { SecretsProxyRuntime } from "./runtime";
29
+ import { caInstallScript, hostsReconcileScript, redirectChainScript } from "./task-scripts";
30
+
31
+ /** Where the task forwarder listens (both launchers pass `--listen-port
32
+ * 5877`): the NAT chain sends a pinned name's :443 here. */
33
+ export const TASK_FORWARDER_LISTEN_PORT = 5877;
34
+
35
+ /** How the proxy's steps run on one runtime. */
36
+ interface ProxyRuntime {
37
+ /** Where the redirect step's NET_ADMIN comes from. */
38
+ readonly netAdmin: TaskNetAdmin;
39
+ /** Where the chain sends a pinned name's :443. */
40
+ readonly listenPort: () => number;
41
+ /** Whose traffic it redirects, where not everyone's in the namespace. */
42
+ readonly uidOwner?: string;
43
+ /** Where the names are pinned, where /etc/hosts is not the task's alone,
44
+ * and whether every process of the task sees them. */
45
+ readonly hostsView?: {
46
+ readonly path: string;
47
+ readonly from: string;
48
+ readonly proven: (taskId: string) => boolean;
49
+ };
50
+ }
51
+
52
+ /**
53
+ * The runtimes this runner puts behind the proxy (the CA and the pins):
54
+ * - Docker: the redirect gets NET_ADMIN for that one exec (`--privileged`),
55
+ * to the task's forwarder.
56
+ * - Apple: the task VM holds NET_ADMIN from its creation (`--cap-add
57
+ * CAP_NET_ADMIN`), so a root exec has it (the provider refuses
58
+ * `privileged`); to the task's forwarder.
59
+ * - A Uai Cloud machine's own task (`self`): the sidecar is root on the
60
+ * machine and runs the steps itself. The chain sends only the task user's
61
+ * traffic, to the sidecar's own gateway, where a loopback TLS connection
62
+ * is its one task's. The names are pinned only in the task's own view of
63
+ * the hosts, which every task process gets bind-mounted over /etc/hosts:
64
+ * the machine's /etc/hosts, and so every root process the sidecar runs
65
+ * (its SSM refresh, its S3 mounts, its own updates), never sees them.
66
+ * A machine an owned runner drives over ssh is not among them: its proxied
67
+ * keys stay withheld.
68
+ */
69
+ const RUNTIMES: ReadonlyMap<string, ProxyRuntime> = new Map<string, ProxyRuntime>([
70
+ ["docker-compose", { netAdmin: "per-exec", listenPort: () => TASK_FORWARDER_LISTEN_PORT }],
71
+ ["apple-container", { netAdmin: "from-creation", listenPort: () => TASK_FORWARDER_LISTEN_PORT }],
72
+ [
73
+ SELF_TASK_ENVIRONMENT_PROVIDER,
74
+ {
75
+ netAdmin: "root",
76
+ listenPort: () => MCP_GATEWAY_PORT,
77
+ uidOwner: SELF_TASK_USER,
78
+ hostsView: { path: TASK_HOSTS_VIEW_PATH, from: "/etc/hosts", proven: taskViewProven },
79
+ },
80
+ ],
81
+ ]);
82
+
83
+ /** Whether this runner puts tasks of `provider` behind the proxy. */
84
+ export function isProxiedRuntime(provider: string | null | undefined): boolean {
85
+ return provider !== null && provider !== undefined && RUNTIMES.has(provider);
86
+ }
87
+
88
+ /** A task as the runner's root steps use it: its identity and `exec`. */
89
+ export type TaskRootExec = Pick<TaskEnvironmentHandle<unknown>, "exec" | "descriptor">;
90
+ type Exec = TaskRootExec;
91
+
92
+ function takesTrust(environment: Exec): boolean {
93
+ return isProxiedRuntime(environment.descriptor.locator.provider);
94
+ }
95
+
96
+ // --- one mutation queue per task -------------------------------------------
97
+
98
+ const queues = new Map<string, Promise<unknown>>();
99
+ let quiescing = false;
100
+
101
+ /** Bumped at a stop, a deletion and a new start: every write queued or under
102
+ * way before is void from then on (it runs no further exec and records
103
+ * nothing), so none can land over, or recreate readiness for, what follows. */
104
+ const epochs = new Map<string, number>();
105
+ const epochOf = (taskId: string): number => epochs.get(taskId) ?? 0;
106
+
107
+ export function invalidateTaskWrites(taskId: string): void {
108
+ epochs.set(taskId, epochOf(taskId) + 1);
109
+ }
110
+
111
+ /** Whether the runner is shutting down: nothing root may start in a task. */
112
+ export function taskWritesQuiescing(): boolean {
113
+ return quiescing;
114
+ }
115
+
116
+ /** True while the operation may still act: the runner is not shutting down,
117
+ * no stop, deletion or start has voided it, and its caller's own check (a
118
+ * recovery's generation) still holds. */
119
+ type Live = () => boolean;
120
+
121
+ /** Why an operation that is no longer live stops. */
122
+ function refusal(): Error {
123
+ return new Error(quiescing ? "the runner is shutting down" : "this write was superseded");
124
+ }
125
+
126
+ /** Runs `step` after every earlier write of the task has ended. Refused once
127
+ * shutdown has begun: a write that started after its runner's last moment
128
+ * could land over the replacement's. */
129
+ function mutate<T>(taskId: string, step: (live: Live) => Promise<T>, isCurrent?: () => boolean): Promise<T> {
130
+ if (quiescing) return Promise.reject(new Error("the runner is shutting down"));
131
+ const epoch = epochOf(taskId);
132
+ const live: Live = () => !quiescing && epochOf(taskId) === epoch && (isCurrent === undefined || isCurrent());
133
+ const previous = queues.get(taskId) ?? Promise.resolve();
134
+ const next = previous
135
+ .catch(() => undefined)
136
+ .then(() => {
137
+ if (!live()) throw refusal();
138
+ return step(live);
139
+ });
140
+ queues.set(taskId, next);
141
+ const settled = (): void => {
142
+ if (queues.get(taskId) === next) queues.delete(taskId);
143
+ };
144
+ next.then(settled, settled);
145
+ return next;
146
+ }
147
+
148
+ /** Every exec of a queued step goes through this: once the operation is not
149
+ * live (shutdown, a stop, a deletion, a new start, a stale recovery), no
150
+ * further exec starts, even inside an operation already under way, and
151
+ * not even one already asked for whose provider is still looking up where
152
+ * to run it (the request's `admit`). The
153
+ * steps are ordered so stopping between any two leaves nothing unsafe (the
154
+ * chain before the names when pinning, the names first when unpinning), and
155
+ * whatever writes next reconciles what is left. */
156
+ function guarded(environment: Exec, live: Live): Exec {
157
+ return {
158
+ descriptor: environment.descriptor,
159
+ exec: (request) => {
160
+ if (!live()) return Promise.reject(refusal());
161
+ // And again where the provider starts the process: a machine looks
162
+ // up its address first, which can take as long as a cloud API call.
163
+ const admit = (): void => {
164
+ request.admit?.();
165
+ if (!live()) throw refusal();
166
+ };
167
+ return environment.exec({ ...request, admit });
168
+ },
169
+ };
170
+ }
171
+
172
+ /** Shutdown: refuses every new write and exec, and resolves once those under
173
+ * way (a recovery's CA install, a re-pin, a forwarder launch) have ended. */
174
+ export async function quiesceTaskProxyMutations(): Promise<void> {
175
+ quiescing = true;
176
+ await Promise.all([...queues.values()].map((queued) => queued.catch(() => undefined)));
177
+ }
178
+
179
+ /**
180
+ * Any other root step a lifecycle takes in a task (the recovery's forwarder
181
+ * launch), run in the same queue and under the same fence: never once
182
+ * shutdown has begun, a stop, deletion or start has voided it, or
183
+ * `isCurrent` says the operation is stale; joined by shutdown. The step
184
+ * reaches the task only through the `environment` it is given, whose every
185
+ * exec is fenced when it is made, not only when the step begins: the
186
+ * launch awaits the gateway's address, and on a machine installs its
187
+ * assets, before the exec that starts the forwarder.
188
+ */
189
+ export function runTaskRootStep<T>(
190
+ environment: TaskRootExec,
191
+ step: (environment: TaskRootExec, live: () => boolean) => Promise<T>,
192
+ isCurrent?: () => boolean,
193
+ ): Promise<T> {
194
+ return mutate(environment.descriptor.taskId, (live) => step(guarded(environment, live), live), isCurrent);
195
+ }
196
+
197
+ /**
198
+ * A start's exclusive hold on the task's writes, from before its root steps
199
+ * until its result is recorded. Earlier writers are voided (they finish at
200
+ * most the exec under way) and waited for; none runs until the returned
201
+ * release. Rejects once shutdown has begun.
202
+ */
203
+ function holdTaskWritesForStart(taskId: string): Promise<() => void> {
204
+ invalidateTaskWrites(taskId);
205
+ return new Promise((resolve, reject) => {
206
+ mutate(
207
+ taskId,
208
+ () =>
209
+ new Promise<void>((release) => {
210
+ resolve(release);
211
+ }),
212
+ ).catch(reject);
213
+ });
214
+ }
215
+
216
+ /** The holds of the starts under way, each ended once (finish or close). */
217
+ const startHolds = new Map<string, () => void>();
218
+
219
+ function endStartHold(taskId: string): void {
220
+ const release = startHolds.get(taskId);
221
+ startHolds.delete(taskId);
222
+ release?.();
223
+ }
224
+
225
+ // --- the steps, run only inside a task's queue --------------------------------
226
+
227
+ async function installStep(environment: Exec, live: Live, bundle: string): Promise<void> {
228
+ const { taskId } = environment.descriptor;
229
+ try {
230
+ await installTaskTrust(environment, bundle);
231
+ } catch (error) {
232
+ if (live()) recordTaskProxyReadiness(taskId, { trust: "failed", bundle: null });
233
+ throw error;
234
+ }
235
+ if (live()) recordTaskProxyReadiness(taskId, { trust: "installed", bundle });
236
+ }
237
+
238
+ /** Unpinned tasks, while the proxy is off: their pins are known to be gone. */
239
+ const unpinned = new Set<string>();
240
+
241
+ /** With the proxy off: the pins out, once (recorded until the task pins
242
+ * again); a failure is not recorded, so the next sweep tries again. */
243
+ async function unpinStep(environment: Exec, live: Live): Promise<void> {
244
+ const { taskId } = environment.descriptor;
245
+ if (unpinned.has(taskId)) return;
246
+ await pinStep(environment, live, []);
247
+ if (live()) {
248
+ forgetTaskProxyReadiness(taskId);
249
+ unpinned.add(taskId);
250
+ }
251
+ }
252
+
253
+ async function pinStep(environment: Exec, live: Live, domains: readonly string[]): Promise<void> {
254
+ const { taskId } = environment.descriptor;
255
+ try {
256
+ const proxied = RUNTIMES.get(environment.descriptor.locator.provider);
257
+ if (proxied === undefined) throw new Error("the proxy does not run on this task's runtime");
258
+ await reconcileTaskPins(environment, {
259
+ domains,
260
+ listenPort: proxied.listenPort(),
261
+ netAdmin: proxied.netAdmin,
262
+ ...(proxied.uidOwner === undefined ? {} : { uidOwner: proxied.uidOwner }),
263
+ ...(proxied.hostsView === undefined
264
+ ? {}
265
+ : {
266
+ hostsView: {
267
+ path: proxied.hostsView.path,
268
+ from: proxied.hostsView.from,
269
+ proven: () => proxied.hostsView?.proven(taskId) === true,
270
+ },
271
+ }),
272
+ });
273
+ } catch (error) {
274
+ // Whatever was pinned may now be half-rewritten: nothing counts as pinned.
275
+ if (live()) recordTaskProxyReadiness(taskId, { pins: "failed", pinnedDomains: [] });
276
+ unpinned.delete(taskId);
277
+ throw error;
278
+ }
279
+ if (live()) {
280
+ recordTaskProxyReadiness(taskId, { pins: domains.length > 0 ? "installed" : "skipped", pinnedDomains: [...domains] });
281
+ }
282
+ }
283
+
284
+ function sameDomains(left: readonly string[], right: readonly string[]): boolean {
285
+ return left.length === right.length && left.every((domain, index) => domain === right[index]);
286
+ }
287
+
288
+ // --- task-up ---------------------------------------------------------------
289
+
290
+ function stepsDir(dataDir: string, taskId: string): string {
291
+ if (!isSafeHostTaskId(taskId)) throw new Error("invalid host task identity");
292
+ return join(dataDir, "secrets-proxy", "task-up", taskId);
293
+ }
294
+
295
+ /**
296
+ * Begins a task-up behind the proxy. First the start takes the task's writes
297
+ * (holdTaskWritesForStart), so no older writer (a recovery, a re-pin) lands
298
+ * over its steps and task-up is the task's only writer until its result is
299
+ * recorded. Then it writes what task-up.sh runs as root inside the
300
+ * container: the CA bundle, its install script, and the chain and pin
301
+ * scripts for the task's current domains, with the order to run them in.
302
+ * Public material only (certificates and fixed scripts). task-up writes each
303
+ * step's outcome back into the same directory as it goes.
304
+ *
305
+ * Returns the directory, passed to task-up.sh as UAI_SECRETS_PROXY_DIR, or
306
+ * null once the runner is shutting down: the task then starts without the
307
+ * proxy's steps, its proxied keys withheld, and the next runner reconciles
308
+ * it. finishTaskUpTrustSteps ends the hold; closeTaskUpTrustSteps ends it
309
+ * for a start that never got that far.
310
+ */
311
+ export async function writeTaskUpTrustSteps(
312
+ dataDir: string,
313
+ taskId: string,
314
+ runtime: SecretsProxyRuntime,
315
+ ): Promise<string | null> {
316
+ const dir = stepsDir(dataDir, taskId);
317
+ let release: () => void;
318
+ try {
319
+ release = await holdTaskWritesForStart(taskId);
320
+ } catch {
321
+ forgetTaskProxyReadiness(taskId);
322
+ return null;
323
+ }
324
+ startHolds.set(taskId, release);
325
+ try {
326
+ rmSync(dir, { recursive: true, force: true });
327
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
328
+ chmodSync(dir, 0o700);
329
+ const bundle = runtime.caBundlePem();
330
+ const domains = runtime.domainsFor(taskId);
331
+ const files: Record<string, string> = {
332
+ "ca-bundle.pem": bundle,
333
+ "ca-install.sh": caInstallScript(),
334
+ "pins.sh": hostsReconcileScript(domains),
335
+ "chain.sh": redirectChainScript({ toPort: TASK_FORWARDER_LISTEN_PORT, enabled: domains.length > 0 }),
336
+ // As for a running task: the chain before the names when pinning, the
337
+ // names first when there is nothing to pin.
338
+ "routing-order": domains.length > 0 ? "pin\n" : "unpin\n",
339
+ };
340
+ for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body, { mode: 0o600 });
341
+ unpinned.delete(taskId);
342
+ beginTaskStart(taskId, { dir, bundle, domains });
343
+ } catch (error) {
344
+ forgetTaskProxyReadiness(taskId);
345
+ endStartHold(taskId);
346
+ throw error;
347
+ }
348
+ return dir;
349
+ }
350
+
351
+ /** Ends a task-up: what it reported becomes the task's readiness (nothing,
352
+ * when it failed), its directory goes, and its hold on the task's writes
353
+ * ends, so a writer queued meanwhile reconciles from what it recorded. */
354
+ export function finishTaskUpTrustSteps(
355
+ dataDir: string,
356
+ taskId: string,
357
+ result: { secretsProxyTrust?: ProxyStepOutcome; secretsProxyPins?: ProxyStepOutcome } | null,
358
+ ): void {
359
+ try {
360
+ const pending = endTaskStart(taskId);
361
+ if (isSafeHostTaskId(taskId)) rmSync(stepsDir(dataDir, taskId), { recursive: true, force: true });
362
+ if (pending === undefined || result === null) {
363
+ forgetTaskProxyReadiness(taskId);
364
+ return;
365
+ }
366
+ const trust = result.secretsProxyTrust ?? "skipped";
367
+ const pins = result.secretsProxyPins ?? "skipped";
368
+ recordTaskProxyReadiness(taskId, {
369
+ trust,
370
+ bundle: trust === "installed" ? pending.bundle : null,
371
+ pins,
372
+ pinnedDomains: pins === "installed" ? pending.domains : [],
373
+ // task-up fails outright unless its forwarder started.
374
+ forwarder: true,
375
+ });
376
+ } finally {
377
+ endStartHold(taskId);
378
+ }
379
+ }
380
+
381
+ /** The end of every start, whatever happened: one that never reported
382
+ * counts as failed (and its hold ends). Nothing once it was finished. */
383
+ export function closeTaskUpTrustSteps(dataDir: string, taskId: string): void {
384
+ if (isTaskStarting(taskId)) finishTaskUpTrustSteps(dataDir, taskId, null);
385
+ }
386
+
387
+ // --- reconciling a running task -----------------------------------------------
388
+
389
+ /**
390
+ * Brings one running Docker or Apple task to where it should be, doing only
391
+ * what differs from its recorded readiness, in its queue:
392
+ * - proxy on: the current CA bundle, then pins for its current domains;
393
+ * - proxy off (`runtime` null): no pins, so formerly proxied names go
394
+ * direct again.
395
+ * Any other runtime is left untouched. Level-triggered, so every path that
396
+ * may have missed something (the end of a start, a change of policy, the
397
+ * trust watcher's retries, the proxy-off sweep) simply calls it again.
398
+ * Throws if a step failed; what did land is recorded.
399
+ */
400
+ export function reconcileTaskProxyState(
401
+ target: Exec,
402
+ runtime: SecretsProxyRuntime | null,
403
+ isCurrent?: () => boolean,
404
+ ): Promise<void> {
405
+ if (!takesTrust(target)) return Promise.resolve();
406
+ const { taskId } = target.descriptor;
407
+ return mutate(
408
+ taskId,
409
+ async (live) => {
410
+ const environment = guarded(target, live);
411
+ if (runtime === null) {
412
+ await unpinStep(environment, live);
413
+ if (!live()) throw refusal();
414
+ return;
415
+ }
416
+ unpinned.delete(taskId);
417
+ const bundle = runtime.caBundlePem();
418
+ const ready = taskProxyReadiness(taskId);
419
+ if (ready.trust !== "installed" || ready.bundle !== bundle) await installStep(environment, live, bundle);
420
+ // Voided while its CA exec ran (a stop, a recovery beginning): it ends
421
+ // there, and what it read before counts for nothing.
422
+ if (!live()) throw refusal();
423
+ const domains = runtime.domainsFor(taskId);
424
+ const after = taskProxyReadiness(taskId);
425
+ const pinned = domains.length > 0 ? after.pins === "installed" : after.pins === "skipped";
426
+ if (!pinned || !sameDomains(after.pinnedDomains, domains)) {
427
+ // Never a name pinned with nothing listening behind it: until a
428
+ // start or a recovery has the task's forwarder running, the pins
429
+ // wait for it (a recovery pins in the same slot as its launch).
430
+ // Taking pins out needs no forwarder.
431
+ if (domains.length > 0 && !after.forwarder) {
432
+ throw new Error("the task's forwarder is not known to run; its next recovery pins it");
433
+ }
434
+ await pinStep(environment, live, domains);
435
+ // Its last exec may have been the one under way when it was voided:
436
+ // landed, but recorded nowhere, so it does not count as done.
437
+ if (!live()) throw refusal();
438
+ }
439
+ },
440
+ isCurrent,
441
+ );
442
+ }
443
+
444
+ /** A proxy step that failed during a recovery: the task comes up without
445
+ * the proxy, its proxied keys withheld, and the trust watcher retries. */
446
+ export interface RecoveryProxyFailure {
447
+ readonly error: unknown;
448
+ }
449
+
450
+ /**
451
+ * A task's root steps in the order a start takes them (ADR-153 "Order"), in
452
+ * one slot of its queue so no other writer lands between them: for a
453
+ * recovery, and for a machine sidecar's start and resume, which run the
454
+ * steps themselves rather than through task-up.sh. Each step runs again
455
+ * whatever is recorded: a restarted runner records nothing, and a restarted
456
+ * VM or machine keeps none of it.
457
+ * - Proxy on: the CA, then `launch` (the task's forwarder; nothing on a
458
+ * machine, whose sidecar serves the task from its own gateway), then the
459
+ * pins for its current domains.
460
+ * - Proxy off: the pins out, then `launch`.
461
+ * No name is pinned before its forwarder runs. A launch that fails pins
462
+ * nothing, takes out the pins the task still holds, and rejects, and the
463
+ * caller keeps consumers out. A failed proxy
464
+ * step does not stop the launch; it is returned, and after a failed CA
465
+ * nothing is pinned either. `proxy` null (a baked task), or a runtime the
466
+ * runner does not put behind the proxy, runs `launch` alone. A stop,
467
+ * deletion, start, stale recovery or shutdown rejects the slot at whichever
468
+ * step it meets.
469
+ */
470
+ export function applyTaskRootSteps(
471
+ target: Exec,
472
+ proxy: { readonly runtime: SecretsProxyRuntime | null } | null,
473
+ launch: (environment: TaskRootExec) => Promise<void>,
474
+ isCurrent?: () => boolean,
475
+ ): Promise<RecoveryProxyFailure | null> {
476
+ const { taskId } = target.descriptor;
477
+ const runtime = proxy?.runtime ?? null;
478
+ const proxied = proxy !== null && takesTrust(target);
479
+ return runTaskRootStep(
480
+ target,
481
+ async (environment, live) => {
482
+ let failure: RecoveryProxyFailure | null = null;
483
+ // A step's failure is the proxy's only while the slot is live; one
484
+ // that met a stop, a stale recovery or shutdown ends the slot.
485
+ const attempt = async (step: () => Promise<void>): Promise<void> => {
486
+ try {
487
+ await step();
488
+ } catch (error) {
489
+ if (!live()) throw error;
490
+ failure = { error };
491
+ return;
492
+ }
493
+ // Its exec may have been the one under way when the slot stopped.
494
+ if (!live()) throw refusal();
495
+ };
496
+ if (proxied) {
497
+ // Replayed because a restart may have taken all of it: nothing
498
+ // recorded before counts until a step here lands it again, so a step
499
+ // that fails leaves the task undeliverable, never ready on the
500
+ // strength of a VM that is gone.
501
+ forgetTaskProxyReadiness(taskId);
502
+ if (runtime === null) {
503
+ await attempt(() => unpinStep(environment, live));
504
+ } else {
505
+ unpinned.delete(taskId);
506
+ await attempt(() => installStep(environment, live, runtime.caBundlePem()));
507
+ }
508
+ }
509
+ if (!live()) throw refusal();
510
+ try {
511
+ await launch(environment);
512
+ } catch (error) {
513
+ // No forwarder, so no name may point at one. Pins already in the task
514
+ // (the earlier VM's, or the last exec of a reconcile voided while it
515
+ // ran, which no fence can call back) come out, as far as the task
516
+ // lets them.
517
+ if (proxied && runtime !== null && live()) await pinStep(environment, live, []).catch(() => undefined);
518
+ throw error;
519
+ }
520
+ if (!live()) throw refusal();
521
+ if (proxied) recordTaskProxyReadiness(taskId, { forwarder: true });
522
+ if (proxied && runtime !== null && failure === null) {
523
+ await attempt(() => pinStep(environment, live, runtime.domainsFor(taskId)));
524
+ }
525
+ return failure;
526
+ },
527
+ isCurrent,
528
+ );
529
+ }
530
+
531
+ /**
532
+ * A machine sidecar's own task, at its start and whenever the sidecar comes
533
+ * back: the proxy's steps in a start's order, the CA and then the pins (the
534
+ * sidecar's gateway, in this process, is the task's listener, so there is
535
+ * nothing to launch); with the proxy off, the pins out. A start first voids
536
+ * any write queued before it. Never fails the task: a failed step leaves it
537
+ * up without the proxy, its keys withheld, and asks the trust watcher to
538
+ * retry. Returns what failed, for the caller's log, or null.
539
+ */
540
+ export async function reconcileSidecarTaskProxy(
541
+ environment: TaskRootExec,
542
+ runtime: SecretsProxyRuntime | null,
543
+ options: { start: boolean },
544
+ ): Promise<string | null> {
545
+ if (!takesTrust(environment)) return null;
546
+ // A start voids every write queued or under way before it. Whether the
547
+ // task's hosts view counts is not its to say: task-up admits a task with
548
+ // no row before recording it, and every other task stands as recorded.
549
+ if (options.start) invalidateTaskWrites(environment.descriptor.taskId);
550
+ let failure: unknown = null;
551
+ try {
552
+ failure = (await applyTaskRootSteps(environment, { runtime }, async () => undefined))?.error ?? null;
553
+ } catch (error) {
554
+ failure = error;
555
+ }
556
+ if (failure === null) return null;
557
+ runtime?.requestTrustDelivery();
558
+ return `secrets proxy: ${failure instanceof Error ? failure.message : String(failure)}`;
559
+ }
560
+
561
+ /**
562
+ * The live path for a change of the task's proxied domains. During a
563
+ * task-up, task-up is the only routing writer: its end reconciles the then
564
+ * current domains instead (reconcileTaskProxyState after the start).
565
+ */
566
+ export function reconcileTaskRouting(environment: Exec, runtime: SecretsProxyRuntime): Promise<void> {
567
+ if (isTaskStarting(environment.descriptor.taskId)) return Promise.resolve();
568
+ return reconcileTaskProxyState(environment, runtime);
569
+ }
570
+
571
+ /** With the proxy off: unpins the task (once; recorded until it pins again). */
572
+ export function unpinTask(environment: Exec, isCurrent?: () => boolean): Promise<void> {
573
+ return reconcileTaskProxyState(environment, null, isCurrent);
574
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The runner's trust watcher (ADR-153 §3 "The CA"): when the CA bundle
3
+ * changes (a successor is minted, or the signer retires), put it into every
4
+ * running loader task the proxy covers (Docker, Apple, a machine's own), so
5
+ * the processes they start from then on trust both. Runs as the
6
+ * SecretsProxyRuntime.watchTrust listener, which retries a delivery that
7
+ * throws.
8
+ */
9
+
10
+ import { activeLoaderHostTasks } from "../runtime-state";
11
+ import { reconstructPersistedTaskEnvironment } from "../task-environment/index";
12
+ import type { TaskEnvironmentHandle } from "../task-environment/types";
13
+ import type { SecretsProxyRuntime } from "./runtime";
14
+ import { isProxiedRuntime, reconcileTaskProxyState } from "./task-trust";
15
+
16
+ export type HostTask = ReturnType<typeof activeLoaderHostTasks>[number];
17
+
18
+ export interface TrustWatchDeps {
19
+ tasks(): readonly HostTask[];
20
+ environmentOf(task: HostTask): Promise<Pick<TaskEnvironmentHandle<unknown>, "exec" | "descriptor"> | null>;
21
+ }
22
+
23
+ const defaultDeps: TrustWatchDeps = {
24
+ tasks: activeLoaderHostTasks,
25
+ environmentOf: reconstructPersistedTaskEnvironment,
26
+ };
27
+
28
+ /**
29
+ * Brings every running loader task the proxy covers (Docker, Apple, a
30
+ * machine's own) to its desired proxy state (reconcileTaskProxyState: the
31
+ * current CA bundle, the pins for its current domains; or, with `runtime`
32
+ * null, no pins), one at a time, checking the signal before each. Each task
33
+ * does only what it lacks. Throws if any task did not get there, or could
34
+ * not yet (still starting), so the caller retries.
35
+ */
36
+ async function reconcileRunningTasks(
37
+ runtime: SecretsProxyRuntime | null,
38
+ signal: AbortSignal | null,
39
+ deps: TrustWatchDeps,
40
+ ): Promise<void> {
41
+ const failed: string[] = [];
42
+ let starting = 0;
43
+ for (const task of deps.tasks()) {
44
+ if (signal?.aborted === true) return;
45
+ if (!isProxiedRuntime(task.environmentProvider)) continue;
46
+ if (task.statusMirror !== "running") {
47
+ starting += 1;
48
+ continue;
49
+ }
50
+ try {
51
+ const environment = await deps.environmentOf(task);
52
+ if (environment === null) throw new Error("its environment could not be reconstructed");
53
+ await reconcileTaskProxyState(environment, runtime);
54
+ } catch (error) {
55
+ failed.push(`${task.taskId} (${error instanceof Error ? error.message : String(error)})`);
56
+ }
57
+ }
58
+ if (failed.length > 0 || starting > 0) {
59
+ throw new Error(
60
+ `the secrets proxy is not in place in every task yet: ${failed.length} failed${failed.length > 0 ? ` (${failed.join("; ")})` : ""}, ` +
61
+ `${starting} still starting`,
62
+ );
63
+ }
64
+ }
65
+
66
+ /** The SecretsProxyRuntime.watchTrust listener: run when the CA bundle
67
+ * changes, at subscription, and as its retries (hourly while it throws). */
68
+ export function reinstallTrustInRunningTasks(
69
+ runtime: SecretsProxyRuntime,
70
+ signal: AbortSignal,
71
+ deps: TrustWatchDeps = defaultDeps,
72
+ ): Promise<void> {
73
+ return reconcileRunningTasks(runtime, signal, deps);
74
+ }
75
+
76
+ /** With the proxy off: unpins every running Docker task not yet known to
77
+ * be unpinned. Run after recovery and hourly while anything is left. */
78
+ export function unpinRunningTasks(deps: TrustWatchDeps = defaultDeps): Promise<void> {
79
+ return reconcileRunningTasks(null, null, deps);
80
+ }
@@ -40,6 +40,7 @@ export async function resolveTaskUserIds(
40
40
  const fallback = { uid: SHARED_FILES_FALLBACK_UID, gid: SHARED_FILES_FALLBACK_GID };
41
41
  try {
42
42
  const result = await handle.exec({
43
+ projectEnvProfile: "maintenance",
43
44
  argv: ["/bin/sh", "-c", 'id -u "$1" && id -g "$1"', "ids", user],
44
45
  user: "root",
45
46
  timeoutMs: 10_000,
@@ -172,6 +173,7 @@ async function mountSharedFilesFromLeaseNow(
172
173
  const owner = await resolveTaskUserIds(handle);
173
174
  for (const plan of sharedFilesMountPlans(workspacePath, mode, lease)) {
174
175
  const result = await handle.exec({
176
+ projectEnvProfile: "maintenance",
175
177
  argv: [
176
178
  "/bin/sh",
177
179
  "-c",
@@ -224,6 +226,7 @@ export async function unmountSharedFiles(
224
226
  await handle
225
227
  .exec({
226
228
  argv: ["/bin/sh", "-c", 'mountpoint -q "$1" && umount "$1" || true', "umount", directory],
229
+ projectEnvProfile: "maintenance",
227
230
  user: "root",
228
231
  timeoutMs: 30_000,
229
232
  maxOutputBytes: 4 * 1024,