@moondeck/station 0.0.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -2
  3. package/bin/uai-host.mjs +130 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/0005_host_env_vars.sql +5 -0
  10. package/db/migrations/0006_host_project_env.sql +9 -0
  11. package/db/migrations/0007_host_github_token_kind.sql +7 -0
  12. package/db/migrations/0008_host_mcp_connections.sql +18 -0
  13. package/db/migrations/0009_host_agent_sessions.sql +15 -0
  14. package/db/migrations/0010_host_engine_accounts.sql +13 -0
  15. package/db/migrations/0011_codex_thread_id.sql +5 -0
  16. package/db/migrations/0012_agent_session_attach_key.sql +4 -0
  17. package/db/migrations/0013_github_credential_generations.sql +11 -0
  18. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  19. package/db/migrations/0015_host_settings.sql +9 -0
  20. package/db/migrations/0016_task_environment.sql +2 -0
  21. package/db/migrations/0017_engine_account_scope.sql +7 -0
  22. package/db/migrations/0018_engine_account_position.sql +3 -0
  23. package/db/migrations/0019_engine_account_store_ref.sql +3 -0
  24. package/db/migrations/0020_user_credential_store_ref.sql +6 -0
  25. package/db/migrations/0021_project_env_store_ref.sql +3 -0
  26. package/db/migrations/0022_task_project_ids.sql +5 -0
  27. package/db/migrations/0023_project_env_org.sql +4 -0
  28. package/db/migrations/0024_task_env_mode.sql +9 -0
  29. package/db/migrations/0025_project_env_authority.sql +147 -0
  30. package/db/migrations/0026_project_env_replica.sql +150 -0
  31. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  32. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  33. package/db/migrations/0029_task_shared_files_mode.sql +5 -0
  34. package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
  35. package/db/migrations/0031_first_turn_receipts.sql +9 -0
  36. package/db/migrations/meta/_journal.json +230 -0
  37. package/db/schema.ts +871 -0
  38. package/images/standard/Dockerfile +347 -0
  39. package/images/standard/README.md +167 -0
  40. package/images/standard/container/code-server-settings.json +63 -0
  41. package/images/standard/container/corepack-version +1 -0
  42. package/images/standard/container/uai-init +704 -0
  43. package/images/standard/container/uai-materialize-runtimes +1527 -0
  44. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  45. package/images/standard/tool-versions +2 -0
  46. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  47. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  48. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  49. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  50. package/lib/agent-cli.ts +1507 -0
  51. package/lib/agent-errors.ts +22 -0
  52. package/lib/agent.ts +672 -0
  53. package/lib/agents/attach-compatibility.ts +51 -0
  54. package/lib/agents/claude.ts +918 -0
  55. package/lib/agents/codex-token-usage.fixture.json +130 -0
  56. package/lib/agents/codex.ts +1392 -0
  57. package/lib/agents/cursor-period-usage.ts +253 -0
  58. package/lib/agents/cursor.ts +562 -0
  59. package/lib/agents/dispatch.ts +78 -0
  60. package/lib/agents/durable-proc.ts +334 -0
  61. package/lib/agents/factory.ts +57 -0
  62. package/lib/agents/grok.ts +381 -0
  63. package/lib/agents/kimi.ts +329 -0
  64. package/lib/agents/machine-durable.ts +604 -0
  65. package/lib/agents/mock.ts +133 -0
  66. package/lib/agents/mode.ts +64 -0
  67. package/lib/agents/opencode.ts +508 -0
  68. package/lib/agents/output-source.ts +3 -0
  69. package/lib/agents/proc.ts +221 -0
  70. package/lib/agents/rate-limit.ts +337 -0
  71. package/lib/agents/registry.ts +182 -0
  72. package/lib/agents/session-state.ts +197 -0
  73. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  74. package/lib/agents/transport.ts +741 -0
  75. package/lib/agents/types.ts +254 -0
  76. package/lib/agents/uai.ts +106 -0
  77. package/lib/agents/usage.ts +44 -0
  78. package/lib/apple-one-off.ts +277 -0
  79. package/lib/apple-runtime-recycle.ts +237 -0
  80. package/lib/apple-uninstall-teardown.ts +224 -0
  81. package/lib/assignment-evidence.ts +94 -0
  82. package/lib/attachments.ts +48 -0
  83. package/lib/browser-testing.ts +1537 -0
  84. package/lib/capture-flow.ts +332 -0
  85. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  86. package/lib/cloud-credential-host.ts +512 -0
  87. package/lib/cloud-credential-leases.ts +2587 -0
  88. package/lib/cloud-credential-runtime.ts +1357 -0
  89. package/lib/cloud-state.ts +56 -0
  90. package/lib/codex-auth.ts +456 -0
  91. package/lib/command-db.ts +310 -0
  92. package/lib/compose-network-recovery.ts +283 -0
  93. package/lib/connection-secret-redaction.ts +25 -0
  94. package/lib/container-runtime.ts +1470 -0
  95. package/lib/credential-cleanup-inventory.ts +490 -0
  96. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  97. package/lib/credential-consumers/engines.ts +797 -0
  98. package/lib/credential-consumers/files.ts +934 -0
  99. package/lib/credential-consumers/github.ts +702 -0
  100. package/lib/credential-consumers/mcp.ts +389 -0
  101. package/lib/credential-consumers/task-files.ts +127 -0
  102. package/lib/credential-consumers/task-secrets.ts +10 -0
  103. package/lib/credential-store-pickup.ts +149 -0
  104. package/lib/credential-store-scope.ts +112 -0
  105. package/lib/credential-store.ts +932 -0
  106. package/lib/credentials-activation-authority.ts +709 -0
  107. package/lib/credentials-purge.ts +1006 -0
  108. package/lib/custom-connection-capture.ts +395 -0
  109. package/lib/custom-connection-launch.ts +384 -0
  110. package/lib/custom-connection-runtime.ts +141 -0
  111. package/lib/db.ts +80 -0
  112. package/lib/disk-pressure.ts +42 -0
  113. package/lib/docker-exec.ts +156 -0
  114. package/lib/engine-accounts.ts +1970 -0
  115. package/lib/engine-connect.ts +321 -0
  116. package/lib/engine-credential-env.ts +52 -0
  117. package/lib/engine-login.ts +2933 -0
  118. package/lib/engines.ts +1342 -0
  119. package/lib/enrollment-state.ts +251 -0
  120. package/lib/env-file.ts +246 -0
  121. package/lib/env-schema.ts +937 -0
  122. package/lib/env.ts +195 -0
  123. package/lib/first-turn-receipts.ts +62 -0
  124. package/lib/fs-aliases.ts +49 -0
  125. package/lib/git-clone-credentials.ts +334 -0
  126. package/lib/git-diff.ts +530 -0
  127. package/lib/git-identity.ts +715 -0
  128. package/lib/github-git-auth.ts +207 -0
  129. package/lib/github-origin.ts +28 -0
  130. package/lib/github-tokens.ts +2439 -0
  131. package/lib/host-cloud-url.ts +62 -0
  132. package/lib/host-config.ts +279 -0
  133. package/lib/host-env.ts +424 -0
  134. package/lib/host-logs.ts +965 -0
  135. package/lib/internal-connections.ts +5 -0
  136. package/lib/keyed-promise-tail.ts +23 -0
  137. package/lib/launch-mode.ts +25 -0
  138. package/lib/lease.ts +239 -0
  139. package/lib/legacy-host.ts +769 -0
  140. package/lib/legacy-runtime-retirement.ts +424 -0
  141. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  142. package/lib/machine-exec.ts +238 -0
  143. package/lib/machine-gateway-tunnel.ts +344 -0
  144. package/lib/machine-keys.ts +65 -0
  145. package/lib/machine-project-env-loader.ts +152 -0
  146. package/lib/machine-provider-aws.ts +923 -0
  147. package/lib/machine-provider-local.ts +338 -0
  148. package/lib/machine-provider.ts +90 -0
  149. package/lib/managed-activation-watcher.ts +72 -0
  150. package/lib/managed-install-owner-watcher.ts +55 -0
  151. package/lib/managed-operation-drain.ts +57 -0
  152. package/lib/managed-runtime.ts +4296 -0
  153. package/lib/managed-update-scheduler.ts +125 -0
  154. package/lib/mcp-capture.ts +865 -0
  155. package/lib/mcp-config-lock.ts +2 -0
  156. package/lib/mcp-connections.ts +959 -0
  157. package/lib/mcp-gateway.ts +2594 -0
  158. package/lib/obs.ts +512 -0
  159. package/lib/openrouter-budget.ts +247 -0
  160. package/lib/orchestrator.ts +11409 -0
  161. package/lib/person-capture.ts +327 -0
  162. package/lib/preview-ports.ts +85 -0
  163. package/lib/preview-sidecar.ts +331 -0
  164. package/lib/project-env-authority.ts +1319 -0
  165. package/lib/project-env-feature.ts +74 -0
  166. package/lib/project-env-legacy-tags.ts +130 -0
  167. package/lib/project-env-loader.ts +242 -0
  168. package/lib/project-env-replica.ts +2094 -0
  169. package/lib/project-env-schema-capture.ts +342 -0
  170. package/lib/project-env-staging.ts +148 -0
  171. package/lib/project-env-task-policy.ts +176 -0
  172. package/lib/project-env-task-replica.ts +1752 -0
  173. package/lib/release-feed.ts +61 -0
  174. package/lib/release-manifest.ts +710 -0
  175. package/lib/release-trust.ts +46 -0
  176. package/lib/repo-clone.ts +38 -0
  177. package/lib/routing/shadow.ts +261 -0
  178. package/lib/runner-boot.ts +10 -0
  179. package/lib/runtime-activation-tail.ts +279 -0
  180. package/lib/runtime-archive.ts +1216 -0
  181. package/lib/runtime-authority.ts +166 -0
  182. package/lib/runtime-guard.ts +36 -0
  183. package/lib/runtime-provider-state.ts +169 -0
  184. package/lib/runtime-reconnect.ts +86 -0
  185. package/lib/runtime-state.ts +1033 -0
  186. package/lib/safe-http.ts +364 -0
  187. package/lib/secrets-proxy/address.ts +95 -0
  188. package/lib/secrets-proxy/audit.ts +30 -0
  189. package/lib/secrets-proxy/ca-store.ts +200 -0
  190. package/lib/secrets-proxy/ca.ts +470 -0
  191. package/lib/secrets-proxy/container-steps.ts +138 -0
  192. package/lib/secrets-proxy/der.ts +273 -0
  193. package/lib/secrets-proxy/engine.ts +1633 -0
  194. package/lib/secrets-proxy/host.ts +93 -0
  195. package/lib/secrets-proxy/hostname.ts +57 -0
  196. package/lib/secrets-proxy/placeholders.ts +196 -0
  197. package/lib/secrets-proxy/policy.ts +150 -0
  198. package/lib/secrets-proxy/readiness.ts +148 -0
  199. package/lib/secrets-proxy/runtime.ts +582 -0
  200. package/lib/secrets-proxy/scrub.ts +559 -0
  201. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  202. package/lib/secrets-proxy/task-scripts.ts +262 -0
  203. package/lib/secrets-proxy/task-trust.ts +605 -0
  204. package/lib/secrets-proxy/trust-watch.ts +80 -0
  205. package/lib/secrets.ts +71 -0
  206. package/lib/shared-files-s3.ts +976 -0
  207. package/lib/shared-files.ts +156 -0
  208. package/lib/sidecar-assign.ts +49 -0
  209. package/lib/sidecar-guard.ts +58 -0
  210. package/lib/sidecar-resume.ts +178 -0
  211. package/lib/sidecar.ts +69 -0
  212. package/lib/signin-box-credentials.ts +465 -0
  213. package/lib/signin-box.ts +114 -0
  214. package/lib/skill-index-example.fixture.ts +56 -0
  215. package/lib/skill-layout.ts +417 -0
  216. package/lib/skills.ts +489 -0
  217. package/lib/ssh.ts +401 -0
  218. package/lib/standard-image-cleanup.ts +117 -0
  219. package/lib/standard-image-pin.ts +207 -0
  220. package/lib/standard-image.ts +1697 -0
  221. package/lib/stopped-task-status-queue.ts +120 -0
  222. package/lib/task-container-cli.ts +269 -0
  223. package/lib/task-credential-cleanup.ts +752 -0
  224. package/lib/task-diff-sandbox.ts +464 -0
  225. package/lib/task-diff.ts +91 -0
  226. package/lib/task-env-forwarder.ts +228 -0
  227. package/lib/task-env-source.ts +249 -0
  228. package/lib/task-environment/anchor.ts +31 -0
  229. package/lib/task-environment/apple-container.ts +860 -0
  230. package/lib/task-environment/docker.ts +1344 -0
  231. package/lib/task-environment/index.ts +466 -0
  232. package/lib/task-environment/legacy-adoption.ts +467 -0
  233. package/lib/task-environment/machine-environment.ts +61 -0
  234. package/lib/task-environment/machine-task-up.ts +1227 -0
  235. package/lib/task-environment/machine.ts +974 -0
  236. package/lib/task-environment/registry.ts +59 -0
  237. package/lib/task-environment/self-identity.ts +20 -0
  238. package/lib/task-environment/self.ts +566 -0
  239. package/lib/task-environment/types.ts +642 -0
  240. package/lib/task-environment/workspace-files.ts +685 -0
  241. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  242. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  243. package/lib/task-files-machine-retirement.ts +116 -0
  244. package/lib/task-files-readiness.ts +127 -0
  245. package/lib/task-gateway-port.ts +22 -0
  246. package/lib/task-gateway-protocol.ts +190 -0
  247. package/lib/task-gateway-recovery.ts +47 -0
  248. package/lib/task-gateway-state.ts +512 -0
  249. package/lib/task-identity.ts +19 -0
  250. package/lib/task-inventory.ts +682 -0
  251. package/lib/task-machine-credentials-activation.ts +95 -0
  252. package/lib/task-machine-swap-safety.ts +729 -0
  253. package/lib/task-person-credentials.ts +76 -0
  254. package/lib/task-project-ops-host.ts +147 -0
  255. package/lib/task-project-ops.ts +1062 -0
  256. package/lib/task-secret-staging.ts +16 -0
  257. package/lib/task-status.ts +46 -0
  258. package/lib/task-token.ts +46 -0
  259. package/lib/token-broker-adopt.ts +344 -0
  260. package/lib/token-broker-lambda.ts +105 -0
  261. package/lib/token-broker-local.ts +392 -0
  262. package/lib/token-broker-revision.ts +310 -0
  263. package/lib/token-broker-ssm.ts +525 -0
  264. package/lib/token-broker.ts +218 -0
  265. package/lib/token-lanes/openai-oauth.ts +123 -0
  266. package/lib/token-lanes/registry.ts +45 -0
  267. package/lib/token-lanes/types.ts +163 -0
  268. package/lib/token-lanes/xai-oauth.ts +165 -0
  269. package/lib/transcript.ts +128 -0
  270. package/lib/tunnel-registry.ts +220 -0
  271. package/lib/tunnel-runtime.ts +235 -0
  272. package/lib/ulid.ts +16 -0
  273. package/lib/user-bus-env.ts +116 -0
  274. package/lib/user-credentials-store.ts +435 -0
  275. package/lib/vnode-pressure.ts +142 -0
  276. package/package.json +115 -8
  277. package/runner/runner.mjs +442 -0
  278. package/scripts/agent/_common.sh +633 -0
  279. package/scripts/agent/env-hook.sh +626 -0
  280. package/scripts/agent/moondeck-cli.sh +5 -0
  281. package/scripts/agent/task-down.sh +291 -0
  282. package/scripts/agent/task-status.sh +70 -0
  283. package/scripts/agent/task-up.sh +3761 -0
  284. package/scripts/agent/uai-env-exec.sh +26 -0
  285. package/scripts/install/darwin.ts +1048 -0
  286. package/scripts/install/linux.ts +958 -0
  287. package/scripts/install/types.ts +78 -0
  288. package/scripts/install/util.ts +254 -0
  289. package/scripts/install/win.ts +143 -0
  290. package/src/activation-respawn.ts +155 -0
  291. package/src/apple-tunnel-route.ts +104 -0
  292. package/src/bridge-diagnostics.ts +39 -0
  293. package/src/capture-wire.ts +102 -0
  294. package/src/cli.ts +2585 -0
  295. package/src/cloud-credentials.ts +2144 -0
  296. package/src/cloud-origins.ts +23 -0
  297. package/src/credential-location.ts +248 -0
  298. package/src/credential-wire-validation.ts +25 -0
  299. package/src/custom-connections.ts +237 -0
  300. package/src/engine-account-paths.ts +11 -0
  301. package/src/engine-models.ts +39 -0
  302. package/src/engine-usage.ts +104 -0
  303. package/src/enrollment-next-steps.ts +24 -0
  304. package/src/event-outbox.ts +185 -0
  305. package/src/index.ts +2907 -0
  306. package/src/load-env.ts +97 -0
  307. package/src/machine-tunnel-route.ts +156 -0
  308. package/src/main.ts +6363 -0
  309. package/src/never-proxied-domains.ts +31 -0
  310. package/src/outbound-frame.ts +31 -0
  311. package/src/paths.ts +81 -0
  312. package/src/project-env-latest.ts +130 -0
  313. package/src/project-env-policy.ts +396 -0
  314. package/src/project-env-wording.ts +53 -0
  315. package/src/protocol.ts +5550 -0
  316. package/src/runtime-bootstrap.ts +165 -0
  317. package/src/station-identity.ts +96 -0
  318. package/src/task-env-names.ts +28 -0
  319. package/src/task-secrets.ts +75 -0
  320. package/src/tunnel-dial.ts +102 -0
  321. package/src/ui/server.ts +852 -0
  322. package/src/ui/types.ts +173 -0
  323. package/src/workspace-path-policy.ts +41 -0
  324. package/ui/app.js +1102 -0
  325. package/ui/index.html +90 -0
  326. package/ui/style.css +685 -0
  327. package/ui/uai-favicon.svg +21 -0
  328. package/ui/uai-logo-black.svg +9 -0
  329. package/ui/uai-wheel.svg +9 -0
  330. package/bin/moondeck-station.mjs +0 -3
@@ -0,0 +1,331 @@
1
+ /**
2
+ * ADR-043: per-(task, preview) node-proxy sidecar for ad-hoc preview ports.
3
+ *
4
+ * Ad-hoc preview ports aren't Docker-published, and the app container's bridge
5
+ * IP isn't host-routable on macOS/OrbStack (EHOSTUNREACH). So proxy through a
6
+ * tiny sidecar: a container on the task's compose network that PUBLISHES a
7
+ * `127.0.0.1` host port (reachable on every backend) and forwards to
8
+ * `<app-container>:<containerPort>`. The sidecar runs the task's OWN image
9
+ * (always local — no registry pull) under `node` with a ~5-line `net` TCP proxy;
10
+ * node streams give backpressure + handle WebSockets (Metro/HMR).
11
+ *
12
+ * The cache deduplicates creation, but every tunnel open re-proves the exact
13
+ * sidecar container and its current Docker mapping. A process-lifetime cached
14
+ * localhost port is unsafe across an unnoticed daemon restart: another process
15
+ * can inherit that port before the bridge reconnects and triggers recovery.
16
+ */
17
+ import net from "node:net";
18
+
19
+ import { dockerCli } from "./docker-exec";
20
+ import { inspectTunnelContainer } from "./tunnel-runtime";
21
+
22
+ /** Port the proxy listens on inside the sidecar (published to a random host port). */
23
+ const INNER_PORT = 9000;
24
+ /**
25
+ * Exported for every "which container is the task's APP" scan: sidecars can
26
+ * legitimately wear the task's compose project/service labels (teardown
27
+ * finds them by project), so any container carrying THIS label must be
28
+ * excluded before counting app containers. Live 2026-08-18: one browser
29
+ * sidecar with compose labels made legacy adoption count two app containers
30
+ * and fail the whole runtime selection closed — every task on the host read
31
+ * as "still starting".
32
+ */
33
+ export const PREVIEW_SIDECAR_TASK_LABEL = "io.moondeck.preview-sidecar.task";
34
+ const LABEL = PREVIEW_SIDECAR_TASK_LABEL;
35
+ const TARGET_LABEL = "io.moondeck.preview-sidecar.target";
36
+
37
+ /** (taskId,name,containerPort) -> published 127.0.0.1 host port of a live sidecar. */
38
+ const cache = new Map<string, number>();
39
+ /** In-flight creates, so concurrent requests for the same key share one sidecar. */
40
+ const inflight = new Map<string, Promise<number | null>>();
41
+
42
+ function cacheKey(taskId: string, name: string, containerPort: number): string {
43
+ return `${taskId} ${name} ${containerPort}`;
44
+ }
45
+
46
+ function sidecarName(taskId: string, name: string): string {
47
+ return `uai-preview-${taskId}-${name}`;
48
+ }
49
+
50
+ function proxyScript(appContainer: string, containerPort: number): string {
51
+ // Bidirectional TCP pipe: 0.0.0.0:INNER_PORT <-> appContainer:containerPort.
52
+ return (
53
+ `const net=require('net');` +
54
+ `net.createServer(c=>{` +
55
+ `const u=net.connect(${containerPort},${JSON.stringify(appContainer)});` +
56
+ `c.on('error',()=>u.destroy());u.on('error',()=>c.destroy());` +
57
+ `c.pipe(u);u.pipe(c);` +
58
+ `}).listen(${INNER_PORT},'0.0.0.0');`
59
+ );
60
+ }
61
+
62
+ /** How long to wait for a new sidecar's proxy to start accepting. */
63
+ const READY_TIMEOUT_MS = 10_000;
64
+ const READY_POLL_MS = 100;
65
+ /** A connection that survives this long AFTER connecting has a live upstream. */
66
+ const PROBE_SETTLE_MS = 150;
67
+ /** A handshake that hasn't completed by now isn't going to. */
68
+ const PROBE_CONNECT_MS = 1_000;
69
+
70
+ /** The bit of `net.Socket` the probe uses — so a test can supply its own. */
71
+ interface SocketLike {
72
+ on: (event: string, fn: () => void) => unknown;
73
+ destroy: () => void;
74
+ }
75
+
76
+ /**
77
+ * Whether the whole chain behind `hostPort` is live: the proxy is listening AND
78
+ * its upstream connect succeeded.
79
+ *
80
+ * A plain "did the TCP connect succeed" check proves nothing here, which is the
81
+ * trap this exists for. Docker publishes the port when the CONTAINER starts,
82
+ * not when the process inside binds — so `docker-proxy` accepts the connection
83
+ * either way and then closes it, and an HTTP client reports that as
84
+ * `socket hang up`. The sidecar's proxy behaves identically when it is up but
85
+ * its own upstream connect fails (`u.on('error',()=>c.destroy())`).
86
+ *
87
+ * So the signal is the connection SURVIVING briefly, not opening. Both failure
88
+ * modes close it immediately; a healthy pipe just sits there waiting for a
89
+ * request, which is the one thing neither of them does.
90
+ */
91
+ function probeSidecar(
92
+ hostPort: number,
93
+ /** Seam for tests: a socket that never connects can't be produced locally. */
94
+ connect: (port: number) => SocketLike = (port) =>
95
+ net.connect(port, "127.0.0.1"),
96
+ ): Promise<boolean> {
97
+ return new Promise((resolve) => {
98
+ const socket = connect(hostPort);
99
+ let settled = false;
100
+ let settleTimer: ReturnType<typeof setTimeout> | undefined;
101
+ const done = (ok: boolean): void => {
102
+ if (settled) return;
103
+ settled = true;
104
+ clearTimeout(settleTimer);
105
+ clearTimeout(connectTimer);
106
+ socket.destroy();
107
+ resolve(ok);
108
+ };
109
+ // The settle window starts at `connect`, NOT at the call. Starting it here
110
+ // would count a connection still stuck in the handshake as healthy — the
111
+ // one state that is neither of the two we can distinguish, and the one a
112
+ // wedged host is most likely to be in.
113
+ //
114
+ // The connect deadline is disarmed at the same moment, or the two overlap:
115
+ // a connection established at 900ms would be failed at 1000ms, 50ms into a
116
+ // 150ms settle window it was on course to pass. The deadlines are
117
+ // sequential — first "did it connect", then "did it stay" — so only one may
118
+ // be running.
119
+ socket.on("connect", () => {
120
+ clearTimeout(connectTimer);
121
+ settleTimer = setTimeout(() => done(true), PROBE_SETTLE_MS);
122
+ });
123
+ const connectTimer = setTimeout(() => done(false), PROBE_CONNECT_MS);
124
+ socket.on("error", () => done(false));
125
+ socket.on("close", () => done(false));
126
+ });
127
+ }
128
+
129
+ /** Test seam: the probe with an injectable socket. Not used in production. */
130
+ export const probeForTest = probeSidecar;
131
+
132
+ /** Poll `probeSidecar` until the chain answers, or give up. */
133
+ async function waitUntilReady(hostPort: number): Promise<boolean> {
134
+ const deadline = Date.now() + READY_TIMEOUT_MS;
135
+ for (;;) {
136
+ if (await probeSidecar(hostPort)) return true;
137
+ if (Date.now() >= deadline) return false;
138
+ await new Promise((r) => setTimeout(r, READY_POLL_MS));
139
+ }
140
+ }
141
+
142
+ async function imageOf(appContainer: string): Promise<string | null> {
143
+ const r = await dockerCli(
144
+ ["inspect", "-f", "{{.Config.Image}}", appContainer],
145
+ { timeoutMs: 5_000 },
146
+ );
147
+ return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : null;
148
+ }
149
+
150
+ /** Live Docker proof -> the published 127.0.0.1 host port and labels, or null. */
151
+ async function publishedSidecar(
152
+ name: string,
153
+ ): Promise<{ port: number; labels: Record<string, string> } | null> {
154
+ const proof = await inspectTunnelContainer(name, INNER_PORT);
155
+ return proof.kind === "running" && proof.published?.host === "127.0.0.1"
156
+ ? { port: proof.published.port, labels: proof.labels }
157
+ : null;
158
+ }
159
+
160
+ async function publishedPort(name: string): Promise<number | null> {
161
+ return (await publishedSidecar(name))?.port ?? null;
162
+ }
163
+
164
+ async function createSidecar(args: {
165
+ taskId: string;
166
+ composeProject: string;
167
+ name: string;
168
+ containerPort: number;
169
+ }): Promise<number | null> {
170
+ const name = sidecarName(args.taskId, args.name);
171
+ const appContainer = `${args.composeProject}-app-1`;
172
+ const target = `${appContainer}:${args.containerPort}`;
173
+
174
+ // Reuse a still-running sidecar from a previous host process (--rm keeps it up
175
+ // across a host restart; the in-memory cache was cleared). `docker port` is an
176
+ // exact-name lookup, so no name-filter regex pitfalls.
177
+ //
178
+ // PROBED, not just found. A sidecar outlives the app container it proxies to,
179
+ // so one left over from a previous container generation is still running and
180
+ // still publishing its port while its route is dead. Reused blind, that
181
+ // sidecar serves `socket hang up` forever: the recovery path invalidates the
182
+ // CACHE on a failed connect, then lands right back here and returns the same
183
+ // corpse. Nothing could break the cycle short of removing it by hand.
184
+ const existing = await publishedSidecar(name);
185
+ if (existing !== null) {
186
+ // The Docker name is stable per (task, preview name), while a task-scoped
187
+ // preview may later point that name at another container port. The
188
+ // process-local cache key carries the port, but it vanishes on host
189
+ // restart; without a durable target label a healthy old proxy would be
190
+ // reused and silently route the authenticated request to the wrong port.
191
+ // Legacy unlabeled sidecars rebuild once.
192
+ if (
193
+ existing.labels[TARGET_LABEL] === target &&
194
+ (await probeSidecar(existing.port))
195
+ ) {
196
+ return existing.port;
197
+ }
198
+ await dockerCli(["rm", "-f", name], { timeoutMs: 10_000 });
199
+ }
200
+
201
+ const image = await imageOf(appContainer);
202
+ if (!image) return null;
203
+
204
+ await dockerCli(["rm", "-f", name], { timeoutMs: 10_000 });
205
+ const run = await dockerCli(
206
+ [
207
+ "run", "-d", "--rm",
208
+ "--name", name,
209
+ "--network", `${args.composeProject}_default`,
210
+ "--label", `${LABEL}=${args.taskId}`,
211
+ "--label", `${TARGET_LABEL}=${target}`,
212
+ "-p", `127.0.0.1::${INNER_PORT}`,
213
+ "--entrypoint", "node",
214
+ image,
215
+ "-e", proxyScript(appContainer, args.containerPort),
216
+ ],
217
+ { timeoutMs: 20_000 },
218
+ );
219
+ if (run.status !== 0) return null;
220
+
221
+ const hostPort = await publishedPort(name);
222
+ if (hostPort === null) {
223
+ await dockerCli(["rm", "-f", name], { timeoutMs: 10_000 });
224
+ return null;
225
+ }
226
+
227
+ // Do not hand back a port that isn't answering yet. `docker run -d` returns
228
+ // when the CONTAINER starts and the mapping exists from that instant, but the
229
+ // node proxy inside has not called `listen` — so every request arriving in
230
+ // that window is accepted by docker-proxy and dropped.
231
+ //
232
+ // A browser makes that window very expensive. It fetches the document, then
233
+ // fires every sub-resource at once: one page load put ~20 requests into a
234
+ // ~3s gap and lost all of them, which renders as an unstyled page with broken
235
+ // images rather than as anything resembling "not ready yet".
236
+ if (!(await waitUntilReady(hostPort))) {
237
+ await dockerCli(["rm", "-f", name], { timeoutMs: 10_000 });
238
+ return null;
239
+ }
240
+ return hostPort;
241
+ }
242
+
243
+ /**
244
+ * Ensure a sidecar is proxying `<app>:<containerPort>` for (taskId, name);
245
+ * returns the `127.0.0.1` host port it publishes, or null on failure. Cache-only
246
+ * on the hot path; a cache miss creates the sidecar (deduped across concurrent
247
+ * callers).
248
+ */
249
+ export async function ensurePreviewSidecar(args: {
250
+ taskId: string;
251
+ composeProject: string;
252
+ name: string;
253
+ containerPort: number;
254
+ }): Promise<number | null> {
255
+ const key = cacheKey(args.taskId, args.name, args.containerPort);
256
+ const cached = cache.get(key);
257
+ if (cached !== undefined) {
258
+ const current = await publishedSidecar(sidecarName(args.taskId, args.name));
259
+ const expectedTarget =
260
+ `${args.composeProject}-app-1:${args.containerPort}`;
261
+ const app =
262
+ current?.port === cached &&
263
+ current.labels[TARGET_LABEL] === expectedTarget
264
+ ? await inspectTunnelContainer(
265
+ `${args.composeProject}-app-1`,
266
+ args.containerPort,
267
+ )
268
+ : null;
269
+ // The sidecar's PID is the proxy itself, and it resolves the app by stable
270
+ // Compose DNS name for each connection. Proving both exact containers are
271
+ // running avoids the old 150ms socket-settle penalty on every browser asset
272
+ // while still rejecting a localhost port inherited after daemon restart.
273
+ if (
274
+ current?.port === cached &&
275
+ current.labels[TARGET_LABEL] === expectedTarget &&
276
+ app?.kind === "running"
277
+ ) {
278
+ return cached;
279
+ }
280
+ cache.delete(key);
281
+ }
282
+
283
+ let pending = inflight.get(key);
284
+ if (!pending) {
285
+ pending = createSidecar(args)
286
+ .then((port) => {
287
+ if (port !== null) cache.set(key, port);
288
+ return port;
289
+ })
290
+ .finally(() => inflight.delete(key));
291
+ inflight.set(key, pending);
292
+ }
293
+ return pending;
294
+ }
295
+
296
+ /**
297
+ * Drop the cached port for a sidecar whose route just failed. No-op for
298
+ * published-declared / container-IP targets, which aren't in this cache.
299
+ *
300
+ * CACHE-ONLY, deliberately. This fires from the host's `upstream.on("error")`,
301
+ * which cannot tell a dead sidecar from a client that hit stop mid-download or
302
+ * a request that tripped the tunnel's ack deadline — both destroy the socket
303
+ * with ECONNRESET. Removing the container here would let one abandoned asset
304
+ * kill a sidecar that is serving twenty other requests fine, which is a worse
305
+ * failure than the one being recovered from.
306
+ *
307
+ * Forgetting the port is enough now: the next `createSidecar` finds the
308
+ * container by name and PROBES it, so a genuinely dead one is removed there,
309
+ * where the evidence is direct rather than inferred. That is the escape hatch
310
+ * this used to lack — invalidation alone kept landing back on the same corpse.
311
+ */
312
+ export function invalidatePreviewSidecar(hostPort: number): void {
313
+ for (const [key, port] of cache) {
314
+ if (port === hostPort) cache.delete(key);
315
+ }
316
+ }
317
+
318
+ /** Stop + remove all preview sidecars for a task (called on task-down). */
319
+ export async function stopPreviewSidecars(taskId: string): Promise<void> {
320
+ for (const key of [...cache.keys()]) {
321
+ if (key.startsWith(`${taskId} `)) cache.delete(key);
322
+ }
323
+ const r = await dockerCli(
324
+ ["ps", "-aq", "--filter", `label=${LABEL}=${taskId}`],
325
+ { timeoutMs: 5_000 },
326
+ );
327
+ if (r.status !== 0) return;
328
+ for (const id of r.stdout.split("\n").map((s) => s.trim()).filter(Boolean)) {
329
+ await dockerCli(["rm", "-f", id], { timeoutMs: 10_000 });
330
+ }
331
+ }