@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
@@ -0,0 +1,310 @@
1
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import Database from "better-sqlite3";
5
+
6
+ import { env } from "./env";
7
+ import type { HostTask } from "../db/schema";
8
+ import type {
9
+ TaskCommandProject,
10
+ TaskCommandTask,
11
+ TaskDownInput,
12
+ TaskLaunchInput,
13
+ } from "../src/protocol";
14
+
15
+ export function createTaskUpCommandDb(input: TaskLaunchInput): string {
16
+ const dbPath = newCommandDbPath(input.task.id);
17
+ const db = openCommandDb(dbPath);
18
+ try {
19
+ seedMetadata(db, withRuntime(input.task, null), input.projects);
20
+ } finally {
21
+ db.close();
22
+ }
23
+ return dbPath;
24
+ }
25
+
26
+ export function createTaskDownCommandDb(
27
+ input: TaskDownInput,
28
+ runtime: HostTask | null,
29
+ ): string {
30
+ const dbPath = newCommandDbPath(input.taskId);
31
+ const db = openCommandDb(dbPath);
32
+ try {
33
+ seedMetadata(db, withRuntime(input.task, runtime), input.projects);
34
+ } finally {
35
+ db.close();
36
+ }
37
+ return dbPath;
38
+ }
39
+
40
+ /**
41
+ * Environment-provider teardown reconstructs from a durable task locator, not
42
+ * from the cloud command payload that originally provisioned it. The teardown
43
+ * script needs only the exact task identity/runtime row; it deliberately
44
+ * derives every destructive Docker/filesystem target from that identity.
45
+ */
46
+ export function createTaskEnvironmentDownCommandDb(
47
+ taskId: string,
48
+ runtime: HostTask | null,
49
+ ): string {
50
+ const dbPath = newCommandDbPath(taskId);
51
+ const db = openCommandDb(dbPath);
52
+ try {
53
+ insertTask(db, runtimeTask(taskId, runtime));
54
+ } finally {
55
+ db.close();
56
+ }
57
+ return dbPath;
58
+ }
59
+
60
+ export function createTaskStatusCommandDb(
61
+ taskId: string,
62
+ runtime: HostTask | null,
63
+ ): string {
64
+ const dbPath = newCommandDbPath(taskId);
65
+ const db = openCommandDb(dbPath);
66
+ try {
67
+ insertTask(db, runtimeTask(taskId, runtime));
68
+ } finally {
69
+ db.close();
70
+ }
71
+ return dbPath;
72
+ }
73
+
74
+ export function removeCommandDb(path: string): void {
75
+ rmSync(path, { force: true });
76
+ }
77
+
78
+ function newCommandDbPath(taskId: string): string {
79
+ if (!existsSync(env.commandDbDir)) {
80
+ mkdirSync(env.commandDbDir, { recursive: true, mode: 0o700 });
81
+ }
82
+ const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
83
+ return join(env.commandDbDir, `${taskId}-${suffix}.sqlite`);
84
+ }
85
+
86
+ function openCommandDb(path: string): Database.Database {
87
+ const db = new Database(path);
88
+ db.pragma("journal_mode = WAL");
89
+ db.exec(schemaSql);
90
+ return db;
91
+ }
92
+
93
+ /**
94
+ * Seed the per-task command DB the bash scripts read (ADR-022 shape):
95
+ * - one uai_users row for the owner,
96
+ * - one uai_projects row per selected project,
97
+ * - one uai_task_projects row per (task, project, position),
98
+ * - one uai_tasks row for the task (+ mirrored runtime fields).
99
+ * Column names mirror the cloud Drizzle schema.
100
+ */
101
+ function seedMetadata(
102
+ db: Database.Database,
103
+ task: CommandTaskRow,
104
+ projects: TaskCommandProject[],
105
+ ): void {
106
+ db.prepare(
107
+ `INSERT OR IGNORE INTO uai_users (id, clerk_user_id, email)
108
+ VALUES (@id, @clerk_user_id, @email)`,
109
+ ).run({
110
+ id: task.owner_user_id,
111
+ clerk_user_id: null,
112
+ email: "host-command@example.invalid",
113
+ });
114
+
115
+ const insertProject = db.prepare(
116
+ `INSERT INTO uai_projects (
117
+ id, owner_user_id, name, slug, repo_url, default_prompt,
118
+ preview_ports, env, tool_versions, extra
119
+ ) VALUES (
120
+ @id, @owner_user_id, @name, @slug, @repo_url, @default_prompt,
121
+ @preview_ports, @env, @tool_versions, @extra
122
+ )`,
123
+ );
124
+ const insertTaskProject = db.prepare(
125
+ `INSERT INTO uai_task_projects (task_id, project_id, position)
126
+ VALUES (@task_id, @project_id, @position)`,
127
+ );
128
+
129
+ for (const project of projects) {
130
+ insertProject.run({
131
+ id: project.id,
132
+ owner_user_id: project.ownerUserId,
133
+ name: project.name,
134
+ slug: project.slug,
135
+ repo_url: project.repoUrl,
136
+ default_prompt: project.defaultPrompt,
137
+ preview_ports: project.previewPorts,
138
+ env: project.env,
139
+ tool_versions: project.toolVersions ?? null,
140
+ extra: project.extra ?? null,
141
+ });
142
+ insertTaskProject.run({
143
+ task_id: task.id,
144
+ project_id: project.id,
145
+ position: project.position,
146
+ });
147
+ }
148
+
149
+ insertTask(db, task);
150
+ }
151
+
152
+ function insertTask(db: Database.Database, task: CommandTaskRow): void {
153
+ db.prepare(
154
+ `INSERT INTO uai_tasks (
155
+ id, owner_user_id, host_id, name, slug, branch, status,
156
+ global_context, reviewer_order, agents, pr_context,
157
+ worktree_path, compose_project, code_server_port, preview_ports,
158
+ preview_env, owner_org_id, shared_files, locked_at, started_at, ended_at
159
+ ) VALUES (
160
+ @id, @owner_user_id, @host_id, @name, @slug, @branch, @status,
161
+ @global_context, @reviewer_order, @agents, @pr_context,
162
+ @worktree_path, @compose_project, @code_server_port, @preview_ports,
163
+ @preview_env, @owner_org_id, @shared_files, @locked_at, @started_at, @ended_at
164
+ )`,
165
+ ).run(task);
166
+ }
167
+
168
+ /**
169
+ * The shape of a row inserted into `uai_tasks`. The leading columns mirror
170
+ * the cloud `tasks` schema; the trailing columns are host runtime fields the
171
+ * bash scripts write back during task-up / task-down.
172
+ */
173
+ interface CommandTaskRow {
174
+ id: string;
175
+ owner_user_id: string;
176
+ host_id: string;
177
+ name: string;
178
+ slug: string;
179
+ branch: string;
180
+ status: string;
181
+ global_context: string | null;
182
+ reviewer_order: string | null;
183
+ agents: string;
184
+ pr_context: string | null;
185
+ worktree_path: string | null;
186
+ compose_project: string | null;
187
+ code_server_port: number | null;
188
+ preview_ports: string;
189
+ preview_env: string | null;
190
+ owner_org_id: string | null;
191
+ shared_files: string | null;
192
+ locked_at: number | null;
193
+ started_at: number | null;
194
+ ended_at: number | null;
195
+ }
196
+
197
+ function withRuntime(
198
+ task: TaskCommandTask,
199
+ runtime: HostTask | null,
200
+ ): CommandTaskRow {
201
+ return {
202
+ id: task.id,
203
+ owner_user_id: task.ownerUserId,
204
+ host_id: task.hostId,
205
+ name: task.name,
206
+ slug: task.slug,
207
+ branch: task.branch,
208
+ status: runtime?.statusMirror ?? task.status,
209
+ global_context: task.globalContext ?? null,
210
+ reviewer_order: task.reviewerOrder
211
+ ? JSON.stringify(task.reviewerOrder)
212
+ : null,
213
+ agents: JSON.stringify(task.agents ?? []),
214
+ pr_context: null,
215
+ worktree_path: runtime?.worktreePath ?? null,
216
+ compose_project: runtime?.composeProject ?? null,
217
+ code_server_port: runtime?.codeServerPort ?? null,
218
+ preview_ports: runtime?.previewPorts ?? "[]",
219
+ preview_env: task.previewEnv ? JSON.stringify(task.previewEnv) : null,
220
+ owner_org_id: task.ownerOrgId ?? null,
221
+ shared_files: task.sharedFiles ?? null,
222
+ locked_at: runtime?.lockedAt ?? null,
223
+ started_at: runtime?.startedAt ?? null,
224
+ ended_at: runtime?.endedAt ?? null,
225
+ };
226
+ }
227
+
228
+ function runtimeTask(taskId: string, runtime: HostTask | null): CommandTaskRow {
229
+ return {
230
+ id: taskId,
231
+ owner_user_id: "host-runtime",
232
+ host_id: "host-runtime",
233
+ name: taskId,
234
+ slug: taskId,
235
+ branch: `task/${taskId}`,
236
+ status: runtime?.statusMirror ?? "queued",
237
+ global_context: null,
238
+ reviewer_order: null,
239
+ agents: "[]",
240
+ pr_context: null,
241
+ worktree_path: runtime?.worktreePath ?? null,
242
+ compose_project: runtime?.composeProject ?? null,
243
+ code_server_port: runtime?.codeServerPort ?? null,
244
+ preview_ports: runtime?.previewPorts ?? "[]",
245
+ preview_env: null,
246
+ owner_org_id: null,
247
+ shared_files: null,
248
+ locked_at: runtime?.lockedAt ?? null,
249
+ started_at: runtime?.startedAt ?? null,
250
+ ended_at: runtime?.endedAt ?? null,
251
+ };
252
+ }
253
+
254
+ const schemaSql = `
255
+ CREATE TABLE IF NOT EXISTS uai_users (
256
+ id text PRIMARY KEY,
257
+ clerk_user_id text UNIQUE,
258
+ email text NOT NULL
259
+ );
260
+ CREATE TABLE IF NOT EXISTS uai_projects (
261
+ id text PRIMARY KEY,
262
+ owner_user_id text NOT NULL,
263
+ name text NOT NULL,
264
+ slug text NOT NULL,
265
+ repo_url text NOT NULL,
266
+ default_prompt text NOT NULL DEFAULT '',
267
+ preview_ports text NOT NULL DEFAULT '[]',
268
+ env text NOT NULL DEFAULT '[]',
269
+ tool_versions text,
270
+ extra text
271
+ );
272
+ CREATE TABLE IF NOT EXISTS uai_task_projects (
273
+ task_id text NOT NULL,
274
+ project_id text NOT NULL,
275
+ position integer NOT NULL DEFAULT 0,
276
+ PRIMARY KEY (task_id, project_id)
277
+ );
278
+ CREATE TABLE IF NOT EXISTS uai_tasks (
279
+ id text PRIMARY KEY,
280
+ owner_user_id text NOT NULL,
281
+ host_id text NOT NULL,
282
+ name text NOT NULL,
283
+ slug text NOT NULL,
284
+ branch text NOT NULL,
285
+ status text NOT NULL DEFAULT 'queued',
286
+ global_context text,
287
+ reviewer_order text,
288
+ agents text NOT NULL DEFAULT '[]',
289
+ pr_context text,
290
+ worktree_path text,
291
+ compose_project text,
292
+ code_server_port integer,
293
+ preview_ports text NOT NULL DEFAULT '[]',
294
+ preview_env text,
295
+ owner_org_id text,
296
+ shared_files text,
297
+ pr_url text,
298
+ locked_at integer,
299
+ started_at integer,
300
+ ended_at integer,
301
+ updated_at integer NOT NULL DEFAULT (unixepoch() * 1000)
302
+ );
303
+ CREATE TABLE IF NOT EXISTS uai_task_events (
304
+ id text PRIMARY KEY,
305
+ task_id text NOT NULL,
306
+ ts integer NOT NULL DEFAULT (unixepoch() * 1000),
307
+ kind text NOT NULL,
308
+ payload text NOT NULL DEFAULT '{}'
309
+ );
310
+ `;
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Bounded recovery for Docker's local-network address pool.
3
+ *
4
+ * Compose allocates one bridge network per local task. A task that fails after
5
+ * creating its network can remain resumable in `error`, and Docker retains the
6
+ * empty network. Enough such networks eventually make every later task fail
7
+ * with "all predefined address pools have been fully subnetted".
8
+ *
9
+ * This sweep is deliberately narrower than `docker network prune`:
10
+ *
11
+ * - the exact task/project identity must be present in trusted host state;
12
+ * - the task must not be starting or running;
13
+ * - Docker must independently report no attached container; and
14
+ * - deletion happens inside that task's lifecycle slot, so Resume/Stop cannot
15
+ * interleave with the proof. The slot is taken only if it is free at once:
16
+ * the sweep never waits on a task's lifecycle.
17
+ *
18
+ * Any unreadable or malformed observation means "do nothing", never
19
+ * permission to delete.
20
+ */
21
+
22
+ import type { HostTask } from "../db/schema";
23
+ import { dockerCli, type DockerResult } from "./docker-exec";
24
+ import { getHostTask } from "./runtime-state";
25
+ import { isSafeHostTaskId } from "./task-identity";
26
+ import { isTerminal } from "./task-status";
27
+ import { parseTaskEnvironmentLocator } from "./task-environment/types";
28
+
29
+ const COMPOSE_PROJECT_LABEL = "com.docker.compose.project";
30
+ const NETWORK_LIST_FORMAT =
31
+ `{{.ID}}\t{{.Name}}\t{{.Label "${COMPOSE_PROJECT_LABEL}"}}`;
32
+ const MAX_NETWORK_LIST_BYTES = 2 * 1024 * 1024;
33
+ const MAX_NETWORK_INSPECT_BYTES = 1024 * 1024;
34
+ const DOCKER_TIMEOUT_MS = 10_000;
35
+
36
+ interface ComposeNetworkCandidate {
37
+ id: string;
38
+ name: string;
39
+ composeProject: string;
40
+ taskId: string;
41
+ }
42
+
43
+ export interface ComposeNetworkLifecycle {
44
+ /** Runs `operation` in the task's lifecycle slot only if that slot is free
45
+ * at once (the orchestrator's `tryRunTaskLifecycle`); never waits. */
46
+ tryRunTaskLifecycle<T>(
47
+ taskId: string,
48
+ operation: () => Promise<T>,
49
+ ): Promise<{ ran: true; value: T } | { ran: false }>;
50
+ }
51
+
52
+ export interface ComposeNetworkRecoveryDeps {
53
+ docker(args: string[], options?: {
54
+ timeoutMs?: number;
55
+ maxOutputBytes?: number;
56
+ }): Promise<DockerResult>;
57
+ task(taskId: string): HostTask | null;
58
+ }
59
+
60
+ export interface ComposeNetworkRecoveryResult {
61
+ inventory: "complete" | "unavailable" | "invalid";
62
+ candidates: number;
63
+ removed: number;
64
+ /** Candidates left alone because their task's lifecycle slot was busy. */
65
+ busy: number;
66
+ }
67
+
68
+ const defaultDeps: ComposeNetworkRecoveryDeps = {
69
+ docker: dockerCli,
70
+ task: getHostTask,
71
+ };
72
+
73
+ function parseCandidate(
74
+ line: string,
75
+ ): ComposeNetworkCandidate | "unrelated" | null {
76
+ const fields = line.split("\t");
77
+ if (fields.length !== 3) return null;
78
+ const [id, name, composeProject] = fields as [string, string, string];
79
+ if (!/^[a-f0-9]{12,64}$/.test(id)) return null;
80
+ if (!composeProject.startsWith("task-")) return "unrelated";
81
+ const taskId = composeProject.slice("task-".length);
82
+ if (!isSafeHostTaskId(taskId) || taskId !== taskId.toLowerCase()) return null;
83
+ if (name !== `${composeProject}_default`) return null;
84
+ return { id, name, composeProject, taskId };
85
+ }
86
+
87
+ function parseCandidates(stdout: string): ComposeNetworkCandidate[] | null {
88
+ const candidates: ComposeNetworkCandidate[] = [];
89
+ const ids = new Set<string>();
90
+ for (const line of stdout.split(/\r?\n/)) {
91
+ if (line.length === 0) continue;
92
+ const candidate = parseCandidate(line);
93
+ if (candidate === "unrelated") continue;
94
+ if (candidate === null || ids.has(candidate.id)) return null;
95
+ ids.add(candidate.id);
96
+ candidates.push(candidate);
97
+ }
98
+ return candidates;
99
+ }
100
+
101
+ function taskOwnsReclaimableNetwork(
102
+ task: HostTask | null,
103
+ candidate: ComposeNetworkCandidate,
104
+ ): boolean {
105
+ if (
106
+ task === null ||
107
+ task.taskId !== candidate.taskId ||
108
+ (task.environmentProvider !== null &&
109
+ task.environmentProvider !== "docker-compose") ||
110
+ (task.statusMirror !== "error" &&
111
+ task.statusMirror !== "stopped" &&
112
+ !(typeof task.statusMirror === "string" && isTerminal(task.statusMirror)))
113
+ ) {
114
+ return false;
115
+ }
116
+ if (task.composeProject === candidate.composeProject) return true;
117
+ if (!task.environmentLocator) return false;
118
+ try {
119
+ const locator = parseTaskEnvironmentLocator(task.environmentLocator);
120
+ if (
121
+ locator.schemaVersion !== 1 ||
122
+ locator.provider !== "docker-compose" ||
123
+ !locator.value ||
124
+ typeof locator.value !== "object" ||
125
+ Array.isArray(locator.value)
126
+ ) {
127
+ return false;
128
+ }
129
+ const value = locator.value as Record<string, unknown>;
130
+ return (
131
+ value.schemaVersion === 2 &&
132
+ value.taskId === candidate.taskId &&
133
+ value.composeProject === candidate.composeProject &&
134
+ value.containerName === `${candidate.composeProject}-app-1`
135
+ );
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
140
+
141
+ function inspectedEmptyNetwork(
142
+ stdout: string,
143
+ candidate: ComposeNetworkCandidate,
144
+ ): { fullId: string } | null {
145
+ let parsed: unknown;
146
+ try {
147
+ parsed = JSON.parse(stdout);
148
+ } catch {
149
+ return null;
150
+ }
151
+ if (!Array.isArray(parsed) || parsed.length !== 1) return null;
152
+ const network = parsed[0];
153
+ if (!network || typeof network !== "object" || Array.isArray(network)) {
154
+ return null;
155
+ }
156
+ const record = network as Record<string, unknown>;
157
+ const labels = record.Labels;
158
+ const containers = record.Containers;
159
+ if (
160
+ typeof record.Id !== "string" ||
161
+ !/^[a-f0-9]{64}$/.test(record.Id) ||
162
+ !record.Id.startsWith(candidate.id) ||
163
+ record.Name !== candidate.name ||
164
+ record.Driver !== "bridge" ||
165
+ record.Scope !== "local" ||
166
+ !labels ||
167
+ typeof labels !== "object" ||
168
+ Array.isArray(labels) ||
169
+ (labels as Record<string, unknown>)[COMPOSE_PROJECT_LABEL] !==
170
+ candidate.composeProject ||
171
+ !containers ||
172
+ typeof containers !== "object" ||
173
+ Array.isArray(containers) ||
174
+ Object.keys(containers).length !== 0
175
+ ) {
176
+ return null;
177
+ }
178
+ return { fullId: record.Id };
179
+ }
180
+
181
+ async function reclaimCandidate(
182
+ candidate: ComposeNetworkCandidate,
183
+ deps: ComposeNetworkRecoveryDeps,
184
+ ): Promise<boolean> {
185
+ if (!taskOwnsReclaimableNetwork(deps.task(candidate.taskId), candidate)) {
186
+ return false;
187
+ }
188
+ const inspected = await deps.docker(
189
+ ["network", "inspect", candidate.id],
190
+ {
191
+ timeoutMs: DOCKER_TIMEOUT_MS,
192
+ maxOutputBytes: MAX_NETWORK_INSPECT_BYTES,
193
+ },
194
+ );
195
+ if (inspected.status !== 0 || inspected.outputTruncated) return false;
196
+ const empty = inspectedEmptyNetwork(inspected.stdout, candidate);
197
+ if (empty === null) return false;
198
+
199
+ // `.Containers` is the network's own endpoint inventory. Independently ask
200
+ // Docker's all-container inventory as a second positive emptiness proof.
201
+ const attached = await deps.docker(
202
+ ["ps", "-aq", "--filter", `network=${empty.fullId}`],
203
+ {
204
+ timeoutMs: DOCKER_TIMEOUT_MS,
205
+ maxOutputBytes: MAX_NETWORK_INSPECT_BYTES,
206
+ },
207
+ );
208
+ if (
209
+ attached.status !== 0 ||
210
+ attached.outputTruncated ||
211
+ attached.stdout.trim().length !== 0
212
+ ) {
213
+ return false;
214
+ }
215
+
216
+ const removed = await deps.docker(["network", "rm", empty.fullId], {
217
+ timeoutMs: DOCKER_TIMEOUT_MS,
218
+ maxOutputBytes: MAX_NETWORK_INSPECT_BYTES,
219
+ });
220
+ return removed.status === 0 && !removed.outputTruncated;
221
+ }
222
+
223
+ /**
224
+ * Remove only positively-owned, empty task networks. This is best-effort host
225
+ * hygiene: an unavailable inventory never blocks a task that may still fit in
226
+ * Docker's pool, and it never authorizes deletion.
227
+ */
228
+ export async function reclaimUnusedUaiComposeNetworks(
229
+ lifecycle: ComposeNetworkLifecycle,
230
+ deps: ComposeNetworkRecoveryDeps = defaultDeps,
231
+ ): Promise<ComposeNetworkRecoveryResult> {
232
+ const listed = await deps.docker(
233
+ [
234
+ "network",
235
+ "ls",
236
+ "--filter",
237
+ `label=${COMPOSE_PROJECT_LABEL}`,
238
+ "--format",
239
+ NETWORK_LIST_FORMAT,
240
+ ],
241
+ {
242
+ timeoutMs: DOCKER_TIMEOUT_MS,
243
+ maxOutputBytes: MAX_NETWORK_LIST_BYTES,
244
+ },
245
+ );
246
+ if (listed.status !== 0 || listed.outputTruncated) {
247
+ return { inventory: "unavailable", candidates: 0, removed: 0, busy: 0 };
248
+ }
249
+ const candidates = parseCandidates(listed.stdout);
250
+ if (candidates === null) {
251
+ return { inventory: "invalid", candidates: 0, removed: 0, busy: 0 };
252
+ }
253
+
254
+ let removed = 0;
255
+ let busy = 0;
256
+ for (const candidate of candidates) {
257
+ // Avoid taking a live start's slot merely to rediscover in-slot that it
258
+ // is not reclaimable. This observation grants no authority; the complete
259
+ // ownership/status check is repeated after the lifecycle slot is held.
260
+ if (!taskOwnsReclaimableNetwork(deps.task(candidate.taskId), candidate)) {
261
+ continue;
262
+ }
263
+ // Never wait for the candidate's slot. A Resume marks its task `starting`
264
+ // only partway into its own slot, so the row can still read `error` while
265
+ // that slot is held, and waiting for it meant waiting out the whole
266
+ // start; a stuck operation on any stopped task would hold every later
267
+ // start here. A busy task's network is left for a later start's sweep.
268
+ const attempt = await lifecycle.tryRunTaskLifecycle(
269
+ candidate.taskId,
270
+ // The trusted row and Docker evidence are deliberately read only after
271
+ // entering the candidate task's lifecycle slot.
272
+ () => reclaimCandidate(candidate, deps),
273
+ );
274
+ if (!attempt.ran) busy += 1;
275
+ else if (attempt.value) removed += 1;
276
+ }
277
+ return {
278
+ inventory: "complete",
279
+ candidates: candidates.length,
280
+ removed,
281
+ busy,
282
+ };
283
+ }
@@ -0,0 +1,25 @@
1
+ /** Exact, session-scoped defense for diagnostics that reach host telemetry. */
2
+ const active = new Map<string, number>();
3
+
4
+ export function registerConnectionSecret(key: string): () => void {
5
+ if (!key) return () => {};
6
+ active.set(key, (active.get(key) ?? 0) + 1);
7
+ let released = false;
8
+ return () => {
9
+ if (released) return;
10
+ released = true;
11
+ const remaining = (active.get(key) ?? 1) - 1;
12
+ if (remaining) active.set(key, remaining);
13
+ else active.delete(key);
14
+ };
15
+ }
16
+
17
+ export function redactActiveConnectionSecrets(text: string): string {
18
+ // Empty replacement cannot itself contain another active secret.
19
+ let previous: string;
20
+ do {
21
+ previous = text;
22
+ for (const key of active.keys()) text = text.split(key).join("");
23
+ } while (text !== previous);
24
+ return text;
25
+ }