@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
@@ -0,0 +1,238 @@
1
+ /**
2
+ * ADR-121: command transport to a task machine — SSH within the private
3
+ * network (VPC for cloud backends, the daemon bridge on the Linux guinea
4
+ * pig). The recommended-and-recorded pick over SSM: it matches the exec
5
+ * plumbing every consumer already speaks, and nothing in it is
6
+ * cloud-specific.
7
+ *
8
+ * Mirrors `dockerCli`'s contract exactly — never rejects, resolves
9
+ * `{status, stdout, stderr}` with `status: null` on spawn error/timeout —
10
+ * so machine-backed call sites read like container-backed ones.
11
+ *
12
+ * Host-key policy is `accept-new`, the same posture task containers get for
13
+ * git (`core.sshCommand` in git-identity.ts): machines are freshly minted
14
+ * by US moments before first contact, so TOFU is sound — but a CHANGED key
15
+ * for a known address must fail loudly, never be silently re-accepted.
16
+ * BatchMode forbids prompts: an unreachable or unauthenticated machine is
17
+ * an error result, not a hang.
18
+ */
19
+
20
+ import { spawn } from "node:child_process";
21
+
22
+ import type { DockerResult } from "./docker-exec";
23
+
24
+ const DEFAULT_TIMEOUT_MS = 60_000;
25
+ const DEFAULT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
26
+ /** SIGKILLed ssh normally closes at once; an unreapable child must not hold
27
+ * the promise open past the caller's own deadline. */
28
+ const KILL_SETTLE_MS = 2_000;
29
+
30
+ export interface MachineExecTarget {
31
+ /** Reachable address from MachineInfo — VPC-private or bridge IP. */
32
+ address: string;
33
+ /** Path to the private key the machine's image authorizes. */
34
+ keyPath: string;
35
+ user?: string;
36
+ port?: number;
37
+ }
38
+
39
+ export interface MachineExecOptions {
40
+ input?: string;
41
+ timeoutMs?: number;
42
+ maxOutputBytes?: number;
43
+ }
44
+
45
+ /** POSIX single-quote escaping. The ONLY safe way to carry an arbitrary
46
+ * argv element through the remote login shell ssh interposes: everything
47
+ * between single quotes is literal, and an embedded quote becomes '\''. */
48
+ export function shellQuote(value: string): string {
49
+ return `'${value.replace(/'/g, `'\\''`)}'`;
50
+ }
51
+
52
+ /**
53
+ * Build the remote command for a TaskEnvironment-style request. SSH has no
54
+ * --workdir/--env/--user flags — cwd and env must be materialized INTO the
55
+ * remote command, each element quoted, and the user rides the ssh login
56
+ * instead (`-l`). Env values therefore appear in the machine-side process
57
+ * table; the machine is a single-task trust domain, and hardening to sshd
58
+ * SetEnv acceptlists is recorded ADR-121 follow-up work, not silently
59
+ * skipped. They also sit on the runner's own `ssh` argv for as long as the
60
+ * connection lives, so a leased engine's credentials never ride here: they
61
+ * travel in its launch's single-use admission (ADR-154 §6,
62
+ * credential-consumers/engine-anchor.ts). With `clean`, the command starts
63
+ * from an empty environment, not the session's (whatever sshd, PAM and the
64
+ * login gave it): only what the request names reaches it.
65
+ */
66
+ export function buildRemoteCommand(request: {
67
+ argv: readonly string[];
68
+ cwd?: string;
69
+ inheritEnv?: readonly string[];
70
+ env?: Readonly<Record<string, string>>;
71
+ detached?: boolean;
72
+ clean?: boolean;
73
+ }): string {
74
+ const parts: string[] = [];
75
+ if (request.cwd) {
76
+ parts.push(`cd ${shellQuote(request.cwd)} &&`);
77
+ }
78
+ const env: string[] = [];
79
+ for (const name of request.inheritEnv ?? []) {
80
+ const value = process.env[name];
81
+ if (value !== undefined) env.push(`${name}=${value}`);
82
+ }
83
+ for (const [name, value] of Object.entries(request.env ?? {}).sort(
84
+ ([left], [right]) => left.localeCompare(right),
85
+ )) {
86
+ env.push(`${name}=${value}`);
87
+ }
88
+ // A clean start names env absolutely: the session's PATH never picks it.
89
+ const envPrefix = request.clean
90
+ ? `/usr/bin/env -i ${env.map((entry) => `${shellQuote(entry)} `).join("")}`
91
+ : env.length > 0
92
+ ? `env ${env.map(shellQuote).join(" ")} `
93
+ : "";
94
+ const command = request.argv.map(shellQuote).join(" ");
95
+ if (request.detached) {
96
+ // Detached durable sessions manage their own transcript IO (runner.mjs);
97
+ // the launch just needs the process to survive this ssh connection AND
98
+ // the connection to close immediately. The redirects must cover the
99
+ // WHOLE background job and the job must `exec` into the payload: with
100
+ // redirects on the inner command only, the waiting shell kept the ssh
101
+ // channel's stdout/stderr open until the runner died, so sshd never saw
102
+ // EOF and every launch rode its timeout into a false spawn failure
103
+ // (live 2026-08-27: a 65s session recycle loop on the second machine
104
+ // task; the first machine's stable session was an accident of its host
105
+ // dying seconds before the timeout could mark the row closed).
106
+ const body = [
107
+ ...(request.cwd ? [`cd ${shellQuote(request.cwd)} &&`] : []),
108
+ `exec ${envPrefix}setsid ${command}`,
109
+ ].join(" ");
110
+ return `{ ${body}; } </dev/null >/dev/null 2>&1 & exit 0`;
111
+ }
112
+ parts.push(`exec ${envPrefix}${command}`);
113
+ return parts.join(" ");
114
+ }
115
+
116
+ export function machineSshArgs(
117
+ target: MachineExecTarget,
118
+ argv: string[],
119
+ ): string[] {
120
+ return [
121
+ "-o",
122
+ "BatchMode=yes",
123
+ "-o",
124
+ "StrictHostKeyChecking=accept-new",
125
+ "-o",
126
+ "ConnectTimeout=10",
127
+ "-i",
128
+ target.keyPath,
129
+ "-p",
130
+ String(target.port ?? 22),
131
+ `${target.user ?? "node"}@${target.address}`,
132
+ "--",
133
+ ...argv,
134
+ ];
135
+ }
136
+
137
+ export function machineExec(
138
+ target: MachineExecTarget,
139
+ argv: string[],
140
+ opts: MachineExecOptions = {},
141
+ ): Promise<DockerResult> {
142
+ return runSsh(machineSshArgs(target, argv), opts);
143
+ }
144
+
145
+ /**
146
+ * Full ssh argv for a TaskEnvironment-style request: base transport options,
147
+ * the request's user as the ssh login, and the quoted remote command as the
148
+ * single trailing argument. Shared by captured, streaming, and detached
149
+ * paths so quoting can never diverge between them.
150
+ */
151
+ export function machineEnvironmentSshArgs(
152
+ target: Omit<MachineExecTarget, "user">,
153
+ request: {
154
+ argv: readonly string[];
155
+ cwd?: string;
156
+ user?: string;
157
+ inheritEnv?: readonly string[];
158
+ env?: Readonly<Record<string, string>>;
159
+ clean?: boolean;
160
+ },
161
+ mode: "interactive" | "detached",
162
+ ): string[] {
163
+ return [
164
+ "-o",
165
+ "BatchMode=yes",
166
+ "-o",
167
+ "StrictHostKeyChecking=accept-new",
168
+ "-o",
169
+ "ConnectTimeout=10",
170
+ "-i",
171
+ target.keyPath,
172
+ "-p",
173
+ String(target.port ?? 22),
174
+ "-l",
175
+ request.user ?? "node",
176
+ target.address,
177
+ "--",
178
+ buildRemoteCommand({ ...request, detached: mode === "detached" }),
179
+ ];
180
+ }
181
+
182
+ function runSsh(
183
+ args: string[],
184
+ opts: MachineExecOptions,
185
+ ): Promise<DockerResult> {
186
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
187
+ const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
188
+ return new Promise<DockerResult>((resolve) => {
189
+ let stdout = "";
190
+ let stderr = "";
191
+ let settled = false;
192
+ let killedForCause = false;
193
+ const child = spawn("ssh", args, { stdio: ["pipe", "pipe", "pipe"] });
194
+ const settle = (result: DockerResult): void => {
195
+ if (settled) return;
196
+ settled = true;
197
+ clearTimeout(timer);
198
+ resolve(result);
199
+ };
200
+ const killAndSettle = (): void => {
201
+ killedForCause = true;
202
+ child.kill("SIGKILL");
203
+ setTimeout(() => {
204
+ settle({ status: null, stdout, stderr, outputTruncated: true });
205
+ }, KILL_SETTLE_MS).unref?.();
206
+ };
207
+ const timer = setTimeout(killAndSettle, timeoutMs);
208
+ timer.unref?.();
209
+ const capOutput = (): void => {
210
+ if (stdout.length + stderr.length > maxOutputBytes) killAndSettle();
211
+ };
212
+ child.stdout.setEncoding("utf8");
213
+ child.stderr.setEncoding("utf8");
214
+ child.stdout.on("data", (chunk: string) => {
215
+ stdout += chunk;
216
+ capOutput();
217
+ });
218
+ child.stderr.on("data", (chunk: string) => {
219
+ stderr += chunk;
220
+ capOutput();
221
+ });
222
+ child.once("error", () => {
223
+ settle({ status: null, stdout, stderr });
224
+ });
225
+ child.once("close", (code) => {
226
+ settle({
227
+ status: killedForCause ? null : code,
228
+ stdout,
229
+ stderr,
230
+ ...(killedForCause ? { outputTruncated: true } : {}),
231
+ });
232
+ });
233
+ if (opts.input !== undefined) {
234
+ child.stdin.write(opts.input);
235
+ }
236
+ child.stdin.end();
237
+ });
238
+ }
@@ -0,0 +1,344 @@
1
+ /**
2
+ * ADR-121: the MCP gateway reverse tunnel for machine tasks.
3
+ *
4
+ * Containers reach the host's MCP gateway via container topology
5
+ * (host.docker.internal, the vmnet gateway IP). A machine — an EC2
6
+ * instance behind its own network — can never route to the host, so the
7
+ * host pushes the gateway TO the machine instead: one persistent reverse
8
+ * tunnel publishes the runner's 5877 listener as machine-loopback 5878.
9
+ * The task-local authenticated forwarder owns 5877 and relays there only
10
+ * after proving the task generation. Task configs keep pointing MCP servers
11
+ * at the machine's own loopback (setupMcpTaskConfig's machine branch).
12
+ *
13
+ * Supervision: the tunnel respawns with a short backoff while ensured —
14
+ * re-resolving the machine address each spawn, since it changes across
15
+ * stop/start — and stops when the task stops, tears down, or the process
16
+ * exits. Initial provisioning can await an OpenSSH forwarding acknowledgement
17
+ * before starting consumers; background recovery keeps the original
18
+ * quiet-but-logged behavior.
19
+ */
20
+
21
+ import { spawn, type ChildProcess } from "node:child_process";
22
+
23
+ import { MCP_GATEWAY_PORT } from "./mcp-gateway";
24
+
25
+ const RESPAWN_DELAY_MS = 3_000;
26
+ /** Long enough for the provider's 10s SSH connect deadline plus remote
27
+ * forwarding setup, but bounded so task-up fails closed rather than starting
28
+ * consumers with an unusable env loader. */
29
+ export const MACHINE_GATEWAY_TUNNEL_READY_TIMEOUT_MS = 20_000;
30
+ /** Task-local 5877 belongs to the authenticated forwarder. SSH publishes the
31
+ * runner's real gateway one hop away so unproved traffic can never dial it. */
32
+ export const MACHINE_GATEWAY_TUNNEL_PORT = 5_878;
33
+ /** After this many consecutive sub-5s deaths, slow the respawn cadence so a
34
+ * permanently unreachable machine does not spin ssh forever. */
35
+ const FAST_DEATH_THRESHOLD = 5;
36
+ const SLOW_RESPAWN_DELAY_MS = 60_000;
37
+ const READY_MARKER = "__UAI_MACHINE_GATEWAY_TUNNEL_READY__";
38
+ const READY_REMOTE_COMMAND =
39
+ `/bin/echo '${READY_MARKER}'; while :; do sleep 3600; done`;
40
+
41
+ export interface MachineTunnelSource {
42
+ gatewayTunnelSshArgs(
43
+ machinePort: number,
44
+ hostPort: number,
45
+ ): Promise<string[]>;
46
+ }
47
+
48
+ interface TunnelState {
49
+ generation: number;
50
+ machinePort: number;
51
+ child: ChildProcess | null;
52
+ timer: ReturnType<typeof setTimeout> | null;
53
+ spawning: boolean;
54
+ ready: boolean;
55
+ readinessWaiters: Set<ReadinessWaiter>;
56
+ fastDeaths: number;
57
+ spawnedAt: number;
58
+ }
59
+
60
+ interface ReadinessWaiter {
61
+ resolve(): void;
62
+ reject(error: Error): void;
63
+ }
64
+
65
+ const tunnels = new Map<string, TunnelState>();
66
+
67
+ /** Idempotently keep a gateway tunnel alive for this machine task. Legacy
68
+ * baked tasks retain direct 5877→5877 forwarding; loader tasks explicitly
69
+ * request 5878 so their authenticated task-local forwarder owns 5877. */
70
+ export function ensureMachineGatewayTunnel(
71
+ taskId: string,
72
+ source: MachineTunnelSource,
73
+ machinePort = MCP_GATEWAY_PORT,
74
+ ): void {
75
+ armMachineGatewayTunnel(taskId, source, machinePort);
76
+ }
77
+
78
+ /** Keep the tunnel supervised and wait until OpenSSH has acknowledged the
79
+ * remote listener. `ExitOnForwardFailure=yes` delays the remote command until
80
+ * every requested forwarding listener is installed; its stdout marker is
81
+ * therefore stronger than the local child `spawn` event. A timeout rejects
82
+ * (fail closed) but deliberately leaves the supervisor armed for recovery. */
83
+ export function ensureMachineGatewayTunnelReady(
84
+ taskId: string,
85
+ source: MachineTunnelSource,
86
+ machinePort = MCP_GATEWAY_PORT,
87
+ timeoutMs = MACHINE_GATEWAY_TUNNEL_READY_TIMEOUT_MS,
88
+ ): Promise<void> {
89
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
90
+ return Promise.reject(
91
+ new Error("machine gateway tunnel readiness timeout must be positive"),
92
+ );
93
+ }
94
+ const state = armMachineGatewayTunnel(taskId, source, machinePort);
95
+ if (state.ready) return Promise.resolve();
96
+
97
+ return new Promise<void>((resolve, reject) => {
98
+ let settled = false;
99
+ let timer: ReturnType<typeof setTimeout> | null = null;
100
+ const waiter: ReadinessWaiter = {
101
+ resolve: () => {
102
+ if (settled) return;
103
+ settled = true;
104
+ if (timer) clearTimeout(timer);
105
+ state.readinessWaiters.delete(waiter);
106
+ resolve();
107
+ },
108
+ reject: (error) => {
109
+ if (settled) return;
110
+ settled = true;
111
+ if (timer) clearTimeout(timer);
112
+ state.readinessWaiters.delete(waiter);
113
+ reject(error);
114
+ },
115
+ };
116
+ state.readinessWaiters.add(waiter);
117
+ // Readiness can race the waiter registration when an already-running ssh
118
+ // child emits its marker in the same turn.
119
+ if (state.ready) {
120
+ waiter.resolve();
121
+ return;
122
+ }
123
+ timer = setTimeout(() => {
124
+ waiter.reject(
125
+ new Error(
126
+ `machine gateway tunnel ${taskId} did not become ready within ${timeoutMs}ms`,
127
+ ),
128
+ );
129
+ }, timeoutMs);
130
+ timer.unref?.();
131
+ });
132
+ }
133
+
134
+ function armMachineGatewayTunnel(
135
+ taskId: string,
136
+ source: MachineTunnelSource,
137
+ machinePort: number,
138
+ ): TunnelState {
139
+ let existing = tunnels.get(taskId);
140
+ if (
141
+ existing &&
142
+ (existing.child !== null || existing.timer !== null || existing.spawning)
143
+ ) {
144
+ if (existing.machinePort === machinePort) return existing;
145
+ // The persisted env mode is attach-incompatible, so this should only be
146
+ // a recovery-order correction. Replace the wrong topology immediately.
147
+ stopMachineGatewayTunnel(taskId);
148
+ existing = undefined;
149
+ }
150
+ const state: TunnelState = existing ?? {
151
+ generation: 0,
152
+ machinePort,
153
+ child: null,
154
+ timer: null,
155
+ spawning: false,
156
+ ready: false,
157
+ readinessWaiters: new Set(),
158
+ fastDeaths: 0,
159
+ spawnedAt: 0,
160
+ };
161
+ state.machinePort = machinePort;
162
+ state.generation += 1;
163
+ tunnels.set(taskId, state);
164
+ if (existing === undefined) {
165
+ console.log(`[machine-tunnel] ${taskId}: ensuring gateway tunnel`);
166
+ }
167
+ void spawnTunnel(taskId, source, state, state.generation);
168
+ return state;
169
+ }
170
+
171
+ async function spawnTunnel(
172
+ taskId: string,
173
+ source: MachineTunnelSource,
174
+ state: TunnelState,
175
+ generation: number,
176
+ ): Promise<void> {
177
+ if (tunnels.get(taskId) !== state || state.generation !== generation) return;
178
+ state.spawning = true;
179
+ let args: string[];
180
+ try {
181
+ args = await source.gatewayTunnelSshArgs(
182
+ state.machinePort,
183
+ MCP_GATEWAY_PORT,
184
+ );
185
+ } catch (err) {
186
+ state.spawning = false;
187
+ // Machine not reachable right now (stopped, address settling) — retry
188
+ // on the slow cadence; a stop/teardown clears us before it matters.
189
+ scheduleRespawn(taskId, source, state, generation, SLOW_RESPAWN_DELAY_MS);
190
+ if (state.fastDeaths === 0) {
191
+ console.warn(
192
+ `[machine-tunnel] ${taskId}: target unavailable (${err instanceof Error ? err.message : err}) — retrying`,
193
+ );
194
+ }
195
+ state.fastDeaths += 1;
196
+ return;
197
+ }
198
+ if (tunnels.get(taskId) !== state || state.generation !== generation) return;
199
+ let sshArgs: string[];
200
+ try {
201
+ sshArgs = readinessAwareSshArgs(
202
+ args,
203
+ state.machinePort,
204
+ MCP_GATEWAY_PORT,
205
+ );
206
+ } catch (err) {
207
+ state.spawning = false;
208
+ scheduleRespawn(taskId, source, state, generation, SLOW_RESPAWN_DELAY_MS);
209
+ console.warn(
210
+ `[machine-tunnel] ${taskId}: invalid ssh arguments (${err instanceof Error ? err.message : err}) — retrying`,
211
+ );
212
+ return;
213
+ }
214
+ state.spawning = false;
215
+ state.ready = false;
216
+ state.spawnedAt = Date.now();
217
+ const child = spawn("ssh", sshArgs, {
218
+ stdio: ["ignore", "pipe", "pipe"],
219
+ });
220
+ state.child = child;
221
+ let stdoutTail = "";
222
+ let stderrTail = "";
223
+ child.stdout?.setEncoding("utf8");
224
+ child.stdout?.on("data", (chunk: string) => {
225
+ if (tunnels.get(taskId) !== state || state.generation !== generation) {
226
+ return;
227
+ }
228
+ stdoutTail = (stdoutTail + chunk).slice(-2048);
229
+ if (!state.ready && stdoutTail.includes(`${READY_MARKER}\n`)) {
230
+ state.ready = true;
231
+ for (const waiter of [...state.readinessWaiters]) waiter.resolve();
232
+ }
233
+ });
234
+ child.stderr?.setEncoding("utf8");
235
+ child.stderr?.on("data", (chunk: string) => {
236
+ stderrTail = (stderrTail + chunk).slice(-2048);
237
+ });
238
+ child.once("close", (code) => {
239
+ if (tunnels.get(taskId) !== state || state.generation !== generation) {
240
+ return;
241
+ }
242
+ state.child = null;
243
+ state.ready = false;
244
+ const lifetimeMs = Date.now() - state.spawnedAt;
245
+ if (lifetimeMs < 5_000) state.fastDeaths += 1;
246
+ else state.fastDeaths = 0;
247
+ const slow = state.fastDeaths >= FAST_DEATH_THRESHOLD;
248
+ if (state.fastDeaths === FAST_DEATH_THRESHOLD) {
249
+ console.warn(
250
+ `[machine-tunnel] ${taskId}: tunnel keeps dying (exit ${code ?? "signal"}${
251
+ stderrTail.trim() ? `: ${stderrTail.trim().slice(0, 200)}` : ""
252
+ }) — slowing retries`,
253
+ );
254
+ }
255
+ scheduleRespawn(
256
+ taskId,
257
+ source,
258
+ state,
259
+ generation,
260
+ slow ? SLOW_RESPAWN_DELAY_MS : RESPAWN_DELAY_MS,
261
+ );
262
+ });
263
+ child.once("error", () => {
264
+ // close fires too; the handler above owns respawn.
265
+ });
266
+ }
267
+
268
+ /** Replace `-N` (no remote command) with a fixed remote command. With the
269
+ * provider's `ExitOnForwardFailure=yes`, OpenSSH runs this command only after
270
+ * the server has acknowledged the `-R` listener. The loop keeps the session
271
+ * alive exactly as `-N` did. */
272
+ function readinessAwareSshArgs(
273
+ args: readonly string[],
274
+ machinePort: number,
275
+ hostPort: number,
276
+ ): string[] {
277
+ const noCommandIndex = args.indexOf("-N");
278
+ if (noCommandIndex === -1) {
279
+ throw new Error("machine gateway tunnel ssh args must include -N");
280
+ }
281
+ const exitOnForwardFailure = args.some(
282
+ (value, index) =>
283
+ value === "ExitOnForwardFailure=yes" && args[index - 1] === "-o",
284
+ );
285
+ if (!exitOnForwardFailure) {
286
+ throw new Error(
287
+ "machine gateway tunnel ssh args must enable ExitOnForwardFailure",
288
+ );
289
+ }
290
+ const expectedForward =
291
+ `127.0.0.1:${machinePort}:127.0.0.1:${hostPort}`;
292
+ const remoteForward = args.some(
293
+ (value, index) => value === expectedForward && args[index - 1] === "-R",
294
+ );
295
+ if (!remoteForward) {
296
+ throw new Error(
297
+ `machine gateway tunnel ssh args must publish ${expectedForward}`,
298
+ );
299
+ }
300
+ const readyArgs = [...args];
301
+ readyArgs.splice(noCommandIndex, 1);
302
+ readyArgs.unshift("-T", "-o", `RemoteCommand=${READY_REMOTE_COMMAND}`);
303
+ return readyArgs;
304
+ }
305
+
306
+ function scheduleRespawn(
307
+ taskId: string,
308
+ source: MachineTunnelSource,
309
+ state: TunnelState,
310
+ generation: number,
311
+ delayMs: number,
312
+ ): void {
313
+ if (tunnels.get(taskId) !== state || state.generation !== generation) return;
314
+ state.timer = setTimeout(() => {
315
+ state.timer = null;
316
+ void spawnTunnel(taskId, source, state, generation);
317
+ }, delayMs);
318
+ state.timer.unref?.();
319
+ }
320
+
321
+ /** Tear the tunnel down (task stop/teardown/GC). Idempotent. */
322
+ export function stopMachineGatewayTunnel(taskId: string): void {
323
+ const state = tunnels.get(taskId);
324
+ if (!state) return;
325
+ tunnels.delete(taskId);
326
+ state.generation += 1; // invalidate in-flight spawns
327
+ if (state.timer) clearTimeout(state.timer);
328
+ for (const waiter of [...state.readinessWaiters]) {
329
+ waiter.reject(new Error(`machine gateway tunnel ${taskId} was stopped`));
330
+ }
331
+ if (state.child) {
332
+ state.child.kill("SIGTERM");
333
+ state.child = null;
334
+ }
335
+ }
336
+
337
+ /** Test-only view. */
338
+ export function machineGatewayTunnelActive(taskId: string): boolean {
339
+ const state = tunnels.get(taskId);
340
+ return Boolean(
341
+ state &&
342
+ (state.child !== null || state.timer !== null || state.spawning),
343
+ );
344
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * ADR-121: per-machine SSH identity. Minted host-side at provision, the
3
+ * public half rides the machine spec (user-data / entrypoint env), the
4
+ * private half lives in the task's control tree — task lifetime, deleted
5
+ * with the task dir, never shared between tasks. `ssh-keygen` is used
6
+ * rather than a JS implementation: the host already shells out for every
7
+ * other crypto-bearing operation, and OpenSSH's own keygen is the one
8
+ * implementation `ssh` is guaranteed to agree with.
9
+ */
10
+
11
+ import { spawn } from "node:child_process";
12
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
13
+ import { resolve } from "node:path";
14
+
15
+ export interface MachineKeyPair {
16
+ privateKeyPath: string;
17
+ publicKey: string;
18
+ }
19
+
20
+ export function machineKeyDir(taskControlDir: string): string {
21
+ return resolve(taskControlDir, "machine");
22
+ }
23
+
24
+ /**
25
+ * Mint (or reuse) the task's machine keypair. Reuse is deliberate: a
26
+ * re-provision after a crash must produce the same identity the persisted
27
+ * locator's machine may already authorize.
28
+ */
29
+ export async function ensureMachineKeyPair(
30
+ taskControlDir: string,
31
+ ): Promise<MachineKeyPair> {
32
+ const dir = machineKeyDir(taskControlDir);
33
+ const privateKeyPath = resolve(dir, "id_ed25519");
34
+ const publicKeyPath = `${privateKeyPath}.pub`;
35
+ if (existsSync(privateKeyPath) && existsSync(publicKeyPath)) {
36
+ return {
37
+ privateKeyPath,
38
+ publicKey: readFileSync(publicKeyPath, "utf8").trim(),
39
+ };
40
+ }
41
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
42
+ rmSync(privateKeyPath, { force: true });
43
+ rmSync(publicKeyPath, { force: true });
44
+ await new Promise<void>((resolveKeygen, reject) => {
45
+ const child = spawn(
46
+ "ssh-keygen",
47
+ ["-t", "ed25519", "-N", "", "-C", "uai-machine", "-f", privateKeyPath],
48
+ { stdio: ["ignore", "ignore", "pipe"] },
49
+ );
50
+ let stderr = "";
51
+ child.stderr.setEncoding("utf8");
52
+ child.stderr.on("data", (chunk: string) => {
53
+ stderr += chunk;
54
+ });
55
+ child.once("error", (error) => reject(error));
56
+ child.once("close", (code) => {
57
+ if (code === 0) resolveKeygen();
58
+ else reject(new Error(`ssh-keygen failed (${code}): ${stderr.trim()}`));
59
+ });
60
+ });
61
+ return {
62
+ privateKeyPath,
63
+ publicKey: readFileSync(publicKeyPath, "utf8").trim(),
64
+ };
65
+ }