@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,237 @@
1
+ /**
2
+ * Self-recycle for wedged Apple `container` runtime services (ADR-106).
3
+ *
4
+ * The wedge, observed live three times over 2026-08-16/17: a
5
+ * created-never-started container (usually minted by a `run --rm` the
6
+ * runtime refused to start) leaves the apiserver in a state where `run` and
7
+ * `delete` hang for minutes while `list`/`inspect` keep answering. Every
8
+ * occurrence previously required a human to run the same shell ritual —
9
+ * stop the system, boot out every `com.apple.container.*` launchd unit
10
+ * (including the per-container `container-runtime-linux.<uuid>` units that
11
+ * hold the wedge), start the system again, and delete the corpses. The
12
+ * owner's verdict on that ritual: "this needs to be automatic."
13
+ *
14
+ * This module IS that ritual. It is invoked by the maintenance-run wedge
15
+ * sensor in `standard-image.ts` when a named run cannot reach `running`
16
+ * while the control plane still answers. Recycling restarts runtime
17
+ * services only — task containers stop with the VMs, and host recovery
18
+ * restarts them afterwards exactly as it does after a host reboot.
19
+ *
20
+ * A cooldown serializes and rate-limits recycles: a second wedge verdict
21
+ * inside the window means recycling did not cure the underlying cause, and
22
+ * looping stop/start forever would only churn tasks. That failure surfaces
23
+ * to the operator instead.
24
+ */
25
+ import { spawn } from "node:child_process";
26
+
27
+ interface CliResult {
28
+ code: number | null;
29
+ stdout: string;
30
+ stderr: string;
31
+ }
32
+
33
+ export interface AppleRecycleDeps {
34
+ /** Run the bundled `container` CLI. */
35
+ cli: (args: string[], timeoutMs: number) => Promise<CliResult>;
36
+ /** Run `launchctl`. Injectable for tests. */
37
+ launchctl?: (args: string[], timeoutMs: number) => Promise<CliResult>;
38
+ uid?: number;
39
+ now?: () => number;
40
+ }
41
+
42
+ const RECYCLE_COOLDOWN_MS = 10 * 60_000;
43
+ const SYSTEM_STOP_TIMEOUT_MS = 60_000;
44
+ const SYSTEM_START_TIMEOUT_MS = 120_000;
45
+
46
+ let lastRecycleAt = 0;
47
+ let inFlight: Promise<AppleRecycleOutcome> | null = null;
48
+
49
+ export type AppleRecycleOutcome =
50
+ | { recycled: true; sweptCorpses: string[] }
51
+ | { recycled: false; reason: string };
52
+
53
+ /** Test hook: clear the cooldown/serialization latches. */
54
+ export function resetAppleRecycleStateForTest(): void {
55
+ lastRecycleAt = 0;
56
+ inFlight = null;
57
+ }
58
+
59
+ function defaultExec(
60
+ command: string,
61
+ ): (args: string[], timeoutMs: number) => Promise<CliResult> {
62
+ return (args, timeoutMs) =>
63
+ new Promise<CliResult>((resolve) => {
64
+ let stdout = "";
65
+ let stderr = "";
66
+ let settled = false;
67
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
68
+ const settle = (code: number | null): void => {
69
+ if (settled) return;
70
+ settled = true;
71
+ clearTimeout(timer);
72
+ resolve({ code, stdout, stderr });
73
+ };
74
+ const timer = setTimeout(() => {
75
+ child.kill("SIGKILL");
76
+ const backstop = setTimeout(() => settle(null), 1_000);
77
+ backstop.unref();
78
+ }, timeoutMs);
79
+ timer.unref();
80
+ child.stdout?.setEncoding("utf8");
81
+ child.stderr?.setEncoding("utf8");
82
+ child.stdout?.on("data", (chunk: string) => {
83
+ stdout += chunk;
84
+ });
85
+ child.stderr?.on("data", (chunk: string) => {
86
+ stderr += chunk;
87
+ });
88
+ child.on("error", () => settle(null));
89
+ child.on("close", (code) => settle(code));
90
+ });
91
+ }
92
+
93
+ /** `launchctl list` third column, filtered to the Apple container family. */
94
+ export function parseAppleContainerServiceLabels(listing: string): string[] {
95
+ return listing
96
+ .split("\n")
97
+ .map((line) => line.trim().split(/\s+/).pop() ?? "")
98
+ .filter((label) => label.startsWith("com.apple.container"));
99
+ }
100
+
101
+ /** The exact ownership label every Uai maintenance container carries. A name
102
+ * alone — even a `moondeck-maint-*` one — is forgeable/collidable; deletion
103
+ * requires the label AND the closed generated grammar together. */
104
+ export const MAINTENANCE_CONTAINER_LABEL = "io.moondeck.maintenance";
105
+
106
+ /** The closed grammar one-off containers are named in (apple-one-off.ts):
107
+ * moondeck-maint-<purpose>-<pid>-<seq>-<16 random hex>; the random part is
108
+ * absent from the names of a runner that predates it. */
109
+ export const MAINTENANCE_CONTAINER_NAME_RE = /^moondeck-maint-[a-z-]+-\d+-\d+(?:-[0-9a-f]{16})?$/;
110
+
111
+ /**
112
+ * The sweep deletes ONLY containers Uai provably owns: the exact generated
113
+ * maintenance-name grammar PLUS the ownership label our runner stamps
114
+ * (review 2026-08-18 round 2: a name prefix alone can collide with a foreign
115
+ * or another-process container). A bare-UUID name is the Apple CLI's
116
+ * identity for ANY anonymous container — including a user's own — so it is
117
+ * never a deletion target; UUID rows are surfaced as wedge suspects instead.
118
+ */
119
+ export function isCorpseContainer(
120
+ id: string,
121
+ state: string | undefined,
122
+ labels: Record<string, unknown> | undefined,
123
+ ): boolean {
124
+ return (
125
+ MAINTENANCE_CONTAINER_NAME_RE.test(id) &&
126
+ labels?.[MAINTENANCE_CONTAINER_LABEL] === "1" &&
127
+ state !== "running"
128
+ );
129
+ }
130
+
131
+ /** Foreign anonymous containers worth telling the operator about after a
132
+ * wedge: not deletable by us, but the likeliest remaining wedge fuel. */
133
+ export function isWedgeSuspect(id: string, state: string | undefined): boolean {
134
+ const uuid =
135
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
136
+ return uuid.test(id) && state !== "running";
137
+ }
138
+
139
+ async function sweepCorpses(deps: AppleRecycleDeps): Promise<string[]> {
140
+ const listed = await deps.cli(["list", "--all", "--format", "json"], 30_000);
141
+ if (listed.code !== 0) return [];
142
+ let rows: Array<{
143
+ configuration?: { id?: unknown; labels?: Record<string, unknown> };
144
+ status?: { state?: unknown };
145
+ }>;
146
+ try {
147
+ const parsed = JSON.parse(listed.stdout) as typeof rows;
148
+ if (!Array.isArray(parsed)) return [];
149
+ rows = parsed;
150
+ } catch {
151
+ return [];
152
+ }
153
+ const swept: string[] = [];
154
+ const suspects: string[] = [];
155
+ for (const row of rows) {
156
+ const id = row?.configuration?.id;
157
+ const rawState = row?.status?.state;
158
+ const state = typeof rawState === "string" ? rawState : undefined;
159
+ if (typeof id !== "string") continue;
160
+ if (isWedgeSuspect(id, state)) {
161
+ suspects.push(id);
162
+ continue;
163
+ }
164
+ if (!isCorpseContainer(id, state, row?.configuration?.labels)) continue;
165
+ const removed = await deps.cli(["delete", "--force", id], 30_000);
166
+ if (removed.code === 0) swept.push(id);
167
+ else {
168
+ console.warn(
169
+ `[host-agent] could not sweep corpse container ${id}: ${removed.stderr.trim().slice(0, 200)}`,
170
+ );
171
+ }
172
+ }
173
+ if (suspects.length > 0) {
174
+ console.warn(
175
+ `[host-agent] ${suspects.length} anonymous non-running container(s) present after the recycle — not Moondeck's to delete, but the likeliest remaining wedge fuel: ${suspects.join(", ")} (remove with: container delete --force <id>)`,
176
+ );
177
+ }
178
+ return swept;
179
+ }
180
+
181
+ /**
182
+ * The ritual, encoded. Serialized (concurrent verdicts join the in-flight
183
+ * recycle) and cooldown-limited (a wedge inside the window reports failure
184
+ * instead of churning stop/start forever).
185
+ */
186
+ export function recycleAppleRuntimeServices(
187
+ deps: AppleRecycleDeps,
188
+ ): Promise<AppleRecycleOutcome> {
189
+ if (inFlight) return inFlight;
190
+ const now = deps.now ?? Date.now;
191
+ if (now() - lastRecycleAt < RECYCLE_COOLDOWN_MS) {
192
+ return Promise.resolve({
193
+ recycled: false,
194
+ reason:
195
+ "the runtime wedged again within minutes of the last recycle; " +
196
+ "restarting services is not curing it — inspect the host " +
197
+ "(container list --all) or reboot the machine",
198
+ });
199
+ }
200
+ lastRecycleAt = now();
201
+ const launchctl = deps.launchctl ?? defaultExec("launchctl");
202
+ const uid = deps.uid ?? process.getuid?.() ?? 501;
203
+ inFlight = (async (): Promise<AppleRecycleOutcome> => {
204
+ console.warn(
205
+ "[host-agent] apple container runtime is wedged (runs cannot start while the control plane answers); recycling runtime services",
206
+ );
207
+ // 1. Polite stop. Known to hang exactly when we need it most — bounded,
208
+ // and its failure changes nothing about the steps that actually work.
209
+ await deps.cli(["system", "stop"], SYSTEM_STOP_TIMEOUT_MS);
210
+ // 2. Boot out every com.apple.container.* unit. The per-container
211
+ // runtime-linux units are the ones that hold a wedge through a plain
212
+ // system restart.
213
+ const listing = await launchctl(["list"], 15_000);
214
+ const labels =
215
+ listing.code === 0 ? parseAppleContainerServiceLabels(listing.stdout) : [];
216
+ for (const label of labels) {
217
+ await launchctl(["bootout", `gui/${uid}/${label}`], 15_000);
218
+ }
219
+ // 3. Fresh start.
220
+ const started = await deps.cli(["system", "start"], SYSTEM_START_TIMEOUT_MS);
221
+ if (started.code !== 0) {
222
+ return {
223
+ recycled: false,
224
+ reason: `container system start failed after the recycle (exit ${started.code ?? "killed"}): ${started.stderr.trim().slice(0, 200)}`,
225
+ };
226
+ }
227
+ // 4. Sweep the debris that caused this, while the apiserver is fresh.
228
+ const sweptCorpses = await sweepCorpses(deps);
229
+ console.warn(
230
+ `[host-agent] apple runtime services recycled (${labels.length} unit(s) booted out, ${sweptCorpses.length} corpse container(s) swept)`,
231
+ );
232
+ return { recycled: true, sweptCorpses };
233
+ })().finally(() => {
234
+ inFlight = null;
235
+ });
236
+ return inFlight;
237
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Uninstall-time teardown of Apple-container state Uai provably owns
3
+ * (ADR-106/108). Runs from the managed-removal transaction AFTER the service
4
+ * stops and BEFORE the signed runtime is removed — the last moment the
5
+ * bundled CLI exists to clean up what Uai provisioned through it.
6
+ *
7
+ * Two review-hardened rules (2026-08-18 round 2):
8
+ *
9
+ * 1. OWNERSHIP IS PROVEN, never inferred from a name. A container is Uai's
10
+ * only when its `io.moondeck.task` label derives its exact name, or when it
11
+ * carries the maintenance ownership label under the closed generated
12
+ * grammar. Images are matched by closed exact grammar over Uai's own
13
+ * naming (`moondeck-standard`, `moondeck-task-<safe-id>`). Nothing else is touched
14
+ * — the runtime's state root and launchd domain may be shared with a
15
+ * user's own Apple container installation, so the shared system is NEVER
16
+ * stopped here.
17
+ *
18
+ * 2. UNCERTAINTY FAILS THE TEARDOWN. Every deletion proves absence
19
+ * afterwards; an unreadable inventory or a surviving resource returns
20
+ * problems, and the caller must THROW so the managed-removal transaction
21
+ * keeps its teardown marker and the signed runtime — leaving a retryable
22
+ * uninstall instead of removing the escape route while Uai state remains.
23
+ */
24
+ import { isSafeHostTaskId } from "./task-identity";
25
+ import { appleTaskContainerName } from "./task-environment/apple-container";
26
+ import {
27
+ MAINTENANCE_CONTAINER_LABEL,
28
+ MAINTENANCE_CONTAINER_NAME_RE,
29
+ } from "./apple-runtime-recycle";
30
+
31
+ interface CliResult {
32
+ code: number | null;
33
+ stdout: string;
34
+ stderr: string;
35
+ }
36
+
37
+ export interface AppleUninstallTeardownDeps {
38
+ cli: (args: string[], timeoutMs: number) => Promise<CliResult>;
39
+ purge: boolean;
40
+ }
41
+
42
+ export interface AppleUninstallTeardownResult {
43
+ removedContainers: string[];
44
+ removedImages: string[];
45
+ /** Non-empty means the teardown is INCOMPLETE and the uninstall must not
46
+ * proceed to remove the signed runtime. */
47
+ problems: string[];
48
+ }
49
+
50
+ const TASK_CONTAINER_NAME_RE = /^moondeck-task-[a-z0-9][a-z0-9_-]*-app$/;
51
+ const DERIVED_IMAGE_NAME_RE = /^moondeck-task-[0-9a-z]+$/;
52
+
53
+ function ownedContainer(
54
+ id: string,
55
+ labels: Record<string, unknown> | undefined,
56
+ ): boolean {
57
+ if (TASK_CONTAINER_NAME_RE.test(id)) {
58
+ const label = labels?.["io.moondeck.task"];
59
+ return (
60
+ typeof label === "string" &&
61
+ isSafeHostTaskId(label) &&
62
+ appleTaskContainerName(label) === id
63
+ );
64
+ }
65
+ if (MAINTENANCE_CONTAINER_NAME_RE.test(id)) {
66
+ return labels?.[MAINTENANCE_CONTAINER_LABEL] === "1";
67
+ }
68
+ return false;
69
+ }
70
+
71
+ /**
72
+ * Ownership over a full image REFERENCE as the CLI lists it. The Apple CLI
73
+ * normalizes unqualified local names to `docker.io/library/<name>:<tag>`
74
+ * (verified live on 1.2.2), so exactly that prefix — and no other registry
75
+ * path — is accepted around Uai's closed naming. A foreign registry's
76
+ * `moondeck-standard` is not ours.
77
+ */
78
+ function ownedImageReference(reference: string): boolean {
79
+ const withoutTag = reference.replace(/(@sha256:[0-9a-f]{64}|:[^/:]+)$/, "");
80
+ const base = withoutTag.startsWith("docker.io/library/")
81
+ ? withoutTag.slice("docker.io/library/".length)
82
+ : withoutTag;
83
+ if (base.includes("/")) return false;
84
+ if (base === "moondeck-standard") return true;
85
+ if (!DERIVED_IMAGE_NAME_RE.test(base)) return false;
86
+ return isSafeHostTaskId(base.slice("moondeck-task-".length));
87
+ }
88
+
89
+ function containerProvenAbsent(name: string, inspected: CliResult): boolean {
90
+ return (
91
+ inspected.code !== 0 &&
92
+ inspected.stdout.trim() === "" &&
93
+ inspected.stderr
94
+ .split("\n")
95
+ .some((line) => line.trim() === `Error: container not found: ${name}`)
96
+ );
97
+ }
98
+
99
+ export async function teardownAppleRuntimeStateForUninstall(
100
+ deps: AppleUninstallTeardownDeps,
101
+ ): Promise<AppleUninstallTeardownResult> {
102
+ const problems: string[] = [];
103
+ const removedContainers: string[] = [];
104
+ const removedImages: string[] = [];
105
+
106
+ // 1. Containers: label-proven Uai ownership only, absence proven after.
107
+ const listed = await deps.cli(["list", "--all", "--format", "json"], 30_000);
108
+ if (listed.code !== 0) {
109
+ problems.push(
110
+ `container inventory failed (exit ${listed.code ?? "killed"}): ${listed.stderr.trim().slice(0, 160)}`,
111
+ );
112
+ } else {
113
+ let rows: Array<{
114
+ configuration?: { id?: unknown; labels?: Record<string, unknown> };
115
+ }>;
116
+ try {
117
+ const parsed = JSON.parse(listed.stdout) as typeof rows;
118
+ if (!Array.isArray(parsed)) throw new Error("not an array");
119
+ rows = parsed;
120
+ } catch {
121
+ problems.push("container inventory was unparseable");
122
+ rows = [];
123
+ }
124
+ for (const row of rows) {
125
+ const id = row?.configuration?.id;
126
+ if (typeof id !== "string") continue;
127
+ if (!ownedContainer(id, row?.configuration?.labels)) continue;
128
+ await deps.cli(["stop", "--time", "10", id], 30_000);
129
+ await deps.cli(["delete", "--force", id], 60_000);
130
+ const after = await deps.cli(["inspect", id], 30_000);
131
+ if (containerProvenAbsent(id, after)) {
132
+ removedContainers.push(id);
133
+ } else {
134
+ problems.push(`container ${id} could not be proven removed`);
135
+ }
136
+ }
137
+ }
138
+
139
+ // 2. Images: Uai's closed naming only, read from the schema the CLI
140
+ // actually emits — full references at `.configuration.name` (verified
141
+ // live on 1.2.2; `.name` is accepted defensively). An unreadable
142
+ // inventory or an unidentifiable row is a problem, not silence, and
143
+ // every deletion is absence-proven afterwards.
144
+ const images = await deps.cli(["image", "list", "--format", "json"], 30_000);
145
+ if (images.code !== 0) {
146
+ problems.push(
147
+ `image inventory failed (exit ${images.code ?? "killed"}): ${images.stderr.trim().slice(0, 160)}`,
148
+ );
149
+ } else {
150
+ let references: string[] = [];
151
+ try {
152
+ const parsed = JSON.parse(images.stdout) as Array<{
153
+ name?: unknown;
154
+ configuration?: { name?: unknown };
155
+ }>;
156
+ if (!Array.isArray(parsed)) throw new Error("not an array");
157
+ const seen = new Set<string>();
158
+ for (const row of parsed) {
159
+ const reference =
160
+ typeof row?.configuration?.name === "string"
161
+ ? row.configuration.name
162
+ : typeof row?.name === "string"
163
+ ? row.name
164
+ : null;
165
+ if (reference === null) {
166
+ problems.push("image inventory contains an unidentifiable row");
167
+ continue;
168
+ }
169
+ if (!seen.has(reference)) {
170
+ seen.add(reference);
171
+ references.push(reference);
172
+ }
173
+ }
174
+ } catch {
175
+ problems.push("image inventory was unparseable");
176
+ references = [];
177
+ }
178
+ for (const reference of references) {
179
+ if (!ownedImageReference(reference)) continue;
180
+ const removed = await deps.cli(["image", "delete", reference], 60_000);
181
+ const after = await deps.cli(["image", "inspect", reference], 30_000);
182
+ const provenAbsent =
183
+ after.code !== 0 &&
184
+ after.stderr
185
+ .split("\n")
186
+ .some(
187
+ (line) => line.trim() === `Error: image not found: ${reference}`,
188
+ );
189
+ if (provenAbsent) {
190
+ removedImages.push(reference);
191
+ } else {
192
+ problems.push(
193
+ `image ${reference} could not be proven removed (delete exit ${removed.code ?? "killed"}): ${removed.stderr.trim().slice(0, 160)}`,
194
+ );
195
+ }
196
+ }
197
+ }
198
+
199
+ // 3. The shared runtime volume goes only with --purge, like ~/.uai data.
200
+ // Success and failure are BOTH judged by the exact absence proof — a
201
+ // timed-out or daemon-failed inspect proves nothing (round 3: any
202
+ // nonzero inspect was read as absence).
203
+ if (deps.purge) {
204
+ const removed = await deps.cli(["volume", "delete", "moondeck-asdf-data"], 30_000);
205
+ const after = await deps.cli(["volume", "inspect", "moondeck-asdf-data"], 30_000);
206
+ const provenAbsent =
207
+ after.code !== 0 &&
208
+ after.stderr
209
+ .split("\n")
210
+ .some(
211
+ (line) => line.trim() === "Error: volume not found: moondeck-asdf-data",
212
+ );
213
+ if (!provenAbsent) {
214
+ problems.push(
215
+ `shared runtime volume could not be proven removed (delete exit ${removed.code ?? "killed"}): ${removed.stderr.trim().slice(0, 160)}`,
216
+ );
217
+ }
218
+ }
219
+
220
+ // Deliberately NO `system stop`: the runtime services and their launchd
221
+ // domain may be shared with the user's own Apple container installation,
222
+ // and stopping them is not required to remove Uai.
223
+ return { removedContainers, removedImages, problems };
224
+ }
@@ -0,0 +1,94 @@
1
+ /** Host-owned durable correlation, independent of a CLI's mutable history. */
2
+ import { randomUUID } from "node:crypto";
3
+ import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
+ import { dirname } from "node:path";
5
+ import type { AssignmentDelivery, AssignmentTurnEvidence } from "../src/protocol";
6
+
7
+ interface State {
8
+ sessionId: string;
9
+ current: AssignmentDelivery | null;
10
+ active: AssignmentTurnEvidence[];
11
+ deliveries: string[];
12
+ observations: Record<string, AssignmentTurnEvidence>;
13
+ }
14
+ /** One journal per task/agent, in runner-private task data, never /workspace. */
15
+ export class AssignmentEvidenceJournal {
16
+ constructor(private readonly path: string) {}
17
+ private read(sessionId: string): State {
18
+ let state: State;
19
+ try { state = JSON.parse(readFileSync(this.path, "utf8")) as State; }
20
+ catch (error) {
21
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
22
+ return { sessionId, current: null, active: [], deliveries: [], observations: {} };
23
+ }
24
+ // A replacement CLI is a new execution generation; never attach the
25
+ // predecessor's turn to it. Explicit redispatch can restore the assignment.
26
+ return state.sessionId === sessionId ? state : { sessionId, current: state.current, active: [], deliveries: [], observations: {} };
27
+ }
28
+ private write(state: State) {
29
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
30
+ const temp = this.path + "." + randomUUID();
31
+ const fd = openSync(temp, "wx", 0o600);
32
+ try { writeFileSync(fd, JSON.stringify(state)); fsyncSync(fd); } finally { closeSync(fd); }
33
+ renameSync(temp, this.path);
34
+ const dir = openSync(dirname(this.path), "r");
35
+ try { fsyncSync(dir); } finally { closeSync(dir); }
36
+ }
37
+ begin(input: { actualAccount?: { credentialId: string; credentialRevision: string; connection: boolean } | null; sessionId: string; engine: string; runEpoch: number; runtimeVersion: string | null; recipientGeneration: string | null; assignment?: AssignmentDelivery | null; replay?: boolean; leadRunId?: string | null }): (AssignmentTurnEvidence & { assignment: AssignmentDelivery | null }) | null {
38
+ const state = this.read(input.sessionId);
39
+ if (state.current && (state.current.runEpoch !== input.runEpoch || state.current.recipientGeneration !== input.recipientGeneration)) {
40
+ state.current = null;
41
+ state.active = [];
42
+ state.deliveries = [];
43
+ }
44
+ // Assignment id, not attempt id: a lost cloud ack must not send the same
45
+ // instruction twice. Old attempts retain their original provenance.
46
+ if (!input.replay && input.assignment && state.deliveries.includes(input.assignment.assignmentId)) return null;
47
+ if (input.assignment) {
48
+ // A replay carries its original identity without retargeting subsequent
49
+ // new inputs to an older assignment. Deduplication applies to cloud
50
+ // deliveries; an internally retained prompt is an execution retry.
51
+ if (!input.replay) state.current = input.assignment;
52
+ if (!state.deliveries.includes(input.assignment.assignmentId)) state.deliveries.push(input.assignment.assignmentId);
53
+ }
54
+ const current = input.assignment === null ? null : input.assignment ?? state.current;
55
+ const evidence: AssignmentTurnEvidence = {
56
+ turnId: randomUUID(), sessionId: input.sessionId, runEpoch: input.runEpoch,
57
+ assignmentId: current?.assignmentId ?? null, deliveryId: current?.deliveryId ?? null,
58
+ recipientGeneration: current?.recipientGeneration ?? (input.leadRunId ? input.recipientGeneration : null),
59
+ ...(input.leadRunId ? { leadRunId: input.leadRunId } : {}),
60
+ correlation: input.assignment || input.leadRunId ? "explicit" : current ? "followup" : "unknown",
61
+ acceptedAt: Date.now(), engine: input.engine, runtimeVersion: input.runtimeVersion,
62
+ ...(input.actualAccount !== undefined ? { credentialId: input.actualAccount?.credentialId ?? null, credentialRevision: input.actualAccount?.credentialRevision ?? null, connection: input.actualAccount?.connection ?? null } : {}),
63
+ };
64
+ // Concurrent prompts aren't assumed to execute FIFO. Without a provider
65
+ // turn/request join, every overlapping turn is explicitly unallocated.
66
+ if (state.active.length) {
67
+ for (const turn of state.active) { turn.assignmentId = null; turn.deliveryId = null; turn.leadRunId = null; turn.correlation = "ambiguous"; }
68
+ evidence.assignmentId = null; evidence.deliveryId = null; evidence.leadRunId = null; evidence.correlation = "ambiguous";
69
+ }
70
+ state.active.push(evidence);
71
+ this.write(state);
72
+ return { ...evidence, assignment: current };
73
+ }
74
+ observe(sessionId: string, sourceId: string | undefined, complete: boolean): AssignmentTurnEvidence | undefined {
75
+ const state = this.read(sessionId);
76
+ if (sourceId && state.observations[sourceId]) return state.observations[sourceId];
77
+ const active = state.active[0];
78
+ if (!active) return undefined;
79
+ const evidence = { ...active, ...(complete ? { endedAt: Date.now() } : {}) };
80
+ if (sourceId) state.observations[sourceId] = evidence;
81
+ if (complete) state.active.shift();
82
+ this.write(state);
83
+ return evidence;
84
+ }
85
+ failed(sessionId: string, turnId: string): void {
86
+ const state = this.read(sessionId);
87
+ const turn = state.active.find(t => t.turnId === turnId);
88
+ state.active = state.active.filter(t => t.turnId !== turnId);
89
+ // Failure before acceptance allows retry. Ambiguous sends stay unknown;
90
+ // never manufacture a successful execution from delivery alone.
91
+ if (turn?.assignmentId) state.deliveries = state.deliveries.filter(id => id !== turn.assignmentId);
92
+ this.write(state);
93
+ }
94
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Chat attachments live on the host, inside the task workspace (ADR-015: the
3
+ * cloud stores no blobs). They land under `<workspace>/.moondeck/attachments/` —
4
+ * inside the workspace so the container (mounted at `/workspace`) reads them
5
+ * directly, and under `.moondeck/` so they sit outside the per-project worktrees and
6
+ * never get committed. The cloud relays bytes in/out; it never persists them.
7
+ */
8
+
9
+ import { mkdir } from "node:fs/promises";
10
+ import { basename, resolve } from "node:path";
11
+
12
+ import { taskWorkspaceDir } from "./env";
13
+ import { makeContainedDirectories, readBoundedFile, writeContainedFile } from "./task-environment/workspace-files";
14
+
15
+ /** Host directory holding a task's attachments. */
16
+ export function attachmentsDir(taskId: string): string {
17
+ return resolve(taskWorkspaceDir(taskId), ".moondeck", "attachments");
18
+ }
19
+
20
+ /** The path an in-container agent reads (workspace is mounted at /workspace). */
21
+ export function containerAttachmentPath(filename: string): string {
22
+ return `/workspace/.moondeck/attachments/${basename(filename)}`;
23
+ }
24
+
25
+ /** An attachment's file name, as it lands in the attachments directory. */
26
+ function attachmentName(filename: string): string {
27
+ const name = basename(filename);
28
+ if (name === "" || name === "." || name === ".." || name.includes("\0")) {
29
+ throw new Error("invalid attachment filename");
30
+ }
31
+ return name;
32
+ }
33
+
34
+ /** Written inside the task's workspace, never through a link a task put
35
+ * there (#cb3j0z). */
36
+ export async function writeAttachment(taskId: string, filename: string, bytes: Buffer): Promise<void> {
37
+ const name = attachmentName(filename);
38
+ // The workspace root is the runner's own path; below it, the task's.
39
+ await mkdir(taskWorkspaceDir(taskId), { recursive: true });
40
+ await makeContainedDirectories(taskWorkspaceDir(taskId), [".moondeck", "attachments"]);
41
+ await writeContainedFile(taskWorkspaceDir(taskId), [".moondeck", "attachments", name], bytes);
42
+ }
43
+
44
+ /** Read the same way: a regular file inside the workspace, or null. */
45
+ export async function readAttachment(taskId: string, filename: string): Promise<Buffer | null> {
46
+ const bytes = await readBoundedFile(taskWorkspaceDir(taskId), [".moondeck", "attachments", attachmentName(filename)]);
47
+ return bytes === null ? null : Buffer.from(bytes);
48
+ }