@moondeck/station 0.0.0-stage → 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 (325) 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 +131 -4
  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
package/lib/env.ts ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Host-agent env access. Runtime state lives in host.sqlite on the host
3
+ * machine; cloud metadata arrives over the host protocol.
4
+ */
5
+
6
+ import { existsSync, realpathSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { dirname, resolve, sep } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ import { z } from "zod";
12
+
13
+ import { STATION } from "../src/station-identity";
14
+
15
+ const schema = z.object({
16
+ // Base dir for mutable host state (.env.local + the data dir). See the
17
+ // resolution of `uaiHome` below. Optional — defaults are repo-aware.
18
+ UAI_HOME: z.string().min(1).optional(),
19
+ UAI_DATA_DIR: z.string().min(1).optional(),
20
+ UAI_AGENT_DIR: z.string().min(1).optional(),
21
+ UAI_HOST_TOKEN: z.string().min(32).optional(),
22
+ UAI_HOST_BRIDGE_PORT: z.coerce.number().int().min(1).max(65535).default(8789),
23
+ UAI_CLOUD_URL: z.string().min(1).optional(),
24
+ UAI_HOST_ID: z.string().min(1).optional(),
25
+ // ADR-101: container-runtime.ts validates this separately. Keeping the raw
26
+ // value here prevents one misspelled runtime preference from making the
27
+ // schema fallback discard every otherwise-valid host setting.
28
+ UAI_RUNTIME: z.string().optional(),
29
+ // Host master key file (ADR-027) — 32 bytes, AES-256-GCM for the host
30
+ // secret store. Defaults to $UAI_DATA_DIR/host-master.key; generated on
31
+ // first use.
32
+ UAI_HOST_KEY_FILE: z.string().min(1).optional(),
33
+ // Transitional single-user GitHub PAT, for the one user who has not
34
+ // connected GitHub. Inert without UAI_GH_PAT_FALLBACK_USER_ID: the PAT is a
35
+ // host-wide credential, so it may only be injected into tasks owned by the
36
+ // user it belongs to. See setupTaskGithub().
37
+ UAI_GH_PAT_FALLBACK: z.string().min(1).optional(),
38
+ UAI_GH_PAT_FALLBACK_USER_ID: z.string().min(1).optional(),
39
+ UAI_WORKSPACE_ROOT: z.string().min(1).default(`~/${STATION.homeDirName}`),
40
+ // Crash reporting (ADR-071, docs/observability.md). Default-ON for
41
+ // installed hosts via the DSN baked into lib/obs.ts; UAI_SENTRY_DSN
42
+ // overrides it (self-hosted Sentry), UAI_TELEMETRY_DISABLED=1 turns it
43
+ // off entirely — zero telemetry bytes leave the machine.
44
+ UAI_SENTRY_DSN: z.string().min(1).optional(),
45
+ UAI_TELEMETRY_DISABLED: z.string().optional(),
46
+ // ADR-134 credential store. ONLY Uai-operated hosts set `aws-ssm`: engine
47
+ // accounts are then mirrored to AWS Parameter Store (SecureString, KMS) and
48
+ // reconciled at start. Owned hosts keep `local` — credentials never leave
49
+ // the machine. See docs/credential-store.md.
50
+ UAI_CREDENTIAL_STORE: z.enum(["local", "aws-ssm"]).default("local"),
51
+ UAI_CREDENTIAL_STORE_PREFIX: z
52
+ .string()
53
+ .regex(/^\/[A-Za-z0-9_.\-]+(?:\/[A-Za-z0-9_.\-]+)*$/)
54
+ .default("/uai"),
55
+ UAI_CREDENTIAL_STORE_KMS_KEY: z.string().min(1).default("alias/uai-credentials"),
56
+ // Shared with the ADR-121 machine provider (read raw there); the store
57
+ // needs it too, so surface it here for one validated read.
58
+ UAI_AWS_REGION: z.string().min(1).optional(),
59
+ // ADR-135: the broker Lambda a Uai-operated host invokes for a reactive
60
+ // refresh (name or ARN). Unset = scheduled refreshes only.
61
+ UAI_TOKEN_BROKER_FUNCTION: z.string().min(1).optional(),
62
+ // Who keeps the standard image and agent CLIs current. `host` (owned hosts):
63
+ // this process, hourly. `external` (Uai-operated hosts): a cloud job keeps
64
+ // the payload AMI fresh and this host only reconciles once at start.
65
+ UAI_IMAGE_MAINTENANCE: z.enum(["host", "external"]).default("host"),
66
+ NODE_ENV: z
67
+ .enum(["development", "production", "test"])
68
+ .default("development"),
69
+ });
70
+
71
+ const parsed = schema.safeParse(process.env);
72
+
73
+ if (!parsed.success) {
74
+ console.error(
75
+ "uai host-agent: invalid environment",
76
+ parsed.error.flatten().fieldErrors,
77
+ );
78
+ }
79
+
80
+ const raw = parsed.success ? parsed.data : schema.parse({});
81
+ // The PAT is validated above but kept nowhere here: its one reader takes it
82
+ // from process.env when it injects it (github-tokens.ts), and the cutover
83
+ // purge removes it from there (ADR-154 §13). A long-lived copy in this
84
+ // module would outlive that purge for the life of the process.
85
+ delete raw.UAI_GH_PAT_FALLBACK;
86
+ const here = dirname(fileURLToPath(import.meta.url)); // <pkg>/lib
87
+
88
+ function expandHome(path: string): string {
89
+ if (path === "~") return homedir();
90
+ if (path.startsWith("~/")) return resolve(homedir(), path.slice(2));
91
+ return path;
92
+ }
93
+
94
+ /** Walk up from `start` until `pred` holds; null at the filesystem root. */
95
+ function findUp(start: string, pred: (dir: string) => boolean): string | null {
96
+ let dir = start;
97
+ for (;;) {
98
+ if (pred(dir)) return dir;
99
+ const parent = dirname(dir);
100
+ if (parent === dir) return null;
101
+ dir = parent;
102
+ }
103
+ }
104
+
105
+ // Monorepo root iff we're running from a checkout: a dir holding BOTH
106
+ // pnpm-workspace.yaml and a host-agent/ child. The node_modules guard makes
107
+ // the "null when installed" comment TRUE (review finding: a tarball beneath
108
+ // a foreign pnpm workspace with a sibling host-agent/ resolved uaiHome — and
109
+ // the telemetry disclosure marker — into that workspace instead of ~/.uai).
110
+ // Real path first: pnpm reaches this file through symlinks. Keep in sync
111
+ // with src/load-env.ts, which applies the same rule.
112
+ const realHere = (() => {
113
+ try {
114
+ return realpathSync(here);
115
+ } catch {
116
+ return here;
117
+ }
118
+ })();
119
+ const repoRoot = realHere.split(sep).includes("node_modules")
120
+ ? null
121
+ : findUp(
122
+ realHere,
123
+ (d) =>
124
+ existsSync(resolve(d, "pnpm-workspace.yaml")) &&
125
+ existsSync(resolve(d, "host-agent")),
126
+ );
127
+
128
+ // UAI_HOME: where .env.local + the data dir live. Explicit UAI_HOME wins; then
129
+ // the parent of an explicit UAI_DATA_DIR; then the repo root (dev checkout);
130
+ // then ~/.moondeck (installed; a station never uses the old host's ~/.uai). Keep this in sync with src/load-env.ts,
131
+ // which resolves the same base independently (it runs before this module).
132
+ const uaiHome = raw.UAI_HOME
133
+ ? resolve(expandHome(raw.UAI_HOME))
134
+ : raw.UAI_DATA_DIR
135
+ ? dirname(resolve(expandHome(raw.UAI_DATA_DIR)))
136
+ : (repoRoot ?? resolve(homedir(), STATION.homeDirName));
137
+
138
+ // Data dir: explicit UAI_DATA_DIR wins, kept verbatim (e.g. the live host's
139
+ // pinned <repo>/.data); else <repo>/.data in a checkout, or ~/.moondeck/data when
140
+ // installed.
141
+ const dataDir = raw.UAI_DATA_DIR
142
+ ? resolve(expandHome(raw.UAI_DATA_DIR))
143
+ : resolve(uaiHome, repoRoot ? ".data" : "data");
144
+
145
+ const workspaceRoot = resolve(expandHome(raw.UAI_WORKSPACE_ROOT));
146
+
147
+ export const env = {
148
+ ...raw,
149
+ uaiHome,
150
+ dataDir,
151
+ hostDbPath: resolve(dataDir, "host.sqlite"),
152
+ hostKeyPath: raw.UAI_HOST_KEY_FILE
153
+ ? resolve(uaiHome, expandHome(raw.UAI_HOST_KEY_FILE))
154
+ : resolve(dataDir, "host-master.key"),
155
+ commandDbDir: resolve(dataDir, "host-command-dbs"),
156
+ // Agent scripts ship inside the package; lib/agent.ts resolves them off the
157
+ // package root by default. Only an explicit UAI_AGENT_DIR override surfaces
158
+ // here (undefined → use the shipped scripts).
159
+ agentDir: raw.UAI_AGENT_DIR
160
+ ? resolve(uaiHome, expandHome(raw.UAI_AGENT_DIR))
161
+ : undefined,
162
+ workspaceRoot,
163
+ } as const;
164
+
165
+ export type Env = typeof env;
166
+
167
+ /** Absolute path to a project's uai-owned directory (holds its bare mirror). */
168
+ export function projectDir(projectId: string): string {
169
+ return resolve(env.workspaceRoot, "projects", projectId);
170
+ }
171
+
172
+ /** Absolute path to a project's single bare mirror (ADR-022: one repo/project). */
173
+ export function projectRepoMirror(projectId: string): string {
174
+ return resolve(projectDir(projectId), "repo.git");
175
+ }
176
+
177
+ /** Absolute path to a task's directory (top-level; a task spans 0..N projects). */
178
+ export function taskDir(taskId: string): string {
179
+ return resolve(env.workspaceRoot, "tasks", taskId);
180
+ }
181
+
182
+ /** Absolute path to a task's workspace root (parent of per-project worktrees). */
183
+ export function taskWorkspaceDir(taskId: string): string {
184
+ return resolve(taskDir(taskId), "workspace");
185
+ }
186
+
187
+ /** Absolute path to one selected project's worktree within a task. */
188
+ export function taskProjectWorktree(taskId: string, projectSlug: string): string {
189
+ return resolve(taskWorkspaceDir(taskId), projectSlug);
190
+ }
191
+
192
+ /** Absolute path to a task's ephemeral `.uai/` render dir (generated compose). */
193
+ export function taskUaiDir(taskId: string): string {
194
+ return resolve(taskDir(taskId), ".uai");
195
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * ADR-154 §13: removing a credential by one name must not leave it under
3
+ * another. Shared by the cutover purge and the credential store's cleanup of
4
+ * staged writes.
5
+ */
6
+
7
+ import { lstatSync, readdirSync, readlinkSync } from "node:fs";
8
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
9
+
10
+ /**
11
+ * Throws, naming each, while removing `path` would leave another name of
12
+ * what is in it: a link in it that leads out of it (what it leads to stays,
13
+ * and the runner's own writes, which follow links, may have filled it), and
14
+ * a file in it with a second name (a hard link: the other name keeps its
15
+ * bytes). A link that leads somewhere inside it goes with it. Walked with
16
+ * lstat, following nothing; a link's target is taken from its own
17
+ * directory, by name, which is how the kernel follows it: one that leads
18
+ * out through another link on the way is caught at that link. `path` itself
19
+ * is no link (the callers checked).
20
+ */
21
+ export function assertNoAliases(path: string): void {
22
+ const found: string[] = [];
23
+ const visit = (at: string): void => {
24
+ const stats = lstatSync(at, { bigint: true });
25
+ if (stats.isSymbolicLink()) {
26
+ const to = resolve(dirname(at), readlinkSync(at));
27
+ if (!isWithin(path, to)) found.push(`${at} (a symbolic link to ${to})`);
28
+ return;
29
+ }
30
+ if (stats.isDirectory()) {
31
+ for (const name of readdirSync(at)) visit(join(at, name));
32
+ return;
33
+ }
34
+ if (stats.nlink > 1n) found.push(`${at} (a file with ${stats.nlink} names)`);
35
+ };
36
+ visit(path);
37
+ if (found.length > 0) {
38
+ throw new Error(
39
+ `${found.join(", ")}: removing it would leave what the station wrote through ` +
40
+ `${found.length === 1 ? "that" : "those"}; nothing here is removed until ` +
41
+ `${found.length === 1 ? "it is" : "they are"} gone`,
42
+ );
43
+ }
44
+ }
45
+
46
+ function isWithin(dir: string, path: string): boolean {
47
+ const rel = relative(dir, path);
48
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
49
+ }
@@ -0,0 +1,334 @@
1
+ /**
2
+ * ADR-154 §6, §12: what task-up's host-side clone and fetches authenticate
3
+ * with, served by this process from memory for exactly as long as the
4
+ * operation holds the task owner's credentials (`holdTaskGitCredentials`).
5
+ *
6
+ * - GitHub over HTTPS: git's credential helper is a small script that asks
7
+ * this process for github.com's token over a private socket, each time git
8
+ * needs it. Nothing caches it: no `git credential-cache` daemon outlives
9
+ * the clone.
10
+ * - SSH: an ssh-agent this process speaks itself, holding only the task
11
+ * owner's ed25519 key (what the sign-in box mints), in memory. Only the
12
+ * public key is written, so ssh can name the identity it wants.
13
+ *
14
+ * Everything lives in a 0700 directory under /tmp, whose path has no
15
+ * whitespace (git runs the helper through a shell). The service closes when
16
+ * the hold's signal aborts or the caller closes it: the sockets stop
17
+ * answering and the directory is removed. A process that dies takes its
18
+ * sockets with it, and what it leaves on disk holds no secret.
19
+ */
20
+
21
+ import { createPrivateKey, createPublicKey, sign, type KeyObject } from "node:crypto";
22
+ import { chmodSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
23
+ import { createServer, type Server, type Socket } from "node:net";
24
+ import { join } from "node:path";
25
+
26
+ import type { TaskGitCredentialHold } from "./credential-consumers/github";
27
+
28
+ /** What task-up is told, all non-secret paths. */
29
+ export interface CloneCredentialEnv {
30
+ /** The executable git runs as its credential helper (github.com only). */
31
+ UAI_GITHUB_CREDENTIAL_HELPER?: string;
32
+ /** The agent socket holding the owner's SSH key. */
33
+ UAI_TASK_SSH_AUTH_SOCK?: string;
34
+ /** The owner's public key: the identity ssh asks the agent for. */
35
+ UAI_TASK_SSH_PUBLIC_KEY?: string;
36
+ }
37
+
38
+ export interface CloneCredentialService {
39
+ env: CloneCredentialEnv;
40
+ /** Stop answering and remove the directory. Idempotent. */
41
+ close(): Promise<void>;
42
+ }
43
+
44
+ /** An SSH key the agent cannot hold: not an unencrypted ed25519 key, or one
45
+ * whose public half does not match. */
46
+ export class UnsupportedSshKey extends Error {
47
+ constructor(reason: string) {
48
+ super(`the SSH key cannot be used for the clone: ${reason}`);
49
+ this.name = "UnsupportedSshKey";
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Serve `hold`'s credentials to task-up until the hold's signal aborts or
55
+ * `close()` is called. Rejects (serving nothing) when the hold is already
56
+ * aborted or its SSH key cannot be held.
57
+ */
58
+ export async function serveCloneCredentials(
59
+ hold: Pick<TaskGitCredentialHold, "github" | "ssh" | "signal">,
60
+ ): Promise<CloneCredentialService> {
61
+ if (hold.signal.aborted) throw new Error("the task's credentials were taken before the clone");
62
+ const key = hold.ssh ? ed25519Identity(hold.ssh.privateKey, hold.ssh.publicKey) : null;
63
+ const directory = mkdtempSync(join("/tmp", "uai-clone-credentials-"));
64
+ chmodSync(directory, 0o700);
65
+ const servers: Server[] = [];
66
+ const sockets = new Set<Socket>();
67
+ /** Listens not yet settled: closing fails them, so a start can never wait
68
+ * on a server closed under it. */
69
+ const starting = new Set<(error: Error) => void>();
70
+ let closing: Promise<void> | null = null;
71
+ const close = (): Promise<void> => {
72
+ closing ??= (async () => {
73
+ hold.signal.removeEventListener("abort", onAbort);
74
+ for (const fail of [...starting]) fail(new Error("the task's credentials were taken before the clone"));
75
+ for (const socket of sockets) socket.destroy();
76
+ await Promise.all(servers.map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
77
+ rmSync(directory, { recursive: true, force: true });
78
+ })();
79
+ return closing;
80
+ };
81
+ const onAbort = (): void => void close();
82
+ hold.signal.addEventListener("abort", onAbort);
83
+
84
+ const listen = async (path: string, handle: (socket: Socket) => void): Promise<void> => {
85
+ const server = createServer((socket) => {
86
+ if (closing) {
87
+ socket.destroy();
88
+ return;
89
+ }
90
+ sockets.add(socket);
91
+ socket.on("close", () => sockets.delete(socket));
92
+ socket.on("error", () => socket.destroy());
93
+ handle(socket);
94
+ });
95
+ servers.push(server);
96
+ await new Promise<void>((resolve, reject) => {
97
+ const fail = (error: Error): void => {
98
+ starting.delete(fail);
99
+ reject(error);
100
+ };
101
+ starting.add(fail);
102
+ server.once("error", fail);
103
+ server.listen(path, () => {
104
+ starting.delete(fail);
105
+ resolve();
106
+ });
107
+ });
108
+ if (closing) throw new Error("the task's credentials were taken before the clone");
109
+ chmodSync(path, 0o600);
110
+ };
111
+
112
+ const env: CloneCredentialEnv = {};
113
+ try {
114
+ if (hold.github) {
115
+ const token = hold.github.token;
116
+ const socketPath = join(directory, "github.sock");
117
+ await listen(socketPath, (socket) => answerGithub(socket, token));
118
+ const node = join(directory, "node");
119
+ symlinkSync(process.execPath, node);
120
+ const helper = join(directory, "git-credential-uai");
121
+ writeFileSync(helper, helperScript(node, socketPath), { mode: 0o700 });
122
+ env.UAI_GITHUB_CREDENTIAL_HELPER = helper;
123
+ }
124
+ if (key) {
125
+ const socketPath = join(directory, "ssh-agent.sock");
126
+ await listen(socketPath, (socket) => answerAgent(socket, key));
127
+ const publicKey = join(directory, "id_ed25519.pub");
128
+ writeFileSync(publicKey, `${key.publicLine}\n`, { mode: 0o600 });
129
+ env.UAI_TASK_SSH_AUTH_SOCK = socketPath;
130
+ env.UAI_TASK_SSH_PUBLIC_KEY = publicKey;
131
+ }
132
+ } catch (error) {
133
+ await close();
134
+ throw error;
135
+ }
136
+ if (hold.signal.aborted) {
137
+ await close();
138
+ throw new Error("the task's credentials were taken before the clone");
139
+ }
140
+ return { env, close };
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // GitHub: the credential helper and what it asks
145
+ // ---------------------------------------------------------------------------
146
+
147
+ /** The helper git runs: only `get`, only https://github.com; it asks this
148
+ * process and prints what it answers (nothing once the service closed). */
149
+ function helperScript(node: string, socketPath: string): string {
150
+ return `#!${node}
151
+ "use strict";
152
+ if (process.argv[2] !== "get") process.exit(0);
153
+ let input = "";
154
+ process.stdin.setEncoding("utf8");
155
+ process.stdin.on("data", (chunk) => { input += chunk; });
156
+ process.stdin.on("end", () => {
157
+ const fields = {};
158
+ for (const line of input.split("\\n")) {
159
+ const at = line.indexOf("=");
160
+ if (at > 0) fields[line.slice(0, at)] = line.slice(at + 1);
161
+ }
162
+ if (fields.protocol !== "https" || fields.host !== "github.com") process.exit(0);
163
+ const socket = require("node:net").connect(${JSON.stringify(socketPath)});
164
+ let answer = "";
165
+ socket.setEncoding("utf8");
166
+ socket.on("data", (chunk) => { answer += chunk; });
167
+ socket.on("end", () => { process.stdout.write(answer); });
168
+ socket.on("error", () => process.exit(0));
169
+ socket.end("protocol=https\\nhost=github.com\\n");
170
+ });
171
+ `;
172
+ }
173
+
174
+ function answerGithub(socket: Socket, token: string): void {
175
+ let request = "";
176
+ socket.setEncoding("utf8");
177
+ socket.on("data", (chunk: string) => {
178
+ request += chunk;
179
+ if (request.length > 1024) socket.destroy();
180
+ });
181
+ socket.on("end", () => {
182
+ const fields = new Set(request.split("\n"));
183
+ if (fields.has("protocol=https") && fields.has("host=github.com")) {
184
+ socket.end(`username=x-access-token\npassword=${token}\n`);
185
+ } else {
186
+ socket.end();
187
+ }
188
+ });
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // SSH: an agent holding one ed25519 key (draft-miller-ssh-agent)
193
+ // ---------------------------------------------------------------------------
194
+
195
+ const SSH_AGENT_FAILURE = 5;
196
+ const SSH_AGENTC_REQUEST_IDENTITIES = 11;
197
+ const SSH_AGENT_IDENTITIES_ANSWER = 12;
198
+ const SSH_AGENTC_SIGN_REQUEST = 13;
199
+ const SSH_AGENT_SIGN_RESPONSE = 14;
200
+ /** A message larger than any request this agent answers. */
201
+ const MAX_AGENT_MESSAGE = 256 * 1024;
202
+ /** PKCS#8 for an ed25519 private key, up to its 32-byte seed (RFC 8410). */
203
+ const PKCS8_ED25519_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
204
+
205
+ interface Ed25519Identity {
206
+ blob: Buffer;
207
+ comment: string;
208
+ publicLine: string;
209
+ privateKey: KeyObject;
210
+ }
211
+
212
+ function sshString(bytes: Buffer): Buffer {
213
+ const length = Buffer.alloc(4);
214
+ length.writeUInt32BE(bytes.length);
215
+ return Buffer.concat([length, bytes]);
216
+ }
217
+
218
+ /** Reads SSH wire fields in order; throws on anything short or malformed. */
219
+ class Reader {
220
+ #offset = 0;
221
+ constructor(private readonly bytes: Buffer) {}
222
+ uint32(): number {
223
+ if (this.#offset + 4 > this.bytes.length) throw new UnsupportedSshKey("a truncated field");
224
+ const value = this.bytes.readUInt32BE(this.#offset);
225
+ this.#offset += 4;
226
+ return value;
227
+ }
228
+ string(): Buffer {
229
+ const length = this.uint32();
230
+ if (this.#offset + length > this.bytes.length) throw new UnsupportedSshKey("a truncated field");
231
+ const value = this.bytes.subarray(this.#offset, this.#offset + length);
232
+ this.#offset += length;
233
+ return value;
234
+ }
235
+ }
236
+
237
+ /** The one ed25519 identity in an unencrypted openssh-key-v1 private key,
238
+ * checked against its public line. */
239
+ export function ed25519Identity(privateKeyPem: string, publicLine: string): Ed25519Identity {
240
+ const pem = /-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]+?)-----END OPENSSH PRIVATE KEY-----/.exec(privateKeyPem);
241
+ if (!pem) throw new UnsupportedSshKey("not an OpenSSH private key");
242
+ const body = Buffer.from(pem[1]!.replace(/\s+/g, ""), "base64");
243
+ const magic = Buffer.from("openssh-key-v1\0");
244
+ if (!body.subarray(0, magic.length).equals(magic)) throw new UnsupportedSshKey("not an OpenSSH private key");
245
+ const outer = new Reader(body.subarray(magic.length));
246
+ const cipher = outer.string().toString();
247
+ const kdf = outer.string().toString();
248
+ outer.string();
249
+ if (cipher !== "none" || kdf !== "none") throw new UnsupportedSshKey("an encrypted key");
250
+ if (outer.uint32() !== 1) throw new UnsupportedSshKey("more than one key");
251
+ const blob = Buffer.from(outer.string());
252
+ const secret = new Reader(outer.string());
253
+ if (secret.uint32() !== secret.uint32()) throw new UnsupportedSshKey("a corrupt key");
254
+ if (secret.string().toString() !== "ssh-ed25519") throw new UnsupportedSshKey("not an ed25519 key");
255
+ const pub = secret.string();
256
+ const pair = secret.string();
257
+ const comment = secret.string().toString();
258
+ if (pub.length !== 32 || pair.length !== 64 || !pair.subarray(32).equals(pub)) {
259
+ throw new UnsupportedSshKey("a corrupt ed25519 key");
260
+ }
261
+ if (!blob.equals(Buffer.concat([sshString(Buffer.from("ssh-ed25519")), sshString(pub)]))) {
262
+ throw new UnsupportedSshKey("a corrupt ed25519 key");
263
+ }
264
+ const [type, encoded] = publicLine.trim().split(/\s+/);
265
+ if (type !== "ssh-ed25519" || !encoded || !Buffer.from(encoded, "base64").equals(blob)) {
266
+ throw new UnsupportedSshKey("its public key does not match");
267
+ }
268
+ const privateKey = createPrivateKey({
269
+ key: Buffer.concat([PKCS8_ED25519_PREFIX, pair.subarray(0, 32)]),
270
+ format: "der",
271
+ type: "pkcs8",
272
+ });
273
+ // The seed's own public key: the one the agent advertises must be the one
274
+ // it signs with.
275
+ const derived = Buffer.from(createPublicKey(privateKey).export({ format: "jwk" }).x ?? "", "base64url");
276
+ if (!derived.equals(pub)) throw new UnsupportedSshKey("a corrupt ed25519 key");
277
+ return { blob, comment, publicLine: `ssh-ed25519 ${encoded}`, privateKey };
278
+ }
279
+
280
+ function agentMessage(type: number, payload: Buffer = Buffer.alloc(0)): Buffer {
281
+ const header = Buffer.alloc(5);
282
+ header.writeUInt32BE(payload.length + 1);
283
+ header.writeUInt8(type, 4);
284
+ return Buffer.concat([header, payload]);
285
+ }
286
+
287
+ /** The agent's answer to one request: its identity, a signature by it, or
288
+ * failure for everything else (adding, removing, locking, extensions). */
289
+ export function agentAnswer(request: Buffer, key: Ed25519Identity): Buffer {
290
+ const type = request.readUInt8(0);
291
+ if (type === SSH_AGENTC_REQUEST_IDENTITIES) {
292
+ const count = Buffer.alloc(4);
293
+ count.writeUInt32BE(1);
294
+ return agentMessage(
295
+ SSH_AGENT_IDENTITIES_ANSWER,
296
+ Buffer.concat([count, sshString(key.blob), sshString(Buffer.from(key.comment))]),
297
+ );
298
+ }
299
+ if (type === SSH_AGENTC_SIGN_REQUEST) {
300
+ try {
301
+ const reader = new Reader(request.subarray(1));
302
+ const blob = reader.string();
303
+ const data = reader.string();
304
+ reader.uint32();
305
+ if (!blob.equals(key.blob)) return agentMessage(SSH_AGENT_FAILURE);
306
+ const signature = sign(null, data, key.privateKey);
307
+ return agentMessage(
308
+ SSH_AGENT_SIGN_RESPONSE,
309
+ sshString(Buffer.concat([sshString(Buffer.from("ssh-ed25519")), sshString(signature)])),
310
+ );
311
+ } catch {
312
+ return agentMessage(SSH_AGENT_FAILURE);
313
+ }
314
+ }
315
+ return agentMessage(SSH_AGENT_FAILURE);
316
+ }
317
+
318
+ function answerAgent(socket: Socket, key: Ed25519Identity): void {
319
+ let pending = Buffer.alloc(0);
320
+ socket.on("data", (chunk: Buffer) => {
321
+ pending = Buffer.concat([pending, chunk]);
322
+ while (pending.length >= 4) {
323
+ const length = pending.readUInt32BE(0);
324
+ if (length === 0 || length > MAX_AGENT_MESSAGE) {
325
+ socket.destroy();
326
+ return;
327
+ }
328
+ if (pending.length < 4 + length) return;
329
+ const request = pending.subarray(4, 4 + length);
330
+ pending = pending.subarray(4 + length);
331
+ socket.write(agentAnswer(request, key));
332
+ }
333
+ });
334
+ }