@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,156 @@
1
+ /**
2
+ * ADR-062 shared files — host-side handler for the `filesOp` bridge command.
3
+ * Two roots per host: org files (per host+org) and personal files (per
4
+ * host+user), both under <workspaceRoot>/shared/. The cloud is a window
5
+ * only; this module is the sole writer host-side.
6
+ *
7
+ * SECURITY: the cloud relays paths from browsers, and containers mount these
8
+ * same dirs — every path is treated as hostile. Two gates on every op:
9
+ * `resolveInsideRoot` (relative-only, ".."-free, resolved-prefix check) and
10
+ * `assertRealInsideRoot` (realpath containment, so a symlink the container
11
+ * planted inside a rw mount cannot point ops outside the root).
12
+ */
13
+
14
+ import { promises as fsp } from "node:fs";
15
+ import { dirname, join, normalize, resolve, sep } from "node:path";
16
+
17
+ import type { FilesEntry, FilesOpInput, FilesOpValue } from "../src/protocol";
18
+ import { env } from "./env";
19
+
20
+ /** Max bytes returned per read chunk (base64 expands ~4/3; WS cap is 16MB). */
21
+ const MAX_READ_CHUNK = 4 * 1024 * 1024;
22
+ /** Max bytes accepted per write chunk. */
23
+ const MAX_WRITE_CHUNK = 8 * 1024 * 1024;
24
+ /** Max entries returned by a single list. */
25
+ const MAX_LIST_ENTRIES = 2_000;
26
+
27
+ /**
28
+ * Filesystem-safe directory name for an org/user id. Personal org ids are
29
+ * `personal:<userId>` — the colon breaks docker's short-syntax bind spec
30
+ * (src:dst:mode), so it is mapped to "_". MUST stay in lockstep with the
31
+ * identical mapping in scripts/agent/task-up.sh (shared_fs_name).
32
+ */
33
+ export function sharedDirName(id: string): string {
34
+ return id.replace(/:/g, "_");
35
+ }
36
+
37
+ export function sharedRoot(scope: "org" | "me", orgId: string, userId: string): string {
38
+ return scope === "org"
39
+ ? resolve(env.workspaceRoot, "shared", "orgs", sharedDirName(orgId))
40
+ : resolve(env.workspaceRoot, "shared", "users", sharedDirName(userId));
41
+ }
42
+
43
+ function resolveInsideRoot(root: string, relPath: string): string {
44
+ // Relative paths only — an absolute path is rejected, never reinterpreted.
45
+ if (/^[/\\]/.test(relPath) || /^[A-Za-z]:/.test(relPath)) {
46
+ throw new Error("invalid path");
47
+ }
48
+ const cleaned = normalize(relPath);
49
+ if (
50
+ cleaned.split(/[/\\]/).some((seg) => seg === "..") ||
51
+ cleaned.includes("\0")
52
+ ) {
53
+ throw new Error("invalid path");
54
+ }
55
+ const target = resolve(root, cleaned);
56
+ if (target !== root && !target.startsWith(root + sep)) {
57
+ throw new Error("invalid path");
58
+ }
59
+ return target;
60
+ }
61
+
62
+ /**
63
+ * String containment (resolveInsideRoot) can't see symlinks the container
64
+ * planted inside a rw mount — `root/evil → /` would pass it. Walk up from
65
+ * the target to its deepest EXISTING ancestor, realpath it, and require the
66
+ * REAL location to stay inside the real root (ADR-062 "no symlink escapes").
67
+ */
68
+ async function assertRealInsideRoot(root: string, target: string): Promise<void> {
69
+ const realRoot = await fsp.realpath(root);
70
+ let probe = target;
71
+ for (;;) {
72
+ try {
73
+ const real = await fsp.realpath(probe);
74
+ if (real !== realRoot && !real.startsWith(realRoot + sep)) {
75
+ throw new Error("invalid path");
76
+ }
77
+ return;
78
+ } catch (err) {
79
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
80
+ const parent = dirname(probe);
81
+ if (parent === probe) throw new Error("invalid path");
82
+ probe = parent;
83
+ }
84
+ }
85
+ }
86
+
87
+ export async function handleFilesOp(input: FilesOpInput): Promise<FilesOpValue> {
88
+ if (!input.orgId && input.scope === "org") throw new Error("missing orgId");
89
+ if (!input.userId && input.scope === "me") throw new Error("missing userId");
90
+ const root = sharedRoot(input.scope, input.orgId, input.userId);
91
+ await fsp.mkdir(root, { recursive: true });
92
+ const target = resolveInsideRoot(root, input.path ?? "");
93
+ await assertRealInsideRoot(root, target);
94
+
95
+ switch (input.op) {
96
+ case "list": {
97
+ const names = await fsp.readdir(target);
98
+ const entries: FilesEntry[] = [];
99
+ for (const name of names.slice(0, MAX_LIST_ENTRIES)) {
100
+ try {
101
+ const st = await fsp.lstat(join(target, name));
102
+ entries.push({
103
+ name,
104
+ dir: st.isDirectory(),
105
+ size: st.isDirectory() ? 0 : st.size,
106
+ mtimeMs: st.mtimeMs,
107
+ });
108
+ } catch {
109
+ // Raced deletion — skip.
110
+ }
111
+ }
112
+ entries.sort((a, b) =>
113
+ a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1,
114
+ );
115
+ return { entries };
116
+ }
117
+ case "read": {
118
+ const st = await fsp.stat(target);
119
+ if (st.isDirectory()) throw new Error("is a directory");
120
+ const offset = Math.max(0, input.offset ?? 0);
121
+ const length = Math.min(input.length ?? MAX_READ_CHUNK, MAX_READ_CHUNK);
122
+ const fh = await fsp.open(target, "r");
123
+ try {
124
+ const buf = Buffer.alloc(Math.min(length, Math.max(0, st.size - offset)));
125
+ const { bytesRead } = await fh.read(buf, 0, buf.length, offset);
126
+ return {
127
+ dataBase64: buf.subarray(0, bytesRead).toString("base64"),
128
+ size: st.size,
129
+ eof: offset + bytesRead >= st.size,
130
+ };
131
+ } finally {
132
+ await fh.close();
133
+ }
134
+ }
135
+ case "write": {
136
+ if (target === root) throw new Error("invalid path");
137
+ const data = Buffer.from(input.dataBase64 ?? "", "base64");
138
+ if (data.length > MAX_WRITE_CHUNK) throw new Error("chunk too large");
139
+ await fsp.mkdir(resolve(target, ".."), { recursive: true });
140
+ await fsp.writeFile(target, data, { flag: input.append ? "a" : "w" });
141
+ return {};
142
+ }
143
+ case "mkdir": {
144
+ if (target === root) throw new Error("invalid path");
145
+ await fsp.mkdir(target, { recursive: true });
146
+ return {};
147
+ }
148
+ case "delete": {
149
+ if (target === root) throw new Error("invalid path");
150
+ await fsp.rm(target, { recursive: true, force: true });
151
+ return {};
152
+ }
153
+ default:
154
+ throw new Error(`unknown op: ${String(input.op)}`);
155
+ }
156
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The warm-pool claim's host half (docs/machine-warm-pool.md): the cloud's
3
+ * `sidecarAssign` names the task this pool machine now serves. The task id
4
+ * is appended to the sidecar unit's EnvironmentFile and the process exits
5
+ * with ASSIGN_RESTART_EXIT_CODE — systemd (`Restart=on-failure`) brings the
6
+ * unit back up in plain sidecar mode, the tested runner-restart path.
7
+ * Rewriting a root-owned file and restarting is deliberately boring: no
8
+ * runtime re-wiring of boot-time gates, no half-pool half-task states.
9
+ */
10
+ import { appendFileSync, readFileSync } from "node:fs";
11
+
12
+ import { sidecarPoolMode } from "./sidecar";
13
+
14
+ /** Anything but 0 and 3: 0 would not restart the unit, 3 is the lease-denied
15
+ * latch (`RestartPreventExitStatus=3`). */
16
+ export const ASSIGN_RESTART_EXIT_CODE = 64;
17
+ const EXIT_DELAY_MS = 500;
18
+
19
+ export function sidecarEnvFile(): string {
20
+ return process.env.UAI_SIDECAR_ENV_FILE ?? "/etc/uai/sidecar.env";
21
+ }
22
+
23
+ export function applySidecarAssign(
24
+ taskId: string,
25
+ deps: {
26
+ envFile?: string;
27
+ exit?: (code: number) => void;
28
+ append?: (file: string, text: string) => void;
29
+ read?: (file: string) => string;
30
+ } = {},
31
+ ): void {
32
+ if (!sidecarPoolMode()) {
33
+ throw new Error("not a pool machine (already assigned, or not a sidecar)");
34
+ }
35
+ if (!/^[a-z0-9]{1,32}$/i.test(taskId)) throw new Error("invalid task id");
36
+ const file = deps.envFile ?? sidecarEnvFile();
37
+ const read = deps.read ?? ((f: string) => readFileSync(f, "utf8"));
38
+ const current = read(file);
39
+ if (/^UAI_SIDECAR_TASK=/m.test(current)) {
40
+ throw new Error("this machine already carries a task assignment");
41
+ }
42
+ const append = deps.append ?? ((f: string, text: string) => appendFileSync(f, text));
43
+ append(file, `UAI_SIDECAR_TASK=${taskId}\n`);
44
+ // Ack first, then die: the delay lets the command reply flush to the
45
+ // bridge before the socket goes with the process.
46
+ const exit = deps.exit ?? ((code: number) => process.exit(code));
47
+ const timer = setTimeout(() => exit(ASSIGN_RESTART_EXIT_CODE), EXIT_DELAY_MS);
48
+ timer.unref?.();
49
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * A task machine's own guards, held by its sidecar (root) before the task
3
+ * runs anything there (ADR-133; ADR-153 §3, "Where the proxy runs"):
4
+ *
5
+ * - The runner's data directory is root's alone, so the task's user cannot
6
+ * read the store's database. Its values are sealed with a root-only key,
7
+ * but its key names and bindings would still be there to read.
8
+ * - The instance metadata service is closed to the task's user, so it can
9
+ * read neither the machine's user-data, which carries the sidecar's host
10
+ * token, nor the instance role's credentials.
11
+ *
12
+ * The image's prepare step sets both up too. The runner insists on them at
13
+ * every task-up, since an image that skipped or failed that step would
14
+ * otherwise let the task start without them.
15
+ */
16
+
17
+ import { execFileSync } from "node:child_process";
18
+ import { chmodSync, mkdirSync } from "node:fs";
19
+
20
+ /** Rejects the task user's traffic to the instance metadata service, with
21
+ * whichever iptables backend the kernel serves (as the proxy's chain does),
22
+ * adding the rule only when it is missing. */
23
+ export const METADATA_BLOCK_SCRIPT = [
24
+ "set -eu",
25
+ "ipt=iptables",
26
+ "if ! command -v iptables >/dev/null 2>&1 || ! iptables -S OUTPUT >/dev/null 2>&1; then ipt=iptables-legacy; fi",
27
+ 'command -v "$ipt" >/dev/null 2>&1 || { echo "iptables is not installed" >&2; exit 1; }',
28
+ '"$ipt" -C OUTPUT -d 169.254.169.254 -m owner --uid-owner node -j REJECT 2>/dev/null \\',
29
+ ' || "$ipt" -I OUTPUT -d 169.254.169.254 -m owner --uid-owner node -j REJECT',
30
+ ].join("\n");
31
+
32
+ export interface SidecarGuardDeps {
33
+ dataDir: string;
34
+ /** Runs a script as this (root) process; throws when it fails. */
35
+ runScript(script: string): void;
36
+ }
37
+
38
+ const defaultRunScript = (script: string): void => {
39
+ execFileSync("/bin/sh", ["-c", script], { stdio: ["ignore", "ignore", "pipe"], timeout: 15_000 });
40
+ };
41
+
42
+ /** Puts both guards in place, or says which could not be. */
43
+ export function guardSidecarMachine(deps: Partial<SidecarGuardDeps> & Pick<SidecarGuardDeps, "dataDir">): { ok: true } | { ok: false; reason: string } {
44
+ const runScript = deps.runScript ?? defaultRunScript;
45
+ try {
46
+ mkdirSync(deps.dataDir, { recursive: true, mode: 0o700 });
47
+ chmodSync(deps.dataDir, 0o700);
48
+ } catch (error) {
49
+ return { ok: false, reason: `its data directory could not be made private (${error instanceof Error ? error.message : String(error)})` };
50
+ }
51
+ try {
52
+ runScript(METADATA_BLOCK_SCRIPT);
53
+ } catch (error) {
54
+ const detail = error instanceof Error && "stderr" in error ? String((error as { stderr?: unknown }).stderr ?? "").trim() : "";
55
+ return { ok: false, reason: `the instance metadata service could not be closed to the task${detail ? ` (${detail})` : ""}` };
56
+ }
57
+ return { ok: true };
58
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * ADR-133: what a sidecar re-asserts when it starts on a machine whose task
3
+ * is ALREADY up — a restart of the unit, not a fresh launch.
4
+ *
5
+ * Task-up mounts the shared files and launches the editor once; nothing ran
6
+ * them again when the runner came back, so an operator's restart (smoke 10,
7
+ * 2026-09-10) left /workspace/files as two dead endpoints and the Editor tab
8
+ * empty for the rest of the task. The unit now keeps those processes alive
9
+ * across a stop (KillMode=process), and this pass covers what that cannot:
10
+ * a crash, an older unit, a SIGKILL. Both steps are idempotent — a healthy
11
+ * mount and a live code-server are left alone — so running it on every
12
+ * lease-ready costs one stat each.
13
+ */
14
+
15
+ import { eq } from "drizzle-orm";
16
+
17
+ import * as schema from "../db/schema";
18
+ import { getDb } from "./db";
19
+ import { mountSharedFilesFromLease } from "./shared-files-s3";
20
+ import { sidecarTaskId } from "./sidecar";
21
+ import { reconstructPersistedTaskEnvironment } from "./task-environment";
22
+ import { installMachineProjectEnvLoader } from "./machine-project-env-loader";
23
+ import { activeSecretsProxy } from "./secrets-proxy/host";
24
+ import { taskViewProven } from "./secrets-proxy/sidecar-view";
25
+ import { reconcileSidecarTaskProxy } from "./secrets-proxy/task-trust";
26
+ import { ensureCodeServer } from "./task-environment/machine-task-up";
27
+ import type { TaskEnvironmentHandle } from "./task-environment/types";
28
+
29
+ type PersistedTask = typeof schema.hostTasks.$inferSelect;
30
+
31
+ export interface SidecarResumeDeps {
32
+ taskId?: () => string | null;
33
+ persistedTask?: (taskId: string) => PersistedTask | undefined;
34
+ reconstruct?: (task: PersistedTask) => Promise<TaskEnvironmentHandle<unknown> | null>;
35
+ mount?: (
36
+ handle: TaskEnvironmentHandle<unknown>,
37
+ workspacePath: string,
38
+ task: { sharedFiles?: string },
39
+ ) => Promise<string | null>;
40
+ /** ADR-154 Files v1: install and mount the exact recovered run's session
41
+ * from trusted host state. Unlike the legacy marker path, failure is
42
+ * terminal for this recovery setup and is not converted to a warning. */
43
+ prepareFiles?: (handle: TaskEnvironmentHandle<unknown>) => Promise<void>;
44
+ loader?: (handle: TaskEnvironmentHandle<unknown>) => Promise<void>;
45
+ editor?: (handle: TaskEnvironmentHandle<unknown>, workspacePath: string) => Promise<void>;
46
+ proxy?: typeof reconcileSidecarTaskProxy;
47
+ /** Remount the shared files from the v0 lease (the default). ADR-154: a
48
+ * task on cloud credentials remounts them from its own run's Files
49
+ * session instead, never from the runner's process-wide v0 lease. */
50
+ remountFiles?: boolean;
51
+ /** ADR-154: aborted when the run this resume is for ends (Stop, teardown,
52
+ * a newer run, shutdown): no step starts after. */
53
+ signal?: AbortSignal;
54
+ }
55
+
56
+ export interface SidecarResumeResult {
57
+ /** `no-task`: not a sidecar. `not-up`: the task has not come up on this
58
+ * machine yet (a fresh launch, or a start that a runner restart cut short —
59
+ * task-up does the work). `resumed`: the
60
+ * task was up before this runner started; its mounts and editor were
61
+ * re-asserted, with any per-step warning listed. */
62
+ outcome: "no-task" | "not-up" | "resumed" | "interrupted";
63
+ warnings: string[];
64
+ }
65
+
66
+ /** The mode task-up mounted with, recorded in the files-mounted marker; null
67
+ * when the files were never mounted (mode off, or the mount failed then). */
68
+ function mountedModeFromMarker(bytes: Uint8Array | null): "ro" | "rw" | null {
69
+ if (bytes === null) return null;
70
+ return Buffer.from(bytes).toString("utf8").trim() === "rw" ? "rw" : "ro";
71
+ }
72
+
73
+ /**
74
+ * ADR-153 machines, at the runner's start and before any command can reach
75
+ * the task: a task this machine has a row for, in whatever state (`starting`
76
+ * included), is held for this boot unless its hosts-view record proves it.
77
+ * A start driven again, or a resume, then finds the hold. Only task-up for a
78
+ * task with no row admits it fresh (src/index.ts).
79
+ */
80
+ export function classifySidecarTaskView(
81
+ deps: Pick<SidecarResumeDeps, "taskId" | "persistedTask"> = {},
82
+ ): void {
83
+ const taskId = (deps.taskId ?? sidecarTaskId)();
84
+ if (!taskId) return;
85
+ if ((deps.persistedTask ?? defaultPersistedTask)(taskId) === undefined) return;
86
+ // Asked for its record: one with none is held from here on.
87
+ taskViewProven(taskId);
88
+ }
89
+
90
+ export async function resumeSidecarTask(
91
+ deps: SidecarResumeDeps = {},
92
+ ): Promise<SidecarResumeResult> {
93
+ const taskId = (deps.taskId ?? sidecarTaskId)();
94
+ if (!taskId) return { outcome: "no-task", warnings: [] };
95
+ const task = (deps.persistedTask ?? defaultPersistedTask)(taskId);
96
+ // Only a task that came all the way up: one still starting (a runner that
97
+ // died mid-provision) is task-up's to finish, and resuming it would race
98
+ // that retry's loader, proxy steps and editor.
99
+ if (!task?.environmentLocator || task.statusMirror !== "running") return { outcome: "not-up", warnings: [] };
100
+ const handle = await (deps.reconstruct ?? reconstructPersistedTaskEnvironment)(task);
101
+ if (!handle) return { outcome: "not-up", warnings: [] };
102
+ const workspacePath = handle.descriptor.workspacePath;
103
+ const warnings: string[] = [];
104
+ const ended = (): boolean => deps.signal?.aborted === true;
105
+ const interrupted = (): SidecarResumeResult => ({ outcome: "interrupted", warnings });
106
+
107
+ if (deps.prepareFiles) {
108
+ if (ended()) return interrupted();
109
+ await deps.prepareFiles(handle);
110
+ }
111
+
112
+ if (task.envMode === "loader") {
113
+ if (ended()) return interrupted();
114
+ try {
115
+ await (deps.loader ?? installMachineProjectEnvLoader)(handle);
116
+ } catch (error) {
117
+ // The loader remains fail-closed: no project value was baked into this
118
+ // machine. Keep repairing the editor/mounts, but make the degraded env
119
+ // channel visible to the sidecar's restart log.
120
+ warnings.push(
121
+ `project env loader: ${error instanceof Error ? error.message : String(error)}`,
122
+ );
123
+ }
124
+ // ADR-153: the proxy's CA and pins again (a reboot keeps neither the
125
+ // chain nor this runner's record of any of it).
126
+ if (ended()) return interrupted();
127
+ const proxyWarning = await (deps.proxy ?? reconcileSidecarTaskProxy)(
128
+ handle,
129
+ activeSecretsProxy()?.runtime ?? null,
130
+ { start: false, ...(deps.signal ? { isCurrent: () => !ended() } : {}) },
131
+ );
132
+ if (proxyWarning !== null) warnings.push(proxyWarning);
133
+ }
134
+
135
+ const marker =
136
+ deps.remountFiles === false
137
+ ? null
138
+ : await handle.readWorkspaceFile(`${workspacePath}/.moondeck/files-mounted`).catch(() => null);
139
+ const mode = mountedModeFromMarker(marker);
140
+ if (mode) {
141
+ try {
142
+ const warning = await (deps.mount ?? mountSharedFilesFromLease)(handle, workspacePath, {
143
+ sharedFiles: mode,
144
+ });
145
+ if (warning) warnings.push(warning);
146
+ } catch (error) {
147
+ warnings.push(
148
+ `shared files: ${error instanceof Error ? error.message : String(error)}`,
149
+ );
150
+ }
151
+ }
152
+
153
+ if (ended()) return interrupted();
154
+ try {
155
+ await (deps.editor ?? defaultEditor)(handle, workspacePath);
156
+ } catch (error) {
157
+ warnings.push(`editor: ${error instanceof Error ? error.message : String(error)}`);
158
+ }
159
+ return { outcome: "resumed", warnings };
160
+ }
161
+
162
+ function defaultPersistedTask(taskId: string): PersistedTask | undefined {
163
+ return getDb()
164
+ .select()
165
+ .from(schema.hostTasks)
166
+ .where(eq(schema.hostTasks.taskId, taskId))
167
+ .get();
168
+ }
169
+
170
+ function defaultEditor(
171
+ handle: TaskEnvironmentHandle<unknown>,
172
+ workspacePath: string,
173
+ ): Promise<void> {
174
+ // The theme rode task-up's preview env and is not persisted; the settings
175
+ // seed is skipped when the file already exists, so an existing task keeps
176
+ // the theme it started with.
177
+ return ensureCodeServer(handle, workspacePath, undefined);
178
+ }
package/lib/sidecar.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * ADR-133 sidecar mode: this runner process lives ON the task's machine and
3
+ * is that one task's host. Selected by the environment the machine image's
4
+ * `uai-sidecar.service` writes from EC2 user-data (hostless-implementation.md
5
+ * §8/§9). Everything the coordinator shape needed and a single-task machine
6
+ * does not — the local UI, the container runtime, host-level engine accounts,
7
+ * the machine provider, recovery sweeps over many tasks — asks here and
8
+ * stays off. Owned runners never set it.
9
+ */
10
+
11
+ export function sidecarTaskId(): string | null {
12
+ const value = process.env.UAI_SIDECAR_TASK?.trim();
13
+ return value && /^[a-z0-9]{1,32}$/i.test(value) ? value : null;
14
+ }
15
+
16
+ /**
17
+ * Warm-pool mode (docs/machine-warm-pool.md): a sidecar with no task yet.
18
+ * Same machine gates as sidecar mode, no lease (nothing to scope one to);
19
+ * the process waits for `sidecarAssign` to write `UAI_SIDECAR_TASK` into
20
+ * the unit env and restart it into plain sidecar mode. Once a task id is
21
+ * present, pool mode is over — even before the restart lands.
22
+ */
23
+ export function sidecarPoolMode(): boolean {
24
+ return process.env.UAI_SIDECAR_POOL === "1" && sidecarTaskId() === null;
25
+ }
26
+
27
+ export function sidecarMode(): boolean {
28
+ return sidecarTaskId() !== null || sidecarPoolMode();
29
+ }
30
+
31
+ /**
32
+ * A sidecar is ready to run its task only once it holds a lease AND the
33
+ * credential store has been reconciled under it — before that there is no
34
+ * engine account, GitHub token, or project env on the machine, and a task-up
35
+ * that raced the first grant would clone without credentials and spawn
36
+ * agents that cannot sign in (the dispatch arrives the moment the bridge
37
+ * connects; the lease answer is one round trip behind it). Resolved by
38
+ * main.ts after the first grant's reconciles settle; never on an owned host.
39
+ */
40
+ let markReady: (() => void) | null = null;
41
+ let ready = false;
42
+ export const sidecarReady: Promise<void> = new Promise<void>((resolve) => {
43
+ markReady = resolve;
44
+ });
45
+ export function markSidecarReady(): void {
46
+ if (ready) return;
47
+ ready = true;
48
+ markReady?.();
49
+ markReady = null;
50
+ }
51
+ export function isSidecarReady(): boolean {
52
+ return ready;
53
+ }
54
+ /** How long a task-up waits for the lease before giving up. */
55
+ export const SIDECAR_READY_TIMEOUT_MS = 3 * 60_000;
56
+ export function awaitSidecarReady(timeoutMs = SIDECAR_READY_TIMEOUT_MS): Promise<void> {
57
+ if (!sidecarMode() || ready) return Promise.resolve();
58
+ return new Promise<void>((resolve, reject) => {
59
+ const timer = setTimeout(
60
+ () => reject(new Error("the task's machine has no credentials yet (no lease granted)")),
61
+ timeoutMs,
62
+ );
63
+ timer.unref?.();
64
+ void sidecarReady.then(() => {
65
+ clearTimeout(timer);
66
+ resolve();
67
+ });
68
+ });
69
+ }