@moondeck/station 0.0.0-stage → 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 (325) 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 +131 -4
  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
@@ -0,0 +1,715 @@
1
+ /**
2
+ * Set the task creator's git identity inside the container (ADR-029) so commits
3
+ * are authored as the user who created the task, not the image's generic
4
+ * identity. Best-effort, mirrors the SSH setup: a failure logs but never aborts
5
+ * task-up. Name falls back to the email when no display name is known.
6
+ *
7
+ * ensureTaskSshIdentity re-asserts the SSH signing identity and its dependent
8
+ * signing config the same way GitHub tokens are re-asserted at channel ensure.
9
+ * Git transport is separate: connected users push over HTTPS through gh; SSH
10
+ * is only the explicit no-connection fallback.
11
+ */
12
+
13
+ import { createHash } from "node:crypto";
14
+ import { readFileSync } from "node:fs";
15
+ import { resolve } from "node:path";
16
+
17
+ import { eq } from "drizzle-orm";
18
+
19
+ import { getDb, schema } from "./db";
20
+ import { dockerCli } from "./docker-exec";
21
+ import {
22
+ removeTaskAppContainer,
23
+ taskAppContainerName,
24
+ taskAppContainerState,
25
+ taskContainerCli,
26
+ } from "./task-container-cli";
27
+ import { onOwnCredentials, whileOnOwnCredentials } from "./cloud-credential-host";
28
+ import { runtimeAuthorityDockerExecArgs } from "./runtime-authority";
29
+ import { RUNTIME_AUTHORITY_ENV } from "./runtime-authority";
30
+ import { assertSafeHostTaskId } from "./task-identity";
31
+ import type { TaskEnvironmentHandle } from "./task-environment/types";
32
+ import {
33
+ deleteKey,
34
+ getPublicKey,
35
+ removeTaskIdentity,
36
+ writeTaskIdentity,
37
+ } from "./ssh";
38
+
39
+ const EXEC_TIMEOUT_MS = 10_000;
40
+
41
+ /** Injectable docker-exec seam (mocked in tests). Async so the docker call
42
+ * doesn't block the host event loop (this runs on every channel ensure). */
43
+ export type DockerExec = (
44
+ args: string[],
45
+ ) => Promise<{ status: number | null; stdout?: string; stderr: string }>;
46
+
47
+ const defaultExec: DockerExec = async (args) => {
48
+ const res = await taskContainerCli(args, { timeoutMs: EXEC_TIMEOUT_MS });
49
+ return { status: res.status, stdout: res.stdout, stderr: res.stderr };
50
+ };
51
+
52
+ function managedTaskExecArgs(
53
+ user: "node" | "root",
54
+ container: string,
55
+ command: string[],
56
+ ): string[] {
57
+ return [
58
+ "exec",
59
+ "-u",
60
+ user,
61
+ ...runtimeAuthorityDockerExecArgs(),
62
+ container,
63
+ ...command,
64
+ ];
65
+ }
66
+
67
+ /** A task environment we can exec in (machine or sidecar): the ssh steps run
68
+ * there as `node` under the runtime authority, key material over stdin. */
69
+ type EnvironmentExec = Pick<TaskEnvironmentHandle, "exec">;
70
+
71
+ async function environmentStep(
72
+ environment: EnvironmentExec,
73
+ argv: [string, ...string[]],
74
+ stdin?: Uint8Array,
75
+ ): Promise<{ status: number | null; stdout: string; stderr: string }> {
76
+ const result = await environment.exec({
77
+ projectEnvProfile: "maintenance",
78
+ argv,
79
+ user: "node",
80
+ env: RUNTIME_AUTHORITY_ENV,
81
+ ...(stdin ? { stdin } : {}),
82
+ timeoutMs: EXEC_TIMEOUT_MS,
83
+ maxOutputBytes: 64 * 1024,
84
+ });
85
+ return {
86
+ status: result.exitCode,
87
+ stdout: Buffer.from(result.stdout).toString("utf8"),
88
+ stderr: Buffer.from(result.stderr).toString("utf8"),
89
+ };
90
+ }
91
+
92
+ export async function setupTaskGitIdentity(
93
+ taskId: string,
94
+ name: string | null,
95
+ email: string | null,
96
+ exec: DockerExec = defaultExec,
97
+ environment?: Pick<TaskEnvironmentHandle, "exec">,
98
+ ): Promise<boolean> {
99
+ if (!email) {
100
+ console.log(`[git] task ${taskId}: no owner email — leaving git identity`);
101
+ return false;
102
+ }
103
+ const displayName = name && name.trim() ? name.trim() : email;
104
+ const container = taskAppContainerName(taskId);
105
+ // Values are passed as argv (never interpolated into a shell string), so a
106
+ // name/email can't shell-inject.
107
+ const entries: ReadonlyArray<readonly [string, string]> = [
108
+ ["user.name", displayName],
109
+ ["user.email", email],
110
+ ];
111
+ for (const [key, value] of entries) {
112
+ const command = ["/usr/bin/git", "config", "--global", key, value] as [
113
+ string,
114
+ ...string[],
115
+ ];
116
+ const res = environment
117
+ ? await environment.exec({
118
+ projectEnvProfile: "maintenance",
119
+ argv: command,
120
+ user: "node",
121
+ env: RUNTIME_AUTHORITY_ENV,
122
+ timeoutMs: EXEC_TIMEOUT_MS,
123
+ maxOutputBytes: 64 * 1024,
124
+ }).then((result) => ({
125
+ status: result.exitCode,
126
+ stderr: Buffer.from(result.stderr).toString("utf8"),
127
+ }))
128
+ : await exec(managedTaskExecArgs("node", container, command));
129
+ if (res.status !== 0) {
130
+ console.warn(
131
+ `[git] task ${taskId}: git config ${key} failed: ${res.stderr.trim()}`,
132
+ );
133
+ return false;
134
+ }
135
+ }
136
+ console.log(`[git] task ${taskId}: git identity → ${displayName} <${email}>`);
137
+ return true;
138
+ }
139
+
140
+ // The key's dependent signing config, applied as one unit with the key itself.
141
+ // Deliberately no URL rewrite: doing that here would override a connected
142
+ // user's gh HTTPS credential and make SSH mandatory again.
143
+ //
144
+ // allowed_signers rides along so verification works, not just signing:
145
+ // without gpg.ssh.allowedSignersFile, `git log --format=%G?` prints N ("no
146
+ // signature") for commits that ARE signed and GitHub-verified — git reports
147
+ // the absence of a verification RESULT as the absence of a signature, and a
148
+ // crew read "all 25 commits unsigned" off perfectly signed work (live
149
+ // 2026-08-26). The wildcard principal is deliberate: the task key is the
150
+ // container's only signer, and the identity email may not be configured yet
151
+ // when the key lands.
152
+ export const SSH_SIGNING_CONFIG =
153
+ "/usr/bin/git config --global gpg.format ssh" +
154
+ " && /usr/bin/git config --global user.signingkey /home/node/.ssh/id_ed25519.pub" +
155
+ " && /usr/bin/git config --global commit.gpgsign true" +
156
+ " && /usr/bin/git config --global tag.gpgsign true" +
157
+ ' && /usr/bin/printf \'* %s\\n\' "$(/bin/cat /home/node/.ssh/id_ed25519.pub)" > /home/node/.ssh/allowed_signers' +
158
+ " && /usr/bin/git config --global gpg.ssh.allowedSignersFile /home/node/.ssh/allowed_signers";
159
+
160
+ export const SSH_TRANSPORT_CONFIG =
161
+ "/usr/bin/git config --global --unset-all 'url.https://github.com/.insteadOf' >/dev/null 2>&1 || true; " +
162
+ "/usr/bin/git config --global --replace-all 'url.git@github.com:.insteadOf' 'https://github.com/'; " +
163
+ '/usr/bin/git config --global core.sshCommand "ssh -o StrictHostKeyChecking=accept-new"';
164
+
165
+ /** Select SSH transport inside a disconnected task container. */
166
+ export async function configureTaskSshTransport(
167
+ taskId: string,
168
+ exec: DockerExec = defaultExec,
169
+ ): Promise<boolean> {
170
+ const res = await exec(managedTaskExecArgs("node", taskAppContainerName(taskId), [
171
+ "/bin/sh",
172
+ "-c",
173
+ SSH_TRANSPORT_CONFIG,
174
+ ]));
175
+ if (res.status !== 0) {
176
+ console.warn(
177
+ `[ssh] task ${taskId}: SSH transport fallback setup failed: ${res.stderr.trim()}`,
178
+ );
179
+ return false;
180
+ }
181
+ console.log(`[ssh] task ${taskId}: SSH Git transport fallback selected`);
182
+ return true;
183
+ }
184
+
185
+ /**
186
+ * Copy the SSH identity at `keyPath` into the task container and apply its
187
+ * dependent git config. Every failure is LOGGED with its step — the silent
188
+ * `|| true` chain this replaces let a missing key masquerade as a GitHub
189
+ * permissions problem for an entire afternoon.
190
+ */
191
+ export async function injectSshIdentity(
192
+ taskId: string,
193
+ keyPath: string,
194
+ exec: DockerExec = defaultExec,
195
+ environment?: EnvironmentExec,
196
+ ): Promise<boolean> {
197
+ if (environment) {
198
+ // ADR-121/133: no container and no `docker cp` on a machine — the key
199
+ // pair rides stdin into files node owns from the start, so there is no
200
+ // chown race and nothing for the machine's other users to read. Before
201
+ // this branch every task machine logged `mkdir ~/.ssh failed: spawn
202
+ // docker ENOENT` on session start and signed nothing (2026-09-10).
203
+ const privateKey = readFileSync(keyPath);
204
+ const publicKey = readFileSync(`${keyPath}.pub`);
205
+ const steps: ReadonlyArray<readonly [string, [string, ...string[]], Uint8Array?]> = [
206
+ ["mkdir ~/.ssh", ["/bin/sh", "-c", "/bin/mkdir -p /home/node/.ssh && /bin/chmod 700 /home/node/.ssh"]],
207
+ ["copy key", ["/bin/sh", "-c", "umask 077 && /bin/cat > /home/node/.ssh/id_ed25519"], privateKey],
208
+ ["copy pubkey", ["/bin/sh", "-c", "umask 022 && /bin/cat > /home/node/.ssh/id_ed25519.pub"], publicKey],
209
+ ["own + chmod", ["/bin/sh", "-c", "/bin/chmod 700 /home/node/.ssh && /bin/chmod 600 /home/node/.ssh/id_ed25519"]],
210
+ ["SSH signing config", ["/bin/sh", "-c", SSH_SIGNING_CONFIG]],
211
+ ];
212
+ for (const [label, argv, stdin] of steps) {
213
+ const res = await environmentStep(environment, argv, stdin);
214
+ if (res.status !== 0) {
215
+ console.warn(
216
+ `[ssh] task ${taskId}: ${label} failed: ${res.stderr.trim() || `exit ${res.status}`}`,
217
+ );
218
+ return false;
219
+ }
220
+ }
221
+ console.log(`[ssh] task ${taskId}: signing identity + git signing config asserted`);
222
+ return true;
223
+ }
224
+ const container = taskAppContainerName(taskId);
225
+ const steps: ReadonlyArray<readonly [string, string[]]> = [
226
+ [
227
+ "mkdir ~/.ssh",
228
+ managedTaskExecArgs("root", container, [
229
+ "/bin/mkdir",
230
+ "-p",
231
+ "/home/node/.ssh",
232
+ ]),
233
+ ],
234
+ ["copy key", ["cp", keyPath, `${container}:/home/node/.ssh/id_ed25519`]],
235
+ ["copy pubkey", ["cp", `${keyPath}.pub`, `${container}:/home/node/.ssh/id_ed25519.pub`]],
236
+ [
237
+ "own + chmod",
238
+ managedTaskExecArgs("root", container, [
239
+ "/bin/sh", "-c",
240
+ "/bin/chown -R node:node /home/node/.ssh && /bin/chmod 700 /home/node/.ssh && /bin/chmod 600 /home/node/.ssh/id_ed25519",
241
+ ]),
242
+ ],
243
+ [
244
+ "SSH signing config",
245
+ managedTaskExecArgs("node", container, [
246
+ "/bin/sh",
247
+ "-c",
248
+ SSH_SIGNING_CONFIG,
249
+ ]),
250
+ ],
251
+ ];
252
+ for (const [label, args] of steps) {
253
+ const res = await exec(args);
254
+ if (res.status !== 0) {
255
+ console.warn(
256
+ `[ssh] task ${taskId}: ${label} failed: ${res.stderr.trim() || `exit ${res.status}`}`,
257
+ );
258
+ return false;
259
+ }
260
+ }
261
+ console.log(`[ssh] task ${taskId}: signing identity + git signing config asserted`);
262
+ return true;
263
+ }
264
+
265
+ // One in-flight ensure per task. Channel-ensure fires on every reconnect/
266
+ // nudge (seconds apart under load), and `docker cp` is unlink+create — two
267
+ // concurrent injects interleave so one's `cp id_ed25519` lands AFTER the
268
+ // other's chown, leaving the private key owned by the macOS uid and
269
+ // UNREADABLE by node inside the container ("Load key: Permission denied",
270
+ // live 2026-07-21). Serialize, and skip entirely when the key is already
271
+ // good, so the steady state does zero docker work.
272
+ interface SshEnsureInFlight {
273
+ fingerprint: string;
274
+ promise: Promise<boolean>;
275
+ }
276
+
277
+ const sshEnsureInFlight = new Map<string, SshEnsureInFlight>();
278
+ const sshDeleteInFlight = new Map<string, Promise<void>>();
279
+ /** Temporary assignment fence: cleanup seals a task, joins the older ensure,
280
+ * and deletes the copied key. A later safe assignment may explicitly reopen
281
+ * the task id. */
282
+ const sealedTaskSshEnsures = new Set<string>();
283
+ /** Durable for this host process: an orphan id is immutable/non-reused, and
284
+ * no later channel/recovery continuation may materialize its private key. */
285
+ const retiredTaskSshEnsures = new Set<string>();
286
+
287
+ export async function sealTaskSshIdentityEnsures(
288
+ taskId: string,
289
+ ): Promise<void> {
290
+ assertSafeHostTaskId(taskId);
291
+ sealedTaskSshEnsures.add(taskId);
292
+ for (;;) {
293
+ const current = sshEnsureInFlight.get(taskId);
294
+ if (!current) return;
295
+ await current.promise.catch(() => false);
296
+ if (sshEnsureInFlight.get(taskId) === current) {
297
+ sshEnsureInFlight.delete(taskId);
298
+ }
299
+ }
300
+ }
301
+
302
+ export function openTaskSshIdentityEnsures(taskId: string): void {
303
+ assertSafeHostTaskId(taskId);
304
+ if (retiredTaskSshEnsures.has(taskId)) {
305
+ throw new Error(`task ${taskId} has been permanently retired`);
306
+ }
307
+ if (sshEnsureInFlight.has(taskId)) {
308
+ throw new Error(`task ${taskId} still has an SSH identity writer`);
309
+ }
310
+ sealedTaskSshEnsures.delete(taskId);
311
+ }
312
+
313
+ /** Fence future task SSH ensures and join the exact older materialization, if
314
+ * any. This is stronger than deleting the directory once: channel startup and
315
+ * runtime recovery intentionally launch SSH reconciliation in the background,
316
+ * and an older health probe can otherwise resume by writing the key after GC
317
+ * has already checked absence. Never rejects; strict removal is the final
318
+ * filesystem authority and will surface any cleanup failure to the cloud. */
319
+ export async function retireTaskSshIdentityEnsures(
320
+ taskId: string,
321
+ ): Promise<void> {
322
+ assertSafeHostTaskId(taskId);
323
+ retiredTaskSshEnsures.add(taskId);
324
+ await sealTaskSshIdentityEnsures(taskId);
325
+ }
326
+
327
+ function sshPublicKeyFingerprint(publicKey: string): string | null {
328
+ const [, encoded] = publicKey.trim().split(/\s+/, 3);
329
+ if (!encoded) return null;
330
+ try {
331
+ const material = Buffer.from(encoded, "base64");
332
+ if (material.length === 0) return null;
333
+ return createHash("sha256")
334
+ .update(material)
335
+ .digest("base64")
336
+ .replace(/=+$/, "");
337
+ } catch {
338
+ return null;
339
+ }
340
+ }
341
+
342
+ /** True only when the readable container key is the current DB-authoritative
343
+ * key for this owner. A copied key is not authority after ssh.key.delete. */
344
+ async function sshKeyHealthy(
345
+ taskId: string,
346
+ expectedPublicKey: string,
347
+ exec: DockerExec,
348
+ environment?: EnvironmentExec,
349
+ ): Promise<boolean> {
350
+ const probe: [string, ...string[]] = [
351
+ "/bin/sh",
352
+ "-c",
353
+ "test -r /home/node/.ssh/id_ed25519 && /bin/cat /home/node/.ssh/id_ed25519.pub",
354
+ ];
355
+ const res = environment
356
+ ? await environmentStep(environment, probe)
357
+ : await exec(managedTaskExecArgs("node", taskAppContainerName(taskId), probe));
358
+ const expectedFingerprint = sshPublicKeyFingerprint(expectedPublicKey);
359
+ const actualFingerprint = sshPublicKeyFingerprint(res.stdout ?? "");
360
+ return (
361
+ res.status === 0 &&
362
+ expectedFingerprint !== null &&
363
+ actualFingerprint === expectedFingerprint
364
+ );
365
+ }
366
+
367
+ interface EnsureTaskSshIdentityDeps {
368
+ publicKeyForUser?: (userId: string) => string | null;
369
+ writeIdentity?: typeof writeTaskIdentity;
370
+ /** The task's environment when it is a machine or sidecar (no container to
371
+ * exec into or `docker cp` to). Null/undefined = the container path. */
372
+ environment?: () => EnvironmentExec | null | undefined;
373
+ }
374
+
375
+ /**
376
+ * Ensure the task container has a node-readable SSH signing identity + git
377
+ * signing config. Idempotent and race-free: skips the copy when the key is already
378
+ * healthy (the common case), and never runs two injects for one task at
379
+ * once. Only (re)materializes + copies the key when it is missing or
380
+ * unreadable. Safe to call on every channel ensure.
381
+ */
382
+ export async function ensureTaskSshIdentity(
383
+ taskId: string,
384
+ ownerUserId: string | null | undefined,
385
+ exec: DockerExec = defaultExec,
386
+ deps: EnsureTaskSshIdentityDeps = {},
387
+ ): Promise<boolean> {
388
+ // ADR-154: the runner's own SSH key reaches a task only while the runner
389
+ // is on its own credentials; leaving them waits for a copy under way.
390
+ return (
391
+ (await whileOnOwnCredentials(() => ensureOwnTaskSshIdentity(taskId, ownerUserId, exec, deps))) ?? false
392
+ );
393
+ }
394
+
395
+ async function ensureOwnTaskSshIdentity(
396
+ taskId: string,
397
+ ownerUserId: string | null | undefined,
398
+ exec: DockerExec,
399
+ deps: EnsureTaskSshIdentityDeps,
400
+ ): Promise<boolean> {
401
+ if (
402
+ retiredTaskSshEnsures.has(taskId) ||
403
+ sealedTaskSshEnsures.has(taskId)
404
+ ) {
405
+ return false;
406
+ }
407
+ if (ownerUserId) {
408
+ const deleting = sshDeleteInFlight.get(ownerUserId);
409
+ if (deleting) {
410
+ await deleting.catch(() => {});
411
+ return ensureTaskSshIdentity(taskId, ownerUserId, exec, deps);
412
+ }
413
+ }
414
+ const publicKeyForUser = deps.publicKeyForUser ?? getPublicKey;
415
+ const expectedPublicKey = ownerUserId
416
+ ? publicKeyForUser(ownerUserId)
417
+ : null;
418
+ // Consult authority before joining an older in-flight ensure. A deleted key
419
+ // must turn fallback off immediately even if its prior copy is still readable.
420
+ if (!ownerUserId || !expectedPublicKey) return false;
421
+ const expectedFingerprint = sshPublicKeyFingerprint(expectedPublicKey);
422
+ if (!expectedFingerprint) return false;
423
+ const existing = sshEnsureInFlight.get(taskId);
424
+ if (existing) {
425
+ const result = await existing.promise;
426
+ if (existing.fingerprint !== expectedFingerprint) {
427
+ // An older key finished copying while authority rotated. Replace its map
428
+ // entry and run once more for the current key; never bless A as B.
429
+ if (sshEnsureInFlight.get(taskId) === existing) {
430
+ sshEnsureInFlight.delete(taskId);
431
+ }
432
+ return ensureTaskSshIdentity(taskId, ownerUserId, exec, deps);
433
+ }
434
+ return (
435
+ result &&
436
+ sshPublicKeyFingerprint(publicKeyForUser(ownerUserId) ?? "") ===
437
+ sshPublicKeyFingerprint(expectedPublicKey)
438
+ );
439
+ }
440
+ const pending = ensureTaskSshIdentityOnce(
441
+ taskId,
442
+ ownerUserId,
443
+ expectedPublicKey,
444
+ exec,
445
+ deps.writeIdentity ?? writeTaskIdentity,
446
+ deps.environment?.() ?? undefined,
447
+ );
448
+ const entry = { fingerprint: expectedFingerprint, promise: pending };
449
+ sshEnsureInFlight.set(taskId, entry);
450
+ try {
451
+ const result = await pending;
452
+ return (
453
+ result &&
454
+ sshPublicKeyFingerprint(publicKeyForUser(ownerUserId) ?? "") ===
455
+ sshPublicKeyFingerprint(expectedPublicKey)
456
+ );
457
+ } finally {
458
+ if (sshEnsureInFlight.get(taskId) === entry) {
459
+ sshEnsureInFlight.delete(taskId);
460
+ }
461
+ }
462
+ }
463
+
464
+ async function ensureTaskSshIdentityOnce(
465
+ taskId: string,
466
+ ownerUserId: string,
467
+ expectedPublicKey: string,
468
+ exec: DockerExec,
469
+ writeIdentity: typeof writeTaskIdentity,
470
+ environment?: EnvironmentExec,
471
+ ): Promise<boolean> {
472
+ // Fast path: the DB-authoritative key is already good → re-assert only the
473
+ // cheap signing config and skip docker cp churn.
474
+ if (await sshKeyHealthy(taskId, expectedPublicKey, exec, environment)) {
475
+ // ADR-154: after the probe too, nothing is written once the runner has
476
+ // begun leaving its own credentials.
477
+ if (!onOwnCredentials()) return false;
478
+ const config: [string, ...string[]] = ["/bin/sh", "-c", SSH_SIGNING_CONFIG];
479
+ const configured = environment
480
+ ? await environmentStep(environment, config)
481
+ : await exec(managedTaskExecArgs("node", taskAppContainerName(taskId), config));
482
+ return configured.status === 0;
483
+ }
484
+
485
+ // ADR-154: the last hop, fenced: no key is materialized or copied once the
486
+ // runner has begun leaving its own credentials.
487
+ if (!onOwnCredentials()) return false;
488
+ const dir = writeIdentity(taskId, ownerUserId);
489
+ if (!dir) {
490
+ console.log(`[ssh] task ${taskId}: no SSH signing identity for its owner`);
491
+ return false;
492
+ }
493
+ const keyPath = resolve(dir, "id_ed25519");
494
+ try {
495
+ return await injectSshIdentity(taskId, keyPath, exec, environment);
496
+ } finally {
497
+ // Host hygiene (same as task-up): the materialized private key never
498
+ // outlives the injection.
499
+ removeTaskIdentity(taskId);
500
+ }
501
+ }
502
+
503
+ const REMOVE_MANAGED_SSH_IDENTITY = [
504
+ "set -eu",
505
+ "/usr/bin/rm -f -- /home/node/.ssh/id_ed25519 /home/node/.ssh/id_ed25519.pub /home/node/.ssh/allowed_signers",
506
+ "/usr/bin/git config --global --unset-all gpg.ssh.allowedSignersFile >/dev/null 2>&1 || true",
507
+ "/usr/bin/git config --global --unset-all user.signingkey >/dev/null 2>&1 || true",
508
+ "/usr/bin/git config --global --unset-all commit.gpgsign >/dev/null 2>&1 || true",
509
+ "/usr/bin/git config --global --unset-all tag.gpgsign >/dev/null 2>&1 || true",
510
+ "/usr/bin/git config --global --unset-all gpg.format >/dev/null 2>&1 || true",
511
+ "/usr/bin/git config --global --unset-all 'url.git@github.com:.insteadOf' >/dev/null 2>&1 || true",
512
+ "/usr/bin/git config --global --unset-all core.sshCommand >/dev/null 2>&1 || true",
513
+ "/usr/bin/git config --global --replace-all 'url.https://github.com/.insteadOf' 'git@github.com:'",
514
+ "/usr/bin/git config --global --add 'url.https://github.com/.insteadOf' 'ssh://git@github.com/'",
515
+ "test ! -e /home/node/.ssh/id_ed25519",
516
+ "test ! -e /home/node/.ssh/id_ed25519.pub",
517
+ "test -z \"$(/usr/bin/git config --global --get-all user.signingkey 2>/dev/null || true)\"",
518
+ ].join("; ");
519
+
520
+ const SANITIZED_SSH_EXEC_ENV = [
521
+ "-e",
522
+ "HOME=/home/node",
523
+ "-e",
524
+ "PATH=/usr/bin:/bin",
525
+ "-e",
526
+ "XDG_CONFIG_HOME=",
527
+ "-e",
528
+ "LD_PRELOAD=",
529
+ "-e",
530
+ "LD_LIBRARY_PATH=",
531
+ "-e",
532
+ "GIT_CONFIG=",
533
+ "-e",
534
+ "GIT_CONFIG_GLOBAL=",
535
+ "-e",
536
+ "GIT_CONFIG_SYSTEM=",
537
+ "-e",
538
+ "GIT_CONFIG_NOSYSTEM=",
539
+ "-e",
540
+ "GIT_CONFIG_COUNT=0",
541
+ "-e",
542
+ "GIT_EXEC_PATH=",
543
+ "-e",
544
+ "GIT_SSH=",
545
+ "-e",
546
+ "GIT_SSH_COMMAND=",
547
+ ] as const;
548
+
549
+ async function checkedSshDocker(
550
+ exec: DockerExec,
551
+ args: string[],
552
+ label: string,
553
+ ): Promise<{ status: number | null; stdout?: string; stderr: string }> {
554
+ const result = await exec(args);
555
+ if (result.status !== 0) {
556
+ throw new Error(
557
+ `${label} failed: ${result.stderr.trim() || `exit ${result.status}`}`,
558
+ );
559
+ }
560
+ return result;
561
+ }
562
+
563
+ /** Remove a copied SSH key and every config entry that depends on it. A
564
+ * non-running container is disposable and is removed without executing its
565
+ * credential-bearing filesystem; Compose recreates it on Resume. */
566
+ export async function removeTaskSshIdentityFromContainer(
567
+ taskId: string,
568
+ exec: DockerExec = defaultExec,
569
+ ): Promise<void> {
570
+ const container = taskAppContainerName(taskId);
571
+ if (exec === defaultExec) {
572
+ // Backend-aware state/removal: the Apple CLI has no `ps`/`rm` verbs
573
+ // (review 2026-08-18 round 3, verified live: "Plugin 'container-ps' not
574
+ // found"), so the shared helpers own the per-backend translation and
575
+ // the whole-line absence proofs. The injected-exec seam below keeps
576
+ // scripting docker-shaped answers for tests.
577
+ const state = await taskAppContainerState(container);
578
+ if (state.kind === "absent") return;
579
+ if (state.kind === "unknown") {
580
+ throw new Error(`inspect ${container} failed: ${state.detail}`);
581
+ }
582
+ if (state.kind === "stopped") {
583
+ // Non-running credential-bearing containers are removed without
584
+ // executing their filesystem; removal is absence-verified inside.
585
+ await removeTaskAppContainer(container);
586
+ return;
587
+ }
588
+ } else {
589
+ const inspectArgs = [
590
+ "ps",
591
+ "--all",
592
+ "--filter",
593
+ `name=^/${container}$`,
594
+ "--format",
595
+ "{{.State}}",
596
+ ];
597
+ const listed = await checkedSshDocker(
598
+ exec,
599
+ inspectArgs,
600
+ `inspect ${container}`,
601
+ );
602
+ const states = (listed.stdout ?? "")
603
+ .split(/\r?\n/)
604
+ .map((state) => state.trim())
605
+ .filter(Boolean);
606
+ if (states.length === 0) return;
607
+ if (states.length !== 1) {
608
+ throw new Error(
609
+ `inspect ${container} returned ${states.length} containers`,
610
+ );
611
+ }
612
+ if (states[0] !== "running") {
613
+ await checkedSshDocker(
614
+ exec,
615
+ ["rm", "--force", container],
616
+ `remove SSH credential-bearing ${container}`,
617
+ );
618
+ const remaining = await checkedSshDocker(
619
+ exec,
620
+ inspectArgs,
621
+ `verify removal of ${container}`,
622
+ );
623
+ if ((remaining.stdout ?? "").trim()) {
624
+ throw new Error(`remove ${container} was not verified`);
625
+ }
626
+ return;
627
+ }
628
+ }
629
+
630
+ await checkedSshDocker(
631
+ exec,
632
+ [
633
+ "exec",
634
+ "-u",
635
+ "node",
636
+ ...SANITIZED_SSH_EXEC_ENV,
637
+ ...runtimeAuthorityDockerExecArgs(),
638
+ container,
639
+ "/usr/bin/env",
640
+ "-i",
641
+ "HOME=/home/node",
642
+ "PATH=/usr/bin:/bin",
643
+ "/bin/sh",
644
+ "-c",
645
+ REMOVE_MANAGED_SSH_IDENTITY,
646
+ ],
647
+ `remove managed SSH identity from ${container}`,
648
+ );
649
+ }
650
+
651
+ function sshCredentialTaskIdsForUser(userId: string): string[] {
652
+ return getDb()
653
+ .select({
654
+ taskId: schema.hostTasks.taskId,
655
+ endedAt: schema.hostTasks.endedAt,
656
+ composeProject: schema.hostTasks.composeProject,
657
+ statusMirror: schema.hostTasks.statusMirror,
658
+ })
659
+ .from(schema.hostTasks)
660
+ .where(eq(schema.hostTasks.ownerUserId, userId))
661
+ .all()
662
+ .filter(
663
+ (task) =>
664
+ task.endedAt == null ||
665
+ task.composeProject != null ||
666
+ task.statusMirror === "error",
667
+ )
668
+ .map((task) => task.taskId);
669
+ }
670
+
671
+ export interface DeleteUserSshIdentityDeps {
672
+ /** Synchronous fail-closed check that must pass before authority changes. */
673
+ preflight?: () => void;
674
+ deleteStored?: (userId: string) => void;
675
+ taskIds?: (userId: string) => string[];
676
+ removeFromContainer?: (taskId: string) => Promise<void>;
677
+ }
678
+
679
+ /** Serialize deletion against new ensures, drain every older ensure, scrub all
680
+ * copied material, and only then delete authority. A failed scrub leaves the
681
+ * key authoritative and retryable instead of creating an unrecoverable split
682
+ * where host authority is gone but a valid private key remains in a container. */
683
+ export async function deleteUserSshIdentity(
684
+ userId: string,
685
+ deps: DeleteUserSshIdentityDeps = {},
686
+ ): Promise<void> {
687
+ const existing = sshDeleteInFlight.get(userId);
688
+ if (existing) return existing;
689
+ const run = async (): Promise<void> => {
690
+ deps.preflight?.();
691
+ const taskIds = (deps.taskIds ?? sshCredentialTaskIdsForUser)(userId);
692
+ const removeFromContainer =
693
+ deps.removeFromContainer ?? removeTaskSshIdentityFromContainer;
694
+ const settled = await Promise.allSettled(
695
+ taskIds.map(async (taskId) => {
696
+ await sshEnsureInFlight.get(taskId)?.promise.catch(() => {});
697
+ removeTaskIdentity(taskId);
698
+ await removeFromContainer(taskId);
699
+ }),
700
+ );
701
+ const failed = settled.find(
702
+ (result): result is PromiseRejectedResult => result.status === "rejected",
703
+ );
704
+ if (failed) throw failed.reason;
705
+ (deps.deleteStored ?? deleteKey)(userId);
706
+ };
707
+ let tracked!: Promise<void>;
708
+ tracked = run().finally(() => {
709
+ if (sshDeleteInFlight.get(userId) === tracked) {
710
+ sshDeleteInFlight.delete(userId);
711
+ }
712
+ });
713
+ sshDeleteInFlight.set(userId, tracked);
714
+ return tracked;
715
+ }