@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,2439 @@
1
+ /**
2
+ * Host-side GitHub token manager (ADR-027).
3
+ *
4
+ * Owns the encrypted refresh token (`host_github_tokens`), the per-task access
5
+ * token mint + container injection, and the refresh schedule. Access tokens are
6
+ * never stored — they go straight into the container's gh config. The refresh
7
+ * exchange is a host→cloud HTTP POST to /api/github/oauth/exchange.
8
+ */
9
+
10
+ import { and, eq } from "drizzle-orm";
11
+
12
+ import { configuredCredentialStore, queueStoreWrite, userParameterName } from "./credential-store";
13
+ import { getDb, schema } from "./db";
14
+ import { githubApiOrigin } from "./github-origin";
15
+ import { sealAesGcm, openAesGcm } from "./secrets";
16
+ import { dockerCli } from "./docker-exec";
17
+ import {
18
+ removeTaskAppContainer,
19
+ taskAppContainerName,
20
+ taskAppContainerState,
21
+ taskContainerCli,
22
+ } from "./task-container-cli";
23
+ import {
24
+ RUNTIME_AUTHORITY_ENV,
25
+ runtimeAuthorityDockerExecArgs,
26
+ } from "./runtime-authority";
27
+ import {
28
+ backgroundContainerWorkAllowed,
29
+ ContainerRuntimeUnavailableError,
30
+ requireContainerRuntimeOperational,
31
+ } from "./runtime-guard";
32
+ import {
33
+ configureTaskSshTransport,
34
+ ensureTaskSshIdentity,
35
+ } from "./git-identity";
36
+ import type { TaskEnvironmentHandle } from "./task-environment/types";
37
+ import { onOwnCredentials, whileOnOwnCredentials } from "./cloud-credential-host";
38
+ import { sidecarMode } from "./sidecar";
39
+ import { signInBoxMode } from "./signin-box";
40
+ import {
41
+ MAX_GITHUB_INSTALLATIONS,
42
+ MAX_GITHUB_REPOSITORIES_PER_INSTALLATION,
43
+ type CloudToHost,
44
+ type GitHubRepositoryAccessErrorCode,
45
+ } from "../src/protocol";
46
+
47
+ const REFRESH_LEAD_MS = 5 * 60 * 1000; // refresh 5 min before expiry
48
+ const EXEC_TIMEOUT_MS = 10_000;
49
+ // Cap the cloud token-exchange round-trip. Generous on purpose: aborting an
50
+ // exchange that GitHub already completed burns the single-use refresh token
51
+ // (we never see the rotated replacement), so a slow success beats a fast
52
+ // ambiguous abort.
53
+ const EXCHANGE_TIMEOUT_MS = 30_000;
54
+ const MANAGED_GH_CONFIG_DIR = "/home/node/.config/gh";
55
+ const SAFE_CONTAINER_PATH = "/usr/bin:/bin";
56
+
57
+ // `docker exec` inherits the task container's project environment. Override
58
+ // loader/config variables before the first executable starts, then use env -i
59
+ // so arbitrary GIT_CONFIG_KEY_* and similar project keys cannot influence the
60
+ // managed gh/git mutation. Commands themselves use absolute paths.
61
+ const SANITIZED_DOCKER_EXEC_ENV = [
62
+ "-e",
63
+ "HOME=/home/node",
64
+ "-e",
65
+ `GH_CONFIG_DIR=${MANAGED_GH_CONFIG_DIR}`,
66
+ "-e",
67
+ `PATH=${SAFE_CONTAINER_PATH}`,
68
+ "-e",
69
+ "XDG_CONFIG_HOME=",
70
+ "-e",
71
+ "LD_PRELOAD=",
72
+ "-e",
73
+ "LD_LIBRARY_PATH=",
74
+ "-e",
75
+ "DYLD_INSERT_LIBRARIES=",
76
+ "-e",
77
+ "DYLD_LIBRARY_PATH=",
78
+ "-e",
79
+ "GIT_CONFIG=",
80
+ "-e",
81
+ "GIT_CONFIG_GLOBAL=",
82
+ "-e",
83
+ "GIT_CONFIG_SYSTEM=",
84
+ "-e",
85
+ "GIT_CONFIG_NOSYSTEM=",
86
+ "-e",
87
+ "GIT_CONFIG_COUNT=0",
88
+ "-e",
89
+ "GIT_EXEC_PATH=",
90
+ "-e",
91
+ "GIT_SSH=",
92
+ "-e",
93
+ "GIT_SSH_COMMAND=",
94
+ "-e",
95
+ "GIT_ASKPASS=",
96
+ "-e",
97
+ "SSH_ASKPASS=",
98
+ ] as const;
99
+
100
+ export const CLEAN_CONTAINER_ENV = [
101
+ "/usr/bin/env",
102
+ "-i",
103
+ "HOME=/home/node",
104
+ `GH_CONFIG_DIR=${MANAGED_GH_CONFIG_DIR}`,
105
+ `PATH=${SAFE_CONTAINER_PATH}`,
106
+ ] as const;
107
+
108
+ /** Provider-exec form of {@link SANITIZED_DOCKER_EXEC_ENV}: same overrides,
109
+ * carried as a bounded env record instead of docker `-e` flags. */
110
+ export const SANITIZED_CONTAINER_ENV: Readonly<Record<string, string>> =
111
+ Object.freeze({
112
+ HOME: "/home/node",
113
+ GH_CONFIG_DIR: MANAGED_GH_CONFIG_DIR,
114
+ PATH: SAFE_CONTAINER_PATH,
115
+ XDG_CONFIG_HOME: "",
116
+ LD_PRELOAD: "",
117
+ LD_LIBRARY_PATH: "",
118
+ DYLD_INSERT_LIBRARIES: "",
119
+ DYLD_LIBRARY_PATH: "",
120
+ GIT_CONFIG: "",
121
+ GIT_CONFIG_GLOBAL: "",
122
+ GIT_CONFIG_SYSTEM: "",
123
+ GIT_CONFIG_NOSYSTEM: "",
124
+ GIT_CONFIG_COUNT: "0",
125
+ GIT_EXEC_PATH: "",
126
+ GIT_SSH: "",
127
+ GIT_SSH_COMMAND: "",
128
+ GIT_ASKPASS: "",
129
+ SSH_ASKPASS: "",
130
+ });
131
+
132
+ function managedContainerExecArgs(
133
+ container: string,
134
+ command: string[],
135
+ stdin = false,
136
+ ): string[] {
137
+ return [
138
+ "exec",
139
+ ...(stdin ? ["-i"] : []),
140
+ "-u",
141
+ "node",
142
+ ...SANITIZED_DOCKER_EXEC_ENV,
143
+ ...runtimeAuthorityDockerExecArgs(),
144
+ container,
145
+ ...CLEAN_CONTAINER_ENV,
146
+ ...command,
147
+ ];
148
+ }
149
+
150
+ type GhConnectSet = Extract<CloudToHost, { kind: "gh.connect.set" }>;
151
+
152
+ // Every local grant replacement/removal advances the user's generation. Slow
153
+ // token exchanges and container mutations capture the generation they began
154
+ // under and must not publish their result after a newer connect/disconnect.
155
+ const githubAuthGenerations = new Map<string, number>();
156
+ const githubConnectionTransitionQueues = new Map<string, Promise<void>>();
157
+
158
+ function githubAuthGeneration(userId: string): number {
159
+ return githubAuthGenerations.get(userId) ?? 0;
160
+ }
161
+
162
+ function advanceGithubAuthGeneration(userId: string): number {
163
+ const next = githubAuthGeneration(userId) + 1;
164
+ githubAuthGenerations.set(userId, next);
165
+ accessCache.delete(userId);
166
+ // The request itself cannot be cancelled safely (the refresh token may have
167
+ // rotated at GitHub), but a new grant must never join that stale promise.
168
+ inflightExchanges.delete(userId);
169
+ return next;
170
+ }
171
+
172
+ /** Serialize connect/set/clear for one user. Without this outer fence, a clear
173
+ * that lands while set is awaiting task-up invalidation can be overwritten by
174
+ * the older set storing its token afterward. */
175
+ export function runGithubConnectionTransition<T>(
176
+ userId: string,
177
+ transition: () => Promise<T>,
178
+ admit?: () => void,
179
+ ): Promise<T> {
180
+ const previous =
181
+ githubConnectionTransitionQueues.get(userId) ?? Promise.resolve();
182
+ let result!: Promise<T>;
183
+ let tail!: Promise<void>;
184
+ result = previous
185
+ .catch(() => {})
186
+ .then(() => {
187
+ admit?.();
188
+ return transition();
189
+ })
190
+ .finally(() => {
191
+ if (githubConnectionTransitionQueues.get(userId) === tail) {
192
+ githubConnectionTransitionQueues.delete(userId);
193
+ }
194
+ });
195
+ tail = result.then(
196
+ () => {},
197
+ () => {},
198
+ );
199
+ githubConnectionTransitionQueues.set(userId, tail);
200
+ return result;
201
+ }
202
+
203
+ /**
204
+ * Persist a monotonic credential mutation fence before changing the token row.
205
+ * The generation survives disconnect because it lives in a separate table, so
206
+ * a delayed older connect cannot recreate a credential after a newer clear.
207
+ */
208
+ export type GitHubCredentialMutation = "set" | "clear";
209
+
210
+ export interface GitHubCredentialGenerationClaim {
211
+ generation: number;
212
+ replay: boolean;
213
+ }
214
+
215
+ export function claimGithubCredentialGeneration(
216
+ userId: string,
217
+ requestedGeneration: number | undefined,
218
+ mutation: GitHubCredentialMutation,
219
+ ): GitHubCredentialGenerationClaim | null {
220
+ if (
221
+ requestedGeneration !== undefined &&
222
+ (!Number.isSafeInteger(requestedGeneration) || requestedGeneration < 0)
223
+ ) {
224
+ return null;
225
+ }
226
+ return getDb().transaction((tx) => {
227
+ const current = tx
228
+ .select({
229
+ generation: schema.githubCredentialGenerations.generation,
230
+ mutation: schema.githubCredentialGenerations.mutation,
231
+ })
232
+ .from(schema.githubCredentialGenerations)
233
+ .where(eq(schema.githubCredentialGenerations.userId, userId))
234
+ .get();
235
+ // Generationless frames belong permanently to the legacy generation-zero
236
+ // namespace. Never derive a newer value from local state: a delayed old
237
+ // cloud frame must not outrank an explicit generation from a newer cloud.
238
+ // Once a clear has closed generation zero, the normal clear->set replay
239
+ // rule below deliberately requires a cloud upgrade before reconnecting.
240
+ const generation = requestedGeneration ?? 0;
241
+ if (!Number.isSafeInteger(generation) || generation < 0) return null;
242
+ if (current && current.generation > generation) return null;
243
+ if (current && current.generation === generation) {
244
+ if (current.mutation === mutation) {
245
+ return { generation, replay: true };
246
+ }
247
+ // Clearing the exact generation that was just stored is the safe
248
+ // compensation path when the cloud mirror is superseded between host
249
+ // storage and DB attachment. The reverse transition would resurrect a
250
+ // cleared credential and is always rejected.
251
+ if (current.mutation === "set" && mutation === "clear") {
252
+ tx.update(schema.githubCredentialGenerations)
253
+ .set({ mutation: "clear", updatedAt: Date.now() })
254
+ .where(eq(schema.githubCredentialGenerations.userId, userId))
255
+ .run();
256
+ return { generation, replay: false };
257
+ }
258
+ return null;
259
+ }
260
+ const values = { userId, generation, mutation, updatedAt: Date.now() };
261
+ tx.insert(schema.githubCredentialGenerations)
262
+ .values(values)
263
+ .onConflictDoUpdate({
264
+ target: schema.githubCredentialGenerations.userId,
265
+ set: { generation, mutation, updatedAt: values.updatedAt },
266
+ })
267
+ .run();
268
+ return { generation, replay: false };
269
+ });
270
+ }
271
+
272
+ export function githubCredentialGenerationMatches(
273
+ userId: string,
274
+ generation: number,
275
+ ): boolean {
276
+ if (!Number.isSafeInteger(generation) || generation < 0) return false;
277
+ return getDb().transaction((tx) => {
278
+ const fence = tx
279
+ .select({
280
+ generation: schema.githubCredentialGenerations.generation,
281
+ mutation: schema.githubCredentialGenerations.mutation,
282
+ })
283
+ .from(schema.githubCredentialGenerations)
284
+ .where(eq(schema.githubCredentialGenerations.userId, userId))
285
+ .get();
286
+ const token = tx
287
+ .select({ generation: schema.githubTokens.generation })
288
+ .from(schema.githubTokens)
289
+ .where(eq(schema.githubTokens.userId, userId))
290
+ .get();
291
+ return (
292
+ fence?.mutation === "set" &&
293
+ fence.generation === generation &&
294
+ token?.generation === generation
295
+ );
296
+ });
297
+ }
298
+
299
+ // --- token store ------------------------------------------------------------
300
+
301
+ /**
302
+ * Encrypt + persist the user's token (gh.connect.set handler). ADR-033: a
303
+ * non-expiring grant carries `accessToken` (stored kind="access", injected
304
+ * directly); a legacy expiring grant carries `refreshToken` (kind="refresh",
305
+ * exchanged per task). Exactly one is present.
306
+ */
307
+ export interface ConnectSetDeps {
308
+ invalidateCredentials?: (userId: string) => Promise<void>;
309
+ readStored?: (
310
+ userId: string,
311
+ ) => StoredGitHubGrant | null;
312
+ deleteLocal?: (userId: string) => void;
313
+ storeLocal?: (
314
+ userId: string,
315
+ fields: {
316
+ installationId: number;
317
+ generation: number;
318
+ refreshTokenCt: Buffer;
319
+ refreshTokenNonce: Buffer;
320
+ refreshTokenExpiresAt: number | null;
321
+ kind: string;
322
+ updatedAt: number;
323
+ },
324
+ ) => void;
325
+ taskIds?: (userId: string) => string[];
326
+ reconcile?: (taskId: string, userId: string) => Promise<boolean>;
327
+ revoke?: (token: string) => Promise<void>;
328
+ seal?: typeof sealAesGcm;
329
+ notifyChange?: () => void;
330
+ }
331
+
332
+ export async function onConnectSet(
333
+ frame: GhConnectSet,
334
+ deps: ConnectSetDeps = {},
335
+ ): Promise<{ ok: boolean; error?: string }> {
336
+ let oldGrantRemoved = false;
337
+ let replacementStored = false;
338
+ let priorGrant: StoredGitHubGrant | null = null;
339
+ try {
340
+ const token = frame.accessToken ?? frame.refreshToken;
341
+ if (!token) return { ok: false, error: "gh.connect.set carried no token" };
342
+ const kind = frame.accessToken ? "access" : "refresh";
343
+ const credentialGeneration = frame.generation ?? 0;
344
+ const sealed = (deps.seal ?? sealAesGcm)(token);
345
+ const now = Date.now();
346
+ const fields = {
347
+ installationId: frame.installationId,
348
+ generation: credentialGeneration,
349
+ refreshTokenCt: sealed.ct,
350
+ refreshTokenNonce: sealed.nonce,
351
+ refreshTokenExpiresAt: frame.refreshTokenExpiresAt ?? null,
352
+ kind,
353
+ updatedAt: now,
354
+ };
355
+
356
+ try {
357
+ priorGrant = (deps.readStored ?? readStoredToken)(frame.userId);
358
+ } catch (err) {
359
+ // A corrupt superseded row must not prevent replacement. It can still be
360
+ // deleted locally and every container is authoritatively scrubbed below.
361
+ console.warn(
362
+ `[github] user ${frame.userId}: could not read token before replacement: ${
363
+ err instanceof Error ? err.message : err
364
+ }`,
365
+ );
366
+ }
367
+
368
+ // A retry of the exact same operation is safe and still reconciles tasks;
369
+ // reusing one generation for different credential material is not. Without
370
+ // this check a replayed opId/generation could replace the token while the
371
+ // cloud continued to trust the original binding generation.
372
+ if (
373
+ priorGrant?.generation === credentialGeneration &&
374
+ (priorGrant.token !== token ||
375
+ priorGrant.kind !== kind ||
376
+ priorGrant.installationId !== frame.installationId)
377
+ ) {
378
+ return {
379
+ ok: false,
380
+ error: "conflicting GitHub credential replay for one generation",
381
+ };
382
+ }
383
+
384
+ // Remove the old grant before fencing host-side Git. A task that begins
385
+ // while account switching is in progress must not mint or seed the old
386
+ // user's credential. The replacement is already sealed above, so failures
387
+ // before this point leave the existing connection untouched.
388
+ (deps.deleteLocal ?? deleteStoredTokenRow)(frame.userId);
389
+ oldGrantRemoved = true;
390
+ advanceGithubAuthGeneration(frame.userId);
391
+
392
+ // Account switching must also retire A remotely. Skip when GitHub sent the
393
+ // same access token again, which would otherwise revoke replacement B.
394
+ if (
395
+ priorGrant?.kind === "access" &&
396
+ priorGrant.token !== token
397
+ ) {
398
+ await bestEffortRevoke(
399
+ frame.userId,
400
+ priorGrant.token,
401
+ deps.revoke ?? revokeAtGitHub,
402
+ );
403
+ }
404
+
405
+ // Existing task-up operations may still be using the old ephemeral cache.
406
+ // Invalidate and drain them before publishing the replacement grant.
407
+ await deps.invalidateCredentials?.(frame.userId);
408
+
409
+ if (deps.storeLocal) deps.storeLocal(frame.userId, fields);
410
+ else if (signInBoxMode()) {
411
+ // ADR-136 sign-in box: the store first, awaited; a row with no
412
+ // material; the token in the bounded cache only.
413
+ await storeGithubTokenStoreFirst(
414
+ frame.userId,
415
+ {
416
+ installationId: fields.installationId,
417
+ generation: fields.generation,
418
+ refreshTokenExpiresAt: fields.refreshTokenExpiresAt,
419
+ kind,
420
+ updatedAt: now,
421
+ },
422
+ token,
423
+ );
424
+ } else storeTokenRow(frame.userId, fields);
425
+ replacementStored = true;
426
+ // Advance again only after the replacement is durable. This detaches work
427
+ // started during the no-grant fence and lets reconciliation capture B.
428
+ advanceGithubAuthGeneration(frame.userId);
429
+ (deps.notifyChange ?? notifyGithubChange)();
430
+
431
+ // Account-switch acknowledgement is a credential boundary: wait until each
432
+ // container that could retain A has been removed or scrubbed and configured
433
+ // with B. Main injects a task-lifecycle wrapper so a concurrent task-up is
434
+ // completed before this per-task credential transition runs.
435
+ const taskIds = (deps.taskIds ?? credentialCleanupTaskIdsForUser)(
436
+ frame.userId,
437
+ );
438
+ const reconcile = deps.reconcile ?? reconcileTaskGitAuth;
439
+ const settled = await Promise.allSettled(
440
+ taskIds.map((taskId) => reconcile(taskId, frame.userId)),
441
+ );
442
+ const rejected = settled.find(
443
+ (result): result is PromiseRejectedResult => result.status === "rejected",
444
+ );
445
+ if (rejected) throw rejected.reason;
446
+ if (settled.some((result) => result.status === "fulfilled" && !result.value)) {
447
+ throw new Error("GitHub credential replacement did not reach every task");
448
+ }
449
+ return { ok: true };
450
+ } catch (err) {
451
+ // If the old grant was already removed, advertise the authoritative local
452
+ // state and scrub live containers through the normal no-token reconciler.
453
+ // Never leave a failed account switch looking connected to the old user.
454
+ if (oldGrantRemoved && !replacementStored) {
455
+ (deps.notifyChange ?? notifyGithubChange)();
456
+ const taskIds = (deps.taskIds ?? credentialCleanupTaskIdsForUser)(
457
+ frame.userId,
458
+ );
459
+ const reconcile = deps.reconcile ?? reconcileTaskGitAuth;
460
+ await Promise.allSettled(
461
+ taskIds.map((taskId) => reconcile(taskId, frame.userId)),
462
+ );
463
+ }
464
+ return { ok: false, error: err instanceof Error ? err.message : "store failed" };
465
+ }
466
+ }
467
+
468
+ function deleteStoredTokenRow(userId: string): void {
469
+ forgetGithubToken(userId);
470
+ const result = getDb()
471
+ .delete(schema.githubTokens)
472
+ .where(eq(schema.githubTokens.userId, userId))
473
+ .run();
474
+ // A connect clears any prior grant first; with none stored there is no
475
+ // parameter to delete either.
476
+ if (result.changes > 0) deleteGithubTokenFromStore(userId);
477
+ }
478
+
479
+ function storeTokenRow(
480
+ userId: string,
481
+ fields: {
482
+ installationId: number;
483
+ generation: number;
484
+ refreshTokenCt: Buffer;
485
+ refreshTokenNonce: Buffer;
486
+ refreshTokenExpiresAt: number | null;
487
+ kind: string;
488
+ updatedAt: number;
489
+ },
490
+ ): void {
491
+ getDb().transaction((tx) => {
492
+ const fence = tx
493
+ .select({
494
+ generation: schema.githubCredentialGenerations.generation,
495
+ mutation: schema.githubCredentialGenerations.mutation,
496
+ })
497
+ .from(schema.githubCredentialGenerations)
498
+ .where(eq(schema.githubCredentialGenerations.userId, userId))
499
+ .get();
500
+ if (
501
+ fence?.mutation !== "set" ||
502
+ fence.generation !== fields.generation
503
+ ) {
504
+ throw new Error("GitHub credential generation changed before storage");
505
+ }
506
+ tx.insert(schema.githubTokens)
507
+ .values({ userId, ...fields })
508
+ .onConflictDoUpdate({ target: schema.githubTokens.userId, set: fields })
509
+ .run();
510
+ });
511
+ mirrorGithubTokenToStore(userId);
512
+ }
513
+
514
+ // ---------------------------------------------------------------------------
515
+ // Credential store mirror (ADR-134 slice 2): `<prefix>/users/<userId>/github`.
516
+ // The generation rides along so a replacement host imports the row behind the
517
+ // same durable fence (a later disconnect this host never saw still wins).
518
+ // Owned hosts: no-ops.
519
+ // ---------------------------------------------------------------------------
520
+
521
+ export interface StoredGithubToken {
522
+ v: 1;
523
+ kind: "github";
524
+ userId: string;
525
+ installationId: number;
526
+ generation: number;
527
+ tokenKind: string;
528
+ token: string;
529
+ expiresAt: number | null;
530
+ updatedAt: number;
531
+ }
532
+
533
+ export function githubTokenParameterName(userId: string): string {
534
+ return userParameterName(userId, "github");
535
+ }
536
+
537
+ /** The row as it is mirrored (token decrypted). Never logged. On the
538
+ * sign-in box a store-held row yields the cached token, or null when the
539
+ * cache is cold — there is nothing here to mirror then. */
540
+ export function githubTokenStoreValue(userId: string): StoredGithubToken | null {
541
+ const row = getDb()
542
+ .select()
543
+ .from(schema.githubTokens)
544
+ .where(eq(schema.githubTokens.userId, userId))
545
+ .get();
546
+ if (!row) return null;
547
+ const token = isStoreHeldGithubRow(row)
548
+ ? cachedGithubToken(userId)
549
+ : openAesGcm(
550
+ Buffer.from(row.refreshTokenCt as Uint8Array),
551
+ Buffer.from(row.refreshTokenNonce as Uint8Array),
552
+ );
553
+ if (token === null) return null;
554
+ return {
555
+ v: 1,
556
+ kind: "github",
557
+ userId,
558
+ installationId: row.installationId,
559
+ generation: row.generation,
560
+ tokenKind: row.kind,
561
+ token,
562
+ expiresAt: row.refreshTokenExpiresAt ?? null,
563
+ updatedAt: row.updatedAt,
564
+ };
565
+ }
566
+
567
+ export function setGithubTokenStoreRef(userId: string, storeRef: string): void {
568
+ getDb()
569
+ .update(schema.githubTokens)
570
+ .set({ storeRef })
571
+ .where(eq(schema.githubTokens.userId, userId))
572
+ .run();
573
+ }
574
+
575
+ export function listGithubTokenStoreRows(): Array<{ userId: string; storeRef: string | null }> {
576
+ return getDb()
577
+ .select({ userId: schema.githubTokens.userId, storeRef: schema.githubTokens.storeRef })
578
+ .from(schema.githubTokens)
579
+ .all();
580
+ }
581
+
582
+ export function mirrorGithubTokenToStore(userId: string): void {
583
+ // ADR-136 sign-in box: rows hold no material; the store was written first.
584
+ if (signInBoxMode()) return;
585
+ queueStoreWrite(`mirror github token ${userId}`, async (store) => {
586
+ const value = githubTokenStoreValue(userId);
587
+ if (!value) return;
588
+ const name = githubTokenParameterName(userId);
589
+ await store.put(name, JSON.stringify(value));
590
+ setGithubTokenStoreRef(userId, name);
591
+ });
592
+ }
593
+
594
+ export function deleteGithubTokenFromStore(userId: string): void {
595
+ queueStoreWrite(`delete github token ${userId}`, (store) =>
596
+ store.delete(githubTokenParameterName(userId)),
597
+ );
598
+ }
599
+
600
+ /**
601
+ * Reconcile import: a token this host lacks. Claims the stored generation as
602
+ * a `set` first — refused (false) when this host's fence already moved past
603
+ * it, so a disconnect that outran the store's delete can never resurrect a
604
+ * token here.
605
+ */
606
+ export function importGithubTokenFromStore(
607
+ value: StoredGithubToken,
608
+ storeRef: string,
609
+ ): boolean {
610
+ const claim = claimGithubCredentialGeneration(value.userId, value.generation, "set");
611
+ if (!claim) return false;
612
+ const sealed = sealAesGcm(value.token);
613
+ const fields = {
614
+ installationId: value.installationId,
615
+ generation: value.generation,
616
+ refreshTokenCt: sealed.ct,
617
+ refreshTokenNonce: sealed.nonce,
618
+ refreshTokenExpiresAt: value.expiresAt,
619
+ kind: value.tokenKind,
620
+ updatedAt: value.updatedAt,
621
+ storeRef,
622
+ };
623
+ getDb()
624
+ .insert(schema.githubTokens)
625
+ .values({ userId: value.userId, ...fields })
626
+ .onConflictDoUpdate({ target: schema.githubTokens.userId, set: fields })
627
+ .run();
628
+ notifyGithubChange();
629
+ return true;
630
+ }
631
+
632
+ async function bestEffortRevoke(
633
+ userId: string,
634
+ token: string,
635
+ revoke: (token: string) => Promise<void>,
636
+ ): Promise<void> {
637
+ try {
638
+ await revoke(token);
639
+ } catch (err) {
640
+ console.warn(
641
+ `[github] user ${userId}: token revoke failed after local removal: ${
642
+ err instanceof Error ? err.message : err
643
+ }`,
644
+ );
645
+ }
646
+ }
647
+
648
+ /**
649
+ * Disconnect GitHub on this host (gh.connect.clear handler). The surrounding
650
+ * claimed `clear` generation makes the stored row inert immediately. Keep that
651
+ * row recoverable until every live container has dropped the credential and
652
+ * selected its disconnected transport; only then delete it and best-effort
653
+ * revoke remotely. A runtime loss can therefore reject without turning a
654
+ * retryable scrub into an irreversible half-transition.
655
+ *
656
+ * ADR-033: only a non-expiring ACCESS token can be revoked by token
657
+ * (`DELETE /applications/{client_id}/token` matches access tokens only — a
658
+ * refresh token 404s, which the cloud endpoint would mis-report as success).
659
+ * Legacy refresh tokens aren't single-token revocable; the short-lived access
660
+ * tokens they mint expire on their own.
661
+ */
662
+ export interface ConnectClearDeps {
663
+ readStored?: (
664
+ userId: string,
665
+ ) => StoredGitHubGrant | null;
666
+ deleteLocal?: (userId: string) => void;
667
+ activeTaskIds?: (userId: string) => string[];
668
+ reconcile?: (taskId: string, userId: string) => Promise<boolean>;
669
+ revoke?: (token: string) => Promise<void>;
670
+ invalidateCredentials?: (userId: string) => Promise<void>;
671
+ }
672
+
673
+ export async function onConnectClear(
674
+ userId: string,
675
+ deps: ConnectClearDeps = {},
676
+ ): Promise<void> {
677
+ let stored: StoredGitHubGrant | null = null;
678
+ try {
679
+ stored = (deps.readStored ?? readStoredToken)(userId);
680
+ } catch (err) {
681
+ // A corrupt legacy row must not make Disconnect impossible. We can still
682
+ // delete it locally and clean every live container; only remote revocation
683
+ // is unavailable because the token could not be decrypted.
684
+ console.warn(
685
+ `[github] user ${userId}: could not read token before disconnect: ${
686
+ err instanceof Error ? err.message : err
687
+ }`,
688
+ );
689
+ }
690
+
691
+ // Incrementing the host-Git generation prevents any not-yet-started use of
692
+ // an old prepared handle. Await already-started task-up operations before
693
+ // discovering and scrubbing containers: a task may create its app container
694
+ // while Disconnect is in flight.
695
+ await deps.invalidateCredentials?.(userId);
696
+
697
+ const activeTasks = (deps.activeTaskIds ?? credentialCleanupTaskIdsForUser)(
698
+ userId,
699
+ );
700
+ for (const taskId of activeTasks) {
701
+ clearRefresh(taskId);
702
+ }
703
+ const reconcile = deps.reconcile ?? reconcileTaskGitAuth;
704
+ const settled = await Promise.allSettled(
705
+ activeTasks.map((taskId) => reconcile(taskId, userId)),
706
+ );
707
+ const failed = settled.find(
708
+ (result): result is PromiseRejectedResult => result.status === "rejected",
709
+ );
710
+ if (failed) throw failed.reason;
711
+
712
+ // The claimed clear already removed this generation from every authority
713
+ // read. Delete the recoverable ciphertext only after all container scrubs
714
+ // crossed their durable boundary, so the same-generation operation can be
715
+ // retried if Docker disappeared mid-transition.
716
+ (deps.deleteLocal ?? deleteToken)(userId);
717
+
718
+ // Await best-effort remote revocation while the caller still owns the
719
+ // per-user transition lock. revokeAtGitHub catches its own failures; the
720
+ // wrapper also contains failures from injected test/alternate implementations.
721
+ if (stored?.kind === "access") {
722
+ await bestEffortRevoke(
723
+ userId,
724
+ stored.token,
725
+ deps.revoke ?? revokeAtGitHub,
726
+ );
727
+ } else if (stored) {
728
+ console.warn(
729
+ `[github] user ${userId}: legacy refresh token cleared locally; cannot single-token revoke at GitHub`,
730
+ );
731
+ }
732
+ }
733
+
734
+ /** Fail-closed rollback for a generation that was claimed for set but could
735
+ * not be fully reconciled. The tombstone transition happens before cleanup is
736
+ * awaited, so task/token readers stop trusting the credential immediately. */
737
+ export async function rollbackClaimedGitHubCredentialSet(
738
+ userId: string,
739
+ generation: number,
740
+ deps: ConnectClearDeps = {},
741
+ ): Promise<boolean> {
742
+ const rollback = claimGithubCredentialGeneration(
743
+ userId,
744
+ generation,
745
+ "clear",
746
+ );
747
+ if (!rollback) return false;
748
+ try {
749
+ await onConnectClear(userId, deps);
750
+ } catch (err) {
751
+ console.warn(
752
+ `[github] user ${userId}: claimed set rollback cleanup failed: ${
753
+ err instanceof Error ? err.message : err
754
+ }`,
755
+ );
756
+ }
757
+ return true;
758
+ }
759
+
760
+ interface StoredGitHubGrant {
761
+ token: string;
762
+ kind: string;
763
+ installationId?: number;
764
+ generation?: number;
765
+ }
766
+
767
+ type PersistedGitHubGrant = StoredGitHubGrant & {
768
+ installationId: number;
769
+ generation: number;
770
+ };
771
+
772
+ /** Decrypt any stored token, including an inert row from an older generation.
773
+ * Transition handlers need the old ciphertext solely so they can revoke it;
774
+ * task and verification readers use `readCurrentStoredToken` below. */
775
+ function readStoredToken(
776
+ userId: string,
777
+ ): PersistedGitHubGrant | null {
778
+ const row = getDb()
779
+ .select()
780
+ .from(schema.githubTokens)
781
+ .where(eq(schema.githubTokens.userId, userId))
782
+ .get();
783
+ if (!row) return null;
784
+ // ADR-136 sign-in box: a store-held row answers from the bounded cache
785
+ // (filled by `loadGithubTokenIntoCache` on the async paths) or not at all.
786
+ const token = isStoreHeldGithubRow(row)
787
+ ? cachedGithubToken(userId)
788
+ : openAesGcm(
789
+ Buffer.from(row.refreshTokenCt as Uint8Array),
790
+ Buffer.from(row.refreshTokenNonce as Uint8Array),
791
+ );
792
+ if (token === null) return null;
793
+ return {
794
+ token,
795
+ kind: row.kind,
796
+ installationId: row.installationId,
797
+ generation: row.generation,
798
+ };
799
+ }
800
+
801
+ function readCurrentStoredToken(userId: string): PersistedGitHubGrant | null {
802
+ const stored = readStoredToken(userId);
803
+ return stored &&
804
+ githubCredentialGenerationMatches(userId, stored.generation)
805
+ ? stored
806
+ : null;
807
+ }
808
+
809
+ export function deleteToken(userId: string): void {
810
+ forgetGithubToken(userId);
811
+ getDb()
812
+ .delete(schema.githubTokens)
813
+ .where(eq(schema.githubTokens.userId, userId))
814
+ .run();
815
+ deleteGithubTokenFromStore(userId);
816
+ advanceGithubAuthGeneration(userId);
817
+ notifyGithubChange();
818
+ }
819
+
820
+ export function hasToken(userId: string): boolean {
821
+ // Metadata only: a store-held row (sign-in box) counts as connected whether
822
+ // or not its token is in the cache right now.
823
+ const row = getDb()
824
+ .select({ generation: schema.githubTokens.generation })
825
+ .from(schema.githubTokens)
826
+ .where(eq(schema.githubTokens.userId, userId))
827
+ .get();
828
+ return row !== undefined && githubCredentialGenerationMatches(userId, row.generation);
829
+ }
830
+
831
+ /** All cloud user ids with a GitHub token on this host (for capabilities). */
832
+ export function connectedUserIds(): string[] {
833
+ return getDb()
834
+ .select({ userId: schema.githubTokens.userId })
835
+ .from(schema.githubTokens)
836
+ .innerJoin(
837
+ schema.githubCredentialGenerations,
838
+ and(
839
+ eq(
840
+ schema.githubCredentialGenerations.userId,
841
+ schema.githubTokens.userId,
842
+ ),
843
+ eq(
844
+ schema.githubCredentialGenerations.generation,
845
+ schema.githubTokens.generation,
846
+ ),
847
+ ),
848
+ )
849
+ .where(eq(schema.githubCredentialGenerations.mutation, "set"))
850
+ .all()
851
+ .map((r) => r.userId);
852
+ }
853
+
854
+ // Fires whenever the set of stored tokens changes (add/remove), so the host
855
+ // can re-advertise capabilities and the UI reflects per-host gh state promptly.
856
+ let githubChangeHandler: (() => void) | null = null;
857
+ export function onGithubChange(fn: (() => void) | null): void {
858
+ githubChangeHandler = fn;
859
+ }
860
+ function notifyGithubChange(): void {
861
+ try {
862
+ githubChangeHandler?.();
863
+ } catch {
864
+ /* never let a listener break token handling */
865
+ }
866
+ }
867
+
868
+ export function taskMayRetainGithubCredential(task: {
869
+ endedAt: number | null;
870
+ composeProject: string | null;
871
+ statusMirror: string | null;
872
+ }): boolean {
873
+ // Active/starting rows may gain a container while Disconnect is running.
874
+ // Ended rows need cleanup only when their compose identity is preserved;
875
+ // task-down clears composeProject after destroying the stack. A failed
876
+ // task-up can leave a conventional app container before compose metadata was
877
+ // persisted, so error rows are probed by task id as well.
878
+ return (
879
+ task.endedAt == null ||
880
+ task.composeProject != null ||
881
+ task.statusMirror === "error"
882
+ );
883
+ }
884
+
885
+ function taskCredentialRowsForUser(userId: string) {
886
+ return getDb()
887
+ .select({
888
+ taskId: schema.hostTasks.taskId,
889
+ endedAt: schema.hostTasks.endedAt,
890
+ composeProject: schema.hostTasks.composeProject,
891
+ statusMirror: schema.hostTasks.statusMirror,
892
+ })
893
+ .from(schema.hostTasks)
894
+ .where(eq(schema.hostTasks.ownerUserId, userId))
895
+ .all();
896
+ }
897
+
898
+ function credentialCleanupTaskIdsForUser(userId: string): string[] {
899
+ return taskCredentialRowsForUser(userId)
900
+ .filter(taskMayRetainGithubCredential)
901
+ .map((r) => r.taskId);
902
+ }
903
+
904
+ function runningTaskIdsForUser(userId: string): string[] {
905
+ return taskCredentialRowsForUser(userId)
906
+ .filter((task) => task.endedAt == null)
907
+ .map((task) => task.taskId);
908
+ }
909
+
910
+ /**
911
+ * Re-mint + inject a (re)connected token into the user's live tasks — so a
912
+ * reconnect on Account applies to running containers, not only new tasks. Clear
913
+ * any armed retry chain first (a fresh grant must not be swallowed by an outage
914
+ * backoff, and `setupTaskGithub`'s duplicate-chain guard would no-op it) then
915
+ * run setup fresh. Fire-and-forget per task; best-effort by construction.
916
+ */
917
+ export function reinjectRunningTasks(userId: string): void {
918
+ for (const taskId of runningTaskIdsForUser(userId)) {
919
+ clearRefresh(taskId);
920
+ void reconcileTaskGitAuth(taskId, userId).catch((err) =>
921
+ console.warn(
922
+ `[github] task ${taskId}: credential reconciliation failed: ${
923
+ err instanceof Error ? err.message : err
924
+ }`,
925
+ ),
926
+ );
927
+ }
928
+ }
929
+
930
+ // --- access-token exchange --------------------------------------------------
931
+
932
+ function cloudHttpBase(): string {
933
+ const cloud = process.env.UAI_CLOUD_URL ?? "ws://127.0.0.1:8789/host";
934
+ const u = new URL(cloud);
935
+ const proto =
936
+ u.protocol === "wss:" ? "https:" : u.protocol === "ws:" ? "http:" : u.protocol;
937
+ return `${proto}//${u.host}`;
938
+ }
939
+
940
+ function exchangeUrl(): string {
941
+ return `${cloudHttpBase()}/api/github/oauth/exchange`;
942
+ }
943
+
944
+ /**
945
+ * Revoke a token at GitHub via the cloud (which holds the App client secret —
946
+ * the host can't revoke alone). Same host→cloud HTTP pattern as the exchange;
947
+ * the token transits the cloud transiently, never stored (ADR-015/ADR-033).
948
+ * Best-effort — callers proceed to delete locally regardless.
949
+ */
950
+ async function revokeAtGitHub(token: string): Promise<void> {
951
+ try {
952
+ const res = await fetch(`${cloudHttpBase()}/api/github/revoke`, {
953
+ method: "POST",
954
+ headers: {
955
+ "content-type": "application/json",
956
+ "x-uai-host-token": process.env.UAI_HOST_TOKEN ?? "",
957
+ },
958
+ body: JSON.stringify({ token }),
959
+ signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS),
960
+ });
961
+ if (!res.ok) {
962
+ console.warn(`[github] token revoke returned HTTP ${res.status}`);
963
+ }
964
+ } catch (err) {
965
+ console.warn(
966
+ `[github] token revoke failed (deleting locally anyway): ${err instanceof Error ? err.message : err}`,
967
+ );
968
+ }
969
+ }
970
+
971
+ interface ExchangeResult {
972
+ accessToken: string;
973
+ expiresAt: number;
974
+ refreshToken: string | null;
975
+ refreshTokenExpiresAt: number | null;
976
+ }
977
+
978
+ /**
979
+ * Mint a fresh access token for the user via the cloud exchange. Persists a
980
+ * rotated refresh token if GitHub returned one. Returns null when the user has
981
+ * no token on this host. Throws on exchange failure.
982
+ *
983
+ * Deduped PER USER: GitHub refresh tokens are single-use (each exchange
984
+ * rotates them), and GitHub treats reuse of a consumed token as a compromise
985
+ * signal that can kill the whole grant. Concurrent task setups for the same
986
+ * user (several task-ups at once, or every channel re-ensure after a host
987
+ * restart) used to race the rotation — the loser burned the grant and every
988
+ * exchange afterwards failed until a manual re-connect. The access token is
989
+ * per-user anyway, so concurrent callers share one in-flight exchange.
990
+ */
991
+ const inflightExchanges = new Map<
992
+ string,
993
+ {
994
+ generation: number;
995
+ promise: Promise<{ accessToken: string; expiresAt: number } | null>;
996
+ }
997
+ >();
998
+
999
+ /**
1000
+ * Per-user ACCESS-token cache. Access tokens live ~8h, but every exchange
1001
+ * ROTATES the single-use refresh token — and a rotation whose response is
1002
+ * lost mid-flight (our timeout firing while GitHub already rotated) burns the
1003
+ * grant, forcing a manual re-connect. So exchange as rarely as possible: all
1004
+ * task setups and refresh timers for a user share one cached access token
1005
+ * until it nears expiry. The refresh timers fire at expiry−5min, inside the
1006
+ * 15min stale window, so exactly one of them performs the real exchange (the
1007
+ * in-flight dedupe serializes any ties) and the rest re-inject from cache.
1008
+ */
1009
+ const ACCESS_CACHE_LEAD_MS = 15 * 60 * 1000;
1010
+ const accessCache = new Map<
1011
+ string,
1012
+ { accessToken: string; expiresAt: number }
1013
+ >();
1014
+
1015
+ const INSTALLATION_PAGE_SIZE = 100;
1016
+ const REPOSITORY_PAGE_SIZE = 100;
1017
+ const REPOSITORY_PAGE_CAP = Math.ceil(
1018
+ MAX_GITHUB_REPOSITORIES_PER_INSTALLATION / REPOSITORY_PAGE_SIZE,
1019
+ );
1020
+ const GITHUB_LIST_TIMEOUT_MS = 10_000;
1021
+ // Bound only the GitHub listing phase. The installation bridge allows 60s so a
1022
+ // legacy refresh-token exchange (up to 30s) can precede this 25s window; the
1023
+ // repository bridge uses 30s because the complete installation call has just
1024
+ // warmed that user's access-token cache. Every REST page gets the smaller of
1025
+ // its normal timeout and the remaining listing budget.
1026
+ const GITHUB_LIST_OPERATION_TIMEOUT_MS = 25_000;
1027
+
1028
+ function githubListSignal(deadline: number): AbortSignal {
1029
+ const remaining = deadline - Date.now();
1030
+ if (remaining <= 0) throw new Error("github list operation timed out");
1031
+ return AbortSignal.timeout(Math.min(GITHUB_LIST_TIMEOUT_MS, remaining));
1032
+ }
1033
+
1034
+ export class GitHubRepositoryAccessError extends Error {
1035
+ constructor(
1036
+ readonly code: GitHubRepositoryAccessErrorCode,
1037
+ message: string,
1038
+ ) {
1039
+ super(message);
1040
+ this.name = "GitHubRepositoryAccessError";
1041
+ }
1042
+ }
1043
+
1044
+ interface GitHubListDeps {
1045
+ requestAccessToken?: typeof requestAccessToken;
1046
+ fetch?: typeof fetch;
1047
+ credentialGenerationMatches?: typeof githubCredentialGenerationMatches;
1048
+ /** When the whole operation must end (the token's read included). Absent,
1049
+ * the listing alone gets its own bound once a token is in hand. */
1050
+ deadlineAt?: number;
1051
+ }
1052
+
1053
+ /**
1054
+ * The token could not be read where it is kept (the sign-in box's store):
1055
+ * a fixed message, and a transient code, never the store's own words.
1056
+ */
1057
+ export class GitHubTokenUnreadableError extends Error {
1058
+ constructor() {
1059
+ super("the GitHub sign-in could not be read");
1060
+ this.name = "GitHubTokenUnreadableError";
1061
+ }
1062
+ }
1063
+
1064
+
1065
+ /**
1066
+ * Every installation GitHub currently lists for this user, read with the token
1067
+ * this host already holds — the host-side half of `gh.installations.list`.
1068
+ *
1069
+ * This is what lets a misbound installation row be pruned without the user
1070
+ * reconnecting: the cloud mirror predates `(user_id, installation_id)` keying
1071
+ * and cannot be trusted, but the host can ask GitHub on the user's behalf at
1072
+ * any time, with no browser round trip.
1073
+ *
1074
+ * THROWS rather than returning a partial list. The caller prunes bindings that
1075
+ * are absent from the result, so a truncated or failed read would delete
1076
+ * legitimate grants — absence is only meaningful in a list we fully retrieved.
1077
+ */
1078
+ export async function listUserInstallationIds(
1079
+ userId: string,
1080
+ deps: GitHubListDeps = {},
1081
+ generation = 0,
1082
+ ): Promise<number[]> {
1083
+ if (
1084
+ !(deps.credentialGenerationMatches ?? githubCredentialGenerationMatches)(
1085
+ userId,
1086
+ generation,
1087
+ )
1088
+ ) {
1089
+ throw new GitHubRepositoryAccessError(
1090
+ "reauth_required",
1091
+ "the host GitHub credential belongs to a different binding generation",
1092
+ );
1093
+ }
1094
+ let tok: Awaited<ReturnType<typeof requestAccessToken>>;
1095
+ try {
1096
+ tok = await (deps.requestAccessToken ?? requestAccessToken)(userId);
1097
+ } catch (err) {
1098
+ if (err instanceof GitHubTokenUnreadableError) {
1099
+ throw new GitHubRepositoryAccessError("github_unavailable", err.message);
1100
+ }
1101
+ const reason = err instanceof Error ? err.message : String(err);
1102
+ throw new GitHubRepositoryAccessError(
1103
+ isTransientGithubError(reason) ? "github_unavailable" : "reauth_required",
1104
+ reason,
1105
+ );
1106
+ }
1107
+ if (!tok) {
1108
+ throw new GitHubRepositoryAccessError(
1109
+ "token_missing",
1110
+ `no github token stored for user ${userId}`,
1111
+ );
1112
+ }
1113
+ const ids = new Set<number>();
1114
+ const deadline = deps.deadlineAt ?? Date.now() + GITHUB_LIST_OPERATION_TIMEOUT_MS;
1115
+ let res: Response;
1116
+ try {
1117
+ res = await (deps.fetch ?? fetch)(
1118
+ `${githubApiOrigin()}/user/installations` +
1119
+ `?per_page=${INSTALLATION_PAGE_SIZE}&page=1`,
1120
+ {
1121
+ headers: {
1122
+ authorization: `Bearer ${tok.accessToken}`,
1123
+ accept: "application/vnd.github+json",
1124
+ "user-agent": "uai-host",
1125
+ },
1126
+ signal: githubListSignal(deadline),
1127
+ },
1128
+ );
1129
+ } catch (err) {
1130
+ throw new GitHubRepositoryAccessError(
1131
+ "github_unavailable",
1132
+ err instanceof Error ? err.message : String(err),
1133
+ );
1134
+ }
1135
+ if (!res.ok) {
1136
+ throw new GitHubRepositoryAccessError(
1137
+ res.status === 401 ? "reauth_required" : "github_unavailable",
1138
+ `github installation list failed: HTTP ${res.status}`,
1139
+ );
1140
+ }
1141
+ let data: { total_count?: unknown; installations?: unknown };
1142
+ try {
1143
+ data = (await res.json()) as {
1144
+ total_count?: unknown;
1145
+ installations?: unknown;
1146
+ };
1147
+ } catch {
1148
+ throw new GitHubRepositoryAccessError(
1149
+ "invalid_response",
1150
+ "github installation list returned invalid json",
1151
+ );
1152
+ }
1153
+ if (
1154
+ !Number.isSafeInteger(data.total_count) ||
1155
+ (data.total_count as number) < 0 ||
1156
+ (data.total_count as number) > MAX_GITHUB_INSTALLATIONS ||
1157
+ !Array.isArray(data.installations) ||
1158
+ data.installations.length !== data.total_count
1159
+ ) {
1160
+ throw new GitHubRepositoryAccessError(
1161
+ "invalid_response",
1162
+ "github installation list returned an incomplete body",
1163
+ );
1164
+ }
1165
+ for (const inst of data.installations) {
1166
+ // Reject the whole response rather than skipping the entry. The caller
1167
+ // prunes bindings absent from this list, so a silently dropped or duplicate
1168
+ // id is indistinguishable from a complete GitHub verdict.
1169
+ if (!inst || typeof inst !== "object") {
1170
+ throw new GitHubRepositoryAccessError(
1171
+ "invalid_response",
1172
+ "github returned a malformed installation entry",
1173
+ );
1174
+ }
1175
+ const id = (inst as { id?: unknown }).id;
1176
+ if (
1177
+ !Number.isSafeInteger(id) ||
1178
+ (id as number) <= 0 ||
1179
+ ids.has(id as number)
1180
+ ) {
1181
+ throw new GitHubRepositoryAccessError(
1182
+ "invalid_response",
1183
+ "github returned a malformed or duplicate installation id",
1184
+ );
1185
+ }
1186
+ ids.add(id as number);
1187
+ }
1188
+
1189
+ // A newer set/clear may have landed while GitHub was answering. Returning
1190
+ // the old actor's complete list after that point would let the cloud prune
1191
+ // bindings under the newer credential, so re-check at the publication edge.
1192
+ if (
1193
+ !(deps.credentialGenerationMatches ?? githubCredentialGenerationMatches)(
1194
+ userId,
1195
+ generation,
1196
+ )
1197
+ ) {
1198
+ throw new GitHubRepositoryAccessError(
1199
+ "reauth_required",
1200
+ "the host GitHub credential changed during installation verification",
1201
+ );
1202
+ }
1203
+
1204
+ return [...ids];
1205
+ }
1206
+
1207
+ export async function listUserInstallationRepositoryIds(
1208
+ userId: string,
1209
+ installationId: number,
1210
+ deps: GitHubListDeps = {},
1211
+ generation = 0,
1212
+ ): Promise<{ repositoryIds: number[]; truncated: boolean }> {
1213
+ if (
1214
+ !(deps.credentialGenerationMatches ?? githubCredentialGenerationMatches)(
1215
+ userId,
1216
+ generation,
1217
+ )
1218
+ ) {
1219
+ throw new GitHubRepositoryAccessError(
1220
+ "reauth_required",
1221
+ "the host GitHub credential belongs to a different binding generation",
1222
+ );
1223
+ }
1224
+ let tok: Awaited<ReturnType<typeof requestAccessToken>>;
1225
+ try {
1226
+ tok = await (deps.requestAccessToken ?? requestAccessToken)(userId);
1227
+ } catch (err) {
1228
+ if (err instanceof GitHubTokenUnreadableError) {
1229
+ throw new GitHubRepositoryAccessError("github_unavailable", err.message);
1230
+ }
1231
+ const reason = err instanceof Error ? err.message : String(err);
1232
+ throw new GitHubRepositoryAccessError(
1233
+ isTransientGithubError(reason) ? "github_unavailable" : "reauth_required",
1234
+ reason,
1235
+ );
1236
+ }
1237
+ if (!tok) {
1238
+ throw new GitHubRepositoryAccessError(
1239
+ "token_missing",
1240
+ `no github token stored for user ${userId}`,
1241
+ );
1242
+ }
1243
+ const repositoryIds = new Set<number>();
1244
+ let truncated = false;
1245
+ const deadline = deps.deadlineAt ?? Date.now() + GITHUB_LIST_OPERATION_TIMEOUT_MS;
1246
+ for (let page = 1; page <= REPOSITORY_PAGE_CAP; page++) {
1247
+ let res: Response;
1248
+ try {
1249
+ res = await (deps.fetch ?? fetch)(
1250
+ `${githubApiOrigin()}/user/installations/${installationId}/repositories` +
1251
+ `?per_page=${REPOSITORY_PAGE_SIZE}&page=${page}`,
1252
+ {
1253
+ headers: {
1254
+ authorization: `Bearer ${tok.accessToken}`,
1255
+ accept: "application/vnd.github+json",
1256
+ "user-agent": "uai-host",
1257
+ },
1258
+ signal: githubListSignal(deadline),
1259
+ },
1260
+ );
1261
+ } catch (err) {
1262
+ throw new GitHubRepositoryAccessError(
1263
+ "github_unavailable",
1264
+ err instanceof Error ? err.message : String(err),
1265
+ );
1266
+ }
1267
+ if (!res.ok) {
1268
+ const code: GitHubRepositoryAccessErrorCode =
1269
+ res.status === 401
1270
+ ? "reauth_required"
1271
+ : res.status === 404
1272
+ ? "installation_inaccessible"
1273
+ : "github_unavailable";
1274
+ throw new GitHubRepositoryAccessError(
1275
+ code,
1276
+ `github repository list failed for installation ${installationId}: HTTP ${res.status}`,
1277
+ );
1278
+ }
1279
+ let data: { repositories?: unknown };
1280
+ try {
1281
+ data = (await res.json()) as { repositories?: unknown };
1282
+ } catch {
1283
+ throw new GitHubRepositoryAccessError(
1284
+ "invalid_response",
1285
+ "github repository list returned invalid json",
1286
+ );
1287
+ }
1288
+ if (!Array.isArray(data.repositories)) {
1289
+ throw new GitHubRepositoryAccessError(
1290
+ "invalid_response",
1291
+ "github repository list returned an invalid body",
1292
+ );
1293
+ }
1294
+ const batch = data.repositories;
1295
+ for (const repo of batch) {
1296
+ if (!repo || typeof repo !== "object") {
1297
+ // Repository results are an allowlist. Omitting an unparseable entry
1298
+ // can only withhold access, so preserve the valid IDs but make the
1299
+ // partial result explicit. (Installation-list entries are different:
1300
+ // omission there could drive a destructive prune and must throw.)
1301
+ truncated = true;
1302
+ continue;
1303
+ }
1304
+ const id = (repo as { id?: unknown }).id;
1305
+ if (!Number.isSafeInteger(id) || (id as number) <= 0) {
1306
+ truncated = true;
1307
+ continue;
1308
+ }
1309
+ repositoryIds.add(id as number);
1310
+ }
1311
+ if (batch.length < REPOSITORY_PAGE_SIZE) break;
1312
+ if (page === REPOSITORY_PAGE_CAP) truncated = true;
1313
+ }
1314
+ if (
1315
+ !(deps.credentialGenerationMatches ?? githubCredentialGenerationMatches)(
1316
+ userId,
1317
+ generation,
1318
+ )
1319
+ ) {
1320
+ throw new GitHubRepositoryAccessError(
1321
+ "reauth_required",
1322
+ "the host GitHub credential changed during repository verification",
1323
+ );
1324
+ }
1325
+ return {
1326
+ repositoryIds: [...repositoryIds].slice(
1327
+ 0,
1328
+ MAX_GITHUB_REPOSITORIES_PER_INSTALLATION,
1329
+ ),
1330
+ truncated:
1331
+ truncated || repositoryIds.size > MAX_GITHUB_REPOSITORIES_PER_INSTALLATION,
1332
+ };
1333
+ }
1334
+
1335
+ /** Test hook: reset the access-token cache. */
1336
+ export function clearAllAccessCache(): void {
1337
+ accessCache.clear();
1338
+ inflightExchanges.clear();
1339
+ githubAuthGenerations.clear();
1340
+ }
1341
+
1342
+ /**
1343
+ * ADR-154 §13, the cutover purge, once the token rows are gone: no GitHub
1344
+ * token stays in this process. Each user with a cached access token or an
1345
+ * exchange under way moves to a new generation, as at a sign-out, which
1346
+ * drops both; an exchange that ends later then caches and stores nothing.
1347
+ * The sign-in box's cache goes too. Unlike the test hook above, no
1348
+ * generation goes back, so nothing fenced off before is let in again.
1349
+ */
1350
+ export function forgetGithubTokensInMemory(): void {
1351
+ for (const userId of new Set([...accessCache.keys(), ...inflightExchanges.keys()])) {
1352
+ advanceGithubAuthGeneration(userId);
1353
+ }
1354
+ boxTokens.clear();
1355
+ }
1356
+
1357
+ export async function requestAccessToken(
1358
+ userId: string,
1359
+ ): Promise<{ accessToken: string; expiresAt: number | null } | null> {
1360
+ // ADR-136 sign-in box: the token is read from the store on demand for
1361
+ // the installation listing and a legacy exchange, never kept on disk.
1362
+ if (signInBoxMode()) {
1363
+ try {
1364
+ await loadGithubTokenIntoCache(userId);
1365
+ } catch (err) {
1366
+ console.warn(
1367
+ `[github] user ${userId}: could not read the token from the credential store: ${err instanceof Error ? err.message : String(err)}`,
1368
+ );
1369
+ }
1370
+ }
1371
+ const generation = githubAuthGeneration(userId);
1372
+ // ADR-033 non-expiring path: the stored token IS the access token — return it
1373
+ // directly, no exchange/cache/rotation. `expiresAt: null` ⇒ no refresh.
1374
+ const row = readCurrentStoredToken(userId);
1375
+ if (!row) return Promise.resolve(null);
1376
+ if (row.kind === "access") {
1377
+ return Promise.resolve({ accessToken: row.token, expiresAt: null });
1378
+ }
1379
+
1380
+ // Legacy expiring path (ADR-027): cache + in-flight dedupe + exchange.
1381
+ const cached = accessCache.get(userId);
1382
+ if (cached && cached.expiresAt - Date.now() > ACCESS_CACHE_LEAD_MS) {
1383
+ return Promise.resolve(cached);
1384
+ }
1385
+ const existing = inflightExchanges.get(userId);
1386
+ if (existing?.generation === generation) return existing.promise;
1387
+ let run!: Promise<{ accessToken: string; expiresAt: number } | null>;
1388
+ run = doRequestAccessToken(userId, generation)
1389
+ .then((tok) => {
1390
+ if (tok && githubAuthGeneration(userId) === generation) {
1391
+ accessCache.set(userId, tok);
1392
+ }
1393
+ return tok;
1394
+ })
1395
+ .finally(() => {
1396
+ if (inflightExchanges.get(userId)?.promise === run) {
1397
+ inflightExchanges.delete(userId);
1398
+ }
1399
+ });
1400
+ inflightExchanges.set(userId, { generation, promise: run });
1401
+ return run;
1402
+ }
1403
+
1404
+ async function doRequestAccessToken(
1405
+ userId: string,
1406
+ generation: number,
1407
+ ): Promise<{ accessToken: string; expiresAt: number } | null> {
1408
+ const row = getDb().transaction((tx) => {
1409
+ const token = tx
1410
+ .select()
1411
+ .from(schema.githubTokens)
1412
+ .where(eq(schema.githubTokens.userId, userId))
1413
+ .get();
1414
+ if (!token) return null;
1415
+ const fence = tx
1416
+ .select({
1417
+ generation: schema.githubCredentialGenerations.generation,
1418
+ mutation: schema.githubCredentialGenerations.mutation,
1419
+ })
1420
+ .from(schema.githubCredentialGenerations)
1421
+ .where(eq(schema.githubCredentialGenerations.userId, userId))
1422
+ .get();
1423
+ return fence?.mutation === "set" &&
1424
+ fence.generation === token.generation
1425
+ ? token
1426
+ : null;
1427
+ });
1428
+ if (!row) return null;
1429
+
1430
+ const refreshToken = isStoreHeldGithubRow(row)
1431
+ ? cachedGithubToken(userId)
1432
+ : openAesGcm(
1433
+ Buffer.from(row.refreshTokenCt as Uint8Array),
1434
+ Buffer.from(row.refreshTokenNonce as Uint8Array),
1435
+ );
1436
+ if (refreshToken === null) return null;
1437
+ const res = await fetch(exchangeUrl(), {
1438
+ method: "POST",
1439
+ headers: {
1440
+ "content-type": "application/json",
1441
+ "x-uai-host-token": process.env.UAI_HOST_TOKEN ?? "",
1442
+ },
1443
+ body: JSON.stringify({ refreshToken }),
1444
+ signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS),
1445
+ });
1446
+ if (!res.ok) {
1447
+ // Include the cloud's error body: it passes GitHub's OAuth error code
1448
+ // through (e.g. "bad_refresh_token"/"invalid_grant" inside the 502
1449
+ // message), which is the ONLY reliable revocation signal. The bare HTTP
1450
+ // status is not one — the cloud returns 401 solely for a host-token
1451
+ // mismatch and wraps every GitHub-side failure as 502.
1452
+ let detail = "";
1453
+ try {
1454
+ const body = (await res.json()) as { message?: string };
1455
+ if (body && typeof body.message === "string") detail = ` — ${body.message}`;
1456
+ } catch {
1457
+ // non-JSON body — status alone will have to do
1458
+ }
1459
+ throw new Error(`gh token exchange failed: HTTP ${res.status}${detail}`);
1460
+ }
1461
+ const data = (await res.json()) as ExchangeResult;
1462
+ // A reconnect/disconnect may have replaced the DB row while the network
1463
+ // exchange was in flight. Never overwrite that newer grant with the rotated
1464
+ // refresh token from the superseded exchange.
1465
+ if (
1466
+ data.refreshToken &&
1467
+ githubAuthGeneration(userId) === generation &&
1468
+ isStoreHeldGithubRow(row)
1469
+ ) {
1470
+ // ADR-136 sign-in box: the rotated token goes to the cache and the store,
1471
+ // never onto the row.
1472
+ rememberGithubToken(userId, data.refreshToken);
1473
+ const rotated: StoredGithubToken = {
1474
+ v: 1,
1475
+ kind: "github",
1476
+ userId,
1477
+ installationId: row.installationId,
1478
+ generation: row.generation,
1479
+ tokenKind: row.kind,
1480
+ token: data.refreshToken,
1481
+ expiresAt: data.refreshTokenExpiresAt ?? row.refreshTokenExpiresAt ?? null,
1482
+ updatedAt: Date.now(),
1483
+ };
1484
+ queueStoreWrite(`rotate github token ${userId}`, (store) =>
1485
+ store.put(githubTokenParameterName(userId), JSON.stringify(rotated)),
1486
+ );
1487
+ } else if (
1488
+ data.refreshToken &&
1489
+ githubAuthGeneration(userId) === generation
1490
+ ) {
1491
+ const sealed = sealAesGcm(data.refreshToken);
1492
+ getDb().transaction((tx) => {
1493
+ const fence = tx
1494
+ .select({
1495
+ generation: schema.githubCredentialGenerations.generation,
1496
+ mutation: schema.githubCredentialGenerations.mutation,
1497
+ })
1498
+ .from(schema.githubCredentialGenerations)
1499
+ .where(eq(schema.githubCredentialGenerations.userId, userId))
1500
+ .get();
1501
+ if (
1502
+ fence?.mutation !== "set" ||
1503
+ fence.generation !== row.generation
1504
+ ) {
1505
+ return;
1506
+ }
1507
+ tx.update(schema.githubTokens)
1508
+ .set({
1509
+ refreshTokenCt: sealed.ct,
1510
+ refreshTokenNonce: sealed.nonce,
1511
+ refreshTokenExpiresAt:
1512
+ data.refreshTokenExpiresAt ?? row.refreshTokenExpiresAt,
1513
+ updatedAt: Date.now(),
1514
+ })
1515
+ .where(
1516
+ and(
1517
+ eq(schema.githubTokens.userId, userId),
1518
+ eq(schema.githubTokens.generation, row.generation),
1519
+ ),
1520
+ )
1521
+ .run();
1522
+ });
1523
+ }
1524
+ return { accessToken: data.accessToken, expiresAt: data.expiresAt };
1525
+ }
1526
+
1527
+ // ---------------------------------------------------------------------------
1528
+ // ADR-136 sign-in box: a token row here holds NO ciphertext — `refresh_token_ct`
1529
+ // is empty, the row is "store-held" — the token lives in the store and, only
1530
+ // while the box needs it (the installation listing, a legacy exchange, the
1531
+ // revoke of a superseded grant), in this bounded in-memory cache. Never on
1532
+ // disk. The row's metadata still drives `hasToken` / `connectedUserIds` and
1533
+ // the generation fence exactly as on any host.
1534
+ // ---------------------------------------------------------------------------
1535
+
1536
+ const BOX_TOKEN_CACHE_MS = 10 * 60_000;
1537
+ const boxTokens = new Map<string, { token: string; until: number }>();
1538
+
1539
+ export const GITHUB_TOKEN_TAGS = {
1540
+ installationId: "uai_installation_id",
1541
+ generation: "uai_generation",
1542
+ tokenKind: "uai_token_kind",
1543
+ expiresAt: "uai_expires_at",
1544
+ updatedAt: "uai_updated_at",
1545
+ } as const;
1546
+
1547
+ export function isStoreHeldGithubRow(row: { refreshTokenCt: unknown }): boolean {
1548
+ const ct = row.refreshTokenCt;
1549
+ if (ct === null || ct === undefined) return true;
1550
+ if (ct instanceof Uint8Array) return ct.byteLength === 0;
1551
+ return false;
1552
+ }
1553
+
1554
+ export function rememberGithubToken(userId: string, token: string): void {
1555
+ boxTokens.set(userId, { token, until: Date.now() + BOX_TOKEN_CACHE_MS });
1556
+ }
1557
+
1558
+ export function cachedGithubToken(userId: string): string | null {
1559
+ const entry = boxTokens.get(userId);
1560
+ if (!entry) return null;
1561
+ if (entry.until <= Date.now()) {
1562
+ boxTokens.delete(userId);
1563
+ return null;
1564
+ }
1565
+ return entry.token;
1566
+ }
1567
+
1568
+ export function forgetGithubToken(userId: string): void {
1569
+ boxTokens.delete(userId);
1570
+ }
1571
+
1572
+ /** Test seam. */
1573
+ export function clearGithubTokenCacheForTesting(): void {
1574
+ boxTokens.clear();
1575
+ }
1576
+
1577
+ /** The secret-free metadata a token parameter carries as tags, so a
1578
+ * replacement box lists the user as connected without reading the value. */
1579
+ export function githubTokenMetadataTags(value: {
1580
+ installationId: number;
1581
+ generation: number;
1582
+ tokenKind: string;
1583
+ expiresAt: number | null;
1584
+ updatedAt: number;
1585
+ }): Record<string, string> {
1586
+ return {
1587
+ [GITHUB_TOKEN_TAGS.installationId]: String(value.installationId),
1588
+ [GITHUB_TOKEN_TAGS.generation]: String(value.generation),
1589
+ [GITHUB_TOKEN_TAGS.tokenKind]: value.tokenKind,
1590
+ [GITHUB_TOKEN_TAGS.expiresAt]: value.expiresAt === null ? "" : String(value.expiresAt),
1591
+ [GITHUB_TOKEN_TAGS.updatedAt]: String(value.updatedAt),
1592
+ };
1593
+ }
1594
+
1595
+ /** Fill the cache from the store for a store-held row. True when a token is
1596
+ * cached afterwards (or the row is not store-held); false when there is no
1597
+ * row, no store, or no parameter. Throws on a store failure. */
1598
+ export async function loadGithubTokenIntoCache(userId: string): Promise<boolean> {
1599
+ if (cachedGithubToken(userId) !== null) return true;
1600
+ const row = getDb()
1601
+ .select({
1602
+ refreshTokenCt: schema.githubTokens.refreshTokenCt,
1603
+ generation: schema.githubTokens.generation,
1604
+ })
1605
+ .from(schema.githubTokens)
1606
+ .where(eq(schema.githubTokens.userId, userId))
1607
+ .get();
1608
+ if (!row) return false;
1609
+ if (!isStoreHeldGithubRow(row)) return true;
1610
+ const store = configuredCredentialStore();
1611
+ if (!store) return false;
1612
+ const raw = await store.get(githubTokenParameterName(userId));
1613
+ if (!raw) return false;
1614
+ const parsed: unknown = JSON.parse(raw);
1615
+ const value =
1616
+ parsed && typeof parsed === "object" && !Array.isArray(parsed)
1617
+ ? (parsed as { token?: unknown; generation?: unknown })
1618
+ : {};
1619
+ if (typeof value.token !== "string" || value.token.length === 0) {
1620
+ throw new Error("the stored GitHub token is malformed");
1621
+ }
1622
+ if (value.generation !== row.generation) {
1623
+ console.warn(
1624
+ `[github] user ${userId}: the credential store holds generation ${String(value.generation)}, this box recorded ${row.generation}`,
1625
+ );
1626
+ }
1627
+ rememberGithubToken(userId, value.token);
1628
+ return true;
1629
+ }
1630
+
1631
+ /** A token row with no material (sign-in box). Same fence as `storeTokenRow`. */
1632
+ export function insertStoreHeldGithubTokenRow(
1633
+ userId: string,
1634
+ fields: {
1635
+ installationId: number;
1636
+ generation: number;
1637
+ refreshTokenExpiresAt: number | null;
1638
+ kind: string;
1639
+ updatedAt: number;
1640
+ storeRef: string;
1641
+ },
1642
+ ): void {
1643
+ const values = {
1644
+ ...fields,
1645
+ refreshTokenCt: Buffer.alloc(0),
1646
+ refreshTokenNonce: Buffer.alloc(0),
1647
+ };
1648
+ getDb().transaction((tx) => {
1649
+ const fence = tx
1650
+ .select({
1651
+ generation: schema.githubCredentialGenerations.generation,
1652
+ mutation: schema.githubCredentialGenerations.mutation,
1653
+ })
1654
+ .from(schema.githubCredentialGenerations)
1655
+ .where(eq(schema.githubCredentialGenerations.userId, userId))
1656
+ .get();
1657
+ if (fence?.mutation !== "set" || fence.generation !== fields.generation) {
1658
+ throw new Error("GitHub credential generation changed before storage");
1659
+ }
1660
+ tx.insert(schema.githubTokens)
1661
+ .values({ userId, ...values })
1662
+ .onConflictDoUpdate({ target: schema.githubTokens.userId, set: values })
1663
+ .run();
1664
+ });
1665
+ }
1666
+
1667
+ /** Sign-in box (ADR-136): the store first, awaited — a refused put is the
1668
+ * connect's failure — then tags, a store-held row, and the cache. */
1669
+ export async function storeGithubTokenStoreFirst(
1670
+ userId: string,
1671
+ fields: {
1672
+ installationId: number;
1673
+ generation: number;
1674
+ refreshTokenExpiresAt: number | null;
1675
+ kind: string;
1676
+ updatedAt: number;
1677
+ },
1678
+ token: string,
1679
+ ): Promise<void> {
1680
+ const store = configuredCredentialStore();
1681
+ if (!store) throw new Error("the sign-in box has no credential store to hold the token");
1682
+ const name = githubTokenParameterName(userId);
1683
+ const value: StoredGithubToken = {
1684
+ v: 1,
1685
+ kind: "github",
1686
+ userId,
1687
+ installationId: fields.installationId,
1688
+ generation: fields.generation,
1689
+ tokenKind: fields.kind,
1690
+ token,
1691
+ expiresAt: fields.refreshTokenExpiresAt,
1692
+ updatedAt: fields.updatedAt,
1693
+ };
1694
+ await store.put(name, JSON.stringify(value));
1695
+ try {
1696
+ await store.tag(name, githubTokenMetadataTags(value));
1697
+ } catch (err) {
1698
+ console.warn(
1699
+ `[github] user ${userId}: tagging ${name} failed (${err instanceof Error ? err.message : String(err)}) — a replacement box reads the value once to list this user`,
1700
+ );
1701
+ }
1702
+ insertStoreHeldGithubTokenRow(userId, { ...fields, storeRef: name });
1703
+ rememberGithubToken(userId, token);
1704
+ }
1705
+
1706
+ /** Delete the row only (no store call) — the start-time reconcile's "gone
1707
+ * elsewhere" move on the sign-in box. */
1708
+ export function deleteGithubTokenRowLocal(userId: string): void {
1709
+ forgetGithubToken(userId);
1710
+ getDb()
1711
+ .delete(schema.githubTokens)
1712
+ .where(eq(schema.githubTokens.userId, userId))
1713
+ .run();
1714
+ notifyGithubChange();
1715
+ }
1716
+
1717
+ // --- container injection ----------------------------------------------------
1718
+
1719
+ /** Injectable docker-exec seam (mocked in tests). Async so the docker call
1720
+ * doesn't block the host event loop (runs on every channel ensure + refresh). */
1721
+ export type DockerExec = (
1722
+ args: string[],
1723
+ input: string,
1724
+ ) => Promise<{ status: number | null; stderr: string }>;
1725
+
1726
+ const defaultExec: DockerExec = async (args, input) => {
1727
+ const res = await taskContainerCli(args, {
1728
+ input,
1729
+ timeoutMs: EXEC_TIMEOUT_MS,
1730
+ });
1731
+ return { status: res.status, stderr: res.stderr };
1732
+ };
1733
+
1734
+ /**
1735
+ * Write the access token into the container's gh config and make gh Git's
1736
+ * HTTPS credential helper. Also removes the legacy global HTTPS→SSH rewrite so
1737
+ * reconnecting heals already-running containers without recreation.
1738
+ */
1739
+ export async function injectIntoContainer(
1740
+ taskId: string,
1741
+ accessToken: string,
1742
+ exec: DockerExec = defaultExec,
1743
+ admitContainerWork: () => void = requireContainerRuntimeOperational,
1744
+ environment?: Pick<TaskEnvironmentHandle, "exec">,
1745
+ ): Promise<void> {
1746
+ const container = taskAppContainerName(taskId);
1747
+ // Provider-neutral path: same in-container `/usr/bin/env -i` command, same
1748
+ // sanitized-plus-authority environment, argv/stdin separation preserved.
1749
+ const run: (
1750
+ command: string[],
1751
+ input: string,
1752
+ ) => Promise<{ status: number | null; stderr: string }> = environment
1753
+ ? async (command, input) => {
1754
+ const [first, ...rest] = [...CLEAN_CONTAINER_ENV, ...command];
1755
+ const result = await environment.exec({
1756
+ projectEnvProfile: "maintenance",
1757
+ argv: [first!, ...rest],
1758
+ user: "node",
1759
+ env: { ...SANITIZED_CONTAINER_ENV, ...RUNTIME_AUTHORITY_ENV },
1760
+ ...(input ? { stdin: Buffer.from(input) } : {}),
1761
+ timeoutMs: EXEC_TIMEOUT_MS,
1762
+ maxOutputBytes: 256 * 1024,
1763
+ });
1764
+ return {
1765
+ status: result.exitCode,
1766
+ stderr: Buffer.from(result.stderr).toString("utf8"),
1767
+ };
1768
+ }
1769
+ : (command, input) =>
1770
+ exec(
1771
+ managedContainerExecArgs(container, command, input.length > 0),
1772
+ input,
1773
+ );
1774
+ admitContainerWork();
1775
+ const res = await run(
1776
+ [
1777
+ "/usr/bin/gh",
1778
+ "auth",
1779
+ "login",
1780
+ "--with-token",
1781
+ "--hostname",
1782
+ "github.com",
1783
+ "--git-protocol",
1784
+ "https",
1785
+ ],
1786
+ `${accessToken}\n`,
1787
+ );
1788
+ admitContainerWork();
1789
+ if (res.status !== 0) {
1790
+ throw new Error(`gh auth login failed in ${container}: ${res.stderr.trim()}`);
1791
+ }
1792
+
1793
+ admitContainerWork();
1794
+ const setup = await run(
1795
+ ["/usr/bin/gh", "auth", "setup-git", "--hostname", "github.com"],
1796
+ "",
1797
+ );
1798
+ admitContainerWork();
1799
+ if (setup.status !== 0) {
1800
+ throw new Error(
1801
+ `gh git credential setup failed in ${container}: ${setup.stderr.trim()}`,
1802
+ );
1803
+ }
1804
+
1805
+ admitContainerWork();
1806
+ const transport = await run(
1807
+ [
1808
+ "/bin/sh",
1809
+ "-c",
1810
+ "/usr/bin/git config --global --unset-all 'url.git@github.com:.insteadOf' >/dev/null 2>&1 || true; " +
1811
+ "/usr/bin/git config --global --unset-all core.sshCommand >/dev/null 2>&1 || true; " +
1812
+ "/usr/bin/git config --global --replace-all 'url.https://github.com/.insteadOf' 'git@github.com:'; " +
1813
+ "/usr/bin/git config --global --add 'url.https://github.com/.insteadOf' 'ssh://git@github.com/'",
1814
+ ],
1815
+ "",
1816
+ );
1817
+ admitContainerWork();
1818
+ if (transport.status !== 0) {
1819
+ throw new Error(
1820
+ `Git HTTPS transport setup failed in ${container}: ${transport.stderr.trim()}`,
1821
+ );
1822
+ }
1823
+ }
1824
+
1825
+ // --- refresh schedule -------------------------------------------------------
1826
+
1827
+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
1828
+
1829
+ type AuthExpiredHandler = (taskId: string, userId: string, reason: string) => void;
1830
+ let authExpiredHandler: AuthExpiredHandler | null = null;
1831
+
1832
+ /** Phase D wires this to the task-chat "auth expired" system message. */
1833
+ export function setAuthExpiredHandler(fn: AuthExpiredHandler | null): void {
1834
+ authExpiredHandler = fn;
1835
+ }
1836
+
1837
+ export function scheduleRefresh(
1838
+ taskId: string,
1839
+ userId: string,
1840
+ expiresAt: number,
1841
+ generation = githubAuthGeneration(userId),
1842
+ ): void {
1843
+ clearRefresh(taskId);
1844
+ const delay = Math.max(0, expiresAt - Date.now() - REFRESH_LEAD_MS);
1845
+ const timer = setTimeout(() => {
1846
+ timers.delete(taskId);
1847
+ if (githubAuthGeneration(userId) !== generation) return;
1848
+ if (!backgroundContainerWorkAllowed()) {
1849
+ scheduleGithubRetry(taskId, userId, 0, {}, generation);
1850
+ return;
1851
+ }
1852
+ void enqueueTaskGitAuth(taskId, userId, {}, {
1853
+ generation,
1854
+ getGeneration: () => githubAuthGeneration(userId),
1855
+ attempt: 0,
1856
+ }).catch((err) => {
1857
+ if (githubAuthGeneration(userId) !== generation) return;
1858
+ console.warn(
1859
+ `[github] task ${taskId}: refresh reconciliation failed: ${
1860
+ err instanceof Error ? err.message : err
1861
+ }`,
1862
+ );
1863
+ scheduleGithubRetry(taskId, userId, 0, {}, generation);
1864
+ });
1865
+ }, delay);
1866
+ timer.unref?.();
1867
+ timers.set(taskId, timer);
1868
+ }
1869
+
1870
+ /**
1871
+ * True when the exchange error names a GitHub-side revocation — the refresh
1872
+ * token itself is dead, so retrying is pointless and the user must re-grant.
1873
+ * Deliberately keyed on GitHub's OAuth error strings (passed through the
1874
+ * cloud's 502 body by requestAccessToken), NOT on bare HTTP status: the cloud
1875
+ * returns 401 only for a host-token mismatch and wraps all GitHub failures as
1876
+ * 502, so a status like "401"/"403" alone never means "revoked" — the old
1877
+ * \b40[13]\b match here deleted VALID refresh tokens on host-auth/proxy noise.
1878
+ */
1879
+ function isRevokedTokenError(reason: string): boolean {
1880
+ return /revoked|invalid_grant|bad_refresh/i.test(reason);
1881
+ }
1882
+
1883
+ /**
1884
+ * True when the failure names a genuinely bad credential — GitHub answered the
1885
+ * validation with 401/403 ("Bad credentials"), so the token really is wrong and
1886
+ * only a reconnect fixes it. Distinct from a 5xx blip (see below).
1887
+ */
1888
+ function isBadCredentialError(reason: string): boolean {
1889
+ return /validating token: HTTP 40[13]\b|Bad credentials|401 Unauthorized|403 Forbidden/i.test(
1890
+ reason,
1891
+ );
1892
+ }
1893
+
1894
+ /**
1895
+ * True when the failure is a GitHub-side / network blip that a retry will heal
1896
+ * — the API returned 5xx (as happens when `gh auth login` validates the token
1897
+ * against api.github.com during an outage), a rate-limit, or the fetch itself
1898
+ * failed. Such a failure must NOT be surfaced as "authentication expired —
1899
+ * reconnect": the token is fine, GitHub is briefly unavailable. A genuine bad
1900
+ * credential (401/403) is explicitly excluded so it still routes to reconnect.
1901
+ */
1902
+ export function isTransientGithubError(reason: string): boolean {
1903
+ if (isBadCredentialError(reason)) return false;
1904
+ // A blank reason proves nothing about the credential. Seen live 2026-08-25:
1905
+ // `gh auth login` raced its own task's teardown (the container died under
1906
+ // the exec), exited nonzero with EMPTY stderr, and the fall-through verdict
1907
+ // told the user their auth expired and to reconnect — it hadn't. Unknown
1908
+ // must route to the retrying/soft path, never to a reconnect demand.
1909
+ if (reason.trim() === "") return true;
1910
+ return (
1911
+ /HTTP 5\d\d|Service Unavailable|Bad Gateway|Gateway Time-?out|server error|rate limit|too many requests|secondary rate|unavailable|unreachable/i.test(
1912
+ reason,
1913
+ ) ||
1914
+ /\bfetch failed\b|timeout|timed out|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ECONNABORTED|ENOTFOUND|EAI_AGAIN|socket hang up|\bnetwork\b/i.test(
1915
+ reason,
1916
+ )
1917
+ );
1918
+ }
1919
+
1920
+ export function clearRefresh(taskId: string): void {
1921
+ clearGithubRetry(taskId);
1922
+ const t = timers.get(taskId);
1923
+ if (t) {
1924
+ clearTimeout(t);
1925
+ timers.delete(taskId);
1926
+ }
1927
+ }
1928
+
1929
+ // --- self-heal: retry a failed gh setup with bounded backoff ----------------
1930
+ // A gh setup/refresh can fail transiently — e.g. the cloud token-exchange fetch
1931
+ // times out while the host is briefly unreachable (as happened when the bridge
1932
+ // was flapping). Without a retry the task is stranded on a stale/invalid token
1933
+ // until someone runs /retry-gh, because ensureSessions won't re-inject once a
1934
+ // channel's sessions are started. Retry with bounded backoff; cleared on
1935
+ // success (clearGithubRetry) and on task teardown (via clearRefresh).
1936
+ const RETRY_BACKOFF_MS = [5_000, 20_000, 60_000, 180_000, 300_000];
1937
+ const retryTimers = new Map<string, ReturnType<typeof setTimeout>>();
1938
+
1939
+ function clearGithubRetry(taskId: string): void {
1940
+ const t = retryTimers.get(taskId);
1941
+ if (t) {
1942
+ clearTimeout(t);
1943
+ retryTimers.delete(taskId);
1944
+ }
1945
+ }
1946
+
1947
+ /** Is the task still live on this host? Retries for ended tasks would docker
1948
+ * exec against a removed container and post notes into a finished chat. */
1949
+ function taskIsActive(taskId: string): boolean {
1950
+ const row = getDb()
1951
+ .select({ endedAt: schema.hostTasks.endedAt })
1952
+ .from(schema.hostTasks)
1953
+ .where(eq(schema.hostTasks.taskId, taskId))
1954
+ .get();
1955
+ return row != null && row.endedAt == null;
1956
+ }
1957
+
1958
+ function scheduleGithubRetry(
1959
+ taskId: string,
1960
+ userId: string,
1961
+ attempt: number,
1962
+ deps: SetupTaskDeps = {},
1963
+ generation = githubAuthGeneration(userId),
1964
+ ): void {
1965
+ clearGithubRetry(taskId);
1966
+ if (attempt >= RETRY_BACKOFF_MS.length) {
1967
+ // attempt counts retries fired so far; +1 for the initial try.
1968
+ console.warn(
1969
+ `[github] task ${taskId}: gh setup still failing after ${attempt + 1} attempts — giving up until /retry-gh`,
1970
+ );
1971
+ authExpiredHandler?.(
1972
+ taskId,
1973
+ userId,
1974
+ "gh setup kept failing — send /retry-gh once the connection recovers",
1975
+ );
1976
+ return;
1977
+ }
1978
+ const delay = RETRY_BACKOFF_MS[attempt] ?? 300_000;
1979
+ const timer = setTimeout(() => {
1980
+ retryTimers.delete(taskId);
1981
+ if (githubAuthGeneration(userId) !== generation) return;
1982
+ const isActive = deps.taskIsActive ?? taskIsActive;
1983
+ if (!isActive(taskId)) return; // task ended while we backed off
1984
+ if (!backgroundContainerWorkAllowed()) {
1985
+ // Runtime downtime is not a failed GitHub attempt. Preserve the same
1986
+ // retry budget and wait for another backoff window instead of spawning
1987
+ // Docker while the host is deliberately fail-closed.
1988
+ scheduleGithubRetry(taskId, userId, attempt, deps, generation);
1989
+ return;
1990
+ }
1991
+ void enqueueTaskGitAuth(taskId, userId, {}, {
1992
+ generation,
1993
+ getGeneration: () => githubAuthGeneration(userId),
1994
+ setupDeps: deps,
1995
+ attempt: attempt + 1,
1996
+ }).catch((err) => {
1997
+ if (githubAuthGeneration(userId) !== generation) return;
1998
+ console.warn(
1999
+ `[github] task ${taskId}: retry reconciliation failed: ${
2000
+ err instanceof Error ? err.message : err
2001
+ }`,
2002
+ );
2003
+ scheduleGithubRetry(taskId, userId, attempt + 1, deps, generation);
2004
+ });
2005
+ }, delay);
2006
+ timer.unref?.();
2007
+ retryTimers.set(taskId, timer);
2008
+ }
2009
+
2010
+ /** Test/diagnostic: is a gh-setup retry currently armed for this task? */
2011
+ export function hasPendingGithubRetry(taskId: string): boolean {
2012
+ return retryTimers.has(taskId);
2013
+ }
2014
+
2015
+ /**
2016
+ * Injectable seams for {@link setupTaskGithub} (mocked in tests so the decision
2017
+ * logic needs neither the host DB nor docker). All default to the real impls.
2018
+ */
2019
+ export interface SetupTaskDeps {
2020
+ hasToken?: (userId: string) => boolean;
2021
+ requestAccessToken?: (
2022
+ userId: string,
2023
+ ) => Promise<{ accessToken: string; expiresAt: number | null } | null>;
2024
+ inject?: (taskId: string, token: string) => void | Promise<void>;
2025
+ schedule?: (taskId: string, userId: string, expiresAt: number) => void;
2026
+ deleteToken?: (userId: string) => void;
2027
+ taskIsActive?: (taskId: string) => boolean;
2028
+ scrub?: (taskId: string) => Promise<boolean | void>;
2029
+ /** Late-bound provider handle for the default injection path. Absent (or
2030
+ * null at call time) keeps the legacy Docker fallback. */
2031
+ environment?: () => Pick<TaskEnvironmentHandle, "exec"> | null;
2032
+ }
2033
+
2034
+ /**
2035
+ * Set up GitHub auth for a task at task-up (ADR-027). Connected user → mint +
2036
+ * inject an access token + schedule refresh. Else UAI_GH_PAT_FALLBACK → inject
2037
+ * the PAT (no refresh; attributes to the PAT owner). Else skip. Best-effort:
2038
+ * a failure emits the auth-expired note but never aborts task-up. Returns true
2039
+ * iff a token was injected.
2040
+ */
2041
+ interface SetupExecutionGuard {
2042
+ generation: number;
2043
+ isCurrent: () => boolean;
2044
+ admitContainerWork: () => void;
2045
+ }
2046
+
2047
+ export async function setupTaskGithub(
2048
+ taskId: string,
2049
+ userId: string,
2050
+ deps: SetupTaskDeps = {},
2051
+ attempt = 0,
2052
+ guard?: SetupExecutionGuard,
2053
+ ): Promise<boolean> {
2054
+ const capturedGeneration =
2055
+ guard?.generation ?? githubAuthGeneration(userId);
2056
+ const isCurrent =
2057
+ guard?.isCurrent ??
2058
+ (() => githubAuthGeneration(userId) === capturedGeneration);
2059
+ const _hasToken = deps.hasToken ?? hasToken;
2060
+ const _request = deps.requestAccessToken ?? requestAccessToken;
2061
+ const _inject =
2062
+ deps.inject ??
2063
+ ((t: string, tok: string) =>
2064
+ injectIntoContainer(
2065
+ t,
2066
+ tok,
2067
+ defaultExec,
2068
+ guard?.admitContainerWork ?? requireContainerRuntimeOperational,
2069
+ deps.environment?.() ?? undefined,
2070
+ ));
2071
+ if (!isCurrent()) return false;
2072
+ try {
2073
+ if (_hasToken(userId)) {
2074
+ const tok = await _request(userId);
2075
+ if (tok && isCurrent()) {
2076
+ guard?.admitContainerWork();
2077
+ await _inject(taskId, tok.accessToken);
2078
+ guard?.admitContainerWork();
2079
+ // The mutation itself cannot be interrupted, but a newer transition is
2080
+ // already queued behind us. Do not let this stale run arm more work.
2081
+ if (!isCurrent()) return false;
2082
+ // Non-expiring (ADR-033) tokens (expiresAt null) need no refresh timer.
2083
+ if (tok.expiresAt !== null) {
2084
+ if (deps.schedule) {
2085
+ deps.schedule(taskId, userId, tok.expiresAt);
2086
+ } else {
2087
+ scheduleRefresh(
2088
+ taskId,
2089
+ userId,
2090
+ tok.expiresAt,
2091
+ capturedGeneration,
2092
+ );
2093
+ }
2094
+ }
2095
+ clearGithubRetry(taskId);
2096
+ console.log(`[github] task ${taskId}: injected user access token`);
2097
+ return true;
2098
+ }
2099
+ }
2100
+ if (!isCurrent()) return false;
2101
+ const pat = process.env.UAI_GH_PAT_FALLBACK;
2102
+ if (pat) {
2103
+ // The PAT is ONE host-wide credential, and injectIntoContainer wires it
2104
+ // into `gh` AND all git transport. Injecting it for whoever happens to
2105
+ // own the task therefore authenticates that user's container as the
2106
+ // PAT's owner, handing every unconnected user the owner's full
2107
+ // read/write repository access. That was invisible while a host had one
2108
+ // user and became a silent cross-user credential leak the moment it had
2109
+ // two. Bind the PAT to the single user it belongs to and fail closed
2110
+ // otherwise: a host that upgrades into multi-user must not start
2111
+ // leaking because nobody remembered to unset an env var.
2112
+ const patOwner = process.env.UAI_GH_PAT_FALLBACK_USER_ID;
2113
+ if (!patOwner) {
2114
+ console.warn(
2115
+ `[github] task ${taskId}: UAI_GH_PAT_FALLBACK is set but ` +
2116
+ "UAI_GH_PAT_FALLBACK_USER_ID is not — refusing to inject a " +
2117
+ "host-wide PAT into a task. Set the owner's user id to keep the " +
2118
+ "single-user fallback, or remove the PAT and connect GitHub per " +
2119
+ "user.",
2120
+ );
2121
+ } else if (patOwner !== userId) {
2122
+ console.warn(
2123
+ `[github] task ${taskId}: PAT fallback is owned by ${patOwner}, ` +
2124
+ `not task owner ${userId} — skipping.`,
2125
+ );
2126
+ } else {
2127
+ guard?.admitContainerWork();
2128
+ await _inject(taskId, pat);
2129
+ guard?.admitContainerWork();
2130
+ clearGithubRetry(taskId);
2131
+ console.log(`[github] task ${taskId}: using PAT fallback (no refresh)`);
2132
+ return true;
2133
+ }
2134
+ }
2135
+ console.log(`[github] task ${taskId}: gh not configured for user ${userId}`);
2136
+ return false;
2137
+ } catch (err) {
2138
+ // A newer connect/disconnect owns the final state and is queued behind this
2139
+ // operation. The superseded run must neither delete its grant nor retry.
2140
+ if (!isCurrent()) return false;
2141
+ // Runtime unavailability is not an ordinary "GitHub not configured" or
2142
+ // retryable credential verdict. Reconciliation callers must reject so a
2143
+ // destructive set/clear transition cannot be acknowledged as complete.
2144
+ if (err instanceof ContainerRuntimeUnavailableError) throw err;
2145
+ const reason = err instanceof Error ? err.message : String(err);
2146
+ console.warn(`[github] task ${taskId}: gh setup failed: ${reason}`);
2147
+ // A task already torn down must not receive notes: its thread is
2148
+ // read-only, and a gh failure RACING that teardown (exec into a dying
2149
+ // container, live 2026-08-25) says nothing a reader can act on.
2150
+ const taskStillActive = (deps.taskIsActive ?? taskIsActive)(taskId);
2151
+ // A revoked refresh token can't recover by retrying — drop it so the user
2152
+ // gets a clean re-grant, and tell the chat once.
2153
+ if (isRevokedTokenError(reason)) {
2154
+ (deps.deleteToken ?? deleteToken)(userId);
2155
+ if (taskStillActive) authExpiredHandler?.(taskId, userId, reason);
2156
+ return false;
2157
+ }
2158
+ // A retry chain already armed for this task means a sibling entry point
2159
+ // (task-up and channel-ensure both call setup) already reported this and
2160
+ // owns recovery — don't post a duplicate note or start a competing chain.
2161
+ // `/retry-gh` clears the chain first, so a manual retry is never swallowed.
2162
+ if (attempt === 0 && retryTimers.has(taskId)) return false;
2163
+ // Transient: self-heal with bounded backoff. Post the chat note only on
2164
+ // the FIRST failure of a chain — each retry re-enters this catch, and six
2165
+ // "gh auth" notes for one outage is noise. Exhaustion posts its own note
2166
+ // (in scheduleGithubRetry). The handler classifies the reason (transient
2167
+ // 5xx blip vs. genuine expiry) and words the note accordingly.
2168
+ if (attempt === 0 && taskStillActive) {
2169
+ authExpiredHandler?.(taskId, userId, reason);
2170
+ }
2171
+ scheduleGithubRetry(
2172
+ taskId,
2173
+ userId,
2174
+ attempt,
2175
+ deps,
2176
+ capturedGeneration,
2177
+ );
2178
+ return false;
2179
+ }
2180
+ }
2181
+
2182
+ // One task-local queue owns transport transitions. Connect/disconnect frames,
2183
+ // channel recovery, and /retry-gh can otherwise overlap: a slow old-token
2184
+ // injection must never win after a newer disconnect (or vice versa).
2185
+ const gitAuthReconcileQueues = new Map<string, Promise<boolean>>();
2186
+
2187
+ const REMOVE_MANAGED_GITHUB_AUTH = [
2188
+ "set -eu",
2189
+ "/usr/bin/rm -f -- \"$GH_CONFIG_DIR/hosts.yml\"",
2190
+ "test ! -e \"$GH_CONFIG_DIR/hosts.yml\"",
2191
+ "/usr/bin/git config --global --unset-all 'credential.https://github.com.helper' >/dev/null 2>&1 || true",
2192
+ "/usr/bin/git config --global --unset-all 'credential.https://gist.github.com.helper' >/dev/null 2>&1 || true",
2193
+ "/usr/bin/git config --global --unset-all 'url.git@github.com:.insteadOf' >/dev/null 2>&1 || true",
2194
+ "/usr/bin/git config --global --unset-all core.sshCommand >/dev/null 2>&1 || true",
2195
+ "/usr/bin/git config --global --replace-all 'url.https://github.com/.insteadOf' 'git@github.com:'",
2196
+ "/usr/bin/git config --global --add 'url.https://github.com/.insteadOf' 'ssh://git@github.com/'",
2197
+ "test -z \"$(/usr/bin/git config --global --get-all 'credential.https://github.com.helper' 2>/dev/null || true)\"",
2198
+ "test -z \"$(/usr/bin/git config --global --get-all 'credential.https://gist.github.com.helper' 2>/dev/null || true)\"",
2199
+ ].join("; ");
2200
+
2201
+ type DockerCommand = typeof dockerCli;
2202
+
2203
+ async function checkedDockerCommand(
2204
+ run: DockerCommand,
2205
+ args: string[],
2206
+ label: string,
2207
+ timeoutMs = EXEC_TIMEOUT_MS,
2208
+ admitContainerWork: () => void = requireContainerRuntimeOperational,
2209
+ ): Promise<Awaited<ReturnType<DockerCommand>>> {
2210
+ admitContainerWork();
2211
+ const result = await run(args, { timeoutMs });
2212
+ admitContainerWork();
2213
+ if (result.status !== 0) {
2214
+ throw new Error(
2215
+ `${label} failed: ${result.stderr.trim() || `exit ${result.status}`}`,
2216
+ );
2217
+ }
2218
+ return result;
2219
+ }
2220
+
2221
+ export async function removeGithubFromContainer(
2222
+ taskId: string,
2223
+ run: DockerCommand = taskContainerCli,
2224
+ admitContainerWork: () => void = requireContainerRuntimeOperational,
2225
+ ): Promise<boolean> {
2226
+ // ADR-133: a task machine has no container runtime and no app container —
2227
+ // the credential lives in the machine's own gh config, installed over the
2228
+ // environment at task-up (`machineTaskGithubAuth`). Inspecting Docker here
2229
+ // threw `spawn docker ENOENT` on every session start, so no agent ever
2230
+ // came up on the first live hostless run (2026-09-10, smoke 6). Same answer
2231
+ // a Docker host gives for a machine task: no container, nothing to scrub.
2232
+ if (sidecarMode()) return false;
2233
+ const container = taskAppContainerName(taskId);
2234
+ admitContainerWork();
2235
+ const observed = await taskAppContainerState(container, run as never);
2236
+ admitContainerWork();
2237
+ if (observed.kind === "unknown") {
2238
+ throw new Error(`inspect ${container} failed: ${observed.detail}`);
2239
+ }
2240
+ if (observed.kind === "absent") {
2241
+ return false; // no container means no credential to retain
2242
+ }
2243
+
2244
+ if (observed.kind !== "running") {
2245
+ // Never start or unpause untrusted task code while its old credential is
2246
+ // still on disk. The app container is disposable; workspace/repository
2247
+ // state lives outside it and provisioning recreates it on Resume.
2248
+ admitContainerWork();
2249
+ await removeTaskAppContainer(container, run as never);
2250
+ admitContainerWork();
2251
+ return false;
2252
+ }
2253
+
2254
+ await checkedDockerCommand(
2255
+ run,
2256
+ managedContainerExecArgs(container, [
2257
+ "/bin/sh",
2258
+ "-c",
2259
+ REMOVE_MANAGED_GITHUB_AUTH,
2260
+ ]),
2261
+ `remove managed GitHub auth from ${container}`,
2262
+ EXEC_TIMEOUT_MS,
2263
+ admitContainerWork,
2264
+ );
2265
+ return true;
2266
+ }
2267
+
2268
+ export interface ReconcileTaskGitAuthDeps {
2269
+ hasToken?: (userId: string) => boolean;
2270
+ setupGithub?: (taskId: string, userId: string) => Promise<boolean>;
2271
+ setupDeps?: SetupTaskDeps;
2272
+ authGeneration?: (userId: string) => number;
2273
+ logoutContainer?: (taskId: string) => Promise<boolean | void>;
2274
+ ensureSsh?: (taskId: string, userId: string) => Promise<boolean>;
2275
+ selectSsh?: (taskId: string) => Promise<boolean>;
2276
+ /** Last-hop Docker admission. Recovery supplies an epoch-scoped predicate
2277
+ * while the public runtime deliberately remains `checking`. */
2278
+ admitContainerWork?: () => void;
2279
+ }
2280
+
2281
+ interface ReconcileInvocation {
2282
+ generation: number;
2283
+ getGeneration: () => number;
2284
+ setupDeps?: SetupTaskDeps;
2285
+ attempt: number;
2286
+ }
2287
+
2288
+ /**
2289
+ * Re-read the current host credential state and select exactly one Git
2290
+ * transport for a live task: connected user token over HTTPS, otherwise the
2291
+ * task owner's SSH identity. Serialized per task for reconnect safety.
2292
+ */
2293
+ export function reconcileTaskGitAuth(
2294
+ taskId: string,
2295
+ userId: string,
2296
+ deps: ReconcileTaskGitAuthDeps = {},
2297
+ ): Promise<boolean> {
2298
+ const getGeneration = () =>
2299
+ (deps.authGeneration ?? githubAuthGeneration)(userId);
2300
+ return enqueueTaskGitAuth(taskId, userId, deps, {
2301
+ generation: getGeneration(),
2302
+ getGeneration,
2303
+ setupDeps: deps.setupDeps,
2304
+ attempt: 0,
2305
+ });
2306
+ }
2307
+
2308
+ /**
2309
+ * ADR-154: the runner's own GitHub sign-in (or its SSH key) reaches a task
2310
+ * only while the runner is on its own credentials. Leaving them waits for a
2311
+ * reconciliation already under way, and each of its steps, the last write
2312
+ * included, is fenced on them: none starts once leaving has begun.
2313
+ */
2314
+ function enqueueTaskGitAuth(
2315
+ taskId: string,
2316
+ userId: string,
2317
+ deps: ReconcileTaskGitAuthDeps,
2318
+ invocation: ReconcileInvocation,
2319
+ ): Promise<boolean> {
2320
+ return (
2321
+ whileOnOwnCredentials(() => enqueueOwnTaskGitAuth(taskId, userId, deps, invocation)) ??
2322
+ Promise.resolve(false)
2323
+ );
2324
+ }
2325
+
2326
+ function enqueueOwnTaskGitAuth(
2327
+ taskId: string,
2328
+ userId: string,
2329
+ deps: ReconcileTaskGitAuthDeps,
2330
+ invocation: ReconcileInvocation,
2331
+ ): Promise<boolean> {
2332
+ const previous = gitAuthReconcileQueues.get(taskId) ?? Promise.resolve(false);
2333
+ let queued: Promise<boolean>;
2334
+ queued = previous
2335
+ .catch(() => false)
2336
+ .then(async () => {
2337
+ const isCurrent = () =>
2338
+ invocation.getGeneration() === invocation.generation && onOwnCredentials();
2339
+ const admitContainerWork =
2340
+ deps.admitContainerWork ?? requireContainerRuntimeOperational;
2341
+ if (!isCurrent()) return false;
2342
+ admitContainerWork();
2343
+
2344
+ const connected =
2345
+ deps.hasToken ?? invocation.setupDeps?.hasToken ?? hasToken;
2346
+ const shouldConnect = connected(userId);
2347
+ if (!shouldConnect) clearRefresh(taskId);
2348
+
2349
+ // Authoritative replacement, not gh's multi-account add/switch behavior:
2350
+ // remove every managed account/helper first, in the same per-task queue,
2351
+ // then install only the current user's credential. Non-running containers
2352
+ // are removed rather than executed with the old credential on disk.
2353
+ const logoutContainer =
2354
+ deps.logoutContainer ??
2355
+ invocation.setupDeps?.scrub ??
2356
+ ((id: string) =>
2357
+ removeGithubFromContainer(id, taskContainerCli, admitContainerWork));
2358
+ const canConfigureLiveTransport = await logoutContainer(taskId);
2359
+ if (!isCurrent()) return false;
2360
+ admitContainerWork();
2361
+ if (canConfigureLiveTransport === false) {
2362
+ // No credential-bearing container remains. A connected credential will
2363
+ // be installed when Compose recreates it; disconnected state is already
2364
+ // fully scrubbed.
2365
+ return shouldConnect;
2366
+ }
2367
+
2368
+ if (shouldConnect) {
2369
+ if (deps.setupGithub) {
2370
+ admitContainerWork();
2371
+ const result = await deps.setupGithub(taskId, userId);
2372
+ if (!isCurrent()) return false;
2373
+ admitContainerWork();
2374
+ return result;
2375
+ }
2376
+ admitContainerWork();
2377
+ const result = await setupTaskGithub(
2378
+ taskId,
2379
+ userId,
2380
+ invocation.setupDeps,
2381
+ invocation.attempt,
2382
+ {
2383
+ generation: invocation.generation,
2384
+ isCurrent,
2385
+ admitContainerWork,
2386
+ },
2387
+ );
2388
+ if (!isCurrent()) return false;
2389
+ admitContainerWork();
2390
+ return result;
2391
+ }
2392
+
2393
+ // SSH remains an opt-in fallback for users who did not connect GitHub;
2394
+ // ensuring the identity also keeps commit/tag signing available. Never
2395
+ // select SSH transport unless a usable identity was actually asserted.
2396
+ admitContainerWork();
2397
+ const hasSshIdentity = deps.ensureSsh
2398
+ ? await deps.ensureSsh(taskId, userId)
2399
+ : await ensureTaskSshIdentity(taskId, userId, undefined, {
2400
+ // Machine/sidecar tasks install the key over the environment.
2401
+ environment: () => invocation.setupDeps?.environment?.() ?? null,
2402
+ });
2403
+ if (!isCurrent()) return false;
2404
+ admitContainerWork();
2405
+ if (hasSshIdentity) {
2406
+ admitContainerWork();
2407
+ const selected = await (
2408
+ deps.selectSsh ?? configureTaskSshTransport
2409
+ )(taskId);
2410
+ if (!isCurrent()) return false;
2411
+ admitContainerWork();
2412
+ if (!selected) {
2413
+ throw new Error(
2414
+ `task ${taskId}: SSH fallback transport could not be configured`,
2415
+ );
2416
+ }
2417
+ } else {
2418
+ console.log(
2419
+ `[github] task ${taskId}: managed GitHub credential removed; using credential-free HTTPS`,
2420
+ );
2421
+ }
2422
+ return false;
2423
+ })
2424
+ .finally(() => {
2425
+ if (gitAuthReconcileQueues.get(taskId) === queued) {
2426
+ gitAuthReconcileQueues.delete(taskId);
2427
+ }
2428
+ });
2429
+ gitAuthReconcileQueues.set(taskId, queued);
2430
+ return queued;
2431
+ }
2432
+
2433
+ /** Test/maintenance hook: drop all scheduled refresh + retry timers. */
2434
+ export function clearAllRefreshTimers(): void {
2435
+ for (const t of timers.values()) clearTimeout(t);
2436
+ timers.clear();
2437
+ for (const t of retryTimers.values()) clearTimeout(t);
2438
+ retryTimers.clear();
2439
+ }