@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,31 @@
1
+ /**
2
+ * Domains a key may never be proxied to (ADR-153 §4 "Fail closed"), a name
3
+ * itself or any name under it. GitHub keeps its own credential path (the GH
4
+ * identity work), and the product's own domains, runuai.com and moondeck.io
5
+ * both, carry the runner's and the cloud's traffic; moondeckapp.com serves
6
+ * user content (previews, the editor) as runuai.com's preview names do. A name with no dot, or
7
+ * under `localhost`, can never hold a public certificate.
8
+ *
9
+ * The one list: the runner's proxy and `.env.schema` parser, the protocol's
10
+ * binding checks and the cloud's task secrets all call isNeverProxiedDomain.
11
+ * lib/__tests__/never-proxied-domains.test.ts checks that each of those three
12
+ * lets its result decide (a `&&`-joined veto in its `return`, or an
13
+ * `||`-joined trigger in a throwing `if`), and fails on
14
+ * any other array literal in the source naming two or more of these domains
15
+ * (literals, concatenations and scope-resolved const strings). It cannot see
16
+ * a copy written another way (a regular expression, a list built at
17
+ * runtime): don't write one.
18
+ */
19
+ export const NEVER_PROXIED_DOMAIN_SUFFIXES = [
20
+ "github.com",
21
+ "githubusercontent.com",
22
+ "runuai.com",
23
+ "moondeck.io",
24
+ "moondeckapp.com",
25
+ "localhost",
26
+ ] as const;
27
+
28
+ /** For a canonical (lower-case, no trailing dot) hostname. */
29
+ export function isNeverProxiedDomain(name: string): boolean {
30
+ return NEVER_PROXIED_DOMAIN_SUFFIXES.some((suffix) => name === suffix || name.endsWith(`.${suffix}`));
31
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * What the runner may send the cloud. The cloud's bridge closes a runner's
3
+ * connection on any frame past its ceiling (100 MiB, lib/host-bridge.ts), so
4
+ * a frame that would serialize past it is never sent: a command's result is
5
+ * replaced by its failure, and anything else is dropped. Either way the
6
+ * connection, and every other task's traffic on it, stays open.
7
+ */
8
+ import { HostErrorCode, type HostToCloud } from "./protocol";
9
+
10
+ /** Under the cloud's ceiling, with room to spare. */
11
+ export const MAX_OUTBOUND_FRAME_BYTES = 96 * 1024 * 1024;
12
+
13
+ /** The serialized frame to send for `frame`: itself, a command result's
14
+ * failure in its place, or null when nothing of it can be sent. */
15
+ export function outboundFrameJson(frame: HostToCloud): string | null {
16
+ const json = JSON.stringify(frame);
17
+ if (Buffer.byteLength(json, "utf8") <= MAX_OUTBOUND_FRAME_BYTES) return json;
18
+ if (frame.kind === "result") {
19
+ return JSON.stringify({
20
+ kind: "result",
21
+ commandId: frame.commandId,
22
+ result: {
23
+ ok: false,
24
+ code: HostErrorCode.Internal,
25
+ message: "the result is too large to send",
26
+ },
27
+ } satisfies HostToCloud);
28
+ }
29
+ console.warn(`[host] dropped an outbound ${frame.kind} frame past the bridge's size limit`);
30
+ return null;
31
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Shared filesystem paths for the host service + local UI (ADR-028).
3
+ * Used by main.ts (server boot), cli.ts (status/open/logs), and the per-OS
4
+ * install modules so the log/port/asset locations have a single definition.
5
+ */
6
+
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { dirname, join, resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ import { env } from "../lib/env";
13
+ import { STATION } from "./station-identity";
14
+
15
+ const srcDir = dirname(fileURLToPath(import.meta.url)); // host-agent/src
16
+
17
+ /** Base dir for host state — the repo root in a checkout, ~/.moondeck when
18
+ * installed via npm. Used as the service working directory. */
19
+ export function uaiHome(): string {
20
+ return env.uaiHome;
21
+ }
22
+
23
+ /** `.env.local` under UAI_HOME — where `setup`/`pair` persist the credential. */
24
+ export function envLocalPath(): string {
25
+ return join(env.uaiHome, ".env.local");
26
+ }
27
+
28
+ /** Static UI asset dir (`host-agent/ui`). */
29
+ export function uiAssetsDir(): string {
30
+ return resolve(srcDir, "..", "ui");
31
+ }
32
+
33
+ /** Where the chosen UI port is persisted (`$UAI_DATA_DIR/host.port`). */
34
+ export function uiPortFilePath(): string {
35
+ return join(env.dataDir, "host.port");
36
+ }
37
+
38
+ /** Stable host identity persisted by the daemon for legacy/token-only setups. */
39
+ export function hostIdFilePath(): string {
40
+ return join(env.dataDir, "host-id");
41
+ }
42
+
43
+ /** Read the daemon's persisted identity without creating one. */
44
+ export function readPersistedHostId(): string | null {
45
+ const path = hostIdFilePath();
46
+ if (!existsSync(path)) return null;
47
+ try {
48
+ return readFileSync(path, "utf8").trim() || null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /** Per-OS service log path — informational in `/api/status`, the file
55
+ * `uai-host logs` tails. Linux logs to journald, so returns a hint there. */
56
+ export function serviceLogPath(): string {
57
+ if (process.platform === "darwin") {
58
+ return resolve(homedir(), "Library", "Logs", STATION.logDirName, STATION.logFileName);
59
+ }
60
+ if (process.platform === "win32") {
61
+ return join(
62
+ process.env.LOCALAPPDATA ?? homedir(),
63
+ STATION.logDirName,
64
+ "service",
65
+ "logs",
66
+ STATION.logFileName,
67
+ );
68
+ }
69
+ return `journald (journalctl --user -u ${STATION.command})`;
70
+ }
71
+
72
+ /** `@moondeck/station` package version (read at runtime; no JSON-import dance). */
73
+ export function packageVersion(): string {
74
+ try {
75
+ const raw = readFileSync(resolve(srcDir, "..", "package.json"), "utf8");
76
+ const pkg = JSON.parse(raw) as { version?: string };
77
+ return pkg.version ?? "0.0.0";
78
+ } catch {
79
+ return "0.0.0";
80
+ }
81
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * ADR-153 §4, "The latest observation": the one order the cloud keeps each
3
+ * project's latest schema observation by. The state is the greatest report
4
+ * the cloud has accepted, so it never depends on the order reports arrive
5
+ * in. Pure: the cloud's storage applies a move atomically, with the
6
+ * project's next generation.
7
+ *
8
+ * Reports are ordered by, in turn:
9
+ * 1. what they say: nothing about the schema, below a proven-empty
10
+ * repository, below a reading of a commit (readable or not);
11
+ * 2. the parser epoch;
12
+ * 3. for readings of a commit, the commit time, then the commit id;
13
+ * 4. a resolved reading over an unreadable one of the same commit;
14
+ * 5. their canonical bytes.
15
+ */
16
+
17
+ import { isProjectEnvSchemaObservation, type ProjectEnvSchemaObservation } from "./protocol";
18
+
19
+ /** A report the cloud could not read, or a stored state that fails its
20
+ * check: it says nothing about the schema, from epoch 0. */
21
+ export interface UnreadableProjectEnvSchemaReport {
22
+ status: "unreadable_report";
23
+ }
24
+
25
+ /** A report as the order reads it. */
26
+ export type OrderedProjectEnvSchemaReport = ProjectEnvSchemaObservation | UnreadableProjectEnvSchemaReport;
27
+
28
+ /** A project's latest observation: none until the first report. */
29
+ export type LatestProjectEnvSchema = { status: "none" } | OrderedProjectEnvSchemaReport;
30
+
31
+ const UNREADABLE: UnreadableProjectEnvSchemaReport = { status: "unreadable_report" };
32
+
33
+ /** A report checked against the contract: one it rejects, including one from
34
+ * a parser epoch newer than this build knows, says nothing. */
35
+ export function orderedProjectEnvSchemaReport(input: unknown): OrderedProjectEnvSchemaReport {
36
+ return isProjectEnvSchemaObservation(input) ? input : UNREADABLE;
37
+ }
38
+
39
+ /** JSON with every object's keys sorted, no whitespace, and arrays in their
40
+ * own order. As on the wire, a field set to undefined is absent, and an
41
+ * array's hole or undefined item is null. */
42
+ function canonicalJson(value: unknown): string {
43
+ if (Array.isArray(value)) return `[${Array.from(value, (item) => (item === undefined ? "null" : canonicalJson(item))).join(",")}]`;
44
+ if (value !== null && typeof value === "object") {
45
+ const fields = Object.entries(value)
46
+ .filter(([, inner]) => inner !== undefined)
47
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
48
+ return `{${fields.map(([key, inner]) => `${JSON.stringify(key)}:${canonicalJson(inner)}`).join(",")}}`;
49
+ }
50
+ return JSON.stringify(value) ?? "null";
51
+ }
52
+
53
+ /** The report's canonical bytes: its canonical JSON as UTF-8. Two reports
54
+ * with the same bytes are the same report as JSON on the wire. */
55
+ export function canonicalProjectEnvSchemaReportBytes(report: OrderedProjectEnvSchemaReport): Uint8Array {
56
+ return new TextEncoder().encode(canonicalJson(report));
57
+ }
58
+
59
+ /** Byte by byte as unsigned values; a prefix before anything longer that
60
+ * starts with it. */
61
+ function compareBytes(a: Uint8Array, b: Uint8Array): number {
62
+ const shared = Math.min(a.length, b.length);
63
+ for (let index = 0; index < shared; index += 1) {
64
+ if (a[index] !== b[index]) return a[index]! < b[index]! ? -1 : 1;
65
+ }
66
+ return Math.sign(a.length - b.length);
67
+ }
68
+
69
+ /** What a report says: nothing about the schema (0), that the repository is
70
+ * empty (1), or a reading of a commit, readable or not (2). */
71
+ function says(report: OrderedProjectEnvSchemaReport): 0 | 1 | 2 {
72
+ if (report.status === "unreadable_report" || report.baseOid === null) {
73
+ return report.status === "resolved" ? 1 : 0;
74
+ }
75
+ return 2;
76
+ }
77
+
78
+ const epochOf = (report: OrderedProjectEnvSchemaReport): number =>
79
+ report.status === "unreadable_report" ? 0 : report.parserEpoch;
80
+
81
+ /** Commit time, then commit id (lowercase hex), for two readings of a commit. */
82
+ function compareCommits(a: OrderedProjectEnvSchemaReport, b: OrderedProjectEnvSchemaReport): number {
83
+ if (a.status === "unreadable_report" || b.status === "unreadable_report") return 0;
84
+ if (a.baseOid === null || b.baseOid === null) return 0;
85
+ const time = Math.sign((a.baseCommitTimeMs ?? 0) - (b.baseCommitTimeMs ?? 0));
86
+ if (time !== 0) return time;
87
+ return a.baseOid < b.baseOid ? -1 : a.baseOid > b.baseOid ? 1 : 0;
88
+ }
89
+
90
+ const readable = (report: OrderedProjectEnvSchemaReport): number => (report.status === "resolved" ? 1 : 0);
91
+
92
+ /** Negative, zero or positive as `a` is below, the same report as, or above
93
+ * `b`. Zero only for reports with the same canonical bytes. */
94
+ export function compareProjectEnvSchemaReports(a: OrderedProjectEnvSchemaReport, b: OrderedProjectEnvSchemaReport): number {
95
+ return (
96
+ Math.sign(says(a) - says(b)) ||
97
+ Math.sign(epochOf(a) - epochOf(b)) ||
98
+ compareCommits(a, b) ||
99
+ Math.sign(readable(a) - readable(b)) ||
100
+ compareBytes(canonicalProjectEnvSchemaReportBytes(a), canonicalProjectEnvSchemaReportBytes(b))
101
+ );
102
+ }
103
+
104
+ function isNone(value: unknown): boolean {
105
+ return (
106
+ value !== null &&
107
+ typeof value === "object" &&
108
+ Object.keys(value).length === 1 &&
109
+ Object.hasOwn(value, "status") &&
110
+ (value as { status: unknown }).status === "none"
111
+ );
112
+ }
113
+
114
+ /**
115
+ * Where one report leaves a project's latest observation. It moves only when
116
+ * the report is greater than the stored state, and nothing returns it to
117
+ * none. A stored state that fails its check counts as a report the cloud
118
+ * could not read, so any valid report repairs it.
119
+ */
120
+ export function nextLatestProjectEnvSchema(
121
+ stored: unknown,
122
+ reportInput: unknown,
123
+ ): { moved: boolean; latest: LatestProjectEnvSchema } {
124
+ const report = orderedProjectEnvSchemaReport(reportInput);
125
+ if (isNone(stored)) return { moved: true, latest: report };
126
+ const current = orderedProjectEnvSchemaReport(stored);
127
+ return compareProjectEnvSchemaReports(report, current) > 0
128
+ ? { moved: true, latest: report }
129
+ : { moved: false, latest: current };
130
+ }
@@ -0,0 +1,396 @@
1
+ /**
2
+ * ADR-153 §4 "Pinning": what a task gets for one stored env key, from what
3
+ * the repository declares for it and what the owner approved (its binding).
4
+ *
5
+ * One derivation for the runner, which applies it to each task's own
6
+ * immutable schema observation, and for the web API, which applies it to a
7
+ * project's latest observation to show the settings page and to know what
8
+ * Approve would apply. The two never disagree, and the browser only renders
9
+ * what the server derived. Pure: no storage, no I/O.
10
+ *
11
+ * The bindings, from least to most the owner approved:
12
+ * - `omit` or `withhold`: nothing reaches the task.
13
+ * - none (null): today's plain value is delivered, and no domain is
14
+ * approved, so a declaration that asks for proxying waits for approval.
15
+ * - `proxy(domains)`: the key is proxied to those domains only; never
16
+ * delivered.
17
+ * - `passthrough`: the real value may be delivered, so proxying it anywhere
18
+ * the repository declares exposes nothing more. It is the top: nothing
19
+ * is ever proposed beyond it.
20
+ * A declaration never widens a binding: anything it asks for beyond it is a
21
+ * proposal, which only the owner's Approve applies, and until then the task
22
+ * gets the narrower policy. Approving moves a binding up, so tasks on other
23
+ * commits only gain (the first proxy approval of an unbound key is the one
24
+ * exception: it stops delivering the plain value, which is its point).
25
+ *
26
+ * Inputs come from storage and frames, so they are checked here with the
27
+ * contract's guards: a binding the contract rejects withholds the key, and
28
+ * schema evidence that is not exactly one of its two shapes, with a
29
+ * declaration the contract accepts, counts as a schema that could not be
30
+ * read.
31
+ */
32
+
33
+ import {
34
+ MAX_PROJECT_ENV_PROXY_DOMAINS,
35
+ isDeclaredProjectEnvBinding,
36
+ isProjectEnvBinding,
37
+ isProjectEnvSchemaDeclaration,
38
+ type ProjectEnvBinding,
39
+ type ProjectEnvPlaceholderShape,
40
+ type ProjectEnvSchemaDeclaration,
41
+ } from "./protocol";
42
+
43
+ /**
44
+ * The newest policy epoch: what `projectEnvKeyPolicy` and
45
+ * `initialProjectEnvBinding` derive. Bump it with any change to what they
46
+ * return for some input, and keep the previous epoch's derivation, frozen,
47
+ * in `PROJECT_ENV_POLICY_EPOCHS`: a binding is read under the epoch it was
48
+ * written under, and a token the settings page was given under another
49
+ * epoch is refused, so an owner never approves what a different policy
50
+ * showed them (ADR-153 §4, "Policy epochs"). The policy test's fingerprints
51
+ * pin each retained epoch's results on a fixed set of inputs: a tripwire,
52
+ * not a proof.
53
+ */
54
+ export const PROJECT_ENV_POLICY_EPOCH = 2;
55
+
56
+ /** Every policy epoch this build derives under, oldest first. A runner
57
+ * advertises this set; the cloud derives under the newest it shares.
58
+ *
59
+ * - 1: ADR-153.
60
+ * - 2: ADR-154 §9, a key the owner declared proxied in Settings (a `proxy`
61
+ * binding with `declared`): the repository can only narrow it. Every other
62
+ * binding derives exactly as under 1, so it is stored under 1
63
+ * (`projectEnvBindingEpoch`) and needs no upgrade. */
64
+ export const PROJECT_ENV_POLICY_EPOCHS: readonly number[] = [1, 2];
65
+
66
+ /** What one task gets for a key. */
67
+ export type ProjectEnvDecision =
68
+ | { kind: "deliver" }
69
+ | { kind: "proxy"; domains: string[] }
70
+ | { kind: "withhold"; reason: string };
71
+
72
+ /** Why a key is withheld, beyond the declaration's own unsupported reasons
73
+ * and a `withhold` binding's reason. Lower-case snake_case, like every code
74
+ * on the wire. */
75
+ export const PROJECT_ENV_WITHHELD_REASONS = [
76
+ /** The declaration asks for more than the binding approves. */
77
+ "awaiting_approval",
78
+ /** The task's schema could not be read, and the key has no binding. */
79
+ "schema_unavailable",
80
+ /** The repository declares `@proxy=omit`. */
81
+ "repository_omits",
82
+ /** The owner's binding omits it. */
83
+ "binding_omits",
84
+ /** The stored binding is not one the contract accepts. */
85
+ "binding_invalid",
86
+ /** Approving what the declaration asks for would take the key past the
87
+ * domain limit. */
88
+ "too_many_domains",
89
+ /** The binding was written under a policy epoch this build does not know. */
90
+ "policy_epoch_unknown",
91
+ ] as const;
92
+
93
+ export interface ProjectEnvKeyPolicy {
94
+ decision: ProjectEnvDecision;
95
+ /** The binding the declaration asks for, when it is wider than the one
96
+ * approved. Only the owner's Approve applies it; it always passes the
97
+ * contract's binding guard. */
98
+ proposal: ProjectEnvBinding | null;
99
+ /** Why the declaration's wider ask cannot be proposed at all. */
100
+ blocked: "too_many_domains" | null;
101
+ }
102
+
103
+ /** What a task's schema says about one key: its declaration (null if the
104
+ * repository does not declare it), or that the schema could not be read. */
105
+ export type ProjectEnvSchemaEvidence =
106
+ | { status: "resolved"; declaration: ProjectEnvSchemaDeclaration | null }
107
+ | { status: "unavailable" };
108
+
109
+ const withhold = (reason: string): ProjectEnvDecision => ({ kind: "withhold", reason });
110
+ const sorted = (domains: Iterable<string>): string[] => [...new Set(domains)].sort();
111
+ const policy = (
112
+ decision: ProjectEnvDecision,
113
+ proposal: ProjectEnvBinding | null = null,
114
+ blocked: ProjectEnvKeyPolicy["blocked"] = null,
115
+ ): ProjectEnvKeyPolicy => ({ decision, proposal, blocked });
116
+
117
+ /** An object with exactly these own fields: none missing, inherited or extra. */
118
+ function exactly(value: unknown, keys: readonly string[]): value is Record<string, unknown> {
119
+ return (
120
+ value !== null &&
121
+ typeof value === "object" &&
122
+ !Array.isArray(value) &&
123
+ keys.every((key) => Object.hasOwn(value, key)) &&
124
+ Object.keys(value).length === keys.length
125
+ );
126
+ }
127
+
128
+ /** Schema evidence in exactly one of its two shapes, with a declaration the
129
+ * contract accepts; anything else could not be read. */
130
+ function evidenceOf(schema: unknown): ProjectEnvSchemaEvidence {
131
+ if (exactly(schema, ["status", "declaration"]) && schema.status === "resolved") {
132
+ const { declaration } = schema;
133
+ if (declaration === null) return { status: "resolved", declaration: null };
134
+ if (isProjectEnvSchemaDeclaration(declaration)) return { status: "resolved", declaration };
135
+ }
136
+ return { status: "unavailable" };
137
+ }
138
+
139
+ /** What the binding alone gives a key, with nothing declared beyond it. */
140
+ function bindingDecision(binding: ProjectEnvBinding | null): ProjectEnvDecision {
141
+ if (binding === null || binding.kind === "passthrough") return { kind: "deliver" };
142
+ if (binding.kind === "proxy") return { kind: "proxy", domains: sorted(binding.domains) };
143
+ if (binding.kind === "omit") return withhold("binding_omits");
144
+ return withhold(binding.reason);
145
+ }
146
+
147
+ /** Whether a binding may hand the task the real value. */
148
+ const deliversValue = (binding: ProjectEnvBinding | null): boolean =>
149
+ binding === null || binding.kind === "passthrough";
150
+
151
+ /**
152
+ * What a task gets for one key under epoch 1 (ADR-153), frozen: what its
153
+ * declaration proposes beyond the binding, and why a wider ask cannot be
154
+ * proposed if so. A binding declared in Settings did not exist under it, and
155
+ * is one it cannot accept.
156
+ */
157
+ function projectEnvKeyPolicyV1(schemaInput: unknown, bindingInput: unknown): ProjectEnvKeyPolicy {
158
+ if (bindingInput !== null && (!isProjectEnvBinding(bindingInput) || isDeclaredProjectEnvBinding(bindingInput))) {
159
+ return policy(withhold("binding_invalid"));
160
+ }
161
+ const binding = bindingInput as ProjectEnvBinding | null;
162
+ const schema = evidenceOf(schemaInput);
163
+
164
+ if (schema.status === "unavailable") {
165
+ // Nothing declared can be trusted: a bound key follows its binding, and
166
+ // an unbound one is withheld rather than delivered as a plain value.
167
+ return policy(binding === null ? withhold("schema_unavailable") : bindingDecision(binding));
168
+ }
169
+ // An undeclared key is a plain value, like one declared with no directive.
170
+ const directive = schema.declaration?.directive ?? { kind: "none" as const };
171
+
172
+ switch (directive.kind) {
173
+ case "none":
174
+ case "passthrough":
175
+ // A plain value: the binding decides, and where it does not deliver
176
+ // (proxy, omit, withhold), delivering is a proposal.
177
+ return policy(bindingDecision(binding), deliversValue(binding) ? null : { kind: "passthrough" });
178
+
179
+ // The repository's own withholding wins under every binding.
180
+ case "omit":
181
+ return policy(withhold("repository_omits"));
182
+
183
+ case "unsupported":
184
+ return policy(withhold(directive.reason));
185
+
186
+ case "proxy": {
187
+ const declared = sorted(directive.domains);
188
+ // passthrough is the top: proxying exposes nothing it did not allow.
189
+ if (binding?.kind === "passthrough") return policy({ kind: "proxy", domains: declared });
190
+ // Otherwise only approved domains, which only a proxy binding has.
191
+ const approved = binding?.kind === "proxy" ? binding.domains : [];
192
+ const allowed = declared.filter((domain) => approved.includes(domain));
193
+ const decision: ProjectEnvDecision =
194
+ allowed.length > 0
195
+ ? { kind: "proxy", domains: allowed }
196
+ : binding === null || binding.kind === "proxy"
197
+ ? withhold("awaiting_approval")
198
+ : bindingDecision(binding);
199
+ if (allowed.length === declared.length) return policy(decision);
200
+ // Wider than approved: propose the approved and declared domains
201
+ // together, unless that would take the key past the domain limit.
202
+ const union = sorted([...approved, ...declared]);
203
+ if (union.length > MAX_PROJECT_ENV_PROXY_DOMAINS) {
204
+ return policy(allowed.length > 0 ? decision : withhold("too_many_domains"), null, "too_many_domains");
205
+ }
206
+ return policy(decision, { kind: "proxy", domains: union });
207
+ }
208
+ }
209
+ }
210
+
211
+ /**
212
+ * ADR-154 §9: a key the owner declared proxied in Settings. Declaring was the
213
+ * owner's approval of exactly these domains, which the repository's schema
214
+ * can only narrow:
215
+ * - unreadable, undeclared, plain or `passthrough`: proxied to the declared
216
+ * domains, and nothing proposed (the repository never asks to deliver it);
217
+ * - `@proxy=omit`, or an unsupported directive: withheld;
218
+ * - `@proxy` naming some of the declared domains: proxied to those; any it
219
+ * names beyond them are a proposal (still declared), as in ADR-153, or
220
+ * blocked past the domain limit.
221
+ */
222
+ function declaredKeyPolicy(
223
+ schema: ProjectEnvSchemaEvidence,
224
+ binding: Extract<ProjectEnvBinding, { kind: "proxy" }> & { declared: { placeholder: ProjectEnvPlaceholderShape | null } },
225
+ ): ProjectEnvKeyPolicy {
226
+ const settings = sorted(binding.domains);
227
+ const asDeclared: ProjectEnvDecision = { kind: "proxy", domains: settings };
228
+ if (schema.status === "unavailable") return policy(asDeclared);
229
+ const directive = schema.declaration?.directive ?? { kind: "none" as const };
230
+ switch (directive.kind) {
231
+ case "none":
232
+ case "passthrough":
233
+ return policy(asDeclared);
234
+ case "omit":
235
+ return policy(withhold("repository_omits"));
236
+ case "unsupported":
237
+ return policy(withhold(directive.reason));
238
+ case "proxy": {
239
+ const declared = sorted(directive.domains);
240
+ const allowed = declared.filter((domain) => settings.includes(domain));
241
+ const decision: ProjectEnvDecision =
242
+ allowed.length > 0 ? { kind: "proxy", domains: allowed } : withhold("awaiting_approval");
243
+ if (allowed.length === declared.length) return policy(decision);
244
+ const union = sorted([...settings, ...declared]);
245
+ if (union.length > MAX_PROJECT_ENV_PROXY_DOMAINS) {
246
+ return policy(allowed.length > 0 ? decision : withhold("too_many_domains"), null, "too_many_domains");
247
+ }
248
+ return policy(decision, { kind: "proxy", domains: union, declared: { placeholder: binding.declared.placeholder } });
249
+ }
250
+ }
251
+ }
252
+
253
+ /**
254
+ * What a task gets for one key under the newest epoch (2), what its
255
+ * declaration proposes beyond the binding, and why a wider ask cannot be
256
+ * proposed if so. `binding` is null for a row the owner never approved. Both
257
+ * arguments are checked: see the file's header. A binding declared in
258
+ * Settings follows `declaredKeyPolicy`; every other derives as under epoch 1.
259
+ */
260
+ export function projectEnvKeyPolicy(schemaInput: unknown, bindingInput: unknown): ProjectEnvKeyPolicy {
261
+ if (bindingInput !== null && !isProjectEnvBinding(bindingInput)) return policy(withhold("binding_invalid"));
262
+ if (isDeclaredProjectEnvBinding(bindingInput as ProjectEnvBinding | null)) {
263
+ return declaredKeyPolicy(
264
+ evidenceOf(schemaInput),
265
+ bindingInput as Parameters<typeof declaredKeyPolicy>[1],
266
+ );
267
+ }
268
+ return projectEnvKeyPolicyV1(schemaInput, bindingInput);
269
+ }
270
+
271
+ /**
272
+ * The binding a value is stored with when its row has none yet (ADR-153 §4:
273
+ * "a value set in settings is stored atomically with the policy the owner
274
+ * was shown"): what the project's latest schema declares for the key, as
275
+ * the settings page showed it. Null when it declares nothing: the row stays
276
+ * unbound, a plain value. Where the schema could not be read the page showed
277
+ * the key withheld, and so it stays (withhold: schema_unavailable): a schema
278
+ * that later reads as plain cannot deliver it without the owner's Approve.
279
+ *
280
+ * A project no task has reported a schema for yet (`none`) has no evidence
281
+ * to pass: the caller branches on it first and leaves the row unbound. Here,
282
+ * anything that is not evidence, null included, is a schema that could not
283
+ * be read.
284
+ */
285
+ export function initialProjectEnvBinding(schemaInput: unknown): ProjectEnvBinding | null {
286
+ const schema = evidenceOf(schemaInput);
287
+ if (schema.status === "unavailable") return { kind: "withhold", reason: "schema_unavailable" };
288
+ if (schema.declaration === null) return null;
289
+ const { directive } = schema.declaration;
290
+ switch (directive.kind) {
291
+ case "none":
292
+ return null;
293
+ case "proxy":
294
+ return { kind: "proxy", domains: sorted(directive.domains) };
295
+ case "passthrough":
296
+ return { kind: "passthrough" };
297
+ case "omit":
298
+ return { kind: "omit" };
299
+ case "unsupported":
300
+ return { kind: "withhold", reason: directive.reason };
301
+ }
302
+ }
303
+
304
+ /**
305
+ * What a task gets for one key under the epoch its binding was written
306
+ * under. An epoch this build does not know withholds the key, with nothing
307
+ * proposed: it never falls back to another epoch's meaning.
308
+ */
309
+ export function projectEnvKeyPolicyAt(epoch: unknown, schemaInput: unknown, bindingInput: unknown): ProjectEnvKeyPolicy {
310
+ switch (epoch) {
311
+ case 1:
312
+ return projectEnvKeyPolicyV1(schemaInput, bindingInput);
313
+ case 2:
314
+ return projectEnvKeyPolicy(schemaInput, bindingInput);
315
+ default:
316
+ return policy(withhold("policy_epoch_unknown"));
317
+ }
318
+ }
319
+
320
+ /** The binding a first save stores under a given epoch; withheld under an
321
+ * epoch this build does not know. Epoch 2 stores what 1 does: a Settings
322
+ * declaration is the owner's own act, never a first save's. */
323
+ export function initialProjectEnvBindingAt(epoch: unknown, schemaInput: unknown): ProjectEnvBinding | null {
324
+ switch (epoch) {
325
+ case 1:
326
+ case 2:
327
+ return initialProjectEnvBinding(schemaInput);
328
+ default:
329
+ return { kind: "withhold", reason: "policy_epoch_unknown" };
330
+ }
331
+ }
332
+
333
+ /**
334
+ * The placeholder shape a proxied key's task gets, under its binding's
335
+ * epoch: the repository's `@placeholder` when its schema declares the key
336
+ * proxied with one; under epoch 2 a Settings declaration's shape next; null
337
+ * otherwise (the default shape). Only meaningful for a key the policy
338
+ * proxies; checked like the policy's own inputs.
339
+ */
340
+ export function projectEnvPlaceholderAt(
341
+ epoch: unknown,
342
+ schemaInput: unknown,
343
+ bindingInput: unknown,
344
+ ): ProjectEnvPlaceholderShape | null {
345
+ if (epoch !== 1 && epoch !== 2) return null;
346
+ const schema = evidenceOf(schemaInput);
347
+ const fromSchema =
348
+ schema.status === "resolved" && schema.declaration?.directive.kind === "proxy"
349
+ ? (schema.declaration.placeholder ?? null)
350
+ : null;
351
+ if (fromSchema !== null || epoch === 1) return fromSchema;
352
+ const binding = bindingInput !== null && isProjectEnvBinding(bindingInput) ? bindingInput : null;
353
+ return isDeclaredProjectEnvBinding(binding) ? binding.declared.placeholder : null;
354
+ }
355
+
356
+ /**
357
+ * The epoch to store a binding under: the lowest this build knows whose
358
+ * derivation for it is the newest's, never below `floor` (a record's epoch
359
+ * only moves up). A declared binding needs 2; every other means under 1 what
360
+ * it means under 2, so it stays readable by runners that know only 1.
361
+ */
362
+ export function projectEnvBindingEpoch(binding: ProjectEnvBinding | null, floor = 1): number {
363
+ return Math.max(isDeclaredProjectEnvBinding(binding) ? 2 : 1, floor);
364
+ }
365
+
366
+ /** Whether moving a binding from one epoch to a newer one changes what any
367
+ * task would get: only then is an upgrade offered for the owner's Approve. */
368
+ export function projectEnvEpochUpgradeChanges(fromEpoch: number, toEpoch: number, binding: unknown): boolean {
369
+ if (fromEpoch >= toEpoch) return false;
370
+ // Epochs 1 and 2 derive the same for every binding valid under 1.
371
+ return !(fromEpoch === 1 && toEpoch === 2 && !(isProjectEnvBinding(binding) && isDeclaredProjectEnvBinding(binding)));
372
+ }
373
+
374
+ /** The most policy epochs a runner may advertise. */
375
+ export const MAX_ADVERTISED_PROJECT_ENV_POLICY_EPOCHS = 64;
376
+
377
+ /** A runner's advertised epochs: a dense array of unique positive safe
378
+ * integers, no longer than the bound. Anything else is no set at all. */
379
+ function isAdvertisedEpochSet(value: unknown): value is number[] {
380
+ if (!Array.isArray(value) || value.length > MAX_ADVERTISED_PROJECT_ENV_POLICY_EPOCHS) return false;
381
+ for (let index = 0; index < value.length; index += 1) {
382
+ if (!Object.hasOwn(value, index)) return false;
383
+ const epoch: unknown = value[index];
384
+ if (typeof epoch !== "number" || !Number.isSafeInteger(epoch) || epoch < 1) return false;
385
+ }
386
+ return new Set(value).size === value.length;
387
+ }
388
+
389
+ /** The newest policy epoch this build shares with a runner's advertised set,
390
+ * or null when they share none, or the set is malformed: then nothing is
391
+ * derived for that runner. */
392
+ export function sharedProjectEnvPolicyEpoch(advertised: unknown): number | null {
393
+ if (!isAdvertisedEpochSet(advertised)) return null;
394
+ const shared = PROJECT_ENV_POLICY_EPOCHS.filter((epoch) => advertised.includes(epoch));
395
+ return shared.length > 0 ? Math.max(...shared) : null;
396
+ }