@moondeck/station 0.0.1 → 1.0.1

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 (330) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -2
  3. package/bin/uai-host.mjs +130 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/0005_host_env_vars.sql +5 -0
  10. package/db/migrations/0006_host_project_env.sql +9 -0
  11. package/db/migrations/0007_host_github_token_kind.sql +7 -0
  12. package/db/migrations/0008_host_mcp_connections.sql +18 -0
  13. package/db/migrations/0009_host_agent_sessions.sql +15 -0
  14. package/db/migrations/0010_host_engine_accounts.sql +13 -0
  15. package/db/migrations/0011_codex_thread_id.sql +5 -0
  16. package/db/migrations/0012_agent_session_attach_key.sql +4 -0
  17. package/db/migrations/0013_github_credential_generations.sql +11 -0
  18. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  19. package/db/migrations/0015_host_settings.sql +9 -0
  20. package/db/migrations/0016_task_environment.sql +2 -0
  21. package/db/migrations/0017_engine_account_scope.sql +7 -0
  22. package/db/migrations/0018_engine_account_position.sql +3 -0
  23. package/db/migrations/0019_engine_account_store_ref.sql +3 -0
  24. package/db/migrations/0020_user_credential_store_ref.sql +6 -0
  25. package/db/migrations/0021_project_env_store_ref.sql +3 -0
  26. package/db/migrations/0022_task_project_ids.sql +5 -0
  27. package/db/migrations/0023_project_env_org.sql +4 -0
  28. package/db/migrations/0024_task_env_mode.sql +9 -0
  29. package/db/migrations/0025_project_env_authority.sql +147 -0
  30. package/db/migrations/0026_project_env_replica.sql +150 -0
  31. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  32. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  33. package/db/migrations/0029_task_shared_files_mode.sql +5 -0
  34. package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
  35. package/db/migrations/0031_first_turn_receipts.sql +9 -0
  36. package/db/migrations/meta/_journal.json +230 -0
  37. package/db/schema.ts +871 -0
  38. package/images/standard/Dockerfile +347 -0
  39. package/images/standard/README.md +167 -0
  40. package/images/standard/container/code-server-settings.json +63 -0
  41. package/images/standard/container/corepack-version +1 -0
  42. package/images/standard/container/uai-init +704 -0
  43. package/images/standard/container/uai-materialize-runtimes +1527 -0
  44. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  45. package/images/standard/tool-versions +2 -0
  46. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  47. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  48. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  49. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  50. package/lib/agent-cli.ts +1507 -0
  51. package/lib/agent-errors.ts +22 -0
  52. package/lib/agent.ts +672 -0
  53. package/lib/agents/attach-compatibility.ts +51 -0
  54. package/lib/agents/claude.ts +918 -0
  55. package/lib/agents/codex-token-usage.fixture.json +130 -0
  56. package/lib/agents/codex.ts +1392 -0
  57. package/lib/agents/cursor-period-usage.ts +253 -0
  58. package/lib/agents/cursor.ts +562 -0
  59. package/lib/agents/dispatch.ts +78 -0
  60. package/lib/agents/durable-proc.ts +334 -0
  61. package/lib/agents/factory.ts +57 -0
  62. package/lib/agents/grok.ts +381 -0
  63. package/lib/agents/kimi.ts +329 -0
  64. package/lib/agents/machine-durable.ts +604 -0
  65. package/lib/agents/mock.ts +133 -0
  66. package/lib/agents/mode.ts +64 -0
  67. package/lib/agents/opencode.ts +508 -0
  68. package/lib/agents/output-source.ts +3 -0
  69. package/lib/agents/proc.ts +221 -0
  70. package/lib/agents/rate-limit.ts +337 -0
  71. package/lib/agents/registry.ts +182 -0
  72. package/lib/agents/session-state.ts +197 -0
  73. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  74. package/lib/agents/transport.ts +741 -0
  75. package/lib/agents/types.ts +254 -0
  76. package/lib/agents/uai.ts +106 -0
  77. package/lib/agents/usage.ts +44 -0
  78. package/lib/apple-one-off.ts +277 -0
  79. package/lib/apple-runtime-recycle.ts +237 -0
  80. package/lib/apple-uninstall-teardown.ts +224 -0
  81. package/lib/assignment-evidence.ts +94 -0
  82. package/lib/attachments.ts +48 -0
  83. package/lib/browser-testing.ts +1537 -0
  84. package/lib/capture-flow.ts +332 -0
  85. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  86. package/lib/cloud-credential-host.ts +512 -0
  87. package/lib/cloud-credential-leases.ts +2587 -0
  88. package/lib/cloud-credential-runtime.ts +1357 -0
  89. package/lib/cloud-state.ts +56 -0
  90. package/lib/codex-auth.ts +456 -0
  91. package/lib/command-db.ts +310 -0
  92. package/lib/compose-network-recovery.ts +283 -0
  93. package/lib/connection-secret-redaction.ts +25 -0
  94. package/lib/container-runtime.ts +1470 -0
  95. package/lib/credential-cleanup-inventory.ts +490 -0
  96. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  97. package/lib/credential-consumers/engines.ts +797 -0
  98. package/lib/credential-consumers/files.ts +934 -0
  99. package/lib/credential-consumers/github.ts +702 -0
  100. package/lib/credential-consumers/mcp.ts +389 -0
  101. package/lib/credential-consumers/task-files.ts +127 -0
  102. package/lib/credential-consumers/task-secrets.ts +10 -0
  103. package/lib/credential-store-pickup.ts +149 -0
  104. package/lib/credential-store-scope.ts +112 -0
  105. package/lib/credential-store.ts +932 -0
  106. package/lib/credentials-activation-authority.ts +709 -0
  107. package/lib/credentials-purge.ts +1006 -0
  108. package/lib/custom-connection-capture.ts +395 -0
  109. package/lib/custom-connection-launch.ts +384 -0
  110. package/lib/custom-connection-runtime.ts +141 -0
  111. package/lib/db.ts +80 -0
  112. package/lib/disk-pressure.ts +42 -0
  113. package/lib/docker-exec.ts +156 -0
  114. package/lib/engine-accounts.ts +1970 -0
  115. package/lib/engine-connect.ts +321 -0
  116. package/lib/engine-credential-env.ts +52 -0
  117. package/lib/engine-login.ts +2933 -0
  118. package/lib/engines.ts +1342 -0
  119. package/lib/enrollment-state.ts +251 -0
  120. package/lib/env-file.ts +246 -0
  121. package/lib/env-schema.ts +937 -0
  122. package/lib/env.ts +195 -0
  123. package/lib/first-turn-receipts.ts +62 -0
  124. package/lib/fs-aliases.ts +49 -0
  125. package/lib/git-clone-credentials.ts +334 -0
  126. package/lib/git-diff.ts +530 -0
  127. package/lib/git-identity.ts +715 -0
  128. package/lib/github-git-auth.ts +207 -0
  129. package/lib/github-origin.ts +28 -0
  130. package/lib/github-tokens.ts +2439 -0
  131. package/lib/host-cloud-url.ts +62 -0
  132. package/lib/host-config.ts +279 -0
  133. package/lib/host-env.ts +424 -0
  134. package/lib/host-logs.ts +965 -0
  135. package/lib/internal-connections.ts +5 -0
  136. package/lib/keyed-promise-tail.ts +23 -0
  137. package/lib/launch-mode.ts +25 -0
  138. package/lib/lease.ts +239 -0
  139. package/lib/legacy-host.ts +769 -0
  140. package/lib/legacy-runtime-retirement.ts +424 -0
  141. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  142. package/lib/machine-exec.ts +238 -0
  143. package/lib/machine-gateway-tunnel.ts +344 -0
  144. package/lib/machine-keys.ts +65 -0
  145. package/lib/machine-project-env-loader.ts +152 -0
  146. package/lib/machine-provider-aws.ts +923 -0
  147. package/lib/machine-provider-local.ts +338 -0
  148. package/lib/machine-provider.ts +90 -0
  149. package/lib/managed-activation-watcher.ts +72 -0
  150. package/lib/managed-install-owner-watcher.ts +55 -0
  151. package/lib/managed-operation-drain.ts +57 -0
  152. package/lib/managed-runtime.ts +4296 -0
  153. package/lib/managed-update-scheduler.ts +125 -0
  154. package/lib/mcp-capture.ts +865 -0
  155. package/lib/mcp-config-lock.ts +2 -0
  156. package/lib/mcp-connections.ts +959 -0
  157. package/lib/mcp-gateway.ts +2594 -0
  158. package/lib/obs.ts +512 -0
  159. package/lib/openrouter-budget.ts +247 -0
  160. package/lib/orchestrator.ts +11409 -0
  161. package/lib/person-capture.ts +327 -0
  162. package/lib/preview-ports.ts +85 -0
  163. package/lib/preview-sidecar.ts +331 -0
  164. package/lib/project-env-authority.ts +1319 -0
  165. package/lib/project-env-feature.ts +74 -0
  166. package/lib/project-env-legacy-tags.ts +130 -0
  167. package/lib/project-env-loader.ts +242 -0
  168. package/lib/project-env-replica.ts +2094 -0
  169. package/lib/project-env-schema-capture.ts +342 -0
  170. package/lib/project-env-staging.ts +148 -0
  171. package/lib/project-env-task-policy.ts +176 -0
  172. package/lib/project-env-task-replica.ts +1752 -0
  173. package/lib/release-feed.ts +61 -0
  174. package/lib/release-manifest.ts +710 -0
  175. package/lib/release-trust.ts +46 -0
  176. package/lib/repo-clone.ts +38 -0
  177. package/lib/routing/shadow.ts +261 -0
  178. package/lib/runner-boot.ts +10 -0
  179. package/lib/runtime-activation-tail.ts +279 -0
  180. package/lib/runtime-archive.ts +1216 -0
  181. package/lib/runtime-authority.ts +166 -0
  182. package/lib/runtime-guard.ts +36 -0
  183. package/lib/runtime-provider-state.ts +169 -0
  184. package/lib/runtime-reconnect.ts +86 -0
  185. package/lib/runtime-state.ts +1033 -0
  186. package/lib/safe-http.ts +364 -0
  187. package/lib/secrets-proxy/address.ts +95 -0
  188. package/lib/secrets-proxy/audit.ts +30 -0
  189. package/lib/secrets-proxy/ca-store.ts +200 -0
  190. package/lib/secrets-proxy/ca.ts +470 -0
  191. package/lib/secrets-proxy/container-steps.ts +138 -0
  192. package/lib/secrets-proxy/der.ts +273 -0
  193. package/lib/secrets-proxy/engine.ts +1633 -0
  194. package/lib/secrets-proxy/host.ts +93 -0
  195. package/lib/secrets-proxy/hostname.ts +57 -0
  196. package/lib/secrets-proxy/placeholders.ts +196 -0
  197. package/lib/secrets-proxy/policy.ts +150 -0
  198. package/lib/secrets-proxy/readiness.ts +148 -0
  199. package/lib/secrets-proxy/runtime.ts +582 -0
  200. package/lib/secrets-proxy/scrub.ts +559 -0
  201. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  202. package/lib/secrets-proxy/task-scripts.ts +262 -0
  203. package/lib/secrets-proxy/task-trust.ts +605 -0
  204. package/lib/secrets-proxy/trust-watch.ts +80 -0
  205. package/lib/secrets.ts +71 -0
  206. package/lib/shared-files-s3.ts +976 -0
  207. package/lib/shared-files.ts +156 -0
  208. package/lib/sidecar-assign.ts +49 -0
  209. package/lib/sidecar-guard.ts +58 -0
  210. package/lib/sidecar-resume.ts +178 -0
  211. package/lib/sidecar.ts +69 -0
  212. package/lib/signin-box-credentials.ts +465 -0
  213. package/lib/signin-box.ts +114 -0
  214. package/lib/skill-index-example.fixture.ts +56 -0
  215. package/lib/skill-layout.ts +417 -0
  216. package/lib/skills.ts +489 -0
  217. package/lib/ssh.ts +401 -0
  218. package/lib/standard-image-cleanup.ts +117 -0
  219. package/lib/standard-image-pin.ts +207 -0
  220. package/lib/standard-image.ts +1697 -0
  221. package/lib/stopped-task-status-queue.ts +120 -0
  222. package/lib/task-container-cli.ts +269 -0
  223. package/lib/task-credential-cleanup.ts +752 -0
  224. package/lib/task-diff-sandbox.ts +464 -0
  225. package/lib/task-diff.ts +91 -0
  226. package/lib/task-env-forwarder.ts +228 -0
  227. package/lib/task-env-source.ts +249 -0
  228. package/lib/task-environment/anchor.ts +31 -0
  229. package/lib/task-environment/apple-container.ts +860 -0
  230. package/lib/task-environment/docker.ts +1344 -0
  231. package/lib/task-environment/index.ts +466 -0
  232. package/lib/task-environment/legacy-adoption.ts +467 -0
  233. package/lib/task-environment/machine-environment.ts +61 -0
  234. package/lib/task-environment/machine-task-up.ts +1227 -0
  235. package/lib/task-environment/machine.ts +974 -0
  236. package/lib/task-environment/registry.ts +59 -0
  237. package/lib/task-environment/self-identity.ts +20 -0
  238. package/lib/task-environment/self.ts +566 -0
  239. package/lib/task-environment/types.ts +642 -0
  240. package/lib/task-environment/workspace-files.ts +685 -0
  241. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  242. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  243. package/lib/task-files-machine-retirement.ts +116 -0
  244. package/lib/task-files-readiness.ts +127 -0
  245. package/lib/task-gateway-port.ts +22 -0
  246. package/lib/task-gateway-protocol.ts +190 -0
  247. package/lib/task-gateway-recovery.ts +47 -0
  248. package/lib/task-gateway-state.ts +512 -0
  249. package/lib/task-identity.ts +19 -0
  250. package/lib/task-inventory.ts +682 -0
  251. package/lib/task-machine-credentials-activation.ts +95 -0
  252. package/lib/task-machine-swap-safety.ts +729 -0
  253. package/lib/task-person-credentials.ts +76 -0
  254. package/lib/task-project-ops-host.ts +147 -0
  255. package/lib/task-project-ops.ts +1062 -0
  256. package/lib/task-secret-staging.ts +16 -0
  257. package/lib/task-status.ts +46 -0
  258. package/lib/task-token.ts +46 -0
  259. package/lib/token-broker-adopt.ts +344 -0
  260. package/lib/token-broker-lambda.ts +105 -0
  261. package/lib/token-broker-local.ts +392 -0
  262. package/lib/token-broker-revision.ts +310 -0
  263. package/lib/token-broker-ssm.ts +525 -0
  264. package/lib/token-broker.ts +218 -0
  265. package/lib/token-lanes/openai-oauth.ts +123 -0
  266. package/lib/token-lanes/registry.ts +45 -0
  267. package/lib/token-lanes/types.ts +163 -0
  268. package/lib/token-lanes/xai-oauth.ts +165 -0
  269. package/lib/transcript.ts +128 -0
  270. package/lib/tunnel-registry.ts +220 -0
  271. package/lib/tunnel-runtime.ts +235 -0
  272. package/lib/ulid.ts +16 -0
  273. package/lib/user-bus-env.ts +116 -0
  274. package/lib/user-credentials-store.ts +435 -0
  275. package/lib/vnode-pressure.ts +142 -0
  276. package/package.json +115 -8
  277. package/runner/runner.mjs +442 -0
  278. package/scripts/agent/_common.sh +633 -0
  279. package/scripts/agent/env-hook.sh +626 -0
  280. package/scripts/agent/moondeck-cli.sh +5 -0
  281. package/scripts/agent/task-down.sh +291 -0
  282. package/scripts/agent/task-status.sh +70 -0
  283. package/scripts/agent/task-up.sh +3761 -0
  284. package/scripts/agent/uai-env-exec.sh +26 -0
  285. package/scripts/install/darwin.ts +1048 -0
  286. package/scripts/install/linux.ts +958 -0
  287. package/scripts/install/types.ts +78 -0
  288. package/scripts/install/util.ts +254 -0
  289. package/scripts/install/win.ts +143 -0
  290. package/src/activation-respawn.ts +155 -0
  291. package/src/apple-tunnel-route.ts +104 -0
  292. package/src/bridge-diagnostics.ts +39 -0
  293. package/src/capture-wire.ts +102 -0
  294. package/src/cli.ts +2585 -0
  295. package/src/cloud-credentials.ts +2144 -0
  296. package/src/cloud-origins.ts +23 -0
  297. package/src/credential-location.ts +248 -0
  298. package/src/credential-wire-validation.ts +25 -0
  299. package/src/custom-connections.ts +237 -0
  300. package/src/engine-account-paths.ts +11 -0
  301. package/src/engine-models.ts +39 -0
  302. package/src/engine-usage.ts +104 -0
  303. package/src/enrollment-next-steps.ts +24 -0
  304. package/src/event-outbox.ts +185 -0
  305. package/src/index.ts +2907 -0
  306. package/src/load-env.ts +97 -0
  307. package/src/machine-tunnel-route.ts +156 -0
  308. package/src/main.ts +6363 -0
  309. package/src/never-proxied-domains.ts +31 -0
  310. package/src/outbound-frame.ts +31 -0
  311. package/src/paths.ts +81 -0
  312. package/src/project-env-latest.ts +130 -0
  313. package/src/project-env-policy.ts +396 -0
  314. package/src/project-env-wording.ts +53 -0
  315. package/src/protocol.ts +5550 -0
  316. package/src/runtime-bootstrap.ts +165 -0
  317. package/src/station-identity.ts +96 -0
  318. package/src/task-env-names.ts +28 -0
  319. package/src/task-secrets.ts +75 -0
  320. package/src/tunnel-dial.ts +102 -0
  321. package/src/ui/server.ts +852 -0
  322. package/src/ui/types.ts +173 -0
  323. package/src/workspace-path-policy.ts +41 -0
  324. package/ui/app.js +1102 -0
  325. package/ui/index.html +90 -0
  326. package/ui/style.css +685 -0
  327. package/ui/uai-favicon.svg +21 -0
  328. package/ui/uai-logo-black.svg +9 -0
  329. package/ui/uai-wheel.svg +9 -0
  330. package/bin/moondeck-station.mjs +0 -3
@@ -0,0 +1,605 @@
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
+ /** A credential cleanup closes this fence before it drains existing writers.
107
+ * Unlike an epoch bump, the seal also refuses writers that arrive while the
108
+ * cleanup is in progress. It is reopened only after a safe assignment/grant
109
+ * has been installed. */
110
+ const sealedTaskWrites = new Set<string>();
111
+
112
+ export function invalidateTaskWrites(taskId: string): void {
113
+ epochs.set(taskId, epochOf(taskId) + 1);
114
+ }
115
+
116
+ export function sealTaskWrites(taskId: string): void {
117
+ sealedTaskWrites.add(taskId);
118
+ invalidateTaskWrites(taskId);
119
+ }
120
+
121
+ export function openTaskWrites(taskId: string): void {
122
+ if (!sealedTaskWrites.delete(taskId)) return;
123
+ // Writers which observed the closed assignment must not become live merely
124
+ // because a later assignment opens the task again.
125
+ invalidateTaskWrites(taskId);
126
+ }
127
+
128
+ /** Join every root mutation already admitted for one task after its epoch has
129
+ * been invalidated. Callers close the task's other producers before awaiting
130
+ * this; a rejected superseded writer counts as drained. */
131
+ export async function drainTaskWrites(taskId: string): Promise<void> {
132
+ await (queues.get(taskId) ?? Promise.resolve()).catch(() => undefined);
133
+ }
134
+
135
+ /** Whether the runner is shutting down: nothing root may start in a task. */
136
+ export function taskWritesQuiescing(): boolean {
137
+ return quiescing;
138
+ }
139
+
140
+ /** True while the operation may still act: the runner is not shutting down,
141
+ * no stop, deletion or start has voided it, and its caller's own check (a
142
+ * recovery's generation) still holds. */
143
+ type Live = () => boolean;
144
+
145
+ /** Why an operation that is no longer live stops. */
146
+ function refusal(): Error {
147
+ return new Error(quiescing ? "the station is shutting down" : "this write was superseded");
148
+ }
149
+
150
+ /** Runs `step` after every earlier write of the task has ended. Refused once
151
+ * shutdown has begun: a write that started after its runner's last moment
152
+ * could land over the replacement's. */
153
+ function mutate<T>(taskId: string, step: (live: Live) => Promise<T>, isCurrent?: () => boolean): Promise<T> {
154
+ if (quiescing) return Promise.reject(new Error("the station is shutting down"));
155
+ if (sealedTaskWrites.has(taskId)) {
156
+ return Promise.reject(new Error("this task's credential gate is closed"));
157
+ }
158
+ const epoch = epochOf(taskId);
159
+ const live: Live = () =>
160
+ !quiescing &&
161
+ !sealedTaskWrites.has(taskId) &&
162
+ epochOf(taskId) === epoch &&
163
+ (isCurrent === undefined || isCurrent());
164
+ const previous = queues.get(taskId) ?? Promise.resolve();
165
+ const next = previous
166
+ .catch(() => undefined)
167
+ .then(() => {
168
+ if (!live()) throw refusal();
169
+ return step(live);
170
+ });
171
+ queues.set(taskId, next);
172
+ const settled = (): void => {
173
+ if (queues.get(taskId) === next) queues.delete(taskId);
174
+ };
175
+ next.then(settled, settled);
176
+ return next;
177
+ }
178
+
179
+ /** Every exec of a queued step goes through this: once the operation is not
180
+ * live (shutdown, a stop, a deletion, a new start, a stale recovery), no
181
+ * further exec starts, even inside an operation already under way, and
182
+ * not even one already asked for whose provider is still looking up where
183
+ * to run it (the request's `admit`). The
184
+ * steps are ordered so stopping between any two leaves nothing unsafe (the
185
+ * chain before the names when pinning, the names first when unpinning), and
186
+ * whatever writes next reconciles what is left. */
187
+ function guarded(environment: Exec, live: Live): Exec {
188
+ return {
189
+ descriptor: environment.descriptor,
190
+ exec: (request) => {
191
+ if (!live()) return Promise.reject(refusal());
192
+ // And again where the provider starts the process: a machine looks
193
+ // up its address first, which can take as long as a cloud API call.
194
+ const admit = (): void => {
195
+ request.admit?.();
196
+ if (!live()) throw refusal();
197
+ };
198
+ return environment.exec({ ...request, admit });
199
+ },
200
+ };
201
+ }
202
+
203
+ /** Shutdown: refuses every new write and exec, and resolves once those under
204
+ * way (a recovery's CA install, a re-pin, a forwarder launch) have ended. */
205
+ export async function quiesceTaskProxyMutations(): Promise<void> {
206
+ quiescing = true;
207
+ await Promise.all([...queues.values()].map((queued) => queued.catch(() => undefined)));
208
+ }
209
+
210
+ /**
211
+ * Any other root step a lifecycle takes in a task (the recovery's forwarder
212
+ * launch), run in the same queue and under the same fence: never once
213
+ * shutdown has begun, a stop, deletion or start has voided it, or
214
+ * `isCurrent` says the operation is stale; joined by shutdown. The step
215
+ * reaches the task only through the `environment` it is given, whose every
216
+ * exec is fenced when it is made, not only when the step begins: the
217
+ * launch awaits the gateway's address, and on a machine installs its
218
+ * assets, before the exec that starts the forwarder.
219
+ */
220
+ export function runTaskRootStep<T>(
221
+ environment: TaskRootExec,
222
+ step: (environment: TaskRootExec, live: () => boolean) => Promise<T>,
223
+ isCurrent?: () => boolean,
224
+ ): Promise<T> {
225
+ return mutate(environment.descriptor.taskId, (live) => step(guarded(environment, live), live), isCurrent);
226
+ }
227
+
228
+ /**
229
+ * A start's exclusive hold on the task's writes, from before its root steps
230
+ * until its result is recorded. Earlier writers are voided (they finish at
231
+ * most the exec under way) and waited for; none runs until the returned
232
+ * release. Rejects once shutdown has begun.
233
+ */
234
+ function holdTaskWritesForStart(taskId: string): Promise<() => void> {
235
+ invalidateTaskWrites(taskId);
236
+ return new Promise((resolve, reject) => {
237
+ mutate(
238
+ taskId,
239
+ () =>
240
+ new Promise<void>((release) => {
241
+ resolve(release);
242
+ }),
243
+ ).catch(reject);
244
+ });
245
+ }
246
+
247
+ /** The holds of the starts under way, each ended once (finish or close). */
248
+ const startHolds = new Map<string, () => void>();
249
+
250
+ function endStartHold(taskId: string): void {
251
+ const release = startHolds.get(taskId);
252
+ startHolds.delete(taskId);
253
+ release?.();
254
+ }
255
+
256
+ // --- the steps, run only inside a task's queue --------------------------------
257
+
258
+ async function installStep(environment: Exec, live: Live, bundle: string): Promise<void> {
259
+ const { taskId } = environment.descriptor;
260
+ try {
261
+ await installTaskTrust(environment, bundle);
262
+ } catch (error) {
263
+ if (live()) recordTaskProxyReadiness(taskId, { trust: "failed", bundle: null });
264
+ throw error;
265
+ }
266
+ if (live()) recordTaskProxyReadiness(taskId, { trust: "installed", bundle });
267
+ }
268
+
269
+ /** Unpinned tasks, while the proxy is off: their pins are known to be gone. */
270
+ const unpinned = new Set<string>();
271
+
272
+ /** With the proxy off: the pins out, once (recorded until the task pins
273
+ * again); a failure is not recorded, so the next sweep tries again. */
274
+ async function unpinStep(environment: Exec, live: Live): Promise<void> {
275
+ const { taskId } = environment.descriptor;
276
+ if (unpinned.has(taskId)) return;
277
+ await pinStep(environment, live, []);
278
+ if (live()) {
279
+ forgetTaskProxyReadiness(taskId);
280
+ unpinned.add(taskId);
281
+ }
282
+ }
283
+
284
+ async function pinStep(environment: Exec, live: Live, domains: readonly string[]): Promise<void> {
285
+ const { taskId } = environment.descriptor;
286
+ try {
287
+ const proxied = RUNTIMES.get(environment.descriptor.locator.provider);
288
+ if (proxied === undefined) throw new Error("the proxy does not run on this task's runtime");
289
+ await reconcileTaskPins(environment, {
290
+ domains,
291
+ listenPort: proxied.listenPort(),
292
+ netAdmin: proxied.netAdmin,
293
+ ...(proxied.uidOwner === undefined ? {} : { uidOwner: proxied.uidOwner }),
294
+ ...(proxied.hostsView === undefined
295
+ ? {}
296
+ : {
297
+ hostsView: {
298
+ path: proxied.hostsView.path,
299
+ from: proxied.hostsView.from,
300
+ proven: () => proxied.hostsView?.proven(taskId) === true,
301
+ },
302
+ }),
303
+ });
304
+ } catch (error) {
305
+ // Whatever was pinned may now be half-rewritten: nothing counts as pinned.
306
+ if (live()) recordTaskProxyReadiness(taskId, { pins: "failed", pinnedDomains: [] });
307
+ unpinned.delete(taskId);
308
+ throw error;
309
+ }
310
+ if (live()) {
311
+ recordTaskProxyReadiness(taskId, { pins: domains.length > 0 ? "installed" : "skipped", pinnedDomains: [...domains] });
312
+ }
313
+ }
314
+
315
+ function sameDomains(left: readonly string[], right: readonly string[]): boolean {
316
+ return left.length === right.length && left.every((domain, index) => domain === right[index]);
317
+ }
318
+
319
+ // --- task-up ---------------------------------------------------------------
320
+
321
+ function stepsDir(dataDir: string, taskId: string): string {
322
+ if (!isSafeHostTaskId(taskId)) throw new Error("invalid host task identity");
323
+ return join(dataDir, "secrets-proxy", "task-up", taskId);
324
+ }
325
+
326
+ /**
327
+ * Begins a task-up behind the proxy. First the start takes the task's writes
328
+ * (holdTaskWritesForStart), so no older writer (a recovery, a re-pin) lands
329
+ * over its steps and task-up is the task's only writer until its result is
330
+ * recorded. Then it writes what task-up.sh runs as root inside the
331
+ * container: the CA bundle, its install script, and the chain and pin
332
+ * scripts for the task's current domains, with the order to run them in.
333
+ * Public material only (certificates and fixed scripts). task-up writes each
334
+ * step's outcome back into the same directory as it goes.
335
+ *
336
+ * Returns the directory, passed to task-up.sh as UAI_SECRETS_PROXY_DIR, or
337
+ * null once the runner is shutting down: the task then starts without the
338
+ * proxy's steps, its proxied keys withheld, and the next runner reconciles
339
+ * it. finishTaskUpTrustSteps ends the hold; closeTaskUpTrustSteps ends it
340
+ * for a start that never got that far.
341
+ */
342
+ export async function writeTaskUpTrustSteps(
343
+ dataDir: string,
344
+ taskId: string,
345
+ runtime: SecretsProxyRuntime,
346
+ ): Promise<string | null> {
347
+ const dir = stepsDir(dataDir, taskId);
348
+ let release: () => void;
349
+ try {
350
+ release = await holdTaskWritesForStart(taskId);
351
+ } catch {
352
+ forgetTaskProxyReadiness(taskId);
353
+ return null;
354
+ }
355
+ startHolds.set(taskId, release);
356
+ try {
357
+ rmSync(dir, { recursive: true, force: true });
358
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
359
+ chmodSync(dir, 0o700);
360
+ const bundle = runtime.caBundlePem();
361
+ const domains = runtime.domainsFor(taskId);
362
+ const files: Record<string, string> = {
363
+ "ca-bundle.pem": bundle,
364
+ "ca-install.sh": caInstallScript(),
365
+ "pins.sh": hostsReconcileScript(domains),
366
+ "chain.sh": redirectChainScript({ toPort: TASK_FORWARDER_LISTEN_PORT, enabled: domains.length > 0 }),
367
+ // As for a running task: the chain before the names when pinning, the
368
+ // names first when there is nothing to pin.
369
+ "routing-order": domains.length > 0 ? "pin\n" : "unpin\n",
370
+ };
371
+ for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body, { mode: 0o600 });
372
+ unpinned.delete(taskId);
373
+ beginTaskStart(taskId, { dir, bundle, domains });
374
+ } catch (error) {
375
+ forgetTaskProxyReadiness(taskId);
376
+ endStartHold(taskId);
377
+ throw error;
378
+ }
379
+ return dir;
380
+ }
381
+
382
+ /** Ends a task-up: what it reported becomes the task's readiness (nothing,
383
+ * when it failed), its directory goes, and its hold on the task's writes
384
+ * ends, so a writer queued meanwhile reconciles from what it recorded. */
385
+ export function finishTaskUpTrustSteps(
386
+ dataDir: string,
387
+ taskId: string,
388
+ result: { secretsProxyTrust?: ProxyStepOutcome; secretsProxyPins?: ProxyStepOutcome } | null,
389
+ ): void {
390
+ try {
391
+ const pending = endTaskStart(taskId);
392
+ if (isSafeHostTaskId(taskId)) rmSync(stepsDir(dataDir, taskId), { recursive: true, force: true });
393
+ if (pending === undefined || result === null) {
394
+ forgetTaskProxyReadiness(taskId);
395
+ return;
396
+ }
397
+ const trust = result.secretsProxyTrust ?? "skipped";
398
+ const pins = result.secretsProxyPins ?? "skipped";
399
+ recordTaskProxyReadiness(taskId, {
400
+ trust,
401
+ bundle: trust === "installed" ? pending.bundle : null,
402
+ pins,
403
+ pinnedDomains: pins === "installed" ? pending.domains : [],
404
+ // task-up fails outright unless its forwarder started.
405
+ forwarder: true,
406
+ });
407
+ } finally {
408
+ endStartHold(taskId);
409
+ }
410
+ }
411
+
412
+ /** The end of every start, whatever happened: one that never reported
413
+ * counts as failed (and its hold ends). Nothing once it was finished. */
414
+ export function closeTaskUpTrustSteps(dataDir: string, taskId: string): void {
415
+ if (isTaskStarting(taskId)) finishTaskUpTrustSteps(dataDir, taskId, null);
416
+ }
417
+
418
+ // --- reconciling a running task -----------------------------------------------
419
+
420
+ /**
421
+ * Brings one running Docker or Apple task to where it should be, doing only
422
+ * what differs from its recorded readiness, in its queue:
423
+ * - proxy on: the current CA bundle, then pins for its current domains;
424
+ * - proxy off (`runtime` null): no pins, so formerly proxied names go
425
+ * direct again.
426
+ * Any other runtime is left untouched. Level-triggered, so every path that
427
+ * may have missed something (the end of a start, a change of policy, the
428
+ * trust watcher's retries, the proxy-off sweep) simply calls it again.
429
+ * Throws if a step failed; what did land is recorded.
430
+ */
431
+ export function reconcileTaskProxyState(
432
+ target: Exec,
433
+ runtime: SecretsProxyRuntime | null,
434
+ isCurrent?: () => boolean,
435
+ ): Promise<void> {
436
+ if (!takesTrust(target)) return Promise.resolve();
437
+ const { taskId } = target.descriptor;
438
+ return mutate(
439
+ taskId,
440
+ async (live) => {
441
+ const environment = guarded(target, live);
442
+ if (runtime === null) {
443
+ await unpinStep(environment, live);
444
+ if (!live()) throw refusal();
445
+ return;
446
+ }
447
+ unpinned.delete(taskId);
448
+ const bundle = runtime.caBundlePem();
449
+ const ready = taskProxyReadiness(taskId);
450
+ if (ready.trust !== "installed" || ready.bundle !== bundle) await installStep(environment, live, bundle);
451
+ // Voided while its CA exec ran (a stop, a recovery beginning): it ends
452
+ // there, and what it read before counts for nothing.
453
+ if (!live()) throw refusal();
454
+ const domains = runtime.domainsFor(taskId);
455
+ const after = taskProxyReadiness(taskId);
456
+ const pinned = domains.length > 0 ? after.pins === "installed" : after.pins === "skipped";
457
+ if (!pinned || !sameDomains(after.pinnedDomains, domains)) {
458
+ // Never a name pinned with nothing listening behind it: until a
459
+ // start or a recovery has the task's forwarder running, the pins
460
+ // wait for it (a recovery pins in the same slot as its launch).
461
+ // Taking pins out needs no forwarder.
462
+ if (domains.length > 0 && !after.forwarder) {
463
+ throw new Error("the task's forwarder is not known to run; its next recovery pins it");
464
+ }
465
+ await pinStep(environment, live, domains);
466
+ // Its last exec may have been the one under way when it was voided:
467
+ // landed, but recorded nowhere, so it does not count as done.
468
+ if (!live()) throw refusal();
469
+ }
470
+ },
471
+ isCurrent,
472
+ );
473
+ }
474
+
475
+ /** A proxy step that failed during a recovery: the task comes up without
476
+ * the proxy, its proxied keys withheld, and the trust watcher retries. */
477
+ export interface RecoveryProxyFailure {
478
+ readonly error: unknown;
479
+ }
480
+
481
+ /**
482
+ * A task's root steps in the order a start takes them (ADR-153 "Order"), in
483
+ * one slot of its queue so no other writer lands between them: for a
484
+ * recovery, and for a machine sidecar's start and resume, which run the
485
+ * steps themselves rather than through task-up.sh. Each step runs again
486
+ * whatever is recorded: a restarted runner records nothing, and a restarted
487
+ * VM or machine keeps none of it.
488
+ * - Proxy on: the CA, then `launch` (the task's forwarder; nothing on a
489
+ * machine, whose sidecar serves the task from its own gateway), then the
490
+ * pins for its current domains.
491
+ * - Proxy off: the pins out, then `launch`.
492
+ * No name is pinned before its forwarder runs. A launch that fails pins
493
+ * nothing, takes out the pins the task still holds, and rejects, and the
494
+ * caller keeps consumers out. A failed proxy
495
+ * step does not stop the launch; it is returned, and after a failed CA
496
+ * nothing is pinned either. `proxy` null (a baked task), or a runtime the
497
+ * runner does not put behind the proxy, runs `launch` alone. A stop,
498
+ * deletion, start, stale recovery or shutdown rejects the slot at whichever
499
+ * step it meets.
500
+ */
501
+ export function applyTaskRootSteps(
502
+ target: Exec,
503
+ proxy: { readonly runtime: SecretsProxyRuntime | null } | null,
504
+ launch: (environment: TaskRootExec) => Promise<void>,
505
+ isCurrent?: () => boolean,
506
+ ): Promise<RecoveryProxyFailure | null> {
507
+ const { taskId } = target.descriptor;
508
+ const runtime = proxy?.runtime ?? null;
509
+ const proxied = proxy !== null && takesTrust(target);
510
+ return runTaskRootStep(
511
+ target,
512
+ async (environment, live) => {
513
+ let failure: RecoveryProxyFailure | null = null;
514
+ // A step's failure is the proxy's only while the slot is live; one
515
+ // that met a stop, a stale recovery or shutdown ends the slot.
516
+ const attempt = async (step: () => Promise<void>): Promise<void> => {
517
+ try {
518
+ await step();
519
+ } catch (error) {
520
+ if (!live()) throw error;
521
+ failure = { error };
522
+ return;
523
+ }
524
+ // Its exec may have been the one under way when the slot stopped.
525
+ if (!live()) throw refusal();
526
+ };
527
+ if (proxied) {
528
+ // Replayed because a restart may have taken all of it: nothing
529
+ // recorded before counts until a step here lands it again, so a step
530
+ // that fails leaves the task undeliverable, never ready on the
531
+ // strength of a VM that is gone.
532
+ forgetTaskProxyReadiness(taskId);
533
+ if (runtime === null) {
534
+ await attempt(() => unpinStep(environment, live));
535
+ } else {
536
+ unpinned.delete(taskId);
537
+ await attempt(() => installStep(environment, live, runtime.caBundlePem()));
538
+ }
539
+ }
540
+ if (!live()) throw refusal();
541
+ try {
542
+ await launch(environment);
543
+ } catch (error) {
544
+ // No forwarder, so no name may point at one. Pins already in the task
545
+ // (the earlier VM's, or the last exec of a reconcile voided while it
546
+ // ran, which no fence can call back) come out, as far as the task
547
+ // lets them.
548
+ if (proxied && runtime !== null && live()) await pinStep(environment, live, []).catch(() => undefined);
549
+ throw error;
550
+ }
551
+ if (!live()) throw refusal();
552
+ if (proxied) recordTaskProxyReadiness(taskId, { forwarder: true });
553
+ if (proxied && runtime !== null && failure === null) {
554
+ await attempt(() => pinStep(environment, live, runtime.domainsFor(taskId)));
555
+ }
556
+ return failure;
557
+ },
558
+ isCurrent,
559
+ );
560
+ }
561
+
562
+ /**
563
+ * A machine sidecar's own task, at its start and whenever the sidecar comes
564
+ * back: the proxy's steps in a start's order, the CA and then the pins (the
565
+ * sidecar's gateway, in this process, is the task's listener, so there is
566
+ * nothing to launch); with the proxy off, the pins out. A start first voids
567
+ * any write queued before it. Never fails the task: a failed step leaves it
568
+ * up without the proxy, its keys withheld, and asks the trust watcher to
569
+ * retry. Returns what failed, for the caller's log, or null.
570
+ */
571
+ export async function reconcileSidecarTaskProxy(
572
+ environment: TaskRootExec,
573
+ runtime: SecretsProxyRuntime | null,
574
+ options: { start: boolean; isCurrent?: () => boolean },
575
+ ): Promise<string | null> {
576
+ if (!takesTrust(environment)) return null;
577
+ // A start voids every write queued or under way before it. Whether the
578
+ // task's hosts view counts is not its to say: task-up admits a task with
579
+ // no row before recording it, and every other task stands as recorded.
580
+ if (options.start) invalidateTaskWrites(environment.descriptor.taskId);
581
+ let failure: unknown = null;
582
+ try {
583
+ failure = (await applyTaskRootSteps(environment, { runtime }, async () => undefined, options.isCurrent))?.error ?? null;
584
+ } catch (error) {
585
+ failure = error;
586
+ }
587
+ if (failure === null) return null;
588
+ runtime?.requestTrustDelivery();
589
+ return `secrets proxy: ${failure instanceof Error ? failure.message : String(failure)}`;
590
+ }
591
+
592
+ /**
593
+ * The live path for a change of the task's proxied domains. During a
594
+ * task-up, task-up is the only routing writer: its end reconciles the then
595
+ * current domains instead (reconcileTaskProxyState after the start).
596
+ */
597
+ export function reconcileTaskRouting(environment: Exec, runtime: SecretsProxyRuntime): Promise<void> {
598
+ if (isTaskStarting(environment.descriptor.taskId)) return Promise.resolve();
599
+ return reconcileTaskProxyState(environment, runtime);
600
+ }
601
+
602
+ /** With the proxy off: unpins the task (once; recorded until it pins again). */
603
+ export function unpinTask(environment: Exec, isCurrent?: () => boolean): Promise<void> {
604
+ return reconcileTaskProxyState(environment, null, isCurrent);
605
+ }
@@ -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
+ }