@moondeck/station 0.0.1 → 1.0.0

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 (326) 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/meta/_journal.json +223 -0
  36. package/db/schema.ts +850 -0
  37. package/images/standard/Dockerfile +347 -0
  38. package/images/standard/README.md +167 -0
  39. package/images/standard/container/code-server-settings.json +63 -0
  40. package/images/standard/container/corepack-version +1 -0
  41. package/images/standard/container/uai-init +704 -0
  42. package/images/standard/container/uai-materialize-runtimes +1527 -0
  43. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  44. package/images/standard/tool-versions +2 -0
  45. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  46. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  47. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  48. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  49. package/lib/agent-cli.ts +1507 -0
  50. package/lib/agent-errors.ts +22 -0
  51. package/lib/agent.ts +672 -0
  52. package/lib/agents/attach-compatibility.ts +51 -0
  53. package/lib/agents/claude.ts +918 -0
  54. package/lib/agents/codex-token-usage.fixture.json +130 -0
  55. package/lib/agents/codex.ts +1392 -0
  56. package/lib/agents/cursor-period-usage.ts +253 -0
  57. package/lib/agents/cursor.ts +562 -0
  58. package/lib/agents/dispatch.ts +78 -0
  59. package/lib/agents/durable-proc.ts +334 -0
  60. package/lib/agents/factory.ts +57 -0
  61. package/lib/agents/grok.ts +381 -0
  62. package/lib/agents/kimi.ts +329 -0
  63. package/lib/agents/machine-durable.ts +604 -0
  64. package/lib/agents/mock.ts +133 -0
  65. package/lib/agents/mode.ts +64 -0
  66. package/lib/agents/opencode.ts +508 -0
  67. package/lib/agents/output-source.ts +3 -0
  68. package/lib/agents/proc.ts +221 -0
  69. package/lib/agents/rate-limit.ts +337 -0
  70. package/lib/agents/registry.ts +182 -0
  71. package/lib/agents/session-state.ts +197 -0
  72. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  73. package/lib/agents/transport.ts +741 -0
  74. package/lib/agents/types.ts +254 -0
  75. package/lib/agents/uai.ts +106 -0
  76. package/lib/agents/usage.ts +44 -0
  77. package/lib/apple-one-off.ts +277 -0
  78. package/lib/apple-runtime-recycle.ts +237 -0
  79. package/lib/apple-uninstall-teardown.ts +224 -0
  80. package/lib/assignment-evidence.ts +94 -0
  81. package/lib/attachments.ts +48 -0
  82. package/lib/browser-testing.ts +1537 -0
  83. package/lib/capture-flow.ts +332 -0
  84. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  85. package/lib/cloud-credential-host.ts +512 -0
  86. package/lib/cloud-credential-leases.ts +2587 -0
  87. package/lib/cloud-credential-runtime.ts +1357 -0
  88. package/lib/cloud-state.ts +56 -0
  89. package/lib/codex-auth.ts +456 -0
  90. package/lib/command-db.ts +310 -0
  91. package/lib/compose-network-recovery.ts +283 -0
  92. package/lib/connection-secret-redaction.ts +25 -0
  93. package/lib/container-runtime.ts +1453 -0
  94. package/lib/credential-cleanup-inventory.ts +490 -0
  95. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  96. package/lib/credential-consumers/engines.ts +797 -0
  97. package/lib/credential-consumers/files.ts +934 -0
  98. package/lib/credential-consumers/github.ts +702 -0
  99. package/lib/credential-consumers/mcp.ts +389 -0
  100. package/lib/credential-consumers/task-files.ts +127 -0
  101. package/lib/credential-consumers/task-secrets.ts +10 -0
  102. package/lib/credential-store-pickup.ts +149 -0
  103. package/lib/credential-store-scope.ts +112 -0
  104. package/lib/credential-store.ts +932 -0
  105. package/lib/credentials-activation-authority.ts +709 -0
  106. package/lib/credentials-purge.ts +1006 -0
  107. package/lib/custom-connection-capture.ts +395 -0
  108. package/lib/custom-connection-launch.ts +384 -0
  109. package/lib/custom-connection-runtime.ts +141 -0
  110. package/lib/db.ts +80 -0
  111. package/lib/disk-pressure.ts +42 -0
  112. package/lib/docker-exec.ts +156 -0
  113. package/lib/engine-accounts.ts +1970 -0
  114. package/lib/engine-connect.ts +321 -0
  115. package/lib/engine-credential-env.ts +52 -0
  116. package/lib/engine-login.ts +2933 -0
  117. package/lib/engines.ts +1342 -0
  118. package/lib/enrollment-state.ts +251 -0
  119. package/lib/env-file.ts +246 -0
  120. package/lib/env-schema.ts +891 -0
  121. package/lib/env.ts +195 -0
  122. package/lib/fs-aliases.ts +49 -0
  123. package/lib/git-clone-credentials.ts +334 -0
  124. package/lib/git-diff.ts +530 -0
  125. package/lib/git-identity.ts +715 -0
  126. package/lib/github-git-auth.ts +207 -0
  127. package/lib/github-origin.ts +28 -0
  128. package/lib/github-tokens.ts +2439 -0
  129. package/lib/host-cloud-url.ts +62 -0
  130. package/lib/host-config.ts +279 -0
  131. package/lib/host-env.ts +424 -0
  132. package/lib/host-logs.ts +965 -0
  133. package/lib/internal-connections.ts +5 -0
  134. package/lib/keyed-promise-tail.ts +23 -0
  135. package/lib/launch-mode.ts +25 -0
  136. package/lib/lease.ts +239 -0
  137. package/lib/legacy-host.ts +769 -0
  138. package/lib/legacy-runtime-retirement.ts +424 -0
  139. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  140. package/lib/machine-exec.ts +238 -0
  141. package/lib/machine-gateway-tunnel.ts +344 -0
  142. package/lib/machine-keys.ts +65 -0
  143. package/lib/machine-project-env-loader.ts +152 -0
  144. package/lib/machine-provider-aws.ts +923 -0
  145. package/lib/machine-provider-local.ts +338 -0
  146. package/lib/machine-provider.ts +90 -0
  147. package/lib/managed-activation-watcher.ts +72 -0
  148. package/lib/managed-install-owner-watcher.ts +55 -0
  149. package/lib/managed-operation-drain.ts +57 -0
  150. package/lib/managed-runtime.ts +4296 -0
  151. package/lib/managed-update-scheduler.ts +125 -0
  152. package/lib/mcp-capture.ts +865 -0
  153. package/lib/mcp-config-lock.ts +2 -0
  154. package/lib/mcp-connections.ts +959 -0
  155. package/lib/mcp-gateway.ts +2594 -0
  156. package/lib/obs.ts +512 -0
  157. package/lib/openrouter-budget.ts +247 -0
  158. package/lib/orchestrator.ts +11409 -0
  159. package/lib/person-capture.ts +327 -0
  160. package/lib/preview-ports.ts +85 -0
  161. package/lib/preview-sidecar.ts +331 -0
  162. package/lib/project-env-authority.ts +1319 -0
  163. package/lib/project-env-feature.ts +74 -0
  164. package/lib/project-env-legacy-tags.ts +130 -0
  165. package/lib/project-env-loader.ts +242 -0
  166. package/lib/project-env-replica.ts +2094 -0
  167. package/lib/project-env-schema-capture.ts +342 -0
  168. package/lib/project-env-staging.ts +148 -0
  169. package/lib/project-env-task-policy.ts +176 -0
  170. package/lib/project-env-task-replica.ts +1752 -0
  171. package/lib/release-feed.ts +61 -0
  172. package/lib/release-manifest.ts +710 -0
  173. package/lib/release-trust.ts +46 -0
  174. package/lib/repo-clone.ts +38 -0
  175. package/lib/routing/shadow.ts +261 -0
  176. package/lib/runner-boot.ts +10 -0
  177. package/lib/runtime-activation-tail.ts +279 -0
  178. package/lib/runtime-archive.ts +1216 -0
  179. package/lib/runtime-authority.ts +166 -0
  180. package/lib/runtime-guard.ts +36 -0
  181. package/lib/runtime-provider-state.ts +169 -0
  182. package/lib/runtime-reconnect.ts +86 -0
  183. package/lib/runtime-state.ts +1033 -0
  184. package/lib/safe-http.ts +364 -0
  185. package/lib/secrets-proxy/address.ts +95 -0
  186. package/lib/secrets-proxy/audit.ts +30 -0
  187. package/lib/secrets-proxy/ca-store.ts +200 -0
  188. package/lib/secrets-proxy/ca.ts +470 -0
  189. package/lib/secrets-proxy/container-steps.ts +138 -0
  190. package/lib/secrets-proxy/der.ts +273 -0
  191. package/lib/secrets-proxy/engine.ts +1633 -0
  192. package/lib/secrets-proxy/host.ts +93 -0
  193. package/lib/secrets-proxy/hostname.ts +57 -0
  194. package/lib/secrets-proxy/placeholders.ts +196 -0
  195. package/lib/secrets-proxy/policy.ts +150 -0
  196. package/lib/secrets-proxy/readiness.ts +148 -0
  197. package/lib/secrets-proxy/runtime.ts +582 -0
  198. package/lib/secrets-proxy/scrub.ts +559 -0
  199. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  200. package/lib/secrets-proxy/task-scripts.ts +262 -0
  201. package/lib/secrets-proxy/task-trust.ts +605 -0
  202. package/lib/secrets-proxy/trust-watch.ts +80 -0
  203. package/lib/secrets.ts +71 -0
  204. package/lib/shared-files-s3.ts +976 -0
  205. package/lib/shared-files.ts +156 -0
  206. package/lib/sidecar-assign.ts +49 -0
  207. package/lib/sidecar-guard.ts +58 -0
  208. package/lib/sidecar-resume.ts +178 -0
  209. package/lib/sidecar.ts +69 -0
  210. package/lib/signin-box-credentials.ts +465 -0
  211. package/lib/signin-box.ts +114 -0
  212. package/lib/skill-index-example.fixture.ts +56 -0
  213. package/lib/skill-layout.ts +417 -0
  214. package/lib/skills.ts +489 -0
  215. package/lib/ssh.ts +401 -0
  216. package/lib/standard-image-cleanup.ts +117 -0
  217. package/lib/standard-image-pin.ts +207 -0
  218. package/lib/standard-image.ts +1697 -0
  219. package/lib/stopped-task-status-queue.ts +120 -0
  220. package/lib/task-container-cli.ts +269 -0
  221. package/lib/task-credential-cleanup.ts +752 -0
  222. package/lib/task-diff-sandbox.ts +464 -0
  223. package/lib/task-diff.ts +91 -0
  224. package/lib/task-env-forwarder.ts +228 -0
  225. package/lib/task-env-source.ts +249 -0
  226. package/lib/task-environment/anchor.ts +31 -0
  227. package/lib/task-environment/apple-container.ts +860 -0
  228. package/lib/task-environment/docker.ts +1344 -0
  229. package/lib/task-environment/index.ts +466 -0
  230. package/lib/task-environment/legacy-adoption.ts +467 -0
  231. package/lib/task-environment/machine-environment.ts +61 -0
  232. package/lib/task-environment/machine-task-up.ts +1227 -0
  233. package/lib/task-environment/machine.ts +974 -0
  234. package/lib/task-environment/registry.ts +59 -0
  235. package/lib/task-environment/self-identity.ts +20 -0
  236. package/lib/task-environment/self.ts +566 -0
  237. package/lib/task-environment/types.ts +642 -0
  238. package/lib/task-environment/workspace-files.ts +685 -0
  239. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  240. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  241. package/lib/task-files-machine-retirement.ts +116 -0
  242. package/lib/task-files-readiness.ts +127 -0
  243. package/lib/task-gateway-port.ts +22 -0
  244. package/lib/task-gateway-protocol.ts +190 -0
  245. package/lib/task-gateway-recovery.ts +47 -0
  246. package/lib/task-gateway-state.ts +512 -0
  247. package/lib/task-identity.ts +19 -0
  248. package/lib/task-inventory.ts +682 -0
  249. package/lib/task-machine-credentials-activation.ts +95 -0
  250. package/lib/task-machine-swap-safety.ts +729 -0
  251. package/lib/task-person-credentials.ts +76 -0
  252. package/lib/task-project-ops-host.ts +147 -0
  253. package/lib/task-project-ops.ts +1062 -0
  254. package/lib/task-secret-staging.ts +16 -0
  255. package/lib/task-status.ts +46 -0
  256. package/lib/task-token.ts +46 -0
  257. package/lib/token-broker-adopt.ts +344 -0
  258. package/lib/token-broker-lambda.ts +105 -0
  259. package/lib/token-broker-local.ts +392 -0
  260. package/lib/token-broker-revision.ts +310 -0
  261. package/lib/token-broker-ssm.ts +525 -0
  262. package/lib/token-broker.ts +218 -0
  263. package/lib/token-lanes/openai-oauth.ts +123 -0
  264. package/lib/token-lanes/registry.ts +45 -0
  265. package/lib/token-lanes/types.ts +163 -0
  266. package/lib/token-lanes/xai-oauth.ts +165 -0
  267. package/lib/transcript.ts +128 -0
  268. package/lib/tunnel-registry.ts +220 -0
  269. package/lib/tunnel-runtime.ts +235 -0
  270. package/lib/ulid.ts +16 -0
  271. package/lib/user-bus-env.ts +116 -0
  272. package/lib/user-credentials-store.ts +435 -0
  273. package/lib/vnode-pressure.ts +142 -0
  274. package/package.json +115 -8
  275. package/runner/runner.mjs +442 -0
  276. package/scripts/agent/_common.sh +633 -0
  277. package/scripts/agent/env-hook.sh +626 -0
  278. package/scripts/agent/moondeck-cli.sh +5 -0
  279. package/scripts/agent/task-down.sh +291 -0
  280. package/scripts/agent/task-status.sh +70 -0
  281. package/scripts/agent/task-up.sh +3761 -0
  282. package/scripts/agent/uai-env-exec.sh +26 -0
  283. package/scripts/install/darwin.ts +1048 -0
  284. package/scripts/install/linux.ts +958 -0
  285. package/scripts/install/types.ts +78 -0
  286. package/scripts/install/util.ts +254 -0
  287. package/scripts/install/win.ts +143 -0
  288. package/src/activation-respawn.ts +155 -0
  289. package/src/apple-tunnel-route.ts +104 -0
  290. package/src/capture-wire.ts +102 -0
  291. package/src/cli.ts +2583 -0
  292. package/src/cloud-credentials.ts +2144 -0
  293. package/src/cloud-origins.ts +23 -0
  294. package/src/credential-location.ts +248 -0
  295. package/src/credential-wire-validation.ts +25 -0
  296. package/src/custom-connections.ts +237 -0
  297. package/src/engine-account-paths.ts +11 -0
  298. package/src/engine-models.ts +39 -0
  299. package/src/engine-usage.ts +104 -0
  300. package/src/event-outbox.ts +185 -0
  301. package/src/index.ts +2899 -0
  302. package/src/load-env.ts +97 -0
  303. package/src/machine-tunnel-route.ts +156 -0
  304. package/src/main.ts +6349 -0
  305. package/src/never-proxied-domains.ts +31 -0
  306. package/src/outbound-frame.ts +31 -0
  307. package/src/paths.ts +81 -0
  308. package/src/project-env-latest.ts +130 -0
  309. package/src/project-env-policy.ts +396 -0
  310. package/src/project-env-wording.ts +53 -0
  311. package/src/protocol.ts +5545 -0
  312. package/src/runtime-bootstrap.ts +165 -0
  313. package/src/station-identity.ts +96 -0
  314. package/src/task-env-names.ts +28 -0
  315. package/src/task-secrets.ts +75 -0
  316. package/src/tunnel-dial.ts +102 -0
  317. package/src/ui/server.ts +852 -0
  318. package/src/ui/types.ts +173 -0
  319. package/src/workspace-path-policy.ts +41 -0
  320. package/ui/app.js +1102 -0
  321. package/ui/index.html +90 -0
  322. package/ui/style.css +685 -0
  323. package/ui/uai-favicon.svg +21 -0
  324. package/ui/uai-logo-black.svg +9 -0
  325. package/ui/uai-wheel.svg +9 -0
  326. package/bin/moondeck-station.mjs +0 -3
@@ -0,0 +1,435 @@
1
+ /**
2
+ * ADR-134 slice 2 — per-user credentials on a Uai-operated host reconcile
3
+ * with the credential store at start, the same way engine accounts do
4
+ * (`reconcileEngineAccountsFromStore`):
5
+ *
6
+ * <prefix>/users/<userId>/github GitHub App user token
7
+ * <prefix>/users/<userId>/ssh the user's host-minted keypair
8
+ * <prefix>/users/<userId>/mcp/<connId> an MCP connection
9
+ *
10
+ * - a parameter with no local row is imported (a replacement host inherits
11
+ * every user's credentials without anyone reconnecting);
12
+ * - a local row with no `store_ref` is uploaded (first start with the flag);
13
+ * - a local row whose `store_ref` is under the listed path but absent from
14
+ * it was removed elsewhere — it goes locally too.
15
+ * The write-through mirrors live next to each table's writes (ssh.ts,
16
+ * github-tokens.ts, mcp-connections.ts). Owned hosts: `configuredCredentialStore()`
17
+ * is null and nothing here runs.
18
+ */
19
+
20
+ import {
21
+ configuredCredentialStore,
22
+ credentialStorePrefix,
23
+ isParameterPathSegment,
24
+ type CredentialStore,
25
+ } from "./credential-store";
26
+ import { projectEnvListPaths,
27
+ projectEnvListsByName, userCredentialListPath } from "./credential-store-scope";
28
+ import {
29
+ deleteToken as deleteGithubTokenLocal,
30
+ githubTokenParameterName,
31
+ githubTokenStoreValue,
32
+ importGithubTokenFromStore,
33
+ listGithubTokenStoreRows,
34
+ setGithubTokenStoreRef,
35
+ type StoredGithubToken,
36
+ } from "./github-tokens";
37
+ import {
38
+ deleteMcpConnectionLocal,
39
+ importMcpConnectionFromStore,
40
+ listMcpConnectionStoreRows,
41
+ mcpConnectionParameterName,
42
+ mcpConnectionStoreValue,
43
+ setMcpConnectionStoreRef,
44
+ type StoredMcpConnection,
45
+ } from "./mcp-connections";
46
+ import {
47
+ deleteProjectEnvLocal,
48
+ importProjectEnvFromStore,
49
+ listProjectEnvStoreRows,
50
+ projectEnvParameterName,
51
+ projectEnvStoreValue,
52
+ setProjectEnvStoreRef,
53
+ noteProjectOrg,
54
+ tagProjectEnvParameter,
55
+ type StoredProjectEnv,
56
+ } from "./host-env";
57
+ import {
58
+ deleteKey as deleteSshKeyLocal,
59
+ importSshKeyFromStore,
60
+ listSshKeyStoreRows,
61
+ setSshKeyStoreRef,
62
+ sshKeyParameterName,
63
+ sshKeyStoreValue,
64
+ type StoredSshKey,
65
+ } from "./ssh";
66
+
67
+ export interface ReconcileUserCredentialsResult {
68
+ skipped: boolean;
69
+ imported: number;
70
+ uploaded: number;
71
+ removed: number;
72
+ errors: number;
73
+ }
74
+
75
+ type ParsedName =
76
+ | { kind: "github"; userId: string }
77
+ | { kind: "ssh"; userId: string }
78
+ | { kind: "mcp"; userId: string; id: string };
79
+
80
+ /** `<prefix>/users/<u>/github|ssh|mcp/<id>`; null for anything else. */
81
+ export function parseUserParameterName(
82
+ name: string,
83
+ prefix: string = credentialStorePrefix(),
84
+ ): ParsedName | null {
85
+ if (!name.startsWith(`${prefix}/users/`)) return null;
86
+ const parts = name.slice(prefix.length + 1).split("/");
87
+ if (!parts.every((p) => isParameterPathSegment(p))) return null;
88
+ const [, userId, what, id] = parts;
89
+ if (!userId || !what) return null;
90
+ if (parts.length === 3 && what === "github") return { kind: "github", userId };
91
+ if (parts.length === 3 && what === "ssh") return { kind: "ssh", userId };
92
+ if (parts.length === 4 && what === "mcp" && id) return { kind: "mcp", userId, id };
93
+ return null;
94
+ }
95
+
96
+ function obj(value: string): Record<string, unknown> {
97
+ const parsed: unknown = JSON.parse(value);
98
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
99
+ throw new Error("not an object");
100
+ }
101
+ const o = parsed as Record<string, unknown>;
102
+ if (o.v !== 1) throw new Error(`unsupported version ${String(o.v)}`);
103
+ return o;
104
+ }
105
+ function str(o: Record<string, unknown>, key: string): string {
106
+ const v = o[key];
107
+ if (typeof v !== "string") throw new Error(`bad ${key}`);
108
+ return v;
109
+ }
110
+ function strOrNull(o: Record<string, unknown>, key: string): string | null {
111
+ const v = o[key];
112
+ if (v === null || v === undefined) return null;
113
+ if (typeof v !== "string") throw new Error(`bad ${key}`);
114
+ return v;
115
+ }
116
+ function num(o: Record<string, unknown>, key: string): number {
117
+ const v = o[key];
118
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`bad ${key}`);
119
+ return v;
120
+ }
121
+ function numOrNull(o: Record<string, unknown>, key: string): number | null {
122
+ const v = o[key];
123
+ if (v === null || v === undefined) return null;
124
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`bad ${key}`);
125
+ return v;
126
+ }
127
+
128
+ export function parseStoredGithubToken(value: string): StoredGithubToken {
129
+ const o = obj(value);
130
+ if (o.kind !== "github") throw new Error("not a github token");
131
+ const generation = num(o, "generation");
132
+ if (!Number.isSafeInteger(generation) || generation < 0) throw new Error("bad generation");
133
+ return {
134
+ v: 1,
135
+ kind: "github",
136
+ userId: str(o, "userId"),
137
+ installationId: num(o, "installationId"),
138
+ generation,
139
+ tokenKind: str(o, "tokenKind"),
140
+ token: str(o, "token"),
141
+ expiresAt: numOrNull(o, "expiresAt"),
142
+ updatedAt: num(o, "updatedAt"),
143
+ };
144
+ }
145
+
146
+ export function parseStoredSshKey(value: string): StoredSshKey {
147
+ const o = obj(value);
148
+ if (o.kind !== "ssh") throw new Error("not an ssh key");
149
+ return {
150
+ v: 1,
151
+ kind: "ssh",
152
+ userId: str(o, "userId"),
153
+ publicKey: str(o, "publicKey"),
154
+ privateKey: str(o, "privateKey"),
155
+ createdAt: num(o, "createdAt"),
156
+ updatedAt: num(o, "updatedAt"),
157
+ };
158
+ }
159
+
160
+ export function parseStoredMcpConnection(value: string): StoredMcpConnection {
161
+ const o = obj(value);
162
+ if (o.kind !== "mcp") throw new Error("not an mcp connection");
163
+ return {
164
+ v: 1,
165
+ kind: "mcp",
166
+ id: str(o, "id"),
167
+ userId: str(o, "userId"),
168
+ url: str(o, "url"),
169
+ authKind: str(o, "authKind"),
170
+ tokenEndpoint: strOrNull(o, "tokenEndpoint"),
171
+ redirectUri: strOrNull(o, "redirectUri"),
172
+ clientId: strOrNull(o, "clientId"),
173
+ clientSecret: strOrNull(o, "clientSecret"),
174
+ pkceVerifier: strOrNull(o, "pkceVerifier"),
175
+ secret: strOrNull(o, "secret"),
176
+ status: str(o, "status"),
177
+ scopes: strOrNull(o, "scopes"),
178
+ updatedAt: num(o, "updatedAt"),
179
+ };
180
+ }
181
+
182
+ async function upload(
183
+ store: CredentialStore,
184
+ name: string,
185
+ value: unknown,
186
+ setRef: (ref: string) => void,
187
+ ): Promise<void> {
188
+ await store.put(name, JSON.stringify(value));
189
+ setRef(name);
190
+ }
191
+
192
+ export async function reconcileUserCredentialsFromStore(): Promise<ReconcileUserCredentialsResult> {
193
+ const result: ReconcileUserCredentialsResult = {
194
+ skipped: false,
195
+ imported: 0,
196
+ uploaded: 0,
197
+ removed: 0,
198
+ errors: 0,
199
+ };
200
+ const store = configuredCredentialStore();
201
+ if (!store) return { ...result, skipped: true };
202
+ const prefix = credentialStorePrefix();
203
+ const path = userCredentialListPath(prefix);
204
+ if (path === null) return { ...result, skipped: true };
205
+ const warn = (line: string): void =>
206
+ console.warn(`[user-credentials] credential store: ${line}`);
207
+
208
+ let listed: Array<{ name: string; value: string }>;
209
+ try {
210
+ listed = await store.list(path);
211
+ } catch (err) {
212
+ result.errors++;
213
+ warn(`listing ${path} failed: ${err instanceof Error ? err.message : String(err)}`);
214
+ // Nothing else is safe to decide without the inventory.
215
+ return result;
216
+ }
217
+
218
+ const github = new Map(listGithubTokenStoreRows().map((r) => [r.userId, r.storeRef]));
219
+ const ssh = new Map(listSshKeyStoreRows().map((r) => [r.userId, r.storeRef]));
220
+ const mcp = new Map(listMcpConnectionStoreRows().map((r) => [r.id, r]));
221
+ const seen = new Set<string>();
222
+
223
+ for (const param of listed) {
224
+ const at = parseUserParameterName(param.name, prefix);
225
+ if (!at) continue; // a neighbour from a later slice
226
+ seen.add(param.name);
227
+ try {
228
+ if (at.kind === "github") {
229
+ const value = parseStoredGithubToken(param.value);
230
+ if (value.userId !== at.userId) throw new Error("payload names another user");
231
+ if (github.has(at.userId)) {
232
+ if (github.get(at.userId) !== param.name) setGithubTokenStoreRef(at.userId, param.name);
233
+ } else if (importGithubTokenFromStore(value, param.name)) {
234
+ result.imported++;
235
+ }
236
+ } else if (at.kind === "ssh") {
237
+ const value = parseStoredSshKey(param.value);
238
+ if (value.userId !== at.userId) throw new Error("payload names another user");
239
+ if (ssh.has(at.userId)) {
240
+ if (ssh.get(at.userId) !== param.name) setSshKeyStoreRef(at.userId, param.name);
241
+ } else if (importSshKeyFromStore(value, param.name)) {
242
+ result.imported++;
243
+ }
244
+ } else {
245
+ const value = parseStoredMcpConnection(param.value);
246
+ if (value.userId !== at.userId || value.id !== at.id) {
247
+ throw new Error("payload does not describe the connection its path names");
248
+ }
249
+ const local = mcp.get(at.id);
250
+ if (local) {
251
+ if (local.storeRef !== param.name) setMcpConnectionStoreRef(at.id, param.name);
252
+ } else if (importMcpConnectionFromStore(value, param.name)) {
253
+ result.imported++;
254
+ }
255
+ }
256
+ } catch (err) {
257
+ result.errors++;
258
+ warn(`${param.name}: ${err instanceof Error ? err.message : String(err)}`);
259
+ }
260
+ }
261
+
262
+ const under = (ref: string): boolean => ref.startsWith(`${path}/`);
263
+ for (const [userId, storeRef] of github) {
264
+ try {
265
+ if (storeRef) {
266
+ if (under(storeRef) && !seen.has(storeRef)) {
267
+ deleteGithubTokenLocal(userId);
268
+ result.removed++;
269
+ }
270
+ continue;
271
+ }
272
+ const value = githubTokenStoreValue(userId);
273
+ if (!value) continue;
274
+ await upload(store, githubTokenParameterName(userId), value, (ref) =>
275
+ setGithubTokenStoreRef(userId, ref),
276
+ );
277
+ result.uploaded++;
278
+ } catch (err) {
279
+ result.errors++;
280
+ warn(`github token ${userId}: ${err instanceof Error ? err.message : String(err)}`);
281
+ }
282
+ }
283
+ for (const [userId, storeRef] of ssh) {
284
+ try {
285
+ if (storeRef) {
286
+ if (under(storeRef) && !seen.has(storeRef)) {
287
+ deleteSshKeyLocal(userId);
288
+ result.removed++;
289
+ }
290
+ continue;
291
+ }
292
+ const value = sshKeyStoreValue(userId);
293
+ if (!value) continue;
294
+ await upload(store, sshKeyParameterName(userId), value, (ref) =>
295
+ setSshKeyStoreRef(userId, ref),
296
+ );
297
+ result.uploaded++;
298
+ } catch (err) {
299
+ result.errors++;
300
+ warn(`ssh key ${userId}: ${err instanceof Error ? err.message : String(err)}`);
301
+ }
302
+ }
303
+ for (const [id, row] of mcp) {
304
+ try {
305
+ if (row.storeRef) {
306
+ if (under(row.storeRef) && !seen.has(row.storeRef)) {
307
+ deleteMcpConnectionLocal(id);
308
+ result.removed++;
309
+ }
310
+ continue;
311
+ }
312
+ const value = mcpConnectionStoreValue(id);
313
+ if (!value) continue;
314
+ await upload(store, mcpConnectionParameterName(row.userId, id), value, (ref) =>
315
+ setMcpConnectionStoreRef(id, ref),
316
+ );
317
+ result.uploaded++;
318
+ } catch (err) {
319
+ result.errors++;
320
+ warn(`mcp connection ${id}: ${err instanceof Error ? err.message : String(err)}`);
321
+ }
322
+ }
323
+ return result;
324
+ }
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // Slice 3: project env values, `<prefix>/projects/<projectId>/env/<KEY>`.
328
+ // Same three moves as above, own listing.
329
+ // ---------------------------------------------------------------------------
330
+
331
+ export function parseProjectEnvParameterName(
332
+ name: string,
333
+ prefix: string = credentialStorePrefix(),
334
+ ): { projectId: string; key: string } | null {
335
+ if (!name.startsWith(`${prefix}/projects/`)) return null;
336
+ const parts = name.slice(prefix.length + 1).split("/");
337
+ if (parts.length !== 4 || parts[2] !== "env") return null;
338
+ if (!parts.every((p) => isParameterPathSegment(p))) return null;
339
+ const [, projectId, , key] = parts as [string, string, string, string];
340
+ return { projectId, key };
341
+ }
342
+
343
+ export function parseStoredProjectEnv(value: string): StoredProjectEnv {
344
+ const o = obj(value);
345
+ if (o.kind !== "project-env") throw new Error("not a project env value");
346
+ return {
347
+ v: 1,
348
+ kind: "project-env",
349
+ projectId: str(o, "projectId"),
350
+ key: str(o, "key"),
351
+ value: str(o, "value"),
352
+ updatedAt: num(o, "updatedAt"),
353
+ ...(typeof o.orgId === "string" && o.orgId.length > 0 ? { orgId: o.orgId } : {}),
354
+ };
355
+ }
356
+
357
+ /**
358
+ * Host start (whole tree) or, on a task machine, task-up with the task's
359
+ * project ids (ADR-133: the lease reads `/uai/projects/<id>` only for
360
+ * parameters tagged with its org, and only the task's projects matter).
361
+ */
362
+ export async function reconcileProjectEnvFromStore(
363
+ opts: { projectIds?: readonly string[] } = {},
364
+ ): Promise<ReconcileUserCredentialsResult> {
365
+ const result: ReconcileUserCredentialsResult = {
366
+ skipped: false,
367
+ imported: 0,
368
+ uploaded: 0,
369
+ removed: 0,
370
+ errors: 0,
371
+ };
372
+ const store = configuredCredentialStore();
373
+ if (!store) return { ...result, skipped: true };
374
+ const prefix = credentialStorePrefix();
375
+ const paths = projectEnvListPaths(prefix, opts.projectIds);
376
+ if (paths === null) return { ...result, skipped: true };
377
+ const warn = (line: string): void => console.warn(`[project-env] credential store: ${line}`);
378
+ const listed: Array<{ name: string; value: string }> = [];
379
+ const listedPrefixes: string[] = [];
380
+ const byName = projectEnvListsByName();
381
+ for (const path of paths) {
382
+ try {
383
+ listed.push(...(byName ? await store.listByName(path) : await store.list(path)));
384
+ listedPrefixes.push(`${path}/`);
385
+ } catch (err) {
386
+ result.errors++;
387
+ warn(`listing ${path} failed: ${err instanceof Error ? err.message : String(err)}`);
388
+ }
389
+ }
390
+ const local = new Map(listProjectEnvStoreRows().map((r) => [`${r.projectId}/${r.key}`, r]));
391
+ const seen = new Set<string>();
392
+ for (const param of listed) {
393
+ const at = parseProjectEnvParameterName(param.name, prefix);
394
+ if (!at) continue;
395
+ seen.add(param.name);
396
+ try {
397
+ const value = parseStoredProjectEnv(param.value);
398
+ if (value.projectId !== at.projectId || value.key !== at.key) {
399
+ throw new Error("payload does not describe the value its path names");
400
+ }
401
+ const row = local.get(`${at.projectId}/${at.key}`);
402
+ if (row) {
403
+ if (row.storeRef !== param.name) setProjectEnvStoreRef(at.projectId, at.key, param.name);
404
+ if (value.orgId) noteProjectOrg(at.projectId, value.orgId);
405
+ } else if (importProjectEnvFromStore(value, param.name)) {
406
+ result.imported++;
407
+ }
408
+ } catch (err) {
409
+ result.errors++;
410
+ warn(`${param.name}: ${err instanceof Error ? err.message : String(err)}`);
411
+ }
412
+ }
413
+ for (const row of local.values()) {
414
+ try {
415
+ if (row.storeRef) {
416
+ const ref = row.storeRef;
417
+ if (listedPrefixes.some((p) => ref.startsWith(p)) && !seen.has(ref)) {
418
+ deleteProjectEnvLocal(row.projectId, row.key);
419
+ result.removed++;
420
+ }
421
+ continue;
422
+ }
423
+ const name = projectEnvParameterName(row.projectId, row.key);
424
+ const value = projectEnvStoreValue(row.projectId, row.key);
425
+ if (!name || !value) continue;
426
+ await upload(store, name, value, (ref) => setProjectEnvStoreRef(row.projectId, row.key, ref));
427
+ result.uploaded++;
428
+ if (value.orgId) await tagProjectEnvParameter(store, name, row.projectId, value.orgId);
429
+ } catch (err) {
430
+ result.errors++;
431
+ warn(`${row.projectId}/${row.key}: ${err instanceof Error ? err.message : String(err)}`);
432
+ }
433
+ }
434
+ return result;
435
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * ADR-118 follow-up: the macOS vnode-table pressure sensor.
3
+ *
4
+ * Apple's Virtualization.framework virtiofs pins one host vnode per distinct
5
+ * guest-touched file for the life of the task VM (~12k per task after
6
+ * ADR-118 moved the pnpm store off virtiofs; ~130k+ before it). The table
7
+ * ceiling (`kern.maxvnodes`) is a boot-time default the host cannot raise
8
+ * without root, and AT the ceiling unrelated host software starts failing
9
+ * with ENFILE — the suspected 2026-08-24 kernel-panic precursor.
10
+ *
11
+ * Three surfaces ride this module:
12
+ * - `readVnodePressure()` — the sensor (cached; null off-macOS).
13
+ * - `vnodeAdmissionProblem()` — task-up guard: refuse to start ANOTHER
14
+ * apple-container VM above the threshold instead of letting the kernel
15
+ * hit the wall. Existing tasks keep running.
16
+ * - `logVnodePressure()` — periodic observability, warn-level near the
17
+ * ceiling so host logs explain refusals before they happen.
18
+ *
19
+ * Raising the ceiling needs root once: `sudo uai-host tune` (see cli.ts)
20
+ * installs a LaunchDaemon that reasserts the sysctls at every boot —
21
+ * sysctl writes alone are ephemeral.
22
+ */
23
+
24
+ import { execFile } from "node:child_process";
25
+ import { promisify } from "node:util";
26
+
27
+ import { STATION } from "../src/station-identity";
28
+
29
+ const exec = promisify(execFile);
30
+
31
+ /** Refuse NEW apple task VMs above this fraction of the vnode ceiling. */
32
+ const ADMISSION_THRESHOLD = 0.9;
33
+ /** Warn-log above this fraction. */
34
+ const WARN_THRESHOLD = 0.8;
35
+ /**
36
+ * The vnode counter ALONE cannot refuse: `kern.num_vnodes` includes the
37
+ * reclaimable vnode cache, and a healthy long-uptime Mac converges to
38
+ * num_vnodes == maxvnodes forever (live false-positive, 2026-08-31: the
39
+ * Studio refused every task at "100%" with ZERO VMs running and the file
40
+ * table at 1.2%). Real virtiofs pinning holds OPEN FILES — the Aug-24
41
+ * incident read 96% of `kern.num_files/kern.maxfiles`. So refusal
42
+ * requires the vnode ratio AND genuine file-table pressure together.
43
+ */
44
+ const FILE_TABLE_CORROBORATION_THRESHOLD = 0.5;
45
+ const CACHE_MS = 15_000;
46
+
47
+ export interface VnodePressure {
48
+ current: number;
49
+ max: number;
50
+ ratio: number;
51
+ filesCurrent: number;
52
+ filesMax: number;
53
+ filesRatio: number;
54
+ }
55
+
56
+ let cached: { at: number; value: VnodePressure | null } | null = null;
57
+
58
+ export async function readVnodePressure(): Promise<VnodePressure | null> {
59
+ if (process.platform !== "darwin") return null;
60
+ if (cached && Date.now() - cached.at < CACHE_MS) return cached.value;
61
+ let value: VnodePressure | null = null;
62
+ try {
63
+ const { stdout } = await exec(
64
+ "/usr/sbin/sysctl",
65
+ ["-n", "kern.num_vnodes", "kern.maxvnodes", "kern.num_files", "kern.maxfiles"],
66
+ { timeout: 5_000 },
67
+ );
68
+ const parts = stdout.trim().split(/\s+/).map(Number);
69
+ const current = parts[0] ?? Number.NaN;
70
+ const max = parts[1] ?? Number.NaN;
71
+ const filesCurrent = parts[2] ?? Number.NaN;
72
+ const filesMax = parts[3] ?? Number.NaN;
73
+ if (
74
+ Number.isFinite(current) &&
75
+ Number.isFinite(max) &&
76
+ max > 0 &&
77
+ Number.isFinite(filesCurrent) &&
78
+ Number.isFinite(filesMax) &&
79
+ filesMax > 0
80
+ ) {
81
+ value = {
82
+ current,
83
+ max,
84
+ ratio: current / max,
85
+ filesCurrent,
86
+ filesMax,
87
+ filesRatio: filesCurrent / filesMax,
88
+ };
89
+ }
90
+ } catch {
91
+ // Sensor failure is never a task failure; admission simply has no data.
92
+ }
93
+ cached = { at: Date.now(), value };
94
+ return value;
95
+ }
96
+
97
+ /** Non-null = refuse to start another apple-container task VM right now. */
98
+ export async function vnodeAdmissionProblem(): Promise<string | null> {
99
+ const pressure = await readVnodePressure();
100
+ if (!pressure || pressure.ratio < ADMISSION_THRESHOLD) return null;
101
+ // A full vnode table with an idle file table is a warm cache, not danger.
102
+ if (pressure.filesRatio < FILE_TABLE_CORROBORATION_THRESHOLD) return null;
103
+ return (
104
+ `the macOS vnode table is at ${Math.round(pressure.ratio * 100)}% ` +
105
+ `(${pressure.current}/${pressure.max}) with the file table at ` +
106
+ `${Math.round(pressure.filesRatio * 100)}% ` +
107
+ `(${pressure.filesCurrent}/${pressure.filesMax}); starting another task ` +
108
+ "VM risks system-wide file-table exhaustion. Close a running task, or " +
109
+ `raise the ceiling once with: sudo ${STATION.command} tune`
110
+ );
111
+ }
112
+
113
+ let lastLoggedBand: "ok" | "warn" | "critical" = "ok";
114
+
115
+ /** Log on band TRANSITIONS only, so a host sitting near the ceiling does not
116
+ * spam its own log every interval. */
117
+ export async function logVnodePressure(): Promise<void> {
118
+ const pressure = await readVnodePressure();
119
+ if (!pressure) return;
120
+ const refusing =
121
+ pressure.ratio >= ADMISSION_THRESHOLD &&
122
+ pressure.filesRatio >= FILE_TABLE_CORROBORATION_THRESHOLD;
123
+ const band = refusing
124
+ ? "critical"
125
+ : pressure.filesRatio >= WARN_THRESHOLD || pressure.ratio >= WARN_THRESHOLD
126
+ ? "warn"
127
+ : "ok";
128
+ if (band === lastLoggedBand) return;
129
+ lastLoggedBand = band;
130
+ const detail =
131
+ `vnodes ${pressure.current}/${pressure.max} (${Math.round(pressure.ratio * 100)}%), ` +
132
+ `files ${pressure.filesCurrent}/${pressure.filesMax} (${Math.round(pressure.filesRatio * 100)}%)`;
133
+ if (band === "critical") {
134
+ console.warn(
135
+ `[vnode] table at ${detail} — new apple task VMs will be refused; run: sudo ${STATION.command} tune`,
136
+ );
137
+ } else if (band === "warn") {
138
+ console.warn(`[vnode] table filling: ${detail}`);
139
+ } else {
140
+ console.log(`[vnode] table pressure back to normal: ${detail}`);
141
+ }
142
+ }