@moondeck/station 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -2
  3. package/bin/uai-host.mjs +130 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/0005_host_env_vars.sql +5 -0
  10. package/db/migrations/0006_host_project_env.sql +9 -0
  11. package/db/migrations/0007_host_github_token_kind.sql +7 -0
  12. package/db/migrations/0008_host_mcp_connections.sql +18 -0
  13. package/db/migrations/0009_host_agent_sessions.sql +15 -0
  14. package/db/migrations/0010_host_engine_accounts.sql +13 -0
  15. package/db/migrations/0011_codex_thread_id.sql +5 -0
  16. package/db/migrations/0012_agent_session_attach_key.sql +4 -0
  17. package/db/migrations/0013_github_credential_generations.sql +11 -0
  18. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  19. package/db/migrations/0015_host_settings.sql +9 -0
  20. package/db/migrations/0016_task_environment.sql +2 -0
  21. package/db/migrations/0017_engine_account_scope.sql +7 -0
  22. package/db/migrations/0018_engine_account_position.sql +3 -0
  23. package/db/migrations/0019_engine_account_store_ref.sql +3 -0
  24. package/db/migrations/0020_user_credential_store_ref.sql +6 -0
  25. package/db/migrations/0021_project_env_store_ref.sql +3 -0
  26. package/db/migrations/0022_task_project_ids.sql +5 -0
  27. package/db/migrations/0023_project_env_org.sql +4 -0
  28. package/db/migrations/0024_task_env_mode.sql +9 -0
  29. package/db/migrations/0025_project_env_authority.sql +147 -0
  30. package/db/migrations/0026_project_env_replica.sql +150 -0
  31. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  32. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  33. package/db/migrations/0029_task_shared_files_mode.sql +5 -0
  34. package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
  35. package/db/migrations/meta/_journal.json +223 -0
  36. package/db/schema.ts +850 -0
  37. package/images/standard/Dockerfile +347 -0
  38. package/images/standard/README.md +167 -0
  39. package/images/standard/container/code-server-settings.json +63 -0
  40. package/images/standard/container/corepack-version +1 -0
  41. package/images/standard/container/uai-init +704 -0
  42. package/images/standard/container/uai-materialize-runtimes +1527 -0
  43. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  44. package/images/standard/tool-versions +2 -0
  45. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  46. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  47. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  48. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  49. package/lib/agent-cli.ts +1507 -0
  50. package/lib/agent-errors.ts +22 -0
  51. package/lib/agent.ts +672 -0
  52. package/lib/agents/attach-compatibility.ts +51 -0
  53. package/lib/agents/claude.ts +918 -0
  54. package/lib/agents/codex-token-usage.fixture.json +130 -0
  55. package/lib/agents/codex.ts +1392 -0
  56. package/lib/agents/cursor-period-usage.ts +253 -0
  57. package/lib/agents/cursor.ts +562 -0
  58. package/lib/agents/dispatch.ts +78 -0
  59. package/lib/agents/durable-proc.ts +334 -0
  60. package/lib/agents/factory.ts +57 -0
  61. package/lib/agents/grok.ts +381 -0
  62. package/lib/agents/kimi.ts +329 -0
  63. package/lib/agents/machine-durable.ts +604 -0
  64. package/lib/agents/mock.ts +133 -0
  65. package/lib/agents/mode.ts +64 -0
  66. package/lib/agents/opencode.ts +508 -0
  67. package/lib/agents/output-source.ts +3 -0
  68. package/lib/agents/proc.ts +221 -0
  69. package/lib/agents/rate-limit.ts +337 -0
  70. package/lib/agents/registry.ts +182 -0
  71. package/lib/agents/session-state.ts +197 -0
  72. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  73. package/lib/agents/transport.ts +741 -0
  74. package/lib/agents/types.ts +254 -0
  75. package/lib/agents/uai.ts +106 -0
  76. package/lib/agents/usage.ts +44 -0
  77. package/lib/apple-one-off.ts +277 -0
  78. package/lib/apple-runtime-recycle.ts +237 -0
  79. package/lib/apple-uninstall-teardown.ts +224 -0
  80. package/lib/assignment-evidence.ts +94 -0
  81. package/lib/attachments.ts +48 -0
  82. package/lib/browser-testing.ts +1537 -0
  83. package/lib/capture-flow.ts +332 -0
  84. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  85. package/lib/cloud-credential-host.ts +512 -0
  86. package/lib/cloud-credential-leases.ts +2587 -0
  87. package/lib/cloud-credential-runtime.ts +1357 -0
  88. package/lib/cloud-state.ts +56 -0
  89. package/lib/codex-auth.ts +456 -0
  90. package/lib/command-db.ts +310 -0
  91. package/lib/compose-network-recovery.ts +283 -0
  92. package/lib/connection-secret-redaction.ts +25 -0
  93. package/lib/container-runtime.ts +1453 -0
  94. package/lib/credential-cleanup-inventory.ts +490 -0
  95. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  96. package/lib/credential-consumers/engines.ts +797 -0
  97. package/lib/credential-consumers/files.ts +934 -0
  98. package/lib/credential-consumers/github.ts +702 -0
  99. package/lib/credential-consumers/mcp.ts +389 -0
  100. package/lib/credential-consumers/task-files.ts +127 -0
  101. package/lib/credential-consumers/task-secrets.ts +10 -0
  102. package/lib/credential-store-pickup.ts +149 -0
  103. package/lib/credential-store-scope.ts +112 -0
  104. package/lib/credential-store.ts +932 -0
  105. package/lib/credentials-activation-authority.ts +709 -0
  106. package/lib/credentials-purge.ts +1006 -0
  107. package/lib/custom-connection-capture.ts +395 -0
  108. package/lib/custom-connection-launch.ts +384 -0
  109. package/lib/custom-connection-runtime.ts +141 -0
  110. package/lib/db.ts +80 -0
  111. package/lib/disk-pressure.ts +42 -0
  112. package/lib/docker-exec.ts +156 -0
  113. package/lib/engine-accounts.ts +1970 -0
  114. package/lib/engine-connect.ts +321 -0
  115. package/lib/engine-credential-env.ts +52 -0
  116. package/lib/engine-login.ts +2933 -0
  117. package/lib/engines.ts +1342 -0
  118. package/lib/enrollment-state.ts +251 -0
  119. package/lib/env-file.ts +246 -0
  120. package/lib/env-schema.ts +891 -0
  121. package/lib/env.ts +195 -0
  122. package/lib/fs-aliases.ts +49 -0
  123. package/lib/git-clone-credentials.ts +334 -0
  124. package/lib/git-diff.ts +530 -0
  125. package/lib/git-identity.ts +715 -0
  126. package/lib/github-git-auth.ts +207 -0
  127. package/lib/github-origin.ts +28 -0
  128. package/lib/github-tokens.ts +2439 -0
  129. package/lib/host-cloud-url.ts +62 -0
  130. package/lib/host-config.ts +279 -0
  131. package/lib/host-env.ts +424 -0
  132. package/lib/host-logs.ts +965 -0
  133. package/lib/internal-connections.ts +5 -0
  134. package/lib/keyed-promise-tail.ts +23 -0
  135. package/lib/launch-mode.ts +25 -0
  136. package/lib/lease.ts +239 -0
  137. package/lib/legacy-host.ts +769 -0
  138. package/lib/legacy-runtime-retirement.ts +424 -0
  139. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  140. package/lib/machine-exec.ts +238 -0
  141. package/lib/machine-gateway-tunnel.ts +344 -0
  142. package/lib/machine-keys.ts +65 -0
  143. package/lib/machine-project-env-loader.ts +152 -0
  144. package/lib/machine-provider-aws.ts +923 -0
  145. package/lib/machine-provider-local.ts +338 -0
  146. package/lib/machine-provider.ts +90 -0
  147. package/lib/managed-activation-watcher.ts +72 -0
  148. package/lib/managed-install-owner-watcher.ts +55 -0
  149. package/lib/managed-operation-drain.ts +57 -0
  150. package/lib/managed-runtime.ts +4296 -0
  151. package/lib/managed-update-scheduler.ts +125 -0
  152. package/lib/mcp-capture.ts +865 -0
  153. package/lib/mcp-config-lock.ts +2 -0
  154. package/lib/mcp-connections.ts +959 -0
  155. package/lib/mcp-gateway.ts +2594 -0
  156. package/lib/obs.ts +512 -0
  157. package/lib/openrouter-budget.ts +247 -0
  158. package/lib/orchestrator.ts +11409 -0
  159. package/lib/person-capture.ts +327 -0
  160. package/lib/preview-ports.ts +85 -0
  161. package/lib/preview-sidecar.ts +331 -0
  162. package/lib/project-env-authority.ts +1319 -0
  163. package/lib/project-env-feature.ts +74 -0
  164. package/lib/project-env-legacy-tags.ts +130 -0
  165. package/lib/project-env-loader.ts +242 -0
  166. package/lib/project-env-replica.ts +2094 -0
  167. package/lib/project-env-schema-capture.ts +342 -0
  168. package/lib/project-env-staging.ts +148 -0
  169. package/lib/project-env-task-policy.ts +176 -0
  170. package/lib/project-env-task-replica.ts +1752 -0
  171. package/lib/release-feed.ts +61 -0
  172. package/lib/release-manifest.ts +710 -0
  173. package/lib/release-trust.ts +46 -0
  174. package/lib/repo-clone.ts +38 -0
  175. package/lib/routing/shadow.ts +261 -0
  176. package/lib/runner-boot.ts +10 -0
  177. package/lib/runtime-activation-tail.ts +279 -0
  178. package/lib/runtime-archive.ts +1216 -0
  179. package/lib/runtime-authority.ts +166 -0
  180. package/lib/runtime-guard.ts +36 -0
  181. package/lib/runtime-provider-state.ts +169 -0
  182. package/lib/runtime-reconnect.ts +86 -0
  183. package/lib/runtime-state.ts +1033 -0
  184. package/lib/safe-http.ts +364 -0
  185. package/lib/secrets-proxy/address.ts +95 -0
  186. package/lib/secrets-proxy/audit.ts +30 -0
  187. package/lib/secrets-proxy/ca-store.ts +200 -0
  188. package/lib/secrets-proxy/ca.ts +470 -0
  189. package/lib/secrets-proxy/container-steps.ts +138 -0
  190. package/lib/secrets-proxy/der.ts +273 -0
  191. package/lib/secrets-proxy/engine.ts +1633 -0
  192. package/lib/secrets-proxy/host.ts +93 -0
  193. package/lib/secrets-proxy/hostname.ts +57 -0
  194. package/lib/secrets-proxy/placeholders.ts +196 -0
  195. package/lib/secrets-proxy/policy.ts +150 -0
  196. package/lib/secrets-proxy/readiness.ts +148 -0
  197. package/lib/secrets-proxy/runtime.ts +582 -0
  198. package/lib/secrets-proxy/scrub.ts +559 -0
  199. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  200. package/lib/secrets-proxy/task-scripts.ts +262 -0
  201. package/lib/secrets-proxy/task-trust.ts +605 -0
  202. package/lib/secrets-proxy/trust-watch.ts +80 -0
  203. package/lib/secrets.ts +71 -0
  204. package/lib/shared-files-s3.ts +976 -0
  205. package/lib/shared-files.ts +156 -0
  206. package/lib/sidecar-assign.ts +49 -0
  207. package/lib/sidecar-guard.ts +58 -0
  208. package/lib/sidecar-resume.ts +178 -0
  209. package/lib/sidecar.ts +69 -0
  210. package/lib/signin-box-credentials.ts +465 -0
  211. package/lib/signin-box.ts +114 -0
  212. package/lib/skill-index-example.fixture.ts +56 -0
  213. package/lib/skill-layout.ts +417 -0
  214. package/lib/skills.ts +489 -0
  215. package/lib/ssh.ts +401 -0
  216. package/lib/standard-image-cleanup.ts +117 -0
  217. package/lib/standard-image-pin.ts +207 -0
  218. package/lib/standard-image.ts +1697 -0
  219. package/lib/stopped-task-status-queue.ts +120 -0
  220. package/lib/task-container-cli.ts +269 -0
  221. package/lib/task-credential-cleanup.ts +752 -0
  222. package/lib/task-diff-sandbox.ts +464 -0
  223. package/lib/task-diff.ts +91 -0
  224. package/lib/task-env-forwarder.ts +228 -0
  225. package/lib/task-env-source.ts +249 -0
  226. package/lib/task-environment/anchor.ts +31 -0
  227. package/lib/task-environment/apple-container.ts +860 -0
  228. package/lib/task-environment/docker.ts +1344 -0
  229. package/lib/task-environment/index.ts +466 -0
  230. package/lib/task-environment/legacy-adoption.ts +467 -0
  231. package/lib/task-environment/machine-environment.ts +61 -0
  232. package/lib/task-environment/machine-task-up.ts +1227 -0
  233. package/lib/task-environment/machine.ts +974 -0
  234. package/lib/task-environment/registry.ts +59 -0
  235. package/lib/task-environment/self-identity.ts +20 -0
  236. package/lib/task-environment/self.ts +566 -0
  237. package/lib/task-environment/types.ts +642 -0
  238. package/lib/task-environment/workspace-files.ts +685 -0
  239. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  240. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  241. package/lib/task-files-machine-retirement.ts +116 -0
  242. package/lib/task-files-readiness.ts +127 -0
  243. package/lib/task-gateway-port.ts +22 -0
  244. package/lib/task-gateway-protocol.ts +190 -0
  245. package/lib/task-gateway-recovery.ts +47 -0
  246. package/lib/task-gateway-state.ts +512 -0
  247. package/lib/task-identity.ts +19 -0
  248. package/lib/task-inventory.ts +682 -0
  249. package/lib/task-machine-credentials-activation.ts +95 -0
  250. package/lib/task-machine-swap-safety.ts +729 -0
  251. package/lib/task-person-credentials.ts +76 -0
  252. package/lib/task-project-ops-host.ts +147 -0
  253. package/lib/task-project-ops.ts +1062 -0
  254. package/lib/task-secret-staging.ts +16 -0
  255. package/lib/task-status.ts +46 -0
  256. package/lib/task-token.ts +46 -0
  257. package/lib/token-broker-adopt.ts +344 -0
  258. package/lib/token-broker-lambda.ts +105 -0
  259. package/lib/token-broker-local.ts +392 -0
  260. package/lib/token-broker-revision.ts +310 -0
  261. package/lib/token-broker-ssm.ts +525 -0
  262. package/lib/token-broker.ts +218 -0
  263. package/lib/token-lanes/openai-oauth.ts +123 -0
  264. package/lib/token-lanes/registry.ts +45 -0
  265. package/lib/token-lanes/types.ts +163 -0
  266. package/lib/token-lanes/xai-oauth.ts +165 -0
  267. package/lib/transcript.ts +128 -0
  268. package/lib/tunnel-registry.ts +220 -0
  269. package/lib/tunnel-runtime.ts +235 -0
  270. package/lib/ulid.ts +16 -0
  271. package/lib/user-bus-env.ts +116 -0
  272. package/lib/user-credentials-store.ts +435 -0
  273. package/lib/vnode-pressure.ts +142 -0
  274. package/package.json +115 -8
  275. package/runner/runner.mjs +442 -0
  276. package/scripts/agent/_common.sh +633 -0
  277. package/scripts/agent/env-hook.sh +626 -0
  278. package/scripts/agent/moondeck-cli.sh +5 -0
  279. package/scripts/agent/task-down.sh +291 -0
  280. package/scripts/agent/task-status.sh +70 -0
  281. package/scripts/agent/task-up.sh +3761 -0
  282. package/scripts/agent/uai-env-exec.sh +26 -0
  283. package/scripts/install/darwin.ts +1048 -0
  284. package/scripts/install/linux.ts +958 -0
  285. package/scripts/install/types.ts +78 -0
  286. package/scripts/install/util.ts +254 -0
  287. package/scripts/install/win.ts +143 -0
  288. package/src/activation-respawn.ts +155 -0
  289. package/src/apple-tunnel-route.ts +104 -0
  290. package/src/capture-wire.ts +102 -0
  291. package/src/cli.ts +2583 -0
  292. package/src/cloud-credentials.ts +2144 -0
  293. package/src/cloud-origins.ts +23 -0
  294. package/src/credential-location.ts +248 -0
  295. package/src/credential-wire-validation.ts +25 -0
  296. package/src/custom-connections.ts +237 -0
  297. package/src/engine-account-paths.ts +11 -0
  298. package/src/engine-models.ts +39 -0
  299. package/src/engine-usage.ts +104 -0
  300. package/src/event-outbox.ts +185 -0
  301. package/src/index.ts +2899 -0
  302. package/src/load-env.ts +97 -0
  303. package/src/machine-tunnel-route.ts +156 -0
  304. package/src/main.ts +6349 -0
  305. package/src/never-proxied-domains.ts +31 -0
  306. package/src/outbound-frame.ts +31 -0
  307. package/src/paths.ts +81 -0
  308. package/src/project-env-latest.ts +130 -0
  309. package/src/project-env-policy.ts +396 -0
  310. package/src/project-env-wording.ts +53 -0
  311. package/src/protocol.ts +5545 -0
  312. package/src/runtime-bootstrap.ts +165 -0
  313. package/src/station-identity.ts +96 -0
  314. package/src/task-env-names.ts +28 -0
  315. package/src/task-secrets.ts +75 -0
  316. package/src/tunnel-dial.ts +102 -0
  317. package/src/ui/server.ts +852 -0
  318. package/src/ui/types.ts +173 -0
  319. package/src/workspace-path-policy.ts +41 -0
  320. package/ui/app.js +1102 -0
  321. package/ui/index.html +90 -0
  322. package/ui/style.css +685 -0
  323. package/ui/uai-favicon.svg +21 -0
  324. package/ui/uai-logo-black.svg +9 -0
  325. package/ui/uai-wheel.svg +9 -0
  326. package/bin/moondeck-station.mjs +0 -3
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@moondeck/station",
3
- "version": "0.0.1",
4
- "description": "Moondeck Station: runs Moondeck tasks on a machine you control. Placeholder; the first release is coming soon.",
3
+ "version": "1.0.0",
4
+ "description": "Moondeck Station: runs Moondeck tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Moondeck <team@moondeck.io>",
7
7
  "homepage": "https://moondeck.io",
@@ -10,17 +10,124 @@
10
10
  "url": "git+https://github.com/moondeckio/uai.git",
11
11
  "directory": "host-agent"
12
12
  },
13
- "bin": {
14
- "moondeck-station": "./bin/moondeck-station.mjs"
13
+ "bugs": {
14
+ "url": "https://github.com/moondeckio/uai/issues"
15
15
  },
16
- "files": [
17
- "bin",
18
- "README.md"
16
+ "keywords": [
17
+ "ai",
18
+ "coding-agent",
19
+ "claude",
20
+ "codex",
21
+ "docker",
22
+ "dev-container",
23
+ "self-hosted",
24
+ "orchestrator"
19
25
  ],
26
+ "type": "module",
27
+ "bin": {
28
+ "moondeck-station": "bin/uai-host.mjs"
29
+ },
20
30
  "engines": {
21
- "node": ">=22"
31
+ "node": "22.x || >=24 <27"
22
32
  },
23
33
  "publishConfig": {
24
34
  "access": "public"
35
+ },
36
+ "files": [
37
+ "bin",
38
+ "src",
39
+ "lib",
40
+ "db",
41
+ "scripts/agent",
42
+ "scripts/install",
43
+ "runner",
44
+ "images/standard",
45
+ "ui",
46
+ "README.md",
47
+ "!**/*.test.ts",
48
+ "!**/*.bats",
49
+ "!scripts/agent/tests"
50
+ ],
51
+ "exports": {
52
+ ".": {
53
+ "types": "./src/index.ts",
54
+ "default": "./src/index.ts"
55
+ },
56
+ "./protocol": {
57
+ "types": "./src/protocol.ts",
58
+ "default": "./src/protocol.ts"
59
+ },
60
+ "./cloud-credentials": {
61
+ "types": "./src/cloud-credentials.ts",
62
+ "default": "./src/cloud-credentials.ts"
63
+ },
64
+ "./project-env-policy": {
65
+ "types": "./src/project-env-policy.ts",
66
+ "default": "./src/project-env-policy.ts"
67
+ },
68
+ "./project-env-latest": {
69
+ "types": "./src/project-env-latest.ts",
70
+ "default": "./src/project-env-latest.ts"
71
+ },
72
+ "./project-env-wording": {
73
+ "types": "./src/project-env-wording.ts",
74
+ "default": "./src/project-env-wording.ts"
75
+ },
76
+ "./engine-models": {
77
+ "types": "./src/engine-models.ts",
78
+ "default": "./src/engine-models.ts"
79
+ },
80
+ "./engine-usage": {
81
+ "types": "./src/engine-usage.ts",
82
+ "default": "./src/engine-usage.ts"
83
+ },
84
+ "./workspace-path-policy": {
85
+ "types": "./src/workspace-path-policy.ts",
86
+ "default": "./src/workspace-path-policy.ts"
87
+ },
88
+ "./task-secrets": {
89
+ "types": "./src/task-secrets.ts",
90
+ "default": "./src/task-secrets.ts"
91
+ },
92
+ "./task-env-names": {
93
+ "types": "./src/task-env-names.ts",
94
+ "default": "./src/task-env-names.ts"
95
+ },
96
+ "./cloud-origins": {
97
+ "types": "./src/cloud-origins.ts",
98
+ "default": "./src/cloud-origins.ts"
99
+ },
100
+ "./never-proxied-domains": {
101
+ "types": "./src/never-proxied-domains.ts",
102
+ "default": "./src/never-proxied-domains.ts"
103
+ }
104
+ },
105
+ "scripts": {
106
+ "lint": "eslint .",
107
+ "typecheck": "tsc -p tsconfig.json",
108
+ "test:unit": "vitest run --config vitest.config.ts",
109
+ "start": "tsx src/cli.ts run",
110
+ "moondeck-station": "tsx src/cli.ts"
111
+ },
112
+ "dependencies": {
113
+ "@aws-sdk/client-ssm": "3.899.0",
114
+ "@sentry/node": "^10.66.0",
115
+ "better-sqlite3": "12.11.1",
116
+ "dotenv": "^16.4.5",
117
+ "drizzle-orm": "^0.36.0",
118
+ "tsx": "^4.19.2",
119
+ "ulid": "^2.3.0",
120
+ "ws": "^8.18.3",
121
+ "zod": "^3.23.8"
122
+ },
123
+ "devDependencies": {
124
+ "@types/node": "^22.9.0",
125
+ "@types/ws": "^8.18.1",
126
+ "@typescript-eslint/eslint-plugin": "^8.59.4",
127
+ "@typescript-eslint/parser": "^8.59.4",
128
+ "esbuild": "0.25.8",
129
+ "eslint": "^9.14.0",
130
+ "typescript": "^5.6.3",
131
+ "vitest": "^2.1.4"
25
132
  }
26
133
  }
@@ -0,0 +1,442 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * uai session runner (ADR-061) — runs INSIDE the task container and owns an
4
+ * agent CLI process, so the CLI's lifetime is decoupled from the host
5
+ * process. The host talks to it only through files on the task-workspace
6
+ * bind mount (no sockets/FIFOs — they don't cross the macOS↔VM boundary):
7
+ *
8
+ * <sessionDir>/inbox.jsonl host appends; runner tails → CLI stdin
9
+ * <sessionDir>/outbox.jsonl CLI stdout protocol + __uai meta (connections redacted)
10
+ * <sessionDir>/heartbeat rewritten every HEARTBEAT_MS (host checks mtime)
11
+ * <sessionDir>/runner.json pid, protocol, argv, startedAt
12
+ *
13
+ * Meta lines are `{"__uai":"spawn"|"exit", ...}`; the host filters them out
14
+ * before handing lines to the protocol adapters. Host metadata/control lines
15
+ * in the inbox use the same shape (`input` timestamps and `stop`); the runner
16
+ * filters those, while every protocol frame goes to the CLI's stdin untouched.
17
+ *
18
+ * Plain Node ≥18, dependency-free, ESM. Testable outside docker: point it
19
+ * at a tmp dir and any line-oriented fake CLI.
20
+ *
21
+ * Usage: node runner.mjs <sessionDir> -- <command> [args...]
22
+ */
23
+
24
+ import { spawn } from "node:child_process";
25
+ import { appendFileSync, mkdirSync, openSync, readSync, fstatSync, closeSync, writeFileSync } from "node:fs";
26
+ import { join } from "node:path";
27
+ import process from "node:process";
28
+
29
+ const PROTOCOL = 1;
30
+ const POLL_MS = 50;
31
+ const HEARTBEAT_MS = 5_000;
32
+ const STDERR_CAP = 8 * 1024;
33
+ const STOP_GRACE_MS = 5_000;
34
+ const customConnection = process.env.UAI_CUSTOM_CONNECTION === "1";
35
+ const connectionKeys = [...new Set([
36
+ process.env.ANTHROPIC_AUTH_TOKEN,
37
+ process.env.ANTHROPIC_API_KEY,
38
+ process.env.UAI_CUSTOM_API_KEY,
39
+ process.env.AWS_BEARER_TOKEN_BEDROCK,
40
+ ].filter((key) => typeof key === "string" && key.length > 0))]
41
+ .sort((a, b) => b.length - a.length);
42
+
43
+ function redactConnectionKeys(text) {
44
+ for (const key of connectionKeys) {
45
+ text = text.split(key).join("[redacted]".includes(key) ? "" : "[redacted]");
46
+ }
47
+ return text;
48
+ }
49
+
50
+ // Never persist upstream diagnostics from a custom endpoint: these can contain
51
+ // credentials, request bodies or arbitrary provider responses, beyond known keys.
52
+ function connectionReason(value) {
53
+ const text = typeof value === "string" ? value : JSON.stringify(value) ?? "";
54
+ if (/401|unauthorized|authentication_failed|unrecognized.?client|expired.?token|token.{0,80}(?:expired|revoked)|invalid.?api.?key/i.test(text)) return "unauthorized";
55
+ if (/403|forbidden|access.?denied/i.test(text)) return "forbidden";
56
+ if (/404|model.{0,30}not.found/i.test(text)) return "model-not-found";
57
+ if (/429|rate.?limit|throttl/i.test(text)) return "rate-limited";
58
+ if (/expired/i.test(text)) return "expired";
59
+ if (/408|timeout|timed.out/i.test(text)) return "timeout";
60
+ return "upstream-error";
61
+ }
62
+
63
+ function connectionError(value) {
64
+ const reason = connectionReason(value);
65
+ // JSON-RPC error responses must retain their error object and numeric code.
66
+ return value && typeof value === "object" && !Array.isArray(value)
67
+ ? { ...(typeof value.code === "number" ? { code: value.code } : {}), message: reason }
68
+ : reason;
69
+ }
70
+
71
+ // Tool/model payloads can contain fields named error, result or stderrTail.
72
+ // Preserve their meaning; only exact credentials are redacted recursively.
73
+ function redactConnectionValue(value) {
74
+ if (typeof value === "string") return redactConnectionKeys(value);
75
+ if (Array.isArray(value)) return value.map(redactConnectionValue);
76
+ if (!value || typeof value !== "object") return value;
77
+ return Object.fromEntries(Object.entries(value).map(([key, item]) =>
78
+ [redactConnectionKeys(key), redactConnectionValue(item)]));
79
+ }
80
+
81
+ // Suppress provider diagnostics only in the CLI's protocol-level failures,
82
+ // never an error object nested in a completed tool item or model output.
83
+ function redactConnectionRecord(value) {
84
+ if (!value || typeof value !== "object" || Array.isArray(value)) return redactConnectionValue(value);
85
+ const claudeApiError = value.type === "assistant" && value.is_api_error_message === true;
86
+ const claudeResultError = value.type === "result" && value.is_error === true;
87
+ const protocolError = claudeApiError || claudeResultError ||
88
+ (value.type === "system" && value.subtype === "api_retry") ||
89
+ value.type === "error" || value.type === "turn.failed" ||
90
+ (Object.hasOwn(value, "id") && value.error);
91
+ if (claudeApiError) {
92
+ value = { ...value, message: { ...value.message,
93
+ content: [{ type: "text", text: connectionReason(value) }] } };
94
+ }
95
+ if (protocolError) {
96
+ value = { ...value };
97
+ if (Object.hasOwn(value, "error")) value.error = connectionError(value.error);
98
+ if (Array.isArray(value.errors)) value.errors = value.errors.map(connectionError);
99
+ if (claudeResultError && Object.hasOwn(value, "result")) value.result = connectionReason(value.result);
100
+ if (value.type === "error") {
101
+ for (const key of ["message", "detail", "body"]) {
102
+ if (Object.hasOwn(value, key)) value[key] = connectionReason(value[key]);
103
+ }
104
+ }
105
+ }
106
+ if (value.method === "error" && value.params?.error) {
107
+ value = { ...value, params: { ...value.params, error: connectionError(value.params.error) } };
108
+ }
109
+ if (value.method === "turn/completed" && value.params?.turn?.status === "failed" && value.params.turn.error) {
110
+ value = { ...value, params: { ...value.params,
111
+ turn: { ...value.params.turn, error: connectionError(value.params.turn.error) } } };
112
+ }
113
+ if (value.__uai === "exit" && Object.hasOwn(value, "stderrTail")) {
114
+ value = { ...value, stderrTail: value.stderrTail ? connectionReason(value.stderrTail) : "" };
115
+ }
116
+ return redactConnectionValue(value);
117
+ }
118
+
119
+ function safeOutboxLine(line) {
120
+ if (!customConnection) return line;
121
+ // Wait for a complete JSON line, decode its strings before redacting, then
122
+ // serialize again. Replacing raw JSON bytes would miss escaped keys or corrupt
123
+ // quote/backslash-containing keys. Separate protocol deltas retain their shape;
124
+ // the host also redacts across assembled message deltas before publication.
125
+ try {
126
+ return JSON.stringify(redactConnectionRecord(JSON.parse(line)));
127
+ } catch {
128
+ return JSON.stringify({ type: "error", error: connectionReason(line) });
129
+ }
130
+ }
131
+
132
+ // SDK/app-server retries must not hide a rejected connection credential.
133
+ // Restrict classification to CLI error records, never generated model text.
134
+ let connectionFailureSeen = false;
135
+ let connectionTransientRetries = 0;
136
+ // Codex's built-in AWS backend has a fixed HTTP retry budget and rejects
137
+ // retry overrides. Do not multiply that budget with another stream cycle.
138
+ const connectionStreamRetryBudget = process.env.AWS_BEARER_TOKEN_BEDROCK &&
139
+ process.env.CLAUDE_CODE_USE_BEDROCK !== "1" ? 0 : 1;
140
+ function connectionFailureFrame(value) {
141
+ if (!customConnection) return value;
142
+ if (value.method === "turn/started" || value.type === "turn.started" ||
143
+ (value.type === "result" && value.is_error === false)) connectionTransientRetries = 0;
144
+ const claudeRetry = value.type === "system" && value.subtype === "api_retry";
145
+ const rpcError = value.error && Object.hasOwn(value, "id");
146
+ const failed = claudeRetry || rpcError || value.type === "error" || value.type === "turn.failed" ||
147
+ (value.type === "assistant" && value.is_api_error_message === true) ||
148
+ (value.type === "result" && value.is_error === true) || value.method === "error" ||
149
+ (value.method === "turn/completed" && value.params?.turn?.status === "failed");
150
+ if (!failed) return value;
151
+ const detail = value.params?.error ?? value.params?.turn?.error ?? value.error ?? value.message ?? value.result ?? "";
152
+ const text = typeof detail === "string" ? detail : JSON.stringify(detail);
153
+ const status = value.error_status ?? detail?.status ?? detail?.statusCode ??
154
+ ([401, 403].includes(detail?.code) ? detail.code : undefined);
155
+ const auth = status === 401 || status === 403 ||
156
+ /\b(?:status|code|API Error:)\s*[:=]?\s*(?:401|403)\b|\b401 Unauthorized\b|\b403 Forbidden\b|authentication_failed|unrecognized.?client|access.?denied|expired.?token|invalid.?api.?key|invalid.?bearer.?token|security token.{0,80}(?:expired|invalid)/i.test(text);
157
+ const retry = claudeRetry || value.params?.willRetry === true ||
158
+ (value.type === "error" && /^Reconnecting\.\.\./.test(text));
159
+ if (!auth && (!retry || ++connectionTransientRetries <= connectionStreamRetryBudget)) return value;
160
+ const reason = auth ? (status === 403 && !/authentication_failed/i.test(text) ? "forbidden" : connectionReason(text)) : connectionReason(value);
161
+ const safeReason = auth && !["unauthorized", "forbidden", "expired"].includes(reason) ? "unauthorized" : reason;
162
+ connectionFailureSeen = true;
163
+ stopCli();
164
+ if (rpcError) return { jsonrpc: value.jsonrpc, id: value.id, error: { ...(typeof value.error.code === "number" ? { code: value.error.code } : {}), message: safeReason } };
165
+ if (value.method) return { method: "error", params: { threadId: value.params?.threadId,
166
+ turnId: value.params?.turnId, willRetry: false, error: { message: safeReason } } };
167
+ if (["system", "assistant", "result"].includes(value.type)) return {
168
+ type: "result", subtype: "error_during_execution", is_error: true,
169
+ result: safeReason, errors: [safeReason],
170
+ };
171
+ return { type: "error", message: safeReason };
172
+ }
173
+
174
+ // Retain only a possible credential prefix across protocol deltas. Every
175
+ // persisted text fragment is safe when concatenated with earlier fragments.
176
+ const deltaTails = new Map();
177
+ let deltaTailBytes = 0;
178
+ // Bound retained bytes, rather than stopping a healthy multi-item turn at
179
+ // an arbitrary item count. Completed items release their state immediately.
180
+ const DELTA_TAIL_MAX_BYTES = 4 * 1024 * 1024;
181
+ function forgetDeltaTail(stream) {
182
+ const pending = deltaTails.get(stream);
183
+ if (pending) deltaTailBytes -= pending.bytes;
184
+ deltaTails.delete(stream);
185
+ }
186
+ function streamingDelta(value) {
187
+ if (value.type === "stream_event" && value.event?.type === "content_block_delta") {
188
+ for (const field of ["text", "thinking", "partial_json", "signature"]) {
189
+ if (typeof value.event.delta?.[field] === "string") {
190
+ return { stream: `claude:${value.event.index ?? 0}:${field}`, target: value.event.delta, field };
191
+ }
192
+ }
193
+ }
194
+ if (typeof value.method === "string" && /delta$/i.test(value.method) && typeof value.params?.delta === "string") {
195
+ return { stream: `codex:${value.method}:${value.params.threadId ?? ""}:${value.params.turnId ?? ""}:${value.params.itemId ?? ""}`,
196
+ target: value.params, field: "delta" };
197
+ }
198
+ return null;
199
+ }
200
+ function safeOutboxLines(line) {
201
+ if (!customConnection) return [line];
202
+ let value;
203
+ try { value = JSON.parse(line); } catch { return [safeOutboxLine(line)]; }
204
+ if (!value || typeof value !== "object") return [safeOutboxLine(line)];
205
+ if (connectionFailureSeen && value.__uai !== "exit") return [];
206
+ value = connectionFailureFrame(value);
207
+ const delta = streamingDelta(value);
208
+ if (delta) {
209
+ const text = redactConnectionKeys((deltaTails.get(delta.stream)?.tail ?? "") + delta.target[delta.field]);
210
+ let keep = 0;
211
+ for (const key of connectionKeys) {
212
+ for (let n = Math.min(key.length - 1, text.length); n > keep; n--) {
213
+ if (text.endsWith(key.slice(0, n))) { keep = n; break; }
214
+ }
215
+ }
216
+ const tail = keep ? text.slice(-keep) : "";
217
+ delta.target[delta.field] = keep ? text.slice(0, -keep) : text;
218
+ forgetDeltaTail(delta.stream);
219
+ if (tail) {
220
+ // Only retain the protocol envelope, not a potentially large provider
221
+ // payload or the already-persisted text.
222
+ const frame = value.type === "stream_event"
223
+ ? { type: value.type, event: { type: value.event.type, index: value.event.index,
224
+ delta: { type: value.event.delta.type, [delta.field]: "" } } }
225
+ : { method: value.method, params: { threadId: value.params.threadId,
226
+ turnId: value.params.turnId, itemId: value.params.itemId, delta: "" } };
227
+ const bytes = Buffer.byteLength(delta.stream) + Buffer.byteLength(tail) + Buffer.byteLength(JSON.stringify(frame));
228
+ if (deltaTailBytes + bytes > DELTA_TAIL_MAX_BYTES) {
229
+ stopCli();
230
+ return [JSON.stringify({ type: "error", error: "upstream-error" })];
231
+ }
232
+ deltaTails.set(delta.stream, { tail, frame, bytes });
233
+ deltaTailBytes += bytes;
234
+ }
235
+ }
236
+ const lines = [];
237
+ for (const [stream, pending] of deltaTails) {
238
+ const threadMatches = value.params?.threadId === undefined || pending.frame.params?.threadId === value.params.threadId;
239
+ const completedTurn = value.params?.turnId ?? value.params?.turn?.id;
240
+ const turnMatches = completedTurn === undefined || pending.frame.params?.turnId === completedTurn;
241
+ const terminal = value.__uai === "exit" ||
242
+ (value.type === "result" && pending.frame.type === "stream_event") ||
243
+ (value.method === "turn/completed" && pending.frame.params && threadMatches && turnMatches);
244
+ const claudeEnd = value.type === "stream_event" && value.event?.type === "content_block_stop" &&
245
+ pending.frame.type === "stream_event" && pending.frame.event.index === value.event.index;
246
+ const codexEnd = value.method === "item/completed" && pending.frame.params &&
247
+ typeof (value.params?.item?.id ?? value.params?.itemId) === "string" &&
248
+ pending.frame.params.itemId === (value.params.item?.id ?? value.params.itemId) &&
249
+ (value.params.threadId === undefined || pending.frame.params.threadId === value.params.threadId) &&
250
+ (value.params.turnId === undefined || pending.frame.params.turnId === value.params.turnId);
251
+ if (!terminal && !claudeEnd && !codexEnd) continue;
252
+ const d = streamingDelta(pending.frame);
253
+ d.target[d.field] = pending.tail;
254
+ lines.push(JSON.stringify(redactConnectionRecord(pending.frame)));
255
+ forgetDeltaTail(stream);
256
+ }
257
+ lines.push(JSON.stringify(redactConnectionRecord(value)));
258
+ return lines;
259
+ }
260
+
261
+ // ---- argv ------------------------------------------------------------------
262
+
263
+ const sep = process.argv.indexOf("--");
264
+ const sessionDir = process.argv[2];
265
+ if (!sessionDir || sep < 0 || sep + 1 >= process.argv.length) {
266
+ process.stderr.write("usage: runner.mjs <sessionDir> -- <command> [args...]\n");
267
+ process.exit(2);
268
+ }
269
+ const command = process.argv[sep + 1];
270
+ const args = process.argv.slice(sep + 2);
271
+
272
+ mkdirSync(sessionDir, { recursive: true });
273
+ const inboxPath = join(sessionDir, "inbox.jsonl");
274
+ const outboxPath = join(sessionDir, "outbox.jsonl");
275
+ const heartbeatPath = join(sessionDir, "heartbeat");
276
+ const runnerJsonPath = join(sessionDir, "runner.json");
277
+
278
+ // Single append point — appendFileSync with O_APPEND keeps lines atomic for
279
+ // the sizes we write; the runner is the outbox's only writer.
280
+ function outbox(line) {
281
+ for (const safe of safeOutboxLines(line)) {
282
+ appendFileSync(outboxPath, safe.endsWith("\n") ? safe : `${safe}\n`);
283
+ }
284
+ }
285
+ function meta(kind, extra = {}) {
286
+ outbox(JSON.stringify({ __uai: kind, ts: Date.now(), ...extra }));
287
+ }
288
+
289
+ // ---- the CLI ---------------------------------------------------------------
290
+
291
+ // The runner is launched through the container's asdf `node` shim, which
292
+ // exports its resolved version (ASDF_NODEJS_VERSION et al.) into our env.
293
+ // Passing that through would pin the CLI's own asdf shim to the WORKSPACE's
294
+ // node version — "No claude executable found for nodejs X" when the agent
295
+ // CLIs are installed under a different one. So strip the per-tool VERSION
296
+ // PINS only (ASDF_NODEJS_VERSION, ASDF_<TOOL>_VERSION) — NOT the whole ASDF_*
297
+ // namespace: ASDF_DATA_DIR (/opt/asdf-data) and ASDF_DIR (/opt/asdf) tell the
298
+ // shim's `asdf exec` WHERE the tools live. Drop those and asdf falls back to
299
+ // $HOME/.asdf (empty), so EVERY shim aborts with "unknown command: codex.
300
+ // Perhaps you have to reshim?" (exit 1) — the codex/claude resume failure on
301
+ // every host restart, host 0.8.32. `ASDF_VERSION` (asdf's own version) has no
302
+ // middle segment, so the pin regex leaves it — harmless either way.
303
+ const cliEnv = { ...process.env };
304
+ for (const key of Object.keys(cliEnv)) {
305
+ if (/^ASDF_.+_VERSION$/.test(key)) delete cliEnv[key];
306
+ }
307
+
308
+ const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], env: cliEnv });
309
+
310
+ writeFileSync(
311
+ runnerJsonPath,
312
+ JSON.stringify({
313
+ protocol: PROTOCOL,
314
+ runnerPid: process.pid,
315
+ cliPid: child.pid ?? null,
316
+ command,
317
+ args,
318
+ startedAt: Date.now(),
319
+ }),
320
+ );
321
+ meta("spawn", { protocol: PROTOCOL, runnerPid: process.pid, cliPid: child.pid ?? null });
322
+
323
+ let stderrTail = "";
324
+ child.stderr?.setEncoding("utf8");
325
+ child.stderr?.on("data", (chunk) => {
326
+ stderrTail = (stderrTail + chunk).slice(-STDERR_CAP);
327
+ });
328
+
329
+ // CLI stdout → outbox, complete lines only (partial line buffered).
330
+ let stdoutBuf = "";
331
+ child.stdout?.setEncoding("utf8");
332
+ child.stdout?.on("data", (chunk) => {
333
+ stdoutBuf += chunk;
334
+ for (;;) {
335
+ const nl = stdoutBuf.indexOf("\n");
336
+ if (nl < 0) break;
337
+ const line = stdoutBuf.slice(0, nl);
338
+ stdoutBuf = stdoutBuf.slice(nl + 1);
339
+ if (line.trim().length > 0) outbox(line);
340
+ }
341
+ });
342
+
343
+ let exiting = false;
344
+ child.on("exit", (code, signal) => {
345
+ if (exiting) return;
346
+ exiting = true;
347
+ if (stdoutBuf.trim().length > 0) outbox(stdoutBuf); // flush the partial tail
348
+ meta("exit", { code: connectionFailureSeen ? 1 : code, signal: signal ?? null, stderrTail });
349
+ process.exit(0);
350
+ });
351
+ child.on("error", (err) => {
352
+ if (exiting) return;
353
+ exiting = true;
354
+ meta("exit", { code: null, signal: null, stderrTail: String(err?.message ?? err) });
355
+ process.exit(0);
356
+ });
357
+
358
+ // ---- inbox tail → CLI stdin --------------------------------------------------
359
+
360
+ let inboxOffset = 0; // fresh session dir per spawn — always start at 0
361
+ let inboxBuf = "";
362
+
363
+ function stopCli() {
364
+ if (exiting) return;
365
+ try {
366
+ child.kill("SIGTERM");
367
+ } catch {
368
+ /* already gone */
369
+ }
370
+ setTimeout(() => {
371
+ try {
372
+ child.kill("SIGKILL");
373
+ } catch {
374
+ /* already gone */
375
+ }
376
+ }, STOP_GRACE_MS).unref();
377
+ }
378
+
379
+ function handleInboxLine(line) {
380
+ if (line.trim().length === 0) return;
381
+ if (line.startsWith('{"__uai"')) {
382
+ try {
383
+ const ctl = JSON.parse(line);
384
+ if (ctl.__uai === "stop") stopCli();
385
+ } catch {
386
+ /* malformed control — ignore */
387
+ }
388
+ return;
389
+ }
390
+ try {
391
+ child.stdin?.write(`${line}\n`);
392
+ } catch {
393
+ /* EPIPE after CLI death — the exit meta already tells the host */
394
+ }
395
+ }
396
+
397
+ function pollInbox() {
398
+ let fd;
399
+ try {
400
+ fd = openSync(inboxPath, "r");
401
+ } catch {
402
+ return; // inbox not created yet
403
+ }
404
+ try {
405
+ const size = fstatSync(fd).size;
406
+ if (size > inboxOffset) {
407
+ const len = size - inboxOffset;
408
+ const buf = Buffer.alloc(len);
409
+ const read = readSync(fd, buf, 0, len, inboxOffset);
410
+ inboxOffset += read;
411
+ inboxBuf += buf.toString("utf8", 0, read);
412
+ for (;;) {
413
+ const nl = inboxBuf.indexOf("\n");
414
+ if (nl < 0) break;
415
+ const line = inboxBuf.slice(0, nl);
416
+ inboxBuf = inboxBuf.slice(nl + 1);
417
+ handleInboxLine(line);
418
+ }
419
+ }
420
+ } finally {
421
+ closeSync(fd);
422
+ }
423
+ }
424
+
425
+ setInterval(pollInbox, POLL_MS);
426
+
427
+ // ---- heartbeat ---------------------------------------------------------------
428
+
429
+ function beat() {
430
+ try {
431
+ writeFileSync(heartbeatPath, `${process.pid} ${Date.now()}\n`);
432
+ } catch {
433
+ /* disk hiccup — next beat retries */
434
+ }
435
+ }
436
+ beat();
437
+ setInterval(beat, HEARTBEAT_MS);
438
+
439
+ // The runner dies only with its CLI (or on stop/SIGTERM) — never because a
440
+ // host-side consumer went away. That is the entire point (ADR-061).
441
+ process.on("SIGTERM", stopCli);
442
+ process.on("SIGINT", stopCli);