@app-vitals/agent-admin 0.0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (325) hide show
  1. package/Dockerfile +43 -0
  2. package/README.md +72 -2
  3. package/e2e/agents-page.e2e.ts +338 -0
  4. package/e2e/chat-degraded.e2e.ts +153 -0
  5. package/e2e/chat-page.e2e.ts +169 -0
  6. package/e2e/chat-progress.e2e.ts +206 -0
  7. package/e2e/chat-thread-page.e2e.ts +444 -0
  8. package/e2e/login-page.e2e.ts +148 -0
  9. package/e2e/test-server.ts +427 -0
  10. package/openapi.json +6098 -0
  11. package/openapi.platform.json +5553 -0
  12. package/package.json +62 -4
  13. package/playwright.config.ts +25 -0
  14. package/prisma/client/browser.ts +134 -0
  15. package/prisma/client/client.ts +158 -0
  16. package/prisma/client/commonInputTypes.ts +578 -0
  17. package/prisma/client/enums.ts +15 -0
  18. package/prisma/client/internal/class.ts +424 -0
  19. package/prisma/client/internal/prismaNamespace.ts +2898 -0
  20. package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
  21. package/prisma/client/models/Account.ts +1774 -0
  22. package/prisma/client/models/AccountInvite.ts +1386 -0
  23. package/prisma/client/models/AccountMember.ts +1351 -0
  24. package/prisma/client/models/Agent.ts +4554 -0
  25. package/prisma/client/models/AgentAsk.ts +1373 -0
  26. package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
  27. package/prisma/client/models/AgentCronJob.ts +2416 -0
  28. package/prisma/client/models/AgentCronRun.ts +2858 -0
  29. package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
  30. package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
  31. package/prisma/client/models/AgentEnv.ts +1421 -0
  32. package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
  33. package/prisma/client/models/AgentMember.ts +1316 -0
  34. package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
  35. package/prisma/client/models/AgentPlugin.ts +1421 -0
  36. package/prisma/client/models/AgentToken.ts +1380 -0
  37. package/prisma/client/models/AgentTool.ts +1351 -0
  38. package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
  39. package/prisma/client/models/ChatThreadWatch.ts +1183 -0
  40. package/prisma/client/models/PushSubscription.ts +1233 -0
  41. package/prisma/client/models/SessionAlertState.ts +1187 -0
  42. package/prisma/client/models/SessionFollow.ts +1187 -0
  43. package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
  44. package/prisma/client/models.ts +34 -0
  45. package/prisma/migrations/20260608000000_init/migration.sql +112 -0
  46. package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
  47. package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
  48. package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
  49. package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
  50. package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
  51. package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
  52. package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
  53. package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
  54. package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
  55. package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
  56. package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
  57. package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
  58. package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
  59. package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
  60. package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
  61. package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
  62. package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
  63. package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
  64. package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
  65. package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
  66. package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
  67. package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
  68. package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
  69. package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
  70. package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
  71. package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
  72. package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
  73. package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
  74. package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
  75. package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
  76. package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
  77. package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
  78. package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
  79. package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
  80. package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
  81. package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
  82. package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
  83. package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
  84. package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
  85. package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
  86. package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
  87. package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
  88. package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
  89. package/prisma/migrations/migration_lock.toml +3 -0
  90. package/prisma/schema/coding.prisma +85 -0
  91. package/prisma/schema/schema.prisma +506 -0
  92. package/prisma.config.ts +34 -0
  93. package/pwa-assets/icons/apple-touch-icon.png +0 -0
  94. package/pwa-assets/icons/favicon-32.png +0 -0
  95. package/pwa-assets/icons/icon-192.png +0 -0
  96. package/pwa-assets/icons/icon-512.png +0 -0
  97. package/pwa-assets/icons/icon-maskable-192.png +0 -0
  98. package/pwa-assets/icons/icon-maskable-512.png +0 -0
  99. package/src/account-agent-create.ts +42 -0
  100. package/src/account-agent-create.unit.test.ts +28 -0
  101. package/src/account-created-notifier.ts +71 -0
  102. package/src/account-created-notifier.unit.test.ts +70 -0
  103. package/src/account-email.ts +9 -0
  104. package/src/account-email.unit.test.ts +21 -0
  105. package/src/account-invites.ts +70 -0
  106. package/src/account-lifecycle.integration.test.ts +329 -0
  107. package/src/account-lifecycle.ts +206 -0
  108. package/src/account-lifecycle.unit.test.ts +108 -0
  109. package/src/account-members.ts +128 -0
  110. package/src/account-onboarding.integration.test.ts +101 -0
  111. package/src/account-onboarding.ts +95 -0
  112. package/src/account-trial-expiry-sweeper.ts +85 -0
  113. package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
  114. package/src/account-trial-warning-sweeper.ts +216 -0
  115. package/src/accounts-api.integration.test.ts +196 -0
  116. package/src/accounts-api.smoke.test.ts +292 -0
  117. package/src/accounts-api.ts +289 -0
  118. package/src/accounts-api.unit.test.ts +93 -0
  119. package/src/accounts.integration.test.ts +178 -0
  120. package/src/accounts.ts +208 -0
  121. package/src/admin-boot-config.ts +574 -0
  122. package/src/admin-boot-config.unit.test.ts +28 -0
  123. package/src/admin-boot.boot-order.unit.test.ts +91 -0
  124. package/src/admin-boot.ts +690 -0
  125. package/src/admin-dev-login.smoke.test.ts +317 -0
  126. package/src/admin-extension.ts +432 -0
  127. package/src/admin-extension.unit.test.ts +532 -0
  128. package/src/admin-local-agent.smoke.test.ts +1643 -0
  129. package/src/admin-spec.smoke.test.ts +96 -0
  130. package/src/admin-spec.ts +186 -0
  131. package/src/admin-ui-account-nav.ts +20 -0
  132. package/src/admin-ui-account-pages.ts +131 -0
  133. package/src/admin-ui-account-pages.unit.test.ts +112 -0
  134. package/src/admin-ui-account.smoke.test.ts +286 -0
  135. package/src/admin-ui-account.ts +185 -0
  136. package/src/admin-ui-accounts-pages.ts +202 -0
  137. package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
  138. package/src/admin-ui-accounts.smoke.test.ts +434 -0
  139. package/src/admin-ui-accounts.ts +266 -0
  140. package/src/admin-ui-layout.ts +60 -0
  141. package/src/admin-ui-layout.unit.test.ts +140 -0
  142. package/src/admin-ui-pages.ts +3489 -0
  143. package/src/admin-ui-pages.unit.test.ts +4355 -0
  144. package/src/admin-ui-push-test.smoke.test.ts +125 -0
  145. package/src/admin-ui-push-test.ts +102 -0
  146. package/src/admin-ui-styles.ts +656 -0
  147. package/src/admin-ui-styles.unit.test.ts +103 -0
  148. package/src/admin-ui.fixtures.ts +457 -0
  149. package/src/admin-ui.smoke.test.ts +7227 -0
  150. package/src/admin-ui.ts +3824 -0
  151. package/src/agent-account-assignment.ts +49 -0
  152. package/src/agent-account-assignment.unit.test.ts +99 -0
  153. package/src/agent-anthropic-identity.integration.test.ts +99 -0
  154. package/src/agent-anthropic-identity.ts +78 -0
  155. package/src/agent-anthropic-identity.unit.test.ts +77 -0
  156. package/src/agent-ask-config.ts +59 -0
  157. package/src/agent-ask-config.unit.test.ts +70 -0
  158. package/src/agent-ask.integration.test.ts +175 -0
  159. package/src/agent-ask.smoke.test.ts +457 -0
  160. package/src/agent-ask.ts +378 -0
  161. package/src/agent-chat-tokens.integration.test.ts +420 -0
  162. package/src/agent-chat-tokens.smoke.test.ts +551 -0
  163. package/src/agent-chat-tokens.ts +259 -0
  164. package/src/agent-cron-jobs.integration.test.ts +582 -0
  165. package/src/agent-cron-jobs.ts +615 -0
  166. package/src/agent-cron-jobs.unit.test.ts +420 -0
  167. package/src/agent-cron-run-stats.integration.test.ts +1551 -0
  168. package/src/agent-cron-run-stats.smoke.test.ts +953 -0
  169. package/src/agent-cron-run-stats.ts +752 -0
  170. package/src/agent-cron-runs.integration.test.ts +1491 -0
  171. package/src/agent-cron-runs.ts +489 -0
  172. package/src/agent-deletion-checklist.ts +128 -0
  173. package/src/agent-deletion-checklist.unit.test.ts +201 -0
  174. package/src/agent-deletion-per-agent.integration.test.ts +323 -0
  175. package/src/agent-deletion.integration.test.ts +323 -0
  176. package/src/agent-deletion.ts +329 -0
  177. package/src/agent-deletion.unit.test.ts +412 -0
  178. package/src/agent-envs.integration.test.ts +323 -0
  179. package/src/agent-envs.ts +244 -0
  180. package/src/agent-github-installations.integration.test.ts +115 -0
  181. package/src/agent-github-installations.ts +52 -0
  182. package/src/agent-manifest.ts +685 -0
  183. package/src/agent-manifest.unit.test.ts +870 -0
  184. package/src/agent-members.ts +69 -0
  185. package/src/agent-members.unit.test.ts +250 -0
  186. package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
  187. package/src/agent-plugins.integration.test.ts +157 -0
  188. package/src/agent-plugins.ts +95 -0
  189. package/src/agent-plugins.unit.test.ts +43 -0
  190. package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
  191. package/src/agent-provisioner.integration.test.ts +1245 -0
  192. package/src/agent-provisioner.ts +1650 -0
  193. package/src/agent-provisioner.unit.test.ts +1852 -0
  194. package/src/agent-tokens.integration.test.ts +150 -0
  195. package/src/agent-tokens.ts +112 -0
  196. package/src/agent-tools.integration.test.ts +129 -0
  197. package/src/agent-tools.ts +111 -0
  198. package/src/agents-api.integration.test.ts +705 -0
  199. package/src/agents-api.sentry.smoke.test.ts +478 -0
  200. package/src/agents-api.smoke.test.ts +5986 -0
  201. package/src/agents-api.ts +2826 -0
  202. package/src/agents.integration.test.ts +512 -0
  203. package/src/agents.ts +1097 -0
  204. package/src/agents.unit.test.ts +1736 -0
  205. package/src/anthropic-credential-guard.ts +44 -0
  206. package/src/anthropic-credential-guard.unit.test.ts +88 -0
  207. package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
  208. package/src/anthropic-federated-token-provider.ts +249 -0
  209. package/src/anthropic-identity-client.fixtures.ts +237 -0
  210. package/src/anthropic-identity-client.integration.test.ts +342 -0
  211. package/src/anthropic-identity-client.ts +473 -0
  212. package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
  213. package/src/anthropic-identity-name.integration.test.ts +83 -0
  214. package/src/anthropic-identity-name.ts +163 -0
  215. package/src/anthropic-identity-name.unit.test.ts +345 -0
  216. package/src/anthropic-openapi-contract.fixtures.ts +192 -0
  217. package/src/api-auth.ts +383 -0
  218. package/src/api-auth.unit.test.ts +837 -0
  219. package/src/api.smoke.test.ts +1225 -0
  220. package/src/api.ts +376 -0
  221. package/src/attachment-validation.ts +124 -0
  222. package/src/attachment-validation.unit.test.ts +218 -0
  223. package/src/caller-scope.ts +131 -0
  224. package/src/caller-scope.unit.test.ts +116 -0
  225. package/src/chat-credential-config.ts +22 -0
  226. package/src/chat-credentials-logging.unit.test.ts +92 -0
  227. package/src/chat-markers.ts +114 -0
  228. package/src/chat-markers.unit.test.ts +116 -0
  229. package/src/chat-service-provisioning-client.integration.test.ts +412 -0
  230. package/src/chat-service-provisioning-client.ts +221 -0
  231. package/src/chat.smoke.test.ts +1875 -0
  232. package/src/clock.ts +47 -0
  233. package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
  234. package/src/coding-task-store-provisioning-client.ts +156 -0
  235. package/src/crypto.ts +83 -0
  236. package/src/dev-auth-guard.ts +29 -0
  237. package/src/dev-auth-guard.unit.test.ts +51 -0
  238. package/src/errors.ts +77 -0
  239. package/src/errors.unit.test.ts +52 -0
  240. package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
  241. package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
  242. package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
  243. package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
  244. package/src/fixtures/github-app-manifest-cassette.json +44 -0
  245. package/src/fixtures/google-auth-cassette.json +36 -0
  246. package/src/fixtures/http-chat-client-cassette.json +226 -0
  247. package/src/fixtures/k8s-cassette.json +470 -0
  248. package/src/fixtures/okta-auth-cassette.json +36 -0
  249. package/src/fixtures/slack-provision-cassette.json +12 -0
  250. package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
  251. package/src/fixtures/task-store-provisioning-cassette.json +85 -0
  252. package/src/github-app-provisioning-client.integration.test.ts +149 -0
  253. package/src/github-app-provisioning-client.ts +161 -0
  254. package/src/github-app-provisioning-client.unit.test.ts +114 -0
  255. package/src/github-provisioning-service.ts +569 -0
  256. package/src/github-provisioning-service.unit.test.ts +123 -0
  257. package/src/google-auth-client.integration.test.ts +195 -0
  258. package/src/google-auth-client.ts +121 -0
  259. package/src/http-chat-client.integration.test.ts +554 -0
  260. package/src/http-chat-client.ts +533 -0
  261. package/src/http-chat-client.unit.test.ts +119 -0
  262. package/src/import-boundary.unit.test.ts +240 -0
  263. package/src/kubernetes-client.integration.test.ts +697 -0
  264. package/src/kubernetes-client.ts +1027 -0
  265. package/src/kubernetes-client.unit.test.ts +726 -0
  266. package/src/main.smoke.test.ts +91 -0
  267. package/src/main.ts +21 -0
  268. package/src/main.unit.test.ts +801 -0
  269. package/src/okta-auth-client.integration.test.ts +263 -0
  270. package/src/okta-auth-client.ts +152 -0
  271. package/src/openapi-schemas.ts +1310 -0
  272. package/src/openapi-schemas.unit.test.ts +348 -0
  273. package/src/per-agent-identity.fixtures.ts +305 -0
  274. package/src/platform-doc-route.ts +40 -0
  275. package/src/prisma-client.integration.test.ts +57 -0
  276. package/src/prisma-client.ts +187 -0
  277. package/src/prisma-client.unit.test.ts +178 -0
  278. package/src/prisma-tx.ts +23 -0
  279. package/src/push-content.ts +177 -0
  280. package/src/push-content.unit.test.ts +213 -0
  281. package/src/push-crypto.ts +135 -0
  282. package/src/push-crypto.unit.test.ts +66 -0
  283. package/src/push-sender.ts +169 -0
  284. package/src/push-sender.unit.test.ts +107 -0
  285. package/src/push-service.ts +332 -0
  286. package/src/push-service.unit.test.ts +679 -0
  287. package/src/push-toggle.ts +135 -0
  288. package/src/push-toggle.unit.test.ts +78 -0
  289. package/src/pwa-https-gating.smoke.test.ts +229 -0
  290. package/src/pwa-icons.unit.test.ts +41 -0
  291. package/src/pwa-routes.smoke.test.ts +276 -0
  292. package/src/pwa.ts +380 -0
  293. package/src/pwa.unit.test.ts +321 -0
  294. package/src/schema.unit.test.ts +122 -0
  295. package/src/self-serve-config.ts +52 -0
  296. package/src/self-serve-config.unit.test.ts +79 -0
  297. package/src/server.smoke.test.ts +273 -0
  298. package/src/server.ts +50 -0
  299. package/src/service-integration.ts +67 -0
  300. package/src/service-integration.unit.test.ts +129 -0
  301. package/src/slack-provisioning-client.integration.test.ts +308 -0
  302. package/src/slack-provisioning-client.ts +411 -0
  303. package/src/slack-provisioning-client.unit.test.ts +80 -0
  304. package/src/slack-provisioning-service.integration.test.ts +637 -0
  305. package/src/slack-provisioning-service.ts +451 -0
  306. package/src/slack-provisioning.integration.test.ts +395 -0
  307. package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
  308. package/src/specialty-manifest-loader.ts +255 -0
  309. package/src/specialty-manifest-loader.unit.test.ts +301 -0
  310. package/src/specialty-registry.schema.unit.test.ts +35 -0
  311. package/src/specialty-registry.ts +236 -0
  312. package/src/specialty-registry.unit.test.ts +272 -0
  313. package/src/startup-policy-reconcile.ts +77 -0
  314. package/src/startup-policy-reconcile.unit.test.ts +251 -0
  315. package/src/theme-tokens.json +12 -0
  316. package/src/token-crypto.ts +60 -0
  317. package/src/token-crypto.unit.test.ts +182 -0
  318. package/src/trial-expiry-sweeper.integration.test.ts +318 -0
  319. package/src/trial-expiry-sweeper.ts +132 -0
  320. package/src/trial-expiry-sweeper.unit.test.ts +391 -0
  321. package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
  322. package/src/trial-expiry-warning-sweeper.ts +447 -0
  323. package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
  324. package/src/voice-contract.unit.test.ts +102 -0
  325. package/tsconfig.json +9 -0
@@ -0,0 +1,569 @@
1
+ /**
2
+ * admin/src/github-provisioning-service.ts
3
+ * GithubProvisioningService — GitHub App manifest-flow / PAT provisioning
4
+ * orchestration for connecting an already-existing agent to GitHub.
5
+ *
6
+ * Extracted out of admin-ui.ts's inline /admin/provision/* handlers (UAP-1.2)
7
+ * so the same logic can be reused by both the legacy provisioning wizard
8
+ * (POST /admin/provision/start GitHub branches, GET
9
+ * /admin/provision/github-app/complete, GET
10
+ * /admin/provision/github-app/installed) and the new per-agent
11
+ * connect-github routes (POST /admin/agents/:id/connect-github, GET
12
+ * .../callback, GET .../installed). Pure extraction — no behavior change.
13
+ *
14
+ * HTTP-specific mechanics (reading/writing the GITHUB_PROVISION_STATE_COOKIE,
15
+ * issuing redirects, rendering HTML) stay in the route handlers; this service
16
+ * owns the GitHub App manifest-flow API calls, the signed provision-state
17
+ * token payload, and the env-var side effects (GH_TOKEN / GH_APP_ID /
18
+ * GH_APP_INSTALLATION_ID / GH_APP_PRIVATE_KEY).
19
+ *
20
+ * Mirrors SlackProvisioningService's DI/result-union shape exactly
21
+ * (slack-provisioning-service.ts) — see that file's header for the same
22
+ * rationale, applied here to the GitHub side of provisioning.
23
+ */
24
+
25
+ import { sign, verify } from "hono/jwt";
26
+ import type { AdminUIGithubAppClient } from "./admin-ui.ts";
27
+ import type { AgentCronJobService } from "./agent-cron-jobs.ts";
28
+ import type { AgentEnvService } from "./agent-envs.ts";
29
+ import type { AgentService } from "./agents.ts";
30
+ import { buildAgentAppManifest } from "./github-app-provisioning-client.ts";
31
+
32
+ // ─── Constants ────────────────────────────────────────────────────────────────
33
+
34
+ /** Signed cookie carrying provision state across the GitHub App manifest-flow redirect. */
35
+ export const GITHUB_PROVISION_STATE_COOKIE = "github_provision_state";
36
+ export const GITHUB_PROVISION_STATE_TTL_SECONDS = 300; // 5 min
37
+
38
+ /**
39
+ * GitHub org login name pattern (matches GitHub's own username/org-name
40
+ * rules: alphanumeric, single hyphens, no leading/trailing hyphen, max 39
41
+ * chars). Operator-supplied `githubOrg` is validated against this at every
42
+ * point it's about to be string-interpolated into a github.com redirect
43
+ * URL — an open-redirect/injection boundary, not defensive-for-no-reason.
44
+ */
45
+ export const GITHUB_ORG_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]){0,38}$/;
46
+
47
+ // ─── Types ────────────────────────────────────────────────────────────────────
48
+
49
+ interface GithubAppProvisionStatePayload {
50
+ agentId: string;
51
+ githubOrg: string;
52
+ }
53
+
54
+ type StartPatConnectResult =
55
+ | { ok: true; agentId: string }
56
+ | { ok: false; agentId: string; error: string };
57
+
58
+ type StartAppManualConnectResult =
59
+ | { ok: true; agentId: string }
60
+ | { ok: false; agentId: string; error: string };
61
+
62
+ type StartAppAutoConnectResult =
63
+ | {
64
+ ok: true;
65
+ /** Signed JWT to store in the GITHUB_PROVISION_STATE_COOKIE. */
66
+ provisionStateToken: string;
67
+ /** GitHub App manifest-creation form target + hidden manifest field. */
68
+ githubOrg: string;
69
+ manifest: ReturnType<typeof buildAgentAppManifest>;
70
+ }
71
+ | { ok: false; error: string };
72
+
73
+ type CompleteConnectResult =
74
+ | {
75
+ /** Provision-state cookie missing or invalid/expired — restart the flow. */
76
+ outcome: "invalid_state";
77
+ error: string;
78
+ }
79
+ | {
80
+ /** Valid state but no manifest code param (e.g. user cancelled). Cookie must NOT be cleared by the caller in this case. */
81
+ outcome: "missing_code";
82
+ error: string;
83
+ }
84
+ | {
85
+ /** State cookie belongs to a different agent than the one in the URL. */
86
+ outcome: "agent_mismatch";
87
+ error: string;
88
+ }
89
+ | {
90
+ /** Manifest code exchange with GitHub failed. */
91
+ outcome: "exchange_failed";
92
+ error: string;
93
+ }
94
+ | {
95
+ /** Success — App credentials stored, show the install link next. */
96
+ outcome: "success";
97
+ agentId: string;
98
+ installUrl: string;
99
+ };
100
+
101
+ type CompleteInstalledResult =
102
+ | {
103
+ /** Provision-state cookie missing or invalid/expired — restart the flow. */
104
+ outcome: "invalid_state";
105
+ error: string;
106
+ }
107
+ | {
108
+ /** State cookie belongs to a different agent than the one in the URL. */
109
+ outcome: "agent_mismatch";
110
+ error: string;
111
+ }
112
+ | {
113
+ /** Missing/non-numeric installation_id query param. */
114
+ outcome: "invalid_installation_id";
115
+ error: string;
116
+ }
117
+ | {
118
+ /** Success — installation ID stored, or already stored and left alone. */
119
+ outcome: "success";
120
+ agentId: string;
121
+ /** True when a stored installation ID was kept (an additional-org install). */
122
+ alreadyStored: boolean;
123
+ };
124
+
125
+ interface GithubProvisioningServiceDeps {
126
+ githubAppClient: AdminUIGithubAppClient;
127
+ agentService: Pick<AgentService, "getDetail">;
128
+ agentEnvService: Pick<AgentEnvService, "patch" | "getByAgentId">;
129
+ agentCronJobService: Pick<AgentCronJobService, "reconcileSystemCrons">;
130
+ sessionSecret: string;
131
+ appBaseUrl: string;
132
+ /** Env var keys that must be stored as secrets — mirrors SECRET_ENV_VARS. */
133
+ secretEnvVars: Set<string>;
134
+ }
135
+
136
+ // ─── Validation helpers ─────────────────────────────────────────────────────
137
+
138
+ function isNumericId(value: string | undefined): value is string {
139
+ return Boolean(value) && /^\d+$/.test(value as string);
140
+ }
141
+
142
+ /** Validates a pasted GitHub App ID (numeric string). */
143
+ function isValidGithubAppId(value: string | undefined): value is string {
144
+ return isNumericId(value);
145
+ }
146
+
147
+ /** Validates a pasted GitHub App Installation ID (numeric string). */
148
+ function isValidGithubAppInstallationId(
149
+ value: string | undefined,
150
+ ): value is string {
151
+ return isNumericId(value);
152
+ }
153
+
154
+ /** GitHub App slugs: alphanumerics and hyphens, max 39 chars (like logins). */
155
+ function isValidGithubAppSlug(value: string | undefined): value is string {
156
+ return (
157
+ Boolean(value) &&
158
+ /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(value as string)
159
+ );
160
+ }
161
+
162
+ /** Validates a pasted GitHub App private key (PEM-encoded). */
163
+ function isValidGithubAppPrivateKey(
164
+ value: string | undefined,
165
+ ): value is string {
166
+ return Boolean(value?.includes("BEGIN") && value.includes("PRIVATE KEY"));
167
+ }
168
+
169
+ // ─── Service ──────────────────────────────────────────────────────────────────
170
+
171
+ export class GithubProvisioningService {
172
+ constructor(private readonly deps: GithubProvisioningServiceDeps) {}
173
+
174
+ // ── PAT mode ────────────────────────────────────────────────────────────
175
+
176
+ /**
177
+ * Stores GH_TOKEN directly — no GitHub API call. Mirrors the ghAuthMode=pat
178
+ * branch of the legacy POST /admin/provision/start handler.
179
+ */
180
+ async startPatConnect(
181
+ agentId: string,
182
+ ghPat: string | undefined,
183
+ ): Promise<StartPatConnectResult> {
184
+ if (!ghPat) {
185
+ return {
186
+ ok: false,
187
+ agentId,
188
+ error: "GitHub Personal Access Token is required.",
189
+ };
190
+ }
191
+
192
+ const agent = await this.deps.agentService.getDetail(agentId);
193
+ if (!agent) {
194
+ return { ok: false, agentId, error: "Agent not found." };
195
+ }
196
+
197
+ await this.deps.agentEnvService.patch(
198
+ agentId,
199
+ { GH_TOKEN: ghPat },
200
+ this.deps.secretEnvVars,
201
+ );
202
+
203
+ return { ok: true, agentId };
204
+ }
205
+
206
+ // ── App manual-paste mode ───────────────────────────────────────────────
207
+
208
+ /**
209
+ * Stores GH_APP_ID / GH_APP_INSTALLATION_ID / GH_APP_PRIVATE_KEY directly —
210
+ * no GitHub API call. Mirrors the ghAuthMode=app + ghAppMode=manual branch
211
+ * of the legacy POST /admin/provision/start handler.
212
+ */
213
+ async startAppManualConnect(
214
+ agentId: string,
215
+ fields: {
216
+ ghAppId: string | undefined;
217
+ ghAppInstallationId: string | undefined;
218
+ ghAppPrivateKey: string | undefined;
219
+ },
220
+ ): Promise<StartAppManualConnectResult> {
221
+ if (!isValidGithubAppId(fields.ghAppId)) {
222
+ return {
223
+ ok: false,
224
+ agentId,
225
+ error: "GitHub App ID must be a numeric value.",
226
+ };
227
+ }
228
+ if (!isValidGithubAppInstallationId(fields.ghAppInstallationId)) {
229
+ return {
230
+ ok: false,
231
+ agentId,
232
+ error: "GitHub App Installation ID must be a numeric value.",
233
+ };
234
+ }
235
+ if (!isValidGithubAppPrivateKey(fields.ghAppPrivateKey)) {
236
+ return {
237
+ ok: false,
238
+ agentId,
239
+ error: "GitHub App Private Key must be a valid PEM-encoded key.",
240
+ };
241
+ }
242
+
243
+ const agent = await this.deps.agentService.getDetail(agentId);
244
+ if (!agent) {
245
+ return { ok: false, agentId, error: "Agent not found." };
246
+ }
247
+
248
+ await this.deps.agentEnvService.patch(
249
+ agentId,
250
+ {
251
+ GH_APP_ID: fields.ghAppId,
252
+ GH_APP_INSTALLATION_ID: fields.ghAppInstallationId,
253
+ GH_APP_PRIVATE_KEY: fields.ghAppPrivateKey,
254
+ },
255
+ this.deps.secretEnvVars,
256
+ );
257
+
258
+ return { ok: true, agentId };
259
+ }
260
+
261
+ // ── App auto-provision (manifest flow) mode ─────────────────────────────
262
+
263
+ /**
264
+ * Signs a provision-state token (for the GITHUB_PROVISION_STATE_COOKIE)
265
+ * carrying `agentId` + `githubOrg`. Exposed so the legacy wizard can fold
266
+ * this into its own combined Slack+GitHub provision-state cookie via
267
+ * `extra` on SlackProvisioningService.signProvisionState() instead, while
268
+ * still using the same payload shape this service's completeConnect() /
269
+ * completeInstalled() below expect.
270
+ */
271
+ async signProvisionState(
272
+ agentId: string,
273
+ githubOrg: string,
274
+ ): Promise<string> {
275
+ const now = Math.floor(Date.now() / 1000);
276
+ return sign(
277
+ {
278
+ agentId,
279
+ githubOrg,
280
+ iat: now,
281
+ exp: now + GITHUB_PROVISION_STATE_TTL_SECONDS,
282
+ },
283
+ this.deps.sessionSecret,
284
+ "HS256",
285
+ );
286
+ }
287
+
288
+ /**
289
+ * Builds the GitHub App manifest for `agentId` targeting `githubOrg`, and
290
+ * returns a signed provision-state token (for the
291
+ * GITHUB_PROVISION_STATE_COOKIE) plus the manifest for the auto-submitting
292
+ * redirect page. Mirrors the ghAuthMode=app + ghAppMode=auto branch of the
293
+ * legacy POST /admin/provision/start handler.
294
+ *
295
+ * `redirectUri`/`setupUrl` are supplied by the caller so both the legacy
296
+ * wizard (targeting /admin/provision/github-app/*) and the new per-agent
297
+ * routes (targeting /admin/agents/:id/connect-github/*) can reuse this
298
+ * same manifest-building logic while pointing GitHub at different URLs.
299
+ */
300
+ async startAppAutoConnect(
301
+ agentId: string,
302
+ githubOrg: string | undefined,
303
+ urls: { redirectUri: string; setupUrl: string },
304
+ opts?: { public?: boolean },
305
+ ): Promise<StartAppAutoConnectResult> {
306
+ if (!githubOrg || !GITHUB_ORG_PATTERN.test(githubOrg)) {
307
+ return {
308
+ ok: false,
309
+ error: "GitHub org must be a valid GitHub organization name.",
310
+ };
311
+ }
312
+
313
+ const agent = await this.deps.agentService.getDetail(agentId);
314
+ if (!agent) {
315
+ return { ok: false, error: "Agent not found." };
316
+ }
317
+
318
+ const provisionStateToken = await this.signProvisionState(
319
+ agentId,
320
+ githubOrg,
321
+ );
322
+ const manifest = buildAgentAppManifest(agent.name, {
323
+ redirectUri: urls.redirectUri,
324
+ setupUrl: urls.setupUrl,
325
+ public: opts?.public,
326
+ });
327
+
328
+ return { ok: true, provisionStateToken, githubOrg, manifest };
329
+ }
330
+
331
+ /**
332
+ * Verifies the provision-state token payload shape (agentId + githubOrg).
333
+ * Exposed so route handlers can additionally check the state's agentId
334
+ * matches the :id URL param before calling completeConnect()/
335
+ * completeInstalled() below.
336
+ */
337
+ async readProvisionState(
338
+ rawStateCookie: string | undefined,
339
+ ): Promise<
340
+ | { ok: true; state: GithubAppProvisionStatePayload }
341
+ | { ok: false; error: string }
342
+ > {
343
+ if (!rawStateCookie) {
344
+ return {
345
+ ok: false,
346
+ error:
347
+ "Provision session expired or missing. Please start the provisioning flow again.",
348
+ };
349
+ }
350
+ try {
351
+ const payload = (await verify(
352
+ rawStateCookie,
353
+ this.deps.sessionSecret,
354
+ "HS256",
355
+ )) as Record<string, unknown>;
356
+ if (
357
+ typeof payload.agentId !== "string" ||
358
+ typeof payload.githubOrg !== "string" ||
359
+ !GITHUB_ORG_PATTERN.test(payload.githubOrg)
360
+ ) {
361
+ throw new Error("invalid payload shape");
362
+ }
363
+ return {
364
+ ok: true,
365
+ state: { agentId: payload.agentId, githubOrg: payload.githubOrg },
366
+ };
367
+ } catch {
368
+ return {
369
+ ok: false,
370
+ error:
371
+ "Provision session expired or invalid. Please start the provisioning flow again.",
372
+ };
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Verifies the provision-state token, exchanges the manifest `code` for App
378
+ * credentials, and stores GH_APP_ID + GH_APP_PRIVATE_KEY. Mirrors the
379
+ * legacy GET /admin/provision/github-app/complete handler exactly.
380
+ *
381
+ * `expectedAgentId`, when provided, is checked against the state's agentId
382
+ * (per-agent connect-github routes pass the :id URL param here) —
383
+ * "agent_mismatch" is returned rather than proceeding cross-agent. The
384
+ * legacy wizard route (no per-agent scoping) omits it.
385
+ *
386
+ * `installUrl` slug interpolation stays here (not the route) — it's a
387
+ * business decision about the exchange result shape, not HTTP mechanics.
388
+ */
389
+ async completeConnect(
390
+ rawStateCookie: string | undefined,
391
+ code: string | undefined,
392
+ expectedAgentId?: string,
393
+ ): Promise<CompleteConnectResult> {
394
+ const stateResult = await this.readProvisionState(rawStateCookie);
395
+ if (!stateResult.ok) {
396
+ return { outcome: "invalid_state", error: stateResult.error };
397
+ }
398
+ const state = stateResult.state;
399
+
400
+ if (expectedAgentId !== undefined && state.agentId !== expectedAgentId) {
401
+ return {
402
+ outcome: "agent_mismatch",
403
+ error: "Provision session does not match this agent.",
404
+ };
405
+ }
406
+
407
+ if (!code) {
408
+ return {
409
+ outcome: "missing_code",
410
+ error:
411
+ "GitHub did not return a manifest code. Please restart the provisioning flow from the beginning.",
412
+ };
413
+ }
414
+
415
+ let exchangeResult: {
416
+ appId: string;
417
+ slug: string;
418
+ pem: string;
419
+ clientId: string;
420
+ clientSecret: string;
421
+ };
422
+ try {
423
+ exchangeResult =
424
+ await this.deps.githubAppClient.exchangeManifestCode(code);
425
+ } catch (err) {
426
+ const msg =
427
+ err instanceof Error
428
+ ? err.message
429
+ : "Unknown error exchanging GitHub App manifest code.";
430
+ return {
431
+ outcome: "exchange_failed",
432
+ error: `GitHub App creation failed: ${msg}`,
433
+ };
434
+ }
435
+
436
+ await this.deps.agentEnvService.patch(
437
+ state.agentId,
438
+ {
439
+ GH_APP_ID: exchangeResult.appId,
440
+ GH_APP_PRIVATE_KEY: exchangeResult.pem,
441
+ GH_APP_CLIENT_ID: exchangeResult.clientId,
442
+ GH_APP_CLIENT_SECRET: exchangeResult.clientSecret,
443
+ GH_APP_SLUG: exchangeResult.slug,
444
+ },
445
+ this.deps.secretEnvVars,
446
+ );
447
+
448
+ return {
449
+ outcome: "success",
450
+ agentId: state.agentId,
451
+ installUrl: `https://github.com/apps/${exchangeResult.slug}/installations/new`,
452
+ };
453
+ }
454
+
455
+ /**
456
+ * Verifies the provision-state token, stores GH_APP_INSTALLATION_ID, and
457
+ * reconciles system crons (best-effort, non-fatal — mirrors
458
+ * SlackProvisioningService.saveAppToken()'s parity call). Mirrors the
459
+ * legacy GET /admin/provision/github-app/installed handler's env write
460
+ * exactly, with the cron reconcile added on top (UAP-1.4).
461
+ * `expectedAgentId` behaves as in completeConnect() above.
462
+ */
463
+ async completeInstalled(
464
+ rawStateCookie: string | undefined,
465
+ installationId: string | undefined,
466
+ expectedAgentId?: string,
467
+ ): Promise<CompleteInstalledResult> {
468
+ // Additional-org installs ("Add another org") set no provision-state
469
+ // cookie, so once an installation ID is stored the callback needs no
470
+ // session: validate the ID and acknowledge without consulting the cookie
471
+ // or writing anything. First installs still require the state cookie.
472
+ if (expectedAgentId !== undefined) {
473
+ const existing = (
474
+ await this.deps.agentEnvService.getByAgentId(expectedAgentId)
475
+ )?.env.GH_APP_INSTALLATION_ID;
476
+ if (existing) {
477
+ if (!isNumericId(installationId)) {
478
+ return {
479
+ outcome: "invalid_installation_id",
480
+ error:
481
+ "GitHub did not return a valid installation ID. Please restart the provisioning flow from the beginning.",
482
+ };
483
+ }
484
+ return {
485
+ outcome: "success",
486
+ agentId: expectedAgentId,
487
+ alreadyStored: true,
488
+ };
489
+ }
490
+ }
491
+
492
+ const stateResult = await this.readProvisionState(rawStateCookie);
493
+ if (!stateResult.ok) {
494
+ return { outcome: "invalid_state", error: stateResult.error };
495
+ }
496
+ const state = stateResult.state;
497
+
498
+ if (expectedAgentId !== undefined && state.agentId !== expectedAgentId) {
499
+ return {
500
+ outcome: "agent_mismatch",
501
+ error: "Provision session does not match this agent.",
502
+ };
503
+ }
504
+
505
+ if (!isNumericId(installationId)) {
506
+ return {
507
+ outcome: "invalid_installation_id",
508
+ error:
509
+ "GitHub did not return a valid installation ID. Please restart the provisioning flow from the beginning.",
510
+ };
511
+ }
512
+
513
+ // A stored installation ID is left alone: additional-org installs of a
514
+ // public App also land here, and overwriting would repoint the agent at
515
+ // the newly added org. Only a first install stores its ID.
516
+ const stored = (await this.deps.agentEnvService.getByAgentId(state.agentId))
517
+ ?.env.GH_APP_INSTALLATION_ID;
518
+ const alreadyStored = Boolean(stored);
519
+ if (!alreadyStored) {
520
+ await this.deps.agentEnvService.patch(state.agentId, {
521
+ GH_APP_INSTALLATION_ID: installationId,
522
+ });
523
+ }
524
+
525
+ // Best-effort, mirroring the same call at agent boot (agent/src/index.ts)
526
+ // and SlackProvisioningService.saveAppToken()'s parity call.
527
+ // reconcileSystemCrons is a full three-pass reconcile, so a second run is
528
+ // a no-op — and failing here would strand the operator next to a live
529
+ // agent whose GitHub App install otherwise succeeded.
530
+ try {
531
+ await this.deps.agentCronJobService.reconcileSystemCrons(state.agentId);
532
+ } catch (err) {
533
+ console.error(
534
+ "[github-provisioning-service] failed to reconcile system crons (non-fatal):",
535
+ err,
536
+ );
537
+ }
538
+
539
+ return { outcome: "success", agentId: state.agentId, alreadyStored };
540
+ }
541
+
542
+ /**
543
+ * Builds the GitHub install URL for adding another org to the agent's App.
544
+ * The slug comes from stored env and is validated before interpolation.
545
+ */
546
+ async getAddOrgUrl(
547
+ agentId: string,
548
+ ): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
549
+ const slug = (await this.deps.agentEnvService.getByAgentId(agentId))?.env
550
+ .GH_APP_SLUG;
551
+ if (!slug) {
552
+ return {
553
+ ok: false,
554
+ error:
555
+ "GH_APP_SLUG is not set — add another org from the App's GitHub install page (see docs/configuration-agent.md).",
556
+ };
557
+ }
558
+ if (!isValidGithubAppSlug(slug)) {
559
+ return {
560
+ ok: false,
561
+ error: "Stored GH_APP_SLUG is not a valid GitHub App slug.",
562
+ };
563
+ }
564
+ return {
565
+ ok: true,
566
+ url: `https://github.com/apps/${slug}/installations/new`,
567
+ };
568
+ }
569
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * admin/src/github-provisioning-service.unit.test.ts
3
+ * Unit tests for GithubProvisioningService.completeInstalled() — the
4
+ * additional-org path (stored installation ID, no provision-state cookie)
5
+ * versus the first-install path (state cookie required).
6
+ */
7
+
8
+ import { describe, expect, it } from "bun:test";
9
+ import { sign } from "hono/jwt";
10
+ import { GithubProvisioningService } from "./github-provisioning-service.ts";
11
+
12
+ const SESSION_SECRET = "test-admin-session-secret-32-bytes!";
13
+ const AGENT_ID = "agent-test-123";
14
+
15
+ function makeService(storedInstallationId?: string) {
16
+ const patchCalls: Array<Record<string, string>> = [];
17
+ const reconcileCalls: string[] = [];
18
+ const service = new GithubProvisioningService({
19
+ githubAppClient: {} as never,
20
+ agentService: {} as never,
21
+ agentEnvService: {
22
+ getByAgentId: async () => ({
23
+ env: storedInstallationId
24
+ ? { GH_APP_INSTALLATION_ID: storedInstallationId }
25
+ : {},
26
+ secretKeys: [],
27
+ }),
28
+ patch: async (_id: string, env: Record<string, string>) => {
29
+ patchCalls.push(env);
30
+ },
31
+ } as never,
32
+ agentCronJobService: {
33
+ reconcileSystemCrons: async (id: string) => {
34
+ reconcileCalls.push(id);
35
+ return { created: 0, updated: 0, deleted: 0 };
36
+ },
37
+ } as never,
38
+ sessionSecret: SESSION_SECRET,
39
+ appBaseUrl: "https://example.com",
40
+ secretEnvVars: new Set(),
41
+ });
42
+ return { service, patchCalls, reconcileCalls };
43
+ }
44
+
45
+ async function stateCookie(agentId = AGENT_ID): Promise<string> {
46
+ const now = Math.floor(Date.now() / 1000);
47
+ return sign(
48
+ { agentId, githubOrg: "my-org", iat: now, exp: now + 300 },
49
+ SESSION_SECRET,
50
+ "HS256",
51
+ );
52
+ }
53
+
54
+ describe("GithubProvisioningService.completeInstalled", () => {
55
+ it("stored ID + no cookie + numeric installation_id → success alreadyStored, no write", async () => {
56
+ const { service, patchCalls } = makeService("111");
57
+ const result = await service.completeInstalled(
58
+ undefined,
59
+ "778899",
60
+ AGENT_ID,
61
+ );
62
+ expect(result).toEqual({
63
+ outcome: "success",
64
+ agentId: AGENT_ID,
65
+ alreadyStored: true,
66
+ });
67
+ expect(patchCalls).toHaveLength(0);
68
+ });
69
+
70
+ it("stored ID + no cookie + non-numeric installation_id → invalid_installation_id", async () => {
71
+ const { service, patchCalls } = makeService("111");
72
+ const result = await service.completeInstalled(undefined, "abc", AGENT_ID);
73
+ expect(result.outcome).toBe("invalid_installation_id");
74
+ expect(patchCalls).toHaveLength(0);
75
+ });
76
+
77
+ it("stored ID + missing installation_id → invalid_installation_id", async () => {
78
+ const { service } = makeService("111");
79
+ const result = await service.completeInstalled(
80
+ undefined,
81
+ undefined,
82
+ AGENT_ID,
83
+ );
84
+ expect(result.outcome).toBe("invalid_installation_id");
85
+ });
86
+
87
+ it("no stored ID + no cookie → invalid_state", async () => {
88
+ const { service, patchCalls } = makeService();
89
+ const result = await service.completeInstalled(
90
+ undefined,
91
+ "778899",
92
+ AGENT_ID,
93
+ );
94
+ expect(result.outcome).toBe("invalid_state");
95
+ expect(patchCalls).toHaveLength(0);
96
+ });
97
+
98
+ it("no stored ID + valid cookie → stores the installation ID and reconciles crons", async () => {
99
+ const { service, patchCalls, reconcileCalls } = makeService();
100
+ const result = await service.completeInstalled(
101
+ await stateCookie(),
102
+ "778899",
103
+ AGENT_ID,
104
+ );
105
+ expect(result).toEqual({
106
+ outcome: "success",
107
+ agentId: AGENT_ID,
108
+ alreadyStored: false,
109
+ });
110
+ expect(patchCalls).toEqual([{ GH_APP_INSTALLATION_ID: "778899" }]);
111
+ expect(reconcileCalls).toEqual([AGENT_ID]);
112
+ });
113
+
114
+ it("no stored ID + cookie for a different agent → agent_mismatch", async () => {
115
+ const { service } = makeService();
116
+ const result = await service.completeInstalled(
117
+ await stateCookie("other-agent"),
118
+ "778899",
119
+ AGENT_ID,
120
+ );
121
+ expect(result.outcome).toBe("agent_mismatch");
122
+ });
123
+ });