@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,3824 @@
1
+ /**
2
+ * agent/src/admin-ui.ts
3
+ * Admin UI — server-rendered Hono app factory.
4
+ *
5
+ * Routes:
6
+ * GET /admin/login — login page (Google sign-in button)
7
+ * GET /admin/auth/google — redirect to Google OAuth consent
8
+ * GET /admin/auth/callback — Google OAuth callback → set session cookie
9
+ * GET /admin/auth/okta — redirect to Okta OIDC consent
10
+ * GET /admin/auth/okta/callback — Okta OAuth callback → set session cookie
11
+ * POST /admin/logout — clear cookie → redirect to login
12
+ * GET /admin/agents — list all agents (auth required)
13
+ * GET /admin/agents/:id — agent detail (auth required)
14
+ * POST /admin/agents/:id/envs — add/update env var (auth required)
15
+ * POST /admin/agents/:id/envs/delete — delete env var (auth required)
16
+ * GET /admin/provision — 302 redirect to /admin/agents/new (legacy entry point)
17
+ *
18
+ * Auth: httpOnly JWT cookie named "admin_session".
19
+ * Login is OAuth (Google or Okta) — no password, no DB user lookup. Both
20
+ * providers share a common post-auth step (completeLogin()): verified-email
21
+ * check, admin-allowlist/AgentMember-fallback check, session JWT + cookie.
22
+ * Allowed users are controlled by the adminAllowedEmails allowlist in deps.
23
+ */
24
+
25
+ import { join } from "node:path";
26
+ import { isGithubLogin } from "@app-vitals/agent-lib/github-login";
27
+ import { isOrgRepo } from "@app-vitals/agent-lib/org-repo";
28
+ import { SECRET_ENV_VARS } from "@app-vitals/agent-lib/secret-env-vars";
29
+ import { type Context, Hono, type MiddlewareHandler } from "hono";
30
+ import { deleteCookie, getCookie, setCookie } from "hono/cookie";
31
+ import { sign, verify } from "hono/jwt";
32
+ import {
33
+ accountIdFromScope,
34
+ getSelfServeSpecialty,
35
+ } from "./account-agent-create.ts";
36
+ import type { AccountCreatedNotifier } from "./account-created-notifier.ts";
37
+ import { fireAccountCreatedNotification } from "./account-created-notifier.ts";
38
+ import type { AccountInviteService } from "./account-invites.ts";
39
+ import type { AccountLifecycle } from "./account-lifecycle.ts";
40
+ import type { AccountMemberService } from "./account-members.ts";
41
+ import type { AccountOnboardingService } from "./account-onboarding.ts";
42
+ import type { AccountService } from "./accounts.ts";
43
+ import {
44
+ type AgentDetailCardSlot,
45
+ renderAgentDetailCardSlots,
46
+ } from "./admin-extension.ts";
47
+ import { registerAccountRoutes } from "./admin-ui-account.ts";
48
+ import { runWithAccountNav } from "./admin-ui-account-nav.ts";
49
+ import { renderZeroQuotaNotice } from "./admin-ui-account-pages.ts";
50
+ import { registerAdminAccountsRoutes } from "./admin-ui-accounts.ts";
51
+ import {
52
+ type AgentDetail,
53
+ type AgentOption,
54
+ renderAgentDetailPage,
55
+ renderAgentsPage,
56
+ renderChatMessageBubble,
57
+ renderChatPage,
58
+ renderChatThreadPage,
59
+ renderGithubAppInstalledPage,
60
+ renderGithubAppInstallPage,
61
+ renderGithubAppManifestRedirectPage,
62
+ renderLoginPage,
63
+ renderNewLocalAgentPage,
64
+ renderProvisionCompletePage,
65
+ renderProvisionXappTokenPage,
66
+ } from "./admin-ui-pages.ts";
67
+ import type { AgentCronJobService } from "./agent-cron-jobs.ts";
68
+ import type { DeleteAgentFullyDeps } from "./agent-deletion.ts";
69
+ import { deleteAgentFully } from "./agent-deletion.ts";
70
+ import type { ManualStep } from "./agent-deletion-checklist.ts";
71
+ import type { AgentEnvService } from "./agent-envs.ts";
72
+ import type { AgentMemberService } from "./agent-members.ts";
73
+ import type { AgentPluginService } from "./agent-plugins.ts";
74
+ import type { AgentProvisioner } from "./agent-provisioner.ts";
75
+ import type { AgentTokenService } from "./agent-tokens.ts";
76
+ import type { AgentToolService } from "./agent-tools.ts";
77
+ import {
78
+ type SpecialtyManifestResolver,
79
+ SpecialtyRegistry,
80
+ } from "./specialty-manifest-loader.ts";
81
+ import type { AgentService } from "./agents.ts";
82
+ import { createAgent, describeRepoNameCollision } from "./agents.ts";
83
+ import { AnthropicCredentialsBlockedError } from "./anthropic-credential-guard.ts";
84
+ import {
85
+ audioContentTypeForFilename,
86
+ validateAttachment,
87
+ } from "./attachment-validation.ts";
88
+ import {
89
+ type CallerScopeResolver,
90
+ memberOnlyCallerScopeResolver,
91
+ scopeIncludesAgent,
92
+ } from "./caller-scope.ts";
93
+ import { ForbiddenError, UnprocessableEntityError } from "./errors.ts";
94
+ import {
95
+ GITHUB_PROVISION_STATE_COOKIE,
96
+ GITHUB_PROVISION_STATE_TTL_SECONDS,
97
+ GithubProvisioningService,
98
+ } from "./github-provisioning-service.ts";
99
+ import type { GoogleAuthClient } from "./google-auth-client.ts";
100
+ import {
101
+ type ChatClient,
102
+ type ChatThread,
103
+ filterSince,
104
+ toPolledMessage,
105
+ } from "./http-chat-client.ts";
106
+ import type { OktaAuthClient } from "./okta-auth-client.ts";
107
+ import type { PrismaTransactionClient } from "./prisma-tx.ts";
108
+ import type { PushService } from "./push-service.ts";
109
+ import {
110
+ buildManifest,
111
+ buildOfflinePageHtml,
112
+ buildServiceWorkerBody,
113
+ getPrecacheList,
114
+ PWA_ASSETS_DIR,
115
+ PWA_ICONS,
116
+ renderPwaHeadTags,
117
+ sanitizeStartUrl,
118
+ } from "./pwa.ts";
119
+ import {
120
+ DEFAULT_SELF_SERVE_CONTACT_EMAIL,
121
+ type SelfServeConfig,
122
+ } from "./self-serve-config.ts";
123
+ import type { AppManifest } from "./slack-provisioning-client.ts";
124
+ import {
125
+ AGENT_BOT_SCOPES,
126
+ buildAgentManifest,
127
+ } from "./slack-provisioning-client.ts";
128
+ import {
129
+ PROVISION_STATE_COOKIE,
130
+ PROVISION_STATE_TTL_SECONDS,
131
+ SlackProvisioningService,
132
+ } from "./slack-provisioning-service.ts";
133
+
134
+ export type AdminUIEnv = { Variables: { userEmail: string; isAdmin: boolean } };
135
+
136
+ // ─── Types ────────────────────────────────────────────────────────────────────
137
+
138
+ /**
139
+ * Narrow interface for the admin UI's Slack dependency.
140
+ * Deliberately narrower than the full SlackProvisioningClient
141
+ * in slack-provisioning-client.ts — only this surface is needed here.
142
+ */
143
+ export interface AdminUISlackClient {
144
+ createAppManifest(
145
+ xoxpToken: string,
146
+ manifest: AppManifest,
147
+ ): Promise<{
148
+ appId: string;
149
+ oauthRedirectUrl: string;
150
+ clientId: string;
151
+ clientSecret: string;
152
+ signingSecret: string;
153
+ }>;
154
+ updateAppManifest(
155
+ xoxpToken: string,
156
+ appId: string,
157
+ manifest: AppManifest,
158
+ ): Promise<void>;
159
+ exchangeOAuthCode(
160
+ code: string,
161
+ clientId: string,
162
+ clientSecret: string,
163
+ redirectUri: string,
164
+ ): Promise<{ botToken: string }>;
165
+ authTest(botToken: string): Promise<{ userId: string }>;
166
+ }
167
+
168
+ /**
169
+ * Narrow interface for the admin UI's GitHub App provisioning dependency.
170
+ * Mirrors AdminUISlackClient's DI pattern — deliberately narrower than the
171
+ * full GithubAppProvisioningClient in github-app-provisioning-client.ts.
172
+ */
173
+ export interface AdminUIGithubAppClient {
174
+ exchangeManifestCode(code: string): Promise<{
175
+ appId: string;
176
+ slug: string;
177
+ pem: string;
178
+ clientId: string;
179
+ clientSecret: string;
180
+ }>;
181
+ }
182
+
183
+ interface PrismaAgentLike {
184
+ findMany(args?: object): Promise<
185
+ Array<{
186
+ id: string;
187
+ name: string;
188
+ slackId: string | null;
189
+ createdAt: Date;
190
+ updatedAt?: Date;
191
+ repos?: string[];
192
+ }>
193
+ >;
194
+ findUnique(args: { where: { id: string } }): Promise<{
195
+ id: string;
196
+ name: string;
197
+ slackId: string | null;
198
+ selfHosted: boolean;
199
+ createdAt: Date;
200
+ updatedAt: Date;
201
+ repos: string[];
202
+ } | null>;
203
+ create(args: {
204
+ data: { name: string; slackId?: string | null; selfHosted?: boolean };
205
+ }): Promise<{
206
+ id: string;
207
+ name: string;
208
+ slackId: string | null;
209
+ createdAt: Date;
210
+ updatedAt: Date;
211
+ repos: string[];
212
+ }>;
213
+ update(args: { where: { id: string }; data: { repos: string[] } }): Promise<{
214
+ id: string;
215
+ name: string;
216
+ slackId: string | null;
217
+ createdAt: Date;
218
+ updatedAt: Date;
219
+ repos: string[];
220
+ }>;
221
+ delete(args: { where: { id: string } }): Promise<{
222
+ id: string;
223
+ name: string;
224
+ slackId: string | null;
225
+ createdAt: Date;
226
+ updatedAt: Date;
227
+ repos: string[];
228
+ }>;
229
+ }
230
+
231
+ interface PrismaLike {
232
+ agent: PrismaAgentLike;
233
+ agentEnv: {
234
+ findMany(args: {
235
+ where: { agentId: string };
236
+ select: { key: true; value: true; secret: true };
237
+ }): Promise<{ key: string; value: string; secret: boolean }[]>;
238
+ };
239
+ agentPlugin: {
240
+ findMany(args: { where: { agentId: string; enabled: boolean } }): Promise<
241
+ Array<{
242
+ id: string;
243
+ name: string;
244
+ version: string | null;
245
+ enabled: boolean;
246
+ }>
247
+ >;
248
+ };
249
+ agentMember: {
250
+ findMany(args: {
251
+ where: { email?: string; agentId?: string };
252
+ }): Promise<
253
+ Array<{ id: string; agentId: string; email: string; createdAt: Date }>
254
+ >;
255
+ findUnique(args: {
256
+ where: { agentId_email: { agentId: string; email: string } };
257
+ }): Promise<{ id: string; agentId: string; email: string } | null>;
258
+ create(args: {
259
+ data: { agentId: string; email: string };
260
+ }): Promise<{ id: string; agentId: string; email: string }>;
261
+ deleteMany(args: { where: { id: string; agentId: string } }): Promise<{
262
+ count: number;
263
+ }>;
264
+ };
265
+ }
266
+
267
+ export interface AdminUIDeps {
268
+ prisma: PrismaLike;
269
+ agentEnvService: Pick<
270
+ AgentEnvService,
271
+ "getByAgentId" | "upsert" | "patch" | "deleteKey" | "getConfigBundle"
272
+ >;
273
+ agentCronJobService: Pick<
274
+ AgentCronJobService,
275
+ | "list"
276
+ | "listWithRunSummary"
277
+ | "create"
278
+ | "update"
279
+ | "setEnabled"
280
+ | "delete"
281
+ | "get"
282
+ | "reconcileSystemCrons"
283
+ >;
284
+ /**
285
+ * Optional extension card slots (AEP-2.1, admin-extension.ts) rendered on
286
+ * the agent detail page at each slot's placement (header or Configuration
287
+ * group); absent renders none.
288
+ */
289
+ agentDetailCards?: readonly AgentDetailCardSlot[];
290
+ /**
291
+ * True when an extension mounts /admin/agents/:id/queue-activity (the coding
292
+ * extension, AEP-3.6); the agents list and agent detail render their Queue &
293
+ * Activity / Logs links only then. Absent renders none.
294
+ */
295
+ queueActivityLinks?: boolean;
296
+ agentToolService: Pick<
297
+ AgentToolService,
298
+ "list" | "add" | "toggle" | "remove"
299
+ >;
300
+ agentTokenService: Pick<
301
+ AgentTokenService,
302
+ "listForAgent" | "create" | "revoke"
303
+ >;
304
+ agentPluginService: Pick<AgentPluginService, "list" | "add">;
305
+ agentMemberService: Pick<
306
+ AgentMemberService,
307
+ "listByEmail" | "exists" | "add" | "remove" | "listByAgentId"
308
+ >;
309
+ /**
310
+ * Account-aware caller-scope resolver (SSP-2.1). Defaults to the
311
+ * AgentMember-only resolver, i.e. today's behavior with the flag off.
312
+ */
313
+ callerScopeResolver?: CallerScopeResolver;
314
+ /**
315
+ * Account lookups for the new-agent form's remaining-quota display
316
+ * (SSP-4.2). Absent → the quota line is omitted.
317
+ */
318
+ accountService?: Pick<
319
+ AccountService,
320
+ "getById" | "countAgents" | "listAgentIds"
321
+ >;
322
+ agentService: Pick<
323
+ AgentService,
324
+ | "listAll"
325
+ | "listByIds"
326
+ | "searchByName"
327
+ | "listOptions"
328
+ | "create"
329
+ | "delete"
330
+ | "getDetail"
331
+ | "updateFields"
332
+ > & {
333
+ /**
334
+ * Runs a function inside a single interactive Prisma transaction — see
335
+ * AgentService.runTransaction(). Optional here (unlike the rest of this
336
+ * Pick) so the many existing test doubles for routes that never touch
337
+ * agent creation don't all need to implement it: POST /admin/agents
338
+ * (the only caller, via createAgent() in agents.ts) falls back to a
339
+ * non-transactional passthrough when it's absent. Every production
340
+ * wiring (main.ts) always supplies the real AgentService method.
341
+ */
342
+ runTransaction?: AgentService["runTransaction"];
343
+ };
344
+ provisioner: AgentProvisioner;
345
+ /** WIF-2.1: Anthropic federation is enabled; defaults to false. */
346
+ anthropicFederationEnabled?: boolean;
347
+ /**
348
+ * Resolves an agent's typeName to its parsed Specialty manifest and lists
349
+ * the registry's discoverable types (for the new-specialty picker).
350
+ * Defaults to a disk-backed SpecialtyRegistry, matching the DI pattern
351
+ * already used by agents-api.ts's AdminDeps.
352
+ */
353
+ specialtyRegistry?: SpecialtyManifestResolver;
354
+ /**
355
+ * Cleanup deps for the full delete-agent orchestration (POST
356
+ * /admin/agents/:id/delete → deleteAgentFully()). Matches
357
+ * DeleteAgentFullyDeps's shapes exactly, mirroring agents-api.ts's AdminDeps
358
+ * so both delete entry points share the same production wiring in main.ts.
359
+ */
360
+ serviceIntegrations: DeleteAgentFullyDeps["serviceIntegrations"];
361
+ chatService: DeleteAgentFullyDeps["chatService"];
362
+ slack: DeleteAgentFullyDeps["slack"];
363
+ decrypt: DeleteAgentFullyDeps["decrypt"];
364
+ sessionSecret: string;
365
+ googleClient: GoogleAuthClient;
366
+ googleClientId: string;
367
+ googleClientSecret: string;
368
+ /**
369
+ * Okta OIDC client for GET /admin/auth/okta and GET /admin/auth/okta/callback.
370
+ * Optional — when oktaClientId/oktaIssuer are unset (or oktaClient is absent),
371
+ * both Okta routes redirect to /admin/login?error=server_error without
372
+ * attempting any external call, mirroring Google's own missing-config guard.
373
+ */
374
+ oktaClient?: OktaAuthClient;
375
+ oktaClientId?: string;
376
+ oktaClientSecret?: string;
377
+ oktaIssuer?: string;
378
+ adminAllowedEmails: string[];
379
+ slackClient: AdminUISlackClient;
380
+ /**
381
+ * GitHub App manifest-flow provisioning client, used by the auto-provision
382
+ * sub-flow under ghAuthMode=app: GET /admin/provision/github-app/complete
383
+ * exchanges the manifest-flow code via exchangeManifestCode().
384
+ */
385
+ githubAppClient: AdminUIGithubAppClient;
386
+ appBaseUrl: string;
387
+ /** Enable the /admin/dev-login route. Hard-blocked in production regardless of this value. */
388
+ devAuthEnabled?: boolean;
389
+ /** Resolved self-serve provisioning config (SSP-1.3). */
390
+ selfServe?: SelfServeConfig;
391
+ /**
392
+ * First-login account provisioning (SSP-3.1). Consulted by the Google
393
+ * callback only, and only when selfServe.enabled; absent → no auto-create.
394
+ */
395
+ accountOnboarding?: Pick<AccountOnboardingService, "provisionForEmail">;
396
+ /** Best-effort operator push on auto-created accounts (SSP-3.3). */
397
+ accountCreatedNotifier?: AccountCreatedNotifier;
398
+ /**
399
+ * Account page + nav services (SSP-3.2). Used only when selfServe.enabled;
400
+ * absent → no /admin/account routes content, no nav entry, no quota notice.
401
+ */
402
+ accountServices?: {
403
+ accounts: Pick<
404
+ AccountService,
405
+ | "getByMemberEmail"
406
+ | "update"
407
+ | "countAgents"
408
+ | "listWithCounts"
409
+ | "getWithCounts"
410
+ | "listAgents"
411
+ >;
412
+ members: Pick<
413
+ AccountMemberService,
414
+ "listByAccount" | "getByEmail" | "add" | "remove" | "promote" | "demote"
415
+ >;
416
+ invites: Pick<AccountInviteService, "create" | "listPending" | "revoke">;
417
+ /** SSP-8.2: admin account edits + suspend/reactivate side effects. */
418
+ lifecycle: Pick<AccountLifecycle, "update">;
419
+ };
420
+ /**
421
+ * IANA timezone name for date/time display in the admin UI.
422
+ * Defaults to "America/Los_Angeles" when absent.
423
+ */
424
+ timezone?: string;
425
+ /**
426
+ * Extra `?error=` codes (code → message) an extension's routes redirect to
427
+ * the agent detail page with — merged over the platform's own codes.
428
+ */
429
+ agentDetailErrorMessages?: Record<string, string>;
430
+ /**
431
+ * Chat service client for the /admin/chat routes.
432
+ * When absent, all chat routes render in degraded mode (notice, no table/messages).
433
+ */
434
+ chatClient?: ChatClient;
435
+ /**
436
+ * Base directory the PWA shell's icon route reads committed PNGs from.
437
+ * Injectable for tests; defaults to the committed admin/pwa-assets/icons
438
+ * dir (see admin/src/pwa.ts's PWA_ASSETS_DIR, populated by the
439
+ * one-time/on-demand scripts/build-pwa-icons.ts).
440
+ */
441
+ pwaAssetsDir?: string;
442
+ /**
443
+ * App version string interpolated into the service worker's cache name
444
+ * (see admin/src/pwa.ts's buildServiceWorkerBody) so a version bump busts
445
+ * any previously installed cache. Read once from version.txt at startup —
446
+ * matches the "inject time via a Clock" isolation convention rather than
447
+ * reading the file inside the route handler.
448
+ */
449
+ appVersion?: string;
450
+ /**
451
+ * Web Push (CFB-4.2). All three are set together or none: main.ts only wires
452
+ * them when isPushEnabled() is true (public + private + subject all set), so
453
+ * when push is not fully configured pushService is undefined, the routes 503,
454
+ * and the toggle renders "" (page degrades to the CFB-3.2 page).
455
+ */
456
+ pushService?: PushService;
457
+ /** VAPID PUBLIC key — sent to the browser (NOT a secret). "" disables the toggle. */
458
+ vapidPublicKey?: string;
459
+ /** Shared token the chat service presents to the push-notify webhook. */
460
+ pushWebhookToken?: string;
461
+ }
462
+
463
+ // ─── Constants ────────────────────────────────────────────────────────────────
464
+
465
+ const SESSION_COOKIE = "admin_session";
466
+ const OAUTH_STATE_COOKIE = "oauth_state";
467
+ const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
468
+ const SESSION_TTL_SECONDS = 8 * 60 * 60; // 8 hours
469
+ const OAUTH_STATE_TTL_SECONDS = 600; // 10 min
470
+
471
+ // ─── Auth helpers ─────────────────────────────────────────────────────────────
472
+
473
+ async function createSessionToken(
474
+ secret: string,
475
+ userId: string,
476
+ email: string,
477
+ isAdmin: boolean,
478
+ ): Promise<string> {
479
+ const now = Math.floor(Date.now() / 1000);
480
+ return sign(
481
+ {
482
+ userId,
483
+ email,
484
+ isAdmin,
485
+ iat: now,
486
+ exp: now + SESSION_TTL_SECONDS,
487
+ },
488
+ secret,
489
+ "HS256",
490
+ );
491
+ }
492
+
493
+ async function getSessionUser(
494
+ token: string,
495
+ secret: string,
496
+ ): Promise<{ email: string; isAdmin: boolean } | null> {
497
+ try {
498
+ const payload = (await verify(token, secret, "HS256")) as Record<
499
+ string,
500
+ unknown
501
+ >;
502
+ if (
503
+ typeof payload.userId === "string" &&
504
+ payload.userId.length > 0 &&
505
+ typeof payload.email === "string" &&
506
+ payload.email.length > 0
507
+ ) {
508
+ return { email: payload.email, isAdmin: payload.isAdmin === true };
509
+ }
510
+ return null;
511
+ } catch {
512
+ return null;
513
+ }
514
+ }
515
+
516
+ // ─── Auth middleware ──────────────────────────────────────────────────────────
517
+
518
+ export function createUIAuthMiddleware(
519
+ sessionSecret: string,
520
+ ): MiddlewareHandler<AdminUIEnv> {
521
+ return async (c, next) => {
522
+ const sessionToken = getCookie(c, SESSION_COOKIE);
523
+ const user = sessionToken
524
+ ? await getSessionUser(sessionToken, sessionSecret)
525
+ : null;
526
+ if (!user) {
527
+ return c.redirect("/admin/login", 302);
528
+ }
529
+ c.set("userEmail", user.email);
530
+ c.set("isAdmin", user.isAdmin);
531
+ return next();
532
+ };
533
+ }
534
+
535
+ // ─── Back-link validation ───────────────────────────────────────────────────
536
+
537
+ // Shared allowlist resolver: a `from` query param is only honored as a back
538
+ // link when it matches the given same-origin pattern. Rejects protocol-relative
539
+ // (//evil.com), absolute (https://evil.com), javascript:, and any other-path
540
+ // values — those fall back to the given default so a "← X" anchor can never be
541
+ // used as an open-redirect/phishing hop off the admin domain.
542
+ export function resolveBackHref(
543
+ fromParam: string | undefined,
544
+ pattern: RegExp,
545
+ fallback: string,
546
+ ): string {
547
+ if (fromParam && pattern.test(fromParam)) {
548
+ return fromParam;
549
+ }
550
+ return fallback;
551
+ }
552
+
553
+ /**
554
+ * Redirects to the agent detail page, appending the zero-members warning
555
+ * query param when restrictSlackToMembers was just enabled on an agent with
556
+ * no AgentMember rows. Non-blocking — the caller's save already succeeded;
557
+ * this only decides which redirect target to use.
558
+ */
559
+ async function redirectWithMembersWarning(
560
+ c: Context<AdminUIEnv>,
561
+ agentMemberService: Pick<AgentMemberService, "listByAgentId">,
562
+ agentId: string,
563
+ restrictSlackToMembers: boolean,
564
+ ) {
565
+ if (restrictSlackToMembers) {
566
+ const members = await agentMemberService.listByAgentId(agentId);
567
+ if (members.length === 0) {
568
+ return c.redirect(
569
+ `/admin/agents/${agentId}?warning=restrict_slack_no_members`,
570
+ 302,
571
+ );
572
+ }
573
+ }
574
+ return c.redirect(`/admin/agents/${agentId}`, 302);
575
+ }
576
+
577
+ // ─── HTML helper ──────────────────────────────────────────────────────────────
578
+
579
+ /** Renders an admin page body as an HTML Response (see createHtmlResponder). */
580
+ export type AdminHtmlResponder = (
581
+ content: string,
582
+ opts?: { includePwaTags?: boolean; status?: number },
583
+ ) => Response;
584
+
585
+ /**
586
+ * The admin UI's HTML response helper, shared by the platform UI and extension
587
+ * UIs (AEP-3.2) so every admin page gets the same head tags and headers.
588
+ */
589
+ export function createHtmlResponder(appBaseUrl: string): AdminHtmlResponder {
590
+ // Precomputed once per app instance (not per-request): the manifest link +
591
+ // SW-registration script, gated on appBaseUrl being https (see
592
+ // admin/src/pwa.ts's renderPwaHeadTags — "" when the gate fails, e.g. a
593
+ // home-lab operator on plain HTTP).
594
+ const pwaHeadTags = renderPwaHeadTags(appBaseUrl);
595
+
596
+ /**
597
+ * Splices the PWA head tags in just before </head> so every rendered
598
+ * admin page gets the manifest link + SW registration site-wide, without
599
+ * threading appBaseUrl through every render*Page function. A no-op when
600
+ * pwaHeadTags is "" (non-https appBaseUrl).
601
+ */
602
+ function injectPwaHeadTags(content: string): string {
603
+ if (!pwaHeadTags) return content;
604
+ return content.replace("</head>", ` ${pwaHeadTags}\n</head>`);
605
+ }
606
+
607
+ /**
608
+ * `includePwaTags` defaults to true for every /admin/* page. The opt-out is
609
+ * for an extension's unauthenticated pages outside /admin/ — the manifest's
610
+ * scope is /admin/, and such a page must never reference /admin/ URLs.
611
+ */
612
+ return (content, opts) => {
613
+ const body =
614
+ opts?.includePwaTags === false ? content : injectPwaHeadTags(content);
615
+ return new Response(body, {
616
+ status: opts?.status ?? 200,
617
+ headers: {
618
+ "Content-Type": "text/html; charset=utf-8",
619
+ // CSP is out of scope (would need unsafe-inline today and buys
620
+ // nothing here) — these three are the low-risk, high-value headers
621
+ // for a server-rendered admin console.
622
+ "X-Content-Type-Options": "nosniff",
623
+ "Referrer-Policy": "same-origin",
624
+ "X-Frame-Options": "DENY",
625
+ },
626
+ });
627
+ };
628
+ }
629
+
630
+ // ─── Agent access ─────────────────────────────────────────────────────────────
631
+
632
+ /**
633
+ * Agent access checks shared by the platform UI and extension UIs (AEP-3.2).
634
+ * resolveAccessibleAgents: admins see the full fleet, non-admins only the
635
+ * agents in their caller scope (no ordering beyond what listAll()/listByIds()
636
+ * return). assertAgentAccess: admins always, others when the agent is in scope.
637
+ */
638
+ export function createAgentAccessChecks(deps: {
639
+ agentService: Pick<AgentService, "listAll" | "listByIds">;
640
+ callerScopeResolver: CallerScopeResolver;
641
+ }) {
642
+ const { agentService, callerScopeResolver } = deps;
643
+ return {
644
+ async resolveAccessibleAgents(
645
+ userEmail: string,
646
+ isAdmin: boolean,
647
+ ): Promise<Awaited<ReturnType<AgentService["listAll"]>>> {
648
+ if (isAdmin) {
649
+ return agentService.listAll();
650
+ }
651
+ const scope = await callerScopeResolver(userEmail, false);
652
+ return agentService.listByIds(scope.kind === "all" ? [] : scope.agentIds);
653
+ },
654
+ async assertAgentAccess(
655
+ agentId: string,
656
+ userEmail: string,
657
+ isAdmin: boolean,
658
+ ): Promise<boolean> {
659
+ if (isAdmin) return true;
660
+ return scopeIncludesAgent(
661
+ await callerScopeResolver(userEmail, false),
662
+ agentId,
663
+ );
664
+ },
665
+ };
666
+ }
667
+
668
+ // ─── App factory ──────────────────────────────────────────────────────────────
669
+
670
+ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
671
+ const {
672
+ prisma,
673
+ agentEnvService,
674
+ agentCronJobService,
675
+ agentToolService,
676
+ agentTokenService,
677
+ agentPluginService,
678
+ agentMemberService,
679
+ agentService,
680
+ provisioner,
681
+ selfServe,
682
+ accountService,
683
+ accountOnboarding,
684
+ accountCreatedNotifier,
685
+ accountServices,
686
+ specialtyRegistry = new SpecialtyRegistry(),
687
+ callerScopeResolver = memberOnlyCallerScopeResolver(agentMemberService),
688
+ serviceIntegrations,
689
+ chatService,
690
+ slack,
691
+ decrypt,
692
+ sessionSecret,
693
+ googleClient,
694
+ googleClientId,
695
+ googleClientSecret,
696
+ oktaClient,
697
+ oktaClientId = "",
698
+ oktaClientSecret = "",
699
+ oktaIssuer = "",
700
+ adminAllowedEmails,
701
+ slackClient,
702
+ githubAppClient,
703
+ appBaseUrl,
704
+ devAuthEnabled = false,
705
+ timezone = "America/Los_Angeles",
706
+ chatClient,
707
+ pwaAssetsDir = PWA_ASSETS_DIR,
708
+ appVersion = "0.0.0",
709
+ pushService,
710
+ vapidPublicKey = "",
711
+ pushWebhookToken,
712
+ } = deps;
713
+
714
+ // Push is enabled only when the service was constructed (VAPID fully set) AND
715
+ // a public key is available for the browser. A single derived flag so the
716
+ // toggle-render gate and the route-503 gate can't drift.
717
+ const pushEnabled = Boolean(pushService && vapidPublicKey);
718
+
719
+ const app = new Hono<AdminUIEnv>();
720
+
721
+ const requireAuth = createUIAuthMiddleware(sessionSecret);
722
+
723
+ // SSP-3.2: show the toolbar's "Account" entry only for signed-in users who
724
+ // belong to an account, and only while self-serve is on. The flag is carried
725
+ // in AsyncLocalStorage so renderAdminToolbar() needs no extra parameter.
726
+ const accountNavEnabled = Boolean(selfServe?.enabled && accountServices);
727
+ if (accountNavEnabled && accountServices) {
728
+ app.use("/admin/*", async (c, next) => {
729
+ const token = getCookie(c, SESSION_COOKIE);
730
+ const user = token ? await getSessionUser(token, sessionSecret) : null;
731
+ let showAccount = false;
732
+ if (user) {
733
+ try {
734
+ showAccount =
735
+ (await accountServices.accounts.getByMemberEmail(user.email)) !==
736
+ null;
737
+ } catch {
738
+ // nav is cosmetic — never fail the page over the lookup
739
+ }
740
+ }
741
+ return runWithAccountNav(showAccount, () => next());
742
+ });
743
+ }
744
+
745
+ // Extracted Slack app-manifest-creation/OAuth/app-token orchestration
746
+ // (UAP-1.1) — shared by the legacy /admin/provision/* wizard's Slack
747
+ // branch and the new per-agent /admin/agents/:id/connect-slack routes so
748
+ // both reach the same outcome via the same code path.
749
+ const slackProvisioningService = new SlackProvisioningService({
750
+ slackClient,
751
+ agentService,
752
+ agentEnvService,
753
+ agentCronJobService,
754
+ sessionSecret,
755
+ appBaseUrl,
756
+ secretEnvVars: new Set(SECRET_ENV_VARS),
757
+ });
758
+
759
+ // Extracted GitHub App manifest-flow/PAT provisioning orchestration
760
+ // (UAP-1.2) — shared by the legacy /admin/provision/* wizard's GitHub
761
+ // branches (+ github-app/complete + github-app/installed) and the new
762
+ // per-agent /admin/agents/:id/connect-github routes so both reach the same
763
+ // outcome via the same code path.
764
+ const githubProvisioningService = new GithubProvisioningService({
765
+ githubAppClient,
766
+ agentService,
767
+ agentEnvService,
768
+ agentCronJobService,
769
+ sessionSecret,
770
+ appBaseUrl,
771
+ secretEnvVars: new Set(SECRET_ENV_VARS),
772
+ });
773
+
774
+ const html = createHtmlResponder(appBaseUrl);
775
+
776
+ // ─── Login / OAuth / Logout ───────────────────────────────────────────────
777
+
778
+ /**
779
+ * Shared post-authentication step for every OAuth provider (Google, Okta):
780
+ * checks the verified-email + admin-allowlist/AgentMember-fallback rules,
781
+ * mints the session JWT, sets the session cookie, and redirects to
782
+ * `returnTo` (or the default landing page). Each provider's callback route
783
+ * normalizes its own userinfo shape into this common `{sub, email,
784
+ * email_verified}` input before calling in — callers never see a
785
+ * provider-specific type here.
786
+ *
787
+ * Returns a Response in every case (a redirect or a 403), matching each
788
+ * inline early-return the Google callback used before this was extracted.
789
+ */
790
+ async function completeLogin(
791
+ // biome-ignore lint/suspicious/noExplicitAny: matches each OAuth callback route's own path-string literal type; the path itself is irrelevant here.
792
+ c: Context<AdminUIEnv, any>,
793
+ userInfo: { sub: string; email?: string; email_verified?: boolean },
794
+ returnTo: string | undefined,
795
+ opts: { selfServeSignup?: boolean } = {},
796
+ ): Promise<Response> {
797
+ if (!userInfo.email) {
798
+ return c.redirect("/admin/login?error=auth_failed", 302);
799
+ }
800
+
801
+ if (!userInfo.email_verified) {
802
+ return c.redirect("/admin/login?error=auth_failed", 302);
803
+ }
804
+
805
+ // Check admin allowlist first, then fall back to member access
806
+ const isAdmin = adminAllowedEmails
807
+ .map((e) => e.toLowerCase())
808
+ .includes(userInfo.email.toLowerCase());
809
+
810
+ if (!isAdmin) {
811
+ const memberships = await agentMemberService.listByEmail(
812
+ userInfo.email.toLowerCase(),
813
+ );
814
+ if (memberships.length === 0) {
815
+ // Self-serve (SSP-3.1): a verified Google email with no AgentMember
816
+ // rows joins its invited account or gets a new one. Okta and flag-off
817
+ // keep the existing 403.
818
+ const onboarding = accountOnboarding;
819
+ if (!(opts.selfServeSignup && selfServe?.enabled && onboarding)) {
820
+ return new Response("Forbidden", { status: 403 });
821
+ }
822
+ const email = userInfo.email.toLowerCase();
823
+ let result: Awaited<ReturnType<typeof onboarding.provisionForEmail>>;
824
+ try {
825
+ result = await onboarding.provisionForEmail(email);
826
+ } catch (err) {
827
+ console.error("[admin] self-serve account provisioning failed", err);
828
+ return c.redirect("/admin/login?error=server_error", 302);
829
+ }
830
+ if (result.kind !== "existing") {
831
+ console.log(
832
+ JSON.stringify({
833
+ event: `account_${result.kind}`,
834
+ accountId: result.accountId,
835
+ emailDomain: email.slice(email.lastIndexOf("@") + 1),
836
+ }),
837
+ );
838
+ }
839
+ // Only a brand-new account notifies; invite-joins do not.
840
+ if (result.kind === "created") {
841
+ fireAccountCreatedNotification(accountCreatedNotifier, {
842
+ accountId: result.accountId,
843
+ emailDomain: email.slice(email.lastIndexOf("@") + 1),
844
+ });
845
+ }
846
+ }
847
+ }
848
+
849
+ // Create session
850
+ const token = await createSessionToken(
851
+ sessionSecret,
852
+ userInfo.sub,
853
+ userInfo.email,
854
+ isAdmin,
855
+ );
856
+ setCookie(c, SESSION_COOKIE, token, {
857
+ httpOnly: true,
858
+ secure: appBaseUrl.startsWith("https://"),
859
+ sameSite: "Lax",
860
+ maxAge: SESSION_TTL_SECONDS,
861
+ path: "/",
862
+ });
863
+ // Redirect to returnTo if it's a valid same-origin relative path, otherwise default.
864
+ const destination =
865
+ returnTo?.startsWith("/") && !returnTo.startsWith("//")
866
+ ? returnTo
867
+ : "/admin/agents";
868
+ return c.redirect(destination, 302);
869
+ }
870
+
871
+ /**
872
+ * Shared OAuth-start step for every provider (Google, Okta): mints a CSRF
873
+ * nonce, validates `returnTo` as a same-origin relative path (dropping
874
+ * malformed/absolute values), and stores both in the oauth_state cookie.
875
+ */
876
+ function beginOAuthFlow(
877
+ // biome-ignore lint/suspicious/noExplicitAny: matches each OAuth start route's own path-string literal type; the path itself is irrelevant here.
878
+ c: Context<AdminUIEnv, any>,
879
+ ): { nonce: string; returnTo: string | undefined } {
880
+ const nonce = crypto.randomUUID();
881
+
882
+ const rawReturnTo = c.req.query("returnTo");
883
+ const returnTo =
884
+ rawReturnTo?.startsWith("/") && !rawReturnTo.startsWith("//")
885
+ ? rawReturnTo
886
+ : undefined;
887
+
888
+ const oauthState = JSON.stringify({ nonce, returnTo });
889
+ setCookie(c, OAUTH_STATE_COOKIE, oauthState, {
890
+ httpOnly: true,
891
+ sameSite: "Lax",
892
+ maxAge: OAUTH_STATE_TTL_SECONDS,
893
+ path: "/admin/auth",
894
+ });
895
+
896
+ return { nonce, returnTo };
897
+ }
898
+
899
+ /**
900
+ * Shared OAuth-callback CSRF check for every provider: reads + clears the
901
+ * oauth_state cookie, and confirms its nonce matches the `state` query
902
+ * param. Returns the carried `returnTo` on success, or `null` if the
903
+ * cookie is missing/malformed or the nonce doesn't match.
904
+ */
905
+ function validateOAuthState(
906
+ // biome-ignore lint/suspicious/noExplicitAny: matches each OAuth callback route's own path-string literal type; the path itself is irrelevant here.
907
+ c: Context<AdminUIEnv, any>,
908
+ state: string | undefined,
909
+ ): { returnTo: string | undefined } | null {
910
+ const storedStateCookie = getCookie(c, OAUTH_STATE_COOKIE);
911
+ deleteCookie(c, OAUTH_STATE_COOKIE, { path: "/admin/auth" });
912
+
913
+ let storedNonce: string | undefined;
914
+ let returnTo: string | undefined;
915
+ try {
916
+ if (storedStateCookie) {
917
+ const parsed = JSON.parse(storedStateCookie) as {
918
+ nonce?: string;
919
+ returnTo?: string;
920
+ };
921
+ storedNonce = parsed.nonce;
922
+ returnTo = parsed.returnTo;
923
+ }
924
+ } catch {
925
+ // Malformed cookie — treat as missing
926
+ }
927
+
928
+ if (!storedNonce || !state || storedNonce !== state) {
929
+ return null;
930
+ }
931
+ return { returnTo };
932
+ }
933
+
934
+ // ─── PWA shell (manifest, service worker, icons, offline page) ───────────
935
+ //
936
+ // Deliberately unauthenticated (no requireAuth) — this is required, not a
937
+ // shortcut. Browsers fetch the manifest and service worker with
938
+ // credentials:omit, so gating either behind the session cookie would 302
939
+ // to /admin/login and silently break install. Neither route leaks
940
+ // secrets: the manifest is static config, and the SW's own caching
941
+ // predicate (admin/src/pwa.ts) refuses every authenticated document/JSON
942
+ // route by construction.
943
+
944
+ app.get("/admin/manifest.webmanifest", (c) => {
945
+ // PWA-1.1: renderPwaHeadTags rewrites the manifest link's href with
946
+ // ?start=<current pathname> on every page, so "Add to Home Screen"
947
+ // installs a shortcut back into whichever page the user was on.
948
+ // sanitizeStartUrl always returns a safe value (defaulting to
949
+ // /admin/chat), so this is safe to call unconditionally even when the
950
+ // query param is absent, malformed, or malicious.
951
+ const startUrl = sanitizeStartUrl(c.req.query("start"));
952
+ return c.json(buildManifest(startUrl), 200, {
953
+ "Content-Type": "application/manifest+json",
954
+ });
955
+ });
956
+
957
+ app.get("/admin/sw.js", (_c) => {
958
+ const body = buildServiceWorkerBody(appVersion, getPrecacheList());
959
+ return new Response(body, {
960
+ headers: {
961
+ "Content-Type": "application/javascript; charset=utf-8",
962
+ // A cached service worker is unupdatable — browsers must always
963
+ // revalidate this file so a version bump's new cache name (and any
964
+ // precache-list change) actually takes effect.
965
+ "Cache-Control": "no-cache",
966
+ },
967
+ });
968
+ });
969
+
970
+ app.get("/admin/offline.html", (_c) => {
971
+ return new Response(buildOfflinePageHtml(), {
972
+ headers: { "Content-Type": "text/html; charset=utf-8" },
973
+ });
974
+ });
975
+
976
+ const PWA_ICON_FILENAMES = new Set(PWA_ICONS.map((icon) => icon.filename));
977
+
978
+ app.get("/admin/icons/:filename", async (c) => {
979
+ const filename = c.req.param("filename");
980
+ if (!PWA_ICON_FILENAMES.has(filename)) {
981
+ return c.json({ error: "Not found" }, 404);
982
+ }
983
+ try {
984
+ const body = await Bun.file(
985
+ join(pwaAssetsDir, "icons", filename),
986
+ ).arrayBuffer();
987
+ return new Response(body, {
988
+ headers: {
989
+ "Content-Type": "image/png",
990
+ "Cache-Control": "public, max-age=86400",
991
+ },
992
+ });
993
+ } catch (err) {
994
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
995
+ return c.json({ error: "Not found" }, 404);
996
+ }
997
+ console.error(`[admin-ui] PWA icon read error [${filename}]:`, err);
998
+ return c.json({ error: "Internal server error" }, 500);
999
+ }
1000
+ });
1001
+
1002
+ app.get("/admin/login", (c) => {
1003
+ const error = c.req.query("error") ?? undefined;
1004
+ const returnTo = c.req.query("returnTo") ?? undefined;
1005
+ const googleEnabled = Boolean(googleClientId && googleClientSecret);
1006
+ const oktaEnabled = Boolean(oktaClientId && oktaIssuer && oktaClient);
1007
+ return html(
1008
+ renderLoginPage({ error, returnTo, googleEnabled, oktaEnabled }),
1009
+ );
1010
+ });
1011
+
1012
+ app.get("/admin/auth/google", (c) => {
1013
+ if (!googleClientId) {
1014
+ return c.redirect("/admin/login?error=server_error", 302);
1015
+ }
1016
+
1017
+ const { nonce } = beginOAuthFlow(c);
1018
+
1019
+ const params = new URLSearchParams({
1020
+ client_id: googleClientId,
1021
+ redirect_uri: `${appBaseUrl}/admin/auth/callback`,
1022
+ response_type: "code",
1023
+ scope: "openid profile email",
1024
+ state: nonce,
1025
+ prompt: "select_account",
1026
+ });
1027
+
1028
+ return c.redirect(`${GOOGLE_AUTH_URL}?${params.toString()}`, 302);
1029
+ });
1030
+
1031
+ app.get("/admin/auth/callback", async (c) => {
1032
+ const { code, state, error: googleError } = c.req.query();
1033
+
1034
+ // Google returned an error (e.g. access_denied)
1035
+ if (googleError) {
1036
+ const slug =
1037
+ googleError === "access_denied" ? "access_denied" : "auth_failed";
1038
+ return c.redirect(`/admin/login?error=${slug}`, 302);
1039
+ }
1040
+
1041
+ // CSRF: validate state nonce. The oauth_state cookie is JSON: {nonce, returnTo?}.
1042
+ const validated = validateOAuthState(c, state);
1043
+ if (!validated) {
1044
+ return c.redirect("/admin/login?error=invalid_state", 302);
1045
+ }
1046
+ const { returnTo } = validated;
1047
+
1048
+ if (!googleClientId || !googleClientSecret) {
1049
+ return c.redirect("/admin/login?error=server_error", 302);
1050
+ }
1051
+
1052
+ if (!code) {
1053
+ return c.redirect("/admin/login?error=auth_failed", 302);
1054
+ }
1055
+
1056
+ // Exchange authorization code for tokens
1057
+ let accessToken: string;
1058
+ try {
1059
+ const tokens = await googleClient.exchangeCode({
1060
+ code,
1061
+ clientId: googleClientId,
1062
+ clientSecret: googleClientSecret,
1063
+ redirectUri: `${appBaseUrl}/admin/auth/callback`,
1064
+ });
1065
+ accessToken = tokens.accessToken;
1066
+ } catch {
1067
+ return c.redirect("/admin/login?error=auth_failed", 302);
1068
+ }
1069
+
1070
+ // Fetch user info from Google
1071
+ let userInfo: {
1072
+ sub: string;
1073
+ email?: string;
1074
+ email_verified?: boolean;
1075
+ name: string;
1076
+ };
1077
+ try {
1078
+ userInfo = await googleClient.getUserInfo(accessToken);
1079
+ } catch {
1080
+ return c.redirect("/admin/login?error=auth_failed", 302);
1081
+ }
1082
+
1083
+ return completeLogin(c, userInfo, returnTo, { selfServeSignup: true });
1084
+ });
1085
+
1086
+ app.get("/admin/auth/okta", (c) => {
1087
+ if (!oktaClientId || !oktaIssuer || !oktaClient) {
1088
+ return c.redirect("/admin/login?error=server_error", 302);
1089
+ }
1090
+
1091
+ const { nonce } = beginOAuthFlow(c);
1092
+
1093
+ const authUrl = oktaClient.getAuthorizationUrl({
1094
+ clientId: oktaClientId,
1095
+ redirectUri: `${appBaseUrl}/admin/auth/okta/callback`,
1096
+ state: nonce,
1097
+ scope: "openid profile email",
1098
+ });
1099
+
1100
+ return c.redirect(authUrl, 302);
1101
+ });
1102
+
1103
+ app.get("/admin/auth/okta/callback", async (c) => {
1104
+ const { code, state, error: oktaError } = c.req.query();
1105
+
1106
+ // Okta returned an error (e.g. access_denied)
1107
+ if (oktaError) {
1108
+ const slug =
1109
+ oktaError === "access_denied" ? "access_denied" : "auth_failed";
1110
+ return c.redirect(`/admin/login?error=${slug}`, 302);
1111
+ }
1112
+
1113
+ // CSRF: validate state nonce. The oauth_state cookie is JSON: {nonce, returnTo?}.
1114
+ const validated = validateOAuthState(c, state);
1115
+ if (!validated) {
1116
+ return c.redirect("/admin/login?error=invalid_state", 302);
1117
+ }
1118
+ const { returnTo } = validated;
1119
+
1120
+ if (!oktaClientId || !oktaClientSecret || !oktaIssuer || !oktaClient) {
1121
+ return c.redirect("/admin/login?error=server_error", 302);
1122
+ }
1123
+
1124
+ if (!code) {
1125
+ return c.redirect("/admin/login?error=auth_failed", 302);
1126
+ }
1127
+
1128
+ // Exchange authorization code for tokens
1129
+ let accessToken: string;
1130
+ try {
1131
+ const tokens = await oktaClient.exchangeCode({
1132
+ code,
1133
+ clientId: oktaClientId,
1134
+ clientSecret: oktaClientSecret,
1135
+ redirectUri: `${appBaseUrl}/admin/auth/okta/callback`,
1136
+ });
1137
+ accessToken = tokens.accessToken;
1138
+ } catch {
1139
+ return c.redirect("/admin/login?error=auth_failed", 302);
1140
+ }
1141
+
1142
+ // Fetch user info from Okta
1143
+ let userInfo: {
1144
+ sub: string;
1145
+ email?: string;
1146
+ email_verified?: boolean;
1147
+ name: string;
1148
+ picture?: string;
1149
+ };
1150
+ try {
1151
+ userInfo = await oktaClient.getUserInfo(accessToken);
1152
+ } catch {
1153
+ return c.redirect("/admin/login?error=auth_failed", 302);
1154
+ }
1155
+
1156
+ return completeLogin(c, userInfo, returnTo);
1157
+ });
1158
+
1159
+ app.post("/admin/logout", (c) => {
1160
+ deleteCookie(c, SESSION_COOKIE, { path: "/" });
1161
+ return c.redirect("/admin/login", 302);
1162
+ });
1163
+
1164
+ // ─── Dev auto-login (non-prod only) ──────────────────────────────────────
1165
+
1166
+ app.get("/admin/dev-login", async (c) => {
1167
+ // Hard-blocked: devAuthEnabled must be true AND we must not be in production.
1168
+ // The devAuthEnabled flag is pre-computed from isDevAuthAllowed() at startup;
1169
+ // this route simply trusts the injected value.
1170
+ if (!devAuthEnabled) {
1171
+ return new Response("Not Found", { status: 404 });
1172
+ }
1173
+ const token = await createSessionToken(
1174
+ sessionSecret,
1175
+ "dev",
1176
+ "dev@localhost",
1177
+ true,
1178
+ );
1179
+ setCookie(c, SESSION_COOKIE, token, {
1180
+ httpOnly: true,
1181
+ secure: appBaseUrl.startsWith("https://"),
1182
+ sameSite: "Lax",
1183
+ maxAge: SESSION_TTL_SECONDS,
1184
+ path: "/",
1185
+ });
1186
+ return c.redirect("/admin/agents", 302);
1187
+ });
1188
+
1189
+ // ─── Agents list ──────────────────────────────────────────────────────────
1190
+
1191
+ // Access-scoping for the agents list (and extension views via the same
1192
+ // createAgentAccessChecks helper).
1193
+ const { resolveAccessibleAgents, assertAgentAccess } =
1194
+ createAgentAccessChecks({ agentService, callerScopeResolver });
1195
+
1196
+ app.get("/admin/agents", requireAuth, async (c) => {
1197
+ const agents = await resolveAccessibleAgents(
1198
+ c.var.userEmail,
1199
+ c.var.isAdmin,
1200
+ );
1201
+ const successMsg =
1202
+ c.req.query("success") === "deleted" ? "Agent deleted." : undefined;
1203
+ // Surfaced by POST /admin/agents/:id/delete after a successful
1204
+ // deleteAgentFully() call — operator-facing reminders that would
1205
+ // otherwise be silently dropped on the redirect to this list.
1206
+ const manualStepsRaw = c.req.query("manualSteps");
1207
+ let manualSteps: ManualStep[] | undefined;
1208
+ if (manualStepsRaw) {
1209
+ try {
1210
+ const parsed = JSON.parse(manualStepsRaw);
1211
+ if (Array.isArray(parsed)) manualSteps = parsed;
1212
+ } catch {
1213
+ // malformed/tampered query param — render without the panel
1214
+ }
1215
+ }
1216
+ // SSP-3.2: a zero-quota account sees a request-a-trial message and a
1217
+ // disabled create button instead of the create CTA.
1218
+ let zeroQuotaContactEmail: string | undefined;
1219
+ if (accountNavEnabled && accountServices && selfServe) {
1220
+ const account = await accountServices.accounts.getByMemberEmail(
1221
+ c.var.userEmail,
1222
+ );
1223
+ if (account && account.maxAgents === 0) {
1224
+ zeroQuotaContactEmail = selfServe.contactEmail;
1225
+ }
1226
+ }
1227
+ // SSP-5.2: admins get an Account column (flag on only).
1228
+ let accountNames: Map<string, string> | undefined;
1229
+ if (c.var.isAdmin && accountNavEnabled && accountServices) {
1230
+ accountNames = new Map(
1231
+ (await accountServices.accounts.listWithCounts()).map((a) => [
1232
+ a.id,
1233
+ a.name,
1234
+ ]),
1235
+ );
1236
+ }
1237
+ return html(
1238
+ renderAgentsPage(agents, c.var.userEmail, c.var.isAdmin, timezone, {
1239
+ accountNames,
1240
+ queueActivityLinks: deps.queueActivityLinks,
1241
+ successMsg,
1242
+ manualSteps,
1243
+ zeroQuotaNotice: zeroQuotaContactEmail
1244
+ ? renderZeroQuotaNotice(zeroQuotaContactEmail)
1245
+ : undefined,
1246
+ }),
1247
+ );
1248
+ });
1249
+
1250
+ // ─── Agent detail ─────────────────────────────────────────────────────────
1251
+
1252
+ /**
1253
+ * SSP-4.3: with self-serve on, anyone who passes assertAgentAccess for an
1254
+ * agent (owner or member) fully controls it — settings, members, delete.
1255
+ * Flag off: admin-only, so existing platform AgentMembers gain no power.
1256
+ */
1257
+ const canManageAgents = (isAdmin: boolean): boolean =>
1258
+ isAdmin || deps.selfServe?.enabled === true;
1259
+
1260
+ async function assertCanManageAgent(
1261
+ agentId: string,
1262
+ userEmail: string,
1263
+ isAdmin: boolean,
1264
+ ): Promise<boolean> {
1265
+ if (isAdmin) return true;
1266
+ if (!canManageAgents(false)) return false;
1267
+ return assertAgentAccess(agentId, userEmail, false);
1268
+ }
1269
+
1270
+ const ERROR_MESSAGES: Record<string, string> = {
1271
+ missing_fields: "Required fields are missing.",
1272
+ create_failed: "Failed to create — please try again.",
1273
+ invalid_schedule: "Invalid cron schedule expression.",
1274
+ invalid_target:
1275
+ "Invalid delivery target — set channel or user (or enable silent mode).",
1276
+ invalid_repo_format:
1277
+ "Repo must be in org/repo format (e.g. my-org/my-repo).",
1278
+ invalid_author_allowlist_format:
1279
+ "Author allowlist entries must be valid GitHub logins.",
1280
+ invalid_type: "Select a valid specialty.",
1281
+ provisioning_disabled:
1282
+ "In-cluster provisioning is not enabled on this admin service — create a self-hosted agent instead.",
1283
+ provision_failed:
1284
+ "Failed to provision the agent's cluster resources — the agent was not created.",
1285
+ quota_exceeded: "This account has reached its agent limit.",
1286
+ account_inactive: "This account is not active.",
1287
+ ...deps.agentDetailErrorMessages,
1288
+ };
1289
+
1290
+ // ─── New agent form (MUST be before /:id to avoid "new" being captured as param)
1291
+
1292
+ // Account the caller may create agents in (SSP-4.2): null for platform
1293
+ // admins (unassigned agents, today's behavior) and for non-admins without an
1294
+ // account (flag off / no membership) — the latter are denied by the callers.
1295
+ async function resolveCreateAccountId(
1296
+ userEmail: string,
1297
+ isAdmin: boolean,
1298
+ ): Promise<string | null> {
1299
+ if (isAdmin) return null;
1300
+ return accountIdFromScope(await callerScopeResolver(userEmail, false));
1301
+ }
1302
+
1303
+ app.get("/admin/agents/new", requireAuth, async (c) => {
1304
+ const accountId = await resolveCreateAccountId(
1305
+ c.var.userEmail,
1306
+ c.var.isAdmin,
1307
+ );
1308
+ if (!c.var.isAdmin && accountId === null) {
1309
+ return new Response("Forbidden", { status: 403 });
1310
+ }
1311
+ let selfServeOpts:
1312
+ | { remaining: number; max: number; contactEmail: string }
1313
+ | undefined;
1314
+ if (accountId !== null && accountService) {
1315
+ const [account, count] = await Promise.all([
1316
+ accountService.getById(accountId),
1317
+ accountService.countAgents(accountId),
1318
+ ]);
1319
+ selfServeOpts = {
1320
+ remaining: Math.max(0, (account?.maxAgents ?? 0) - count),
1321
+ max: account?.maxAgents ?? 0,
1322
+ contactEmail:
1323
+ selfServe?.contactEmail ?? DEFAULT_SELF_SERVE_CONTACT_EMAIL,
1324
+ };
1325
+ }
1326
+ const rawError = c.req.query("error") ?? undefined;
1327
+ const error = rawError ? (ERROR_MESSAGES[rawError] ?? rawError) : undefined;
1328
+ const types = specialtyRegistry.listTypes();
1329
+ return html(
1330
+ renderNewLocalAgentPage(c.var.userEmail, types, {
1331
+ error,
1332
+ canProvision: provisioner.canProvision,
1333
+ anthropicFederationEnabled: deps.anthropicFederationEnabled,
1334
+ ...(selfServeOpts ? { selfServe: selfServeOpts } : {}),
1335
+ }),
1336
+ );
1337
+ });
1338
+
1339
+ // ─── Create agent (self-hosted or provisioned in-cluster) ────────────────
1340
+
1341
+ app.post("/admin/agents", requireAuth, async (c) => {
1342
+ const accountId = await resolveCreateAccountId(
1343
+ c.var.userEmail,
1344
+ c.var.isAdmin,
1345
+ );
1346
+ if (!c.var.isAdmin && accountId === null) {
1347
+ return new Response("Forbidden", { status: 403 });
1348
+ }
1349
+ let name: string | undefined;
1350
+ let typeName: string | undefined;
1351
+ let reposRaw: string | undefined;
1352
+ let authorAllowlistRaw: string | undefined;
1353
+ let patchAuthorAllowlistRaw: string | undefined;
1354
+ let memberEmailsRaw: string | undefined;
1355
+ let restrictSlackToMembersRaw: string | undefined;
1356
+ let runtime: string | undefined;
1357
+ let claudeCodeOauthToken: string | undefined;
1358
+ let anthropicApiKey: string | undefined;
1359
+ let connectSlackRaw: string | undefined;
1360
+ let xoxpToken: string | undefined;
1361
+ let ghAuthMode: string | undefined;
1362
+ let ghPat: string | undefined;
1363
+ let githubOrg: string | undefined;
1364
+ let ghAppMode: string | undefined;
1365
+ let ghAppId: string | undefined;
1366
+ let ghAppInstallationId: string | undefined;
1367
+ let ghAppPrivateKey: string | undefined;
1368
+ try {
1369
+ const formData = await c.req.formData();
1370
+ name = formData.get("name")?.toString()?.trim();
1371
+ typeName = formData.get("type")?.toString()?.trim();
1372
+ reposRaw = formData.get("repos")?.toString()?.trim();
1373
+ authorAllowlistRaw = formData.get("authorAllowlist")?.toString()?.trim();
1374
+ patchAuthorAllowlistRaw = formData
1375
+ .get("patchAuthorAllowlist")
1376
+ ?.toString()
1377
+ ?.trim();
1378
+ memberEmailsRaw = formData.get("memberEmails")?.toString()?.trim();
1379
+ restrictSlackToMembersRaw = formData
1380
+ .get("restrictSlackToMembers")
1381
+ ?.toString();
1382
+ runtime = formData.get("runtime")?.toString()?.trim();
1383
+ claudeCodeOauthToken = formData
1384
+ .get("claudeCodeOauthToken")
1385
+ ?.toString()
1386
+ ?.trim();
1387
+ anthropicApiKey = formData.get("anthropicApiKey")?.toString()?.trim();
1388
+ connectSlackRaw = formData.get("connectSlack")?.toString();
1389
+ xoxpToken = formData.get("xoxpToken")?.toString();
1390
+ ghAuthMode = formData.get("ghAuthMode")?.toString()?.trim();
1391
+ ghPat = formData.get("ghPat")?.toString();
1392
+ githubOrg = formData.get("githubOrg")?.toString()?.trim();
1393
+ ghAppMode = formData.get("ghAppMode")?.toString()?.trim();
1394
+ ghAppId = formData.get("ghAppId")?.toString();
1395
+ ghAppInstallationId = formData.get("ghAppInstallationId")?.toString();
1396
+ // UAP-5.3: the "Use existing GitHub App" option submits the private
1397
+ // key as a file upload rather than pasted text — read its contents
1398
+ // server-side, mirroring the per-agent connect-github route.
1399
+ const ghAppPrivateKeyFile = formData.get("ghAppPrivateKeyFile");
1400
+ ghAppPrivateKey =
1401
+ ghAppPrivateKeyFile instanceof File
1402
+ ? await ghAppPrivateKeyFile.text()
1403
+ : undefined;
1404
+ } catch {
1405
+ return c.redirect("/admin/agents/new", 302);
1406
+ }
1407
+ const connectSlack = connectSlackRaw === "true";
1408
+ // SSP-4.2: account users always create "coding" agents in their own
1409
+ // account — any submitted type/accountId is ignored.
1410
+ if (accountId !== null) typeName = getSelfServeSpecialty();
1411
+ // Absent/unrecognized runtime means self-hosted — the historical behavior
1412
+ // of this form. createAgent() below re-derives this same value itself
1413
+ // (it owns the runtime/provisioning validation); it's recomputed here
1414
+ // too only to gate the UAP-2.1 Slack/GitHub connect block further down,
1415
+ // which runs after createAgent() returns and has no other way to learn
1416
+ // whether this agent is in-cluster.
1417
+ const inCluster = runtime === "in-cluster";
1418
+
1419
+ // APA-1.1: the entire create-agent sequence (validate name/typeName,
1420
+ // create the Agent row, seed AgentTool/AgentPlugin from the type
1421
+ // manifest, attach repos/allowlists/members, patch Claude credentials,
1422
+ // provision Kubernetes when requested, best-effort reconcile system
1423
+ // crons) lives in createAgent() (agents.ts) as the single implementation
1424
+ // of agent creation. Every failure mode redirects to the same
1425
+ // `/admin/agents/new?error=<code>` shape the inline handler used to
1426
+ // build itself, so `result.errorCode` maps directly into the query
1427
+ // param.
1428
+ const result = await createAgent(
1429
+ {
1430
+ // Bind each method createAgent() needs (CreateAgentDeps.agentService
1431
+ // = create | delete | updateFields | runTransaction) explicitly
1432
+ // rather than spreading `agentService`: in production this is a real
1433
+ // `new AgentService(prisma)` whose methods live on
1434
+ // AgentService.prototype, and object spread copies only own
1435
+ // enumerable properties — a spread would silently drop
1436
+ // create/delete/updateFields and throw at the first call.
1437
+ agentService: {
1438
+ create: agentService.create.bind(agentService),
1439
+ delete: agentService.delete.bind(agentService),
1440
+ updateFields: agentService.updateFields.bind(agentService),
1441
+ // Test doubles for routes unrelated to agent creation don't
1442
+ // implement runTransaction (see AdminUIDeps.agentService above) —
1443
+ // fall back to a non-transactional passthrough so createAgent()
1444
+ // always has a concrete implementation to call. Production always
1445
+ // supplies the real AgentService method.
1446
+ runTransaction:
1447
+ agentService.runTransaction?.bind(agentService) ??
1448
+ (<T>(fn: (tx: PrismaTransactionClient) => Promise<T>) =>
1449
+ fn(undefined as unknown as PrismaTransactionClient)),
1450
+ },
1451
+ agentToolService,
1452
+ agentPluginService,
1453
+ agentMemberService,
1454
+ agentEnvService,
1455
+ agentCronJobService,
1456
+ provisioner,
1457
+ specialtyRegistry,
1458
+ anthropicFederationEnabled: deps.anthropicFederationEnabled,
1459
+ },
1460
+ {
1461
+ name,
1462
+ typeName,
1463
+ runtime,
1464
+ reposRaw,
1465
+ authorAllowlistRaw,
1466
+ patchAuthorAllowlistRaw,
1467
+ memberEmailsRaw,
1468
+ restrictSlackToMembersRaw,
1469
+ claudeCodeOauthToken,
1470
+ anthropicApiKey,
1471
+ ...(accountId !== null ? { accountId } : {}),
1472
+ // PAI-1.2: owner is the authenticated session user, never form data.
1473
+ ownerEmail: c.var.userEmail,
1474
+ },
1475
+ );
1476
+ if (!result.ok) {
1477
+ return c.redirect(
1478
+ `/admin/agents/new?error=${encodeURIComponent(result.errorMessage ?? result.errorCode)}`,
1479
+ 302,
1480
+ );
1481
+ }
1482
+ const { agent, restrictSlackToMembers } = result;
1483
+
1484
+ // ─── UAP-2.1: inline Slack/GitHub connect branches ─────────────────────
1485
+ // The agent row already exists at this point, so any failure below
1486
+ // redirects to the agent DETAIL page with an error — never rolls back
1487
+ // the agent (mirrors how the standalone connect-slack/connect-github
1488
+ // routes behave against an already-existing agent).
1489
+ //
1490
+ // Slack's OAuth flow is an external browser redirect, so at most one of
1491
+ // Slack/GitHub can produce the final HTTP response. When both are
1492
+ // requested, GitHub's storage/redirect-prep runs first (PAT storage is
1493
+ // synchronous; the App-manifest flow can't run before Slack's redirect
1494
+ // either, so it's skipped when Slack is also requested) so GH_TOKEN is
1495
+ // already in place by the time the user lands back from Slack's OAuth
1496
+ // callback — then Slack's redirect is returned last.
1497
+ //
1498
+ // UAP-5.1: this whole block only applies to in-cluster agents. Self-hosted
1499
+ // agents use local git config for GitHub auth, not admin-managed
1500
+ // provisioning, so Slack/GitHub connect must never run for them — even if
1501
+ // connectSlack/ghAuthMode were submitted via a raw POST bypassing the UI
1502
+ // (the New Agent form hides these sections for self-hosted, but that's
1503
+ // client-side only).
1504
+ if (inCluster) {
1505
+ let ghPatResult:
1506
+ | Awaited<ReturnType<typeof githubProvisioningService.startPatConnect>>
1507
+ | undefined;
1508
+ if (ghAuthMode === "pat") {
1509
+ ghPatResult = await githubProvisioningService.startPatConnect(
1510
+ agent.id,
1511
+ ghPat,
1512
+ );
1513
+ if (!ghPatResult.ok && !connectSlack) {
1514
+ return c.redirect(
1515
+ `/admin/agents/${agent.id}?error=${encodeURIComponent(ghPatResult.error)}`,
1516
+ 302,
1517
+ );
1518
+ }
1519
+ if (!ghPatResult.ok) {
1520
+ console.error(
1521
+ "[admin-ui] GitHub PAT storage failed during combined Slack+GitHub connect:",
1522
+ ghPatResult.error,
1523
+ );
1524
+ }
1525
+ }
1526
+
1527
+ if (connectSlack) {
1528
+ const redirectUri = `${appBaseUrl}/admin/agents/${agent.id}/connect-slack/callback`;
1529
+ // Carry a failed-PAT-storage warning (UAP-2.1) through Slack's OAuth
1530
+ // round trip via the signed provision-state cookie so it resurfaces on
1531
+ // the post-callback landing page. Without this, a GitHub PAT that failed
1532
+ // to store above would be silently swallowed and the operator misled
1533
+ // into believing GH_TOKEN was connected.
1534
+ const ghConnectError =
1535
+ ghPatResult && !ghPatResult.ok ? ghPatResult.error : undefined;
1536
+ const slackResult = await slackProvisioningService.startConnect(
1537
+ agent.id,
1538
+ xoxpToken,
1539
+ redirectUri,
1540
+ ghConnectError,
1541
+ );
1542
+ if (!slackResult.ok) {
1543
+ return c.redirect(
1544
+ `/admin/agents/${agent.id}?error=${encodeURIComponent(slackResult.error)}`,
1545
+ 302,
1546
+ );
1547
+ }
1548
+ setCookie(c, PROVISION_STATE_COOKIE, slackResult.provisionStateToken, {
1549
+ httpOnly: true,
1550
+ maxAge: PROVISION_STATE_TTL_SECONDS,
1551
+ sameSite: "Lax",
1552
+ path: "/",
1553
+ secure: appBaseUrl.startsWith("https://"),
1554
+ });
1555
+ return c.redirect(slackResult.oauthRedirectUrl, 302);
1556
+ }
1557
+
1558
+ if (ghAuthMode === "app" && ghAppMode === "manual") {
1559
+ // UAP-5.3: "Use existing GitHub App" — validates and persists
1560
+ // GH_APP_ID/GH_APP_INSTALLATION_ID/GH_APP_PRIVATE_KEY directly, no
1561
+ // GitHub API call. On success falls through to the shared
1562
+ // redirectWithMembersWarning below, matching the ghAuthMode=pat
1563
+ // branch's success path.
1564
+ const manualResult =
1565
+ await githubProvisioningService.startAppManualConnect(agent.id, {
1566
+ ghAppId,
1567
+ ghAppInstallationId,
1568
+ ghAppPrivateKey,
1569
+ });
1570
+ if (!manualResult.ok) {
1571
+ return c.redirect(
1572
+ `/admin/agents/${agent.id}?error=${encodeURIComponent(manualResult.error)}`,
1573
+ 302,
1574
+ );
1575
+ }
1576
+ } else if (ghAuthMode === "app") {
1577
+ const appResult = await githubProvisioningService.startAppAutoConnect(
1578
+ agent.id,
1579
+ githubOrg,
1580
+ {
1581
+ redirectUri: `${appBaseUrl}/admin/agents/${agent.id}/connect-github/callback`,
1582
+ setupUrl: `${appBaseUrl}/admin/agents/${agent.id}/connect-github/installed`,
1583
+ },
1584
+ );
1585
+ if (!appResult.ok) {
1586
+ return c.redirect(
1587
+ `/admin/agents/${agent.id}?error=${encodeURIComponent(appResult.error)}`,
1588
+ 302,
1589
+ );
1590
+ }
1591
+ setCookie(
1592
+ c,
1593
+ GITHUB_PROVISION_STATE_COOKIE,
1594
+ appResult.provisionStateToken,
1595
+ {
1596
+ httpOnly: true,
1597
+ maxAge: GITHUB_PROVISION_STATE_TTL_SECONDS,
1598
+ sameSite: "Lax",
1599
+ path: "/",
1600
+ secure: appBaseUrl.startsWith("https://"),
1601
+ },
1602
+ );
1603
+ return c.html(
1604
+ renderGithubAppManifestRedirectPage(c.var.userEmail, {
1605
+ githubOrg: appResult.githubOrg,
1606
+ manifest: appResult.manifest,
1607
+ }),
1608
+ );
1609
+ }
1610
+ }
1611
+
1612
+ return redirectWithMembersWarning(
1613
+ c,
1614
+ agentMemberService,
1615
+ agent.id,
1616
+ restrictSlackToMembers,
1617
+ );
1618
+ });
1619
+
1620
+ app.get("/admin/agents/:id", requireAuth, async (c) => {
1621
+ const agentId = c.req.param("id");
1622
+ const agent = await agentService.getDetail(agentId);
1623
+ if (!agent) {
1624
+ return new Response("Agent not found", { status: 404 });
1625
+ }
1626
+ const agentDetail: AgentDetail = {
1627
+ id: agent.id,
1628
+ name: agent.name,
1629
+ slackId: agent.slackId ?? null,
1630
+ selfHosted: agent.selfHosted,
1631
+ createdAt: agent.createdAt,
1632
+ updatedAt: agent.updatedAt,
1633
+ repos: agent.repos,
1634
+ authorAllowlist: agent.reviewAuthorAllowlist,
1635
+ patchAuthorAllowlist: agent.patchAuthorAllowlist,
1636
+ restrictSlackToMembers: agent.restrictSlackToMembers,
1637
+ typeName: agent.typeName,
1638
+ missingRequiredEnv: agent.missingRequiredEnv,
1639
+ autoPostReviews: agent.autoPostReviews ?? true,
1640
+ allowSelfReview: agent.allowSelfReview ?? false,
1641
+ minConfidence: agent.minConfidence ?? 75,
1642
+ maxFindings: agent.maxFindings ?? 5,
1643
+ cleanupMergedWorktrees: agent.cleanupMergedWorktrees ?? true,
1644
+ cleanupAfterDays: agent.cleanupAfterDays ?? 14,
1645
+ ownerEmail: agent.ownerEmail ?? null,
1646
+ anthropicIdentityName: agent.anthropicIdentityName ?? null,
1647
+ anthropicServiceAccountId: agent.anthropicServiceAccountId ?? null,
1648
+ anthropicFederationRuleId: agent.anthropicFederationRuleId ?? null,
1649
+ };
1650
+
1651
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
1652
+ return new Response("Forbidden", { status: 403 });
1653
+ }
1654
+
1655
+ const rawError = c.req.query("error") ?? undefined;
1656
+ const error = rawError ? (ERROR_MESSAGES[rawError] ?? rawError) : undefined;
1657
+ const newToken = c.req.query("newToken") ?? undefined;
1658
+ const successParam = c.req.query("success");
1659
+ const successMsg =
1660
+ successParam === "manifest_synced"
1661
+ ? "Manifest synced successfully."
1662
+ : successParam === "reinstalled"
1663
+ ? "Slack app reinstalled successfully."
1664
+ : undefined;
1665
+ const warningParam = c.req.query("warning");
1666
+ const warning =
1667
+ warningParam === "restrict_slack_no_members"
1668
+ ? "This agent has no members — enabling restrictSlackToMembers will block all Slack senders."
1669
+ : // Free-form warning text (e.g. UAP-2.1's combined-flow GitHub PAT
1670
+ // failure carried through the Slack OAuth round trip). Passed through
1671
+ // verbatim rather than mapped to a fixed key.
1672
+ (warningParam ?? undefined);
1673
+
1674
+ const [envResult, crons, tools, tokens, plugins, members] =
1675
+ await Promise.all([
1676
+ agentEnvService
1677
+ .getByAgentId(agentId)
1678
+ .then((e) => e ?? { env: {}, secretKeys: [] }),
1679
+ agentCronJobService.listWithRunSummary(agentId),
1680
+ agentToolService.list(agentId),
1681
+ agentTokenService.listForAgent(agentId),
1682
+ agentPluginService.list(agentId),
1683
+ canManageAgents(c.var.isAdmin)
1684
+ ? agentMemberService.listByAgentId(agentId)
1685
+ : Promise.resolve([]),
1686
+ ]);
1687
+
1688
+ // Extension card slots (AEP-2.1/AEP-3.2), grouped by placement; a failing
1689
+ // slot is omitted.
1690
+ const cards = await renderAgentDetailCardSlots(
1691
+ deps.agentDetailCards ?? [],
1692
+ {
1693
+ agentId,
1694
+ userEmail: c.var.userEmail,
1695
+ isAdmin: c.var.isAdmin,
1696
+ accountId: agent.accountId ?? null,
1697
+ timezone,
1698
+ },
1699
+ );
1700
+
1701
+ return html(
1702
+ renderAgentDetailPage(
1703
+ agentDetail,
1704
+ envResult,
1705
+ crons,
1706
+ tools,
1707
+ tokens,
1708
+ plugins,
1709
+ members,
1710
+ c.var.userEmail,
1711
+ c.var.isAdmin,
1712
+ {
1713
+ canManage: canManageAgents(c.var.isAdmin),
1714
+ error,
1715
+ newToken,
1716
+ successMsg,
1717
+ warning,
1718
+ timezone,
1719
+ extensionCardsHtml: cards.header,
1720
+ configurationCardsHtml: cards.configuration,
1721
+ queueActivityLinks: deps.queueActivityLinks,
1722
+ },
1723
+ ),
1724
+ );
1725
+ });
1726
+
1727
+ // ─── Env var mutations ────────────────────────────────────────────────────
1728
+
1729
+ app.post("/admin/agents/:id/envs", requireAuth, async (c) => {
1730
+ const agentId = c.req.param("id");
1731
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
1732
+ return new Response("Forbidden", { status: 403 });
1733
+ }
1734
+ let key: string | undefined;
1735
+ let value: string | undefined;
1736
+ let secretStr: string | undefined;
1737
+ try {
1738
+ const formData = await c.req.formData();
1739
+ key = formData.get("key")?.toString();
1740
+ value = formData.get("value")?.toString();
1741
+ secretStr = formData.get("secret")?.toString();
1742
+ } catch {
1743
+ return c.redirect(`/admin/agents/${agentId}`, 302);
1744
+ }
1745
+ if (key && value !== undefined) {
1746
+ const isSecret = secretStr === "true";
1747
+ try {
1748
+ await agentEnvService.patch(
1749
+ agentId,
1750
+ { [key]: value },
1751
+ isSecret ? new Set([key]) : new Set(),
1752
+ );
1753
+ } catch (err) {
1754
+ // WIF-2.1: surface the typed rejection (key names only, never the
1755
+ // value) on the agent page instead of a 500.
1756
+ if (err instanceof AnthropicCredentialsBlockedError) {
1757
+ return c.redirect(
1758
+ `/admin/agents/${agentId}?error=${encodeURIComponent(err.message)}`,
1759
+ 302,
1760
+ );
1761
+ }
1762
+ throw err;
1763
+ }
1764
+ }
1765
+ return c.redirect(`/admin/agents/${agentId}`, 302);
1766
+ });
1767
+
1768
+ app.post("/admin/agents/:id/envs/delete", requireAuth, async (c) => {
1769
+ const agentId = c.req.param("id");
1770
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
1771
+ return new Response("Forbidden", { status: 403 });
1772
+ }
1773
+ let key: string | undefined;
1774
+ try {
1775
+ const formData = await c.req.formData();
1776
+ key = formData.get("key")?.toString();
1777
+ } catch {
1778
+ return c.redirect(`/admin/agents/${agentId}`, 302);
1779
+ }
1780
+ if (key) {
1781
+ await agentEnvService.deleteKey(agentId, key);
1782
+ }
1783
+ return c.redirect(`/admin/agents/${agentId}`, 302);
1784
+ });
1785
+
1786
+ // ─── Repo mutations ───────────────────────────────────────────────────────
1787
+
1788
+ app.post("/admin/agents/:id/repos/add", requireAuth, async (c) => {
1789
+ const agentId = c.req.param("id");
1790
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
1791
+ return new Response("Forbidden", { status: 403 });
1792
+ }
1793
+ let repo: string | undefined;
1794
+ try {
1795
+ const formData = await c.req.formData();
1796
+ repo = formData.get("repo")?.toString()?.trim();
1797
+ } catch {
1798
+ return c.redirect(`/admin/agents/${agentId}`, 302);
1799
+ }
1800
+ if (!repo || !isOrgRepo(repo)) {
1801
+ return c.redirect(
1802
+ `/admin/agents/${agentId}?error=invalid_repo_format`,
1803
+ 302,
1804
+ );
1805
+ }
1806
+ const agent = await agentService.getDetail(agentId);
1807
+ if (!agent) {
1808
+ return new Response("Agent not found", { status: 404 });
1809
+ }
1810
+ const existing = agent.repos ?? [];
1811
+ const deduped = existing.includes(repo) ? existing : [...existing, repo];
1812
+ const collision = describeRepoNameCollision(deduped);
1813
+ if (collision) {
1814
+ return c.redirect(
1815
+ `/admin/agents/${agentId}?error=${encodeURIComponent(collision)}`,
1816
+ 302,
1817
+ );
1818
+ }
1819
+ await agentService.updateFields(agentId, { repos: deduped });
1820
+ return c.redirect(`/admin/agents/${agentId}`, 302);
1821
+ });
1822
+
1823
+ app.post("/admin/agents/:id/repos/delete", requireAuth, async (c) => {
1824
+ const agentId = c.req.param("id");
1825
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
1826
+ return new Response("Forbidden", { status: 403 });
1827
+ }
1828
+ let repo: string | undefined;
1829
+ try {
1830
+ const formData = await c.req.formData();
1831
+ repo = formData.get("repo")?.toString()?.trim();
1832
+ } catch {
1833
+ return c.redirect(`/admin/agents/${agentId}`, 302);
1834
+ }
1835
+ if (repo) {
1836
+ const agent = await agentService.getDetail(agentId);
1837
+ if (!agent) {
1838
+ return new Response("Agent not found", { status: 404 });
1839
+ }
1840
+ const updated = (agent.repos ?? []).filter((r) => r !== repo);
1841
+ await agentService.updateFields(agentId, { repos: updated });
1842
+ }
1843
+ return c.redirect(`/admin/agents/${agentId}`, 302);
1844
+ });
1845
+
1846
+ // ─── Author allowlist (review) mutations ───────────────────────────────────
1847
+
1848
+ app.post(
1849
+ "/admin/agents/:id/review-author-allowlist/add",
1850
+ requireAuth,
1851
+ async (c) => {
1852
+ const agentId = c.req.param("id");
1853
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
1854
+ return new Response("Forbidden", { status: 403 });
1855
+ }
1856
+ let login: string | undefined;
1857
+ try {
1858
+ const formData = await c.req.formData();
1859
+ login = formData.get("login")?.toString()?.trim();
1860
+ } catch {
1861
+ return c.redirect(`/admin/agents/${agentId}`, 302);
1862
+ }
1863
+ if (!login || !isGithubLogin(login)) {
1864
+ return c.redirect(
1865
+ `/admin/agents/${agentId}?error=invalid_author_allowlist_format`,
1866
+ 302,
1867
+ );
1868
+ }
1869
+ const agent = await agentService.getDetail(agentId);
1870
+ if (!agent) {
1871
+ return new Response("Agent not found", { status: 404 });
1872
+ }
1873
+ const existing = agent.reviewAuthorAllowlist ?? [];
1874
+ const deduped = existing.includes(login)
1875
+ ? existing
1876
+ : [...existing, login];
1877
+ await agentService.updateFields(agentId, {
1878
+ reviewAuthorAllowlist: deduped,
1879
+ });
1880
+ return c.redirect(`/admin/agents/${agentId}`, 302);
1881
+ },
1882
+ );
1883
+
1884
+ app.post(
1885
+ "/admin/agents/:id/review-author-allowlist/delete",
1886
+ requireAuth,
1887
+ async (c) => {
1888
+ const agentId = c.req.param("id");
1889
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
1890
+ return new Response("Forbidden", { status: 403 });
1891
+ }
1892
+ let login: string | undefined;
1893
+ try {
1894
+ const formData = await c.req.formData();
1895
+ login = formData.get("login")?.toString()?.trim();
1896
+ } catch {
1897
+ return c.redirect(`/admin/agents/${agentId}`, 302);
1898
+ }
1899
+ if (login) {
1900
+ const agent = await agentService.getDetail(agentId);
1901
+ if (!agent) {
1902
+ return new Response("Agent not found", { status: 404 });
1903
+ }
1904
+ const updated = (agent.reviewAuthorAllowlist ?? []).filter(
1905
+ (l) => l !== login,
1906
+ );
1907
+ await agentService.updateFields(agentId, {
1908
+ reviewAuthorAllowlist: updated,
1909
+ });
1910
+ }
1911
+ return c.redirect(`/admin/agents/${agentId}`, 302);
1912
+ },
1913
+ );
1914
+
1915
+ // ─── Author allowlist (patch) mutations ────────────────────────────────────
1916
+
1917
+ app.post(
1918
+ "/admin/agents/:id/patch-author-allowlist/add",
1919
+ requireAuth,
1920
+ async (c) => {
1921
+ const agentId = c.req.param("id");
1922
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
1923
+ return new Response("Forbidden", { status: 403 });
1924
+ }
1925
+ let login: string | undefined;
1926
+ try {
1927
+ const formData = await c.req.formData();
1928
+ login = formData.get("login")?.toString()?.trim();
1929
+ } catch {
1930
+ return c.redirect(`/admin/agents/${agentId}`, 302);
1931
+ }
1932
+ if (!login || !isGithubLogin(login)) {
1933
+ return c.redirect(
1934
+ `/admin/agents/${agentId}?error=invalid_author_allowlist_format`,
1935
+ 302,
1936
+ );
1937
+ }
1938
+ const agent = await agentService.getDetail(agentId);
1939
+ if (!agent) {
1940
+ return new Response("Agent not found", { status: 404 });
1941
+ }
1942
+ const existing = agent.patchAuthorAllowlist ?? [];
1943
+ const deduped = existing.includes(login)
1944
+ ? existing
1945
+ : [...existing, login];
1946
+ await agentService.updateFields(agentId, {
1947
+ patchAuthorAllowlist: deduped,
1948
+ });
1949
+ return c.redirect(`/admin/agents/${agentId}`, 302);
1950
+ },
1951
+ );
1952
+
1953
+ app.post(
1954
+ "/admin/agents/:id/patch-author-allowlist/delete",
1955
+ requireAuth,
1956
+ async (c) => {
1957
+ const agentId = c.req.param("id");
1958
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
1959
+ return new Response("Forbidden", { status: 403 });
1960
+ }
1961
+ let login: string | undefined;
1962
+ try {
1963
+ const formData = await c.req.formData();
1964
+ login = formData.get("login")?.toString()?.trim();
1965
+ } catch {
1966
+ return c.redirect(`/admin/agents/${agentId}`, 302);
1967
+ }
1968
+ if (login) {
1969
+ const agent = await agentService.getDetail(agentId);
1970
+ if (!agent) {
1971
+ return new Response("Agent not found", { status: 404 });
1972
+ }
1973
+ const updated = (agent.patchAuthorAllowlist ?? []).filter(
1974
+ (l) => l !== login,
1975
+ );
1976
+ await agentService.updateFields(agentId, {
1977
+ patchAuthorAllowlist: updated,
1978
+ });
1979
+ }
1980
+ return c.redirect(`/admin/agents/${agentId}`, 302);
1981
+ },
1982
+ );
1983
+
1984
+ // ─── Slack access + Agent Policy settings ──────────────────────────────────
1985
+ //
1986
+ // Shared by two forms on the agent detail page: "Slack access"
1987
+ // (restrictSlackToMembers) and "Agent Policy" (APM-1.3's six DB-backed
1988
+ // policy fields). Each field is read independently and only included in
1989
+ // the updateFields() call when present in THIS submission's form data —
1990
+ // this lets the two forms share one route without a save from one form
1991
+ // silently clobbering the other's fields back to false/defaults. Every
1992
+ // checkbox in both forms renders a hidden "false" fallback alongside it
1993
+ // (see admin-ui-pages.ts) so an unchecked box still submits a real value
1994
+ // rather than being omitted entirely (the standard HTML checkbox gotcha) —
1995
+ // that hidden fallback is what makes "this form's box is unchecked"
1996
+ // distinguishable from "a different form was submitted, this field is
1997
+ // simply absent".
1998
+
1999
+ app.post("/admin/agents/:id/settings", requireAuth, async (c) => {
2000
+ const agentId = c.req.param("id");
2001
+ if (
2002
+ !(await assertCanManageAgent(agentId, c.var.userEmail, c.var.isAdmin))
2003
+ ) {
2004
+ return new Response("Forbidden", { status: 403 });
2005
+ }
2006
+ let formData: FormData;
2007
+ try {
2008
+ formData = await c.req.formData();
2009
+ } catch {
2010
+ return c.redirect(`/admin/agents/${agentId}`, 302);
2011
+ }
2012
+
2013
+ const readBool = (name: string): boolean | undefined => {
2014
+ const raw = formData.get(name)?.toString();
2015
+ return raw === undefined ? undefined : raw === "true";
2016
+ };
2017
+ // Defensive: reject non-finite/negative values rather than writing NaN
2018
+ // (or a nonsensical negative count) to Postgres — falls back to
2019
+ // undefined (leave the field unchanged) instead of throwing or silently
2020
+ // persisting garbage. An emptied <input type="number"> submits "" (not
2021
+ // undefined), and Number("") is 0 — so blank/whitespace-only input is
2022
+ // treated as absent too, otherwise clearing a field would silently zero it.
2023
+ const readInt = (name: string): number | undefined => {
2024
+ const raw = formData.get(name)?.toString();
2025
+ if (raw === undefined || raw.trim() === "") return undefined;
2026
+ const parsed = Number(raw);
2027
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
2028
+ };
2029
+
2030
+ const restrictSlackToMembers = readBool("restrictSlackToMembers");
2031
+ const autoPostReviews = readBool("autoPostReviews");
2032
+ const allowSelfReview = readBool("allowSelfReview");
2033
+ const cleanupMergedWorktrees = readBool("cleanupMergedWorktrees");
2034
+ const minConfidence = readInt("minConfidence");
2035
+ const maxFindings = readInt("maxFindings");
2036
+ const cleanupAfterDays = readInt("cleanupAfterDays");
2037
+
2038
+ // Signed commits live in the AgentEnv table, not on the Agent row. Absent
2039
+ // (box hidden for agents without GH_APP_ID, or another form posted) leaves
2040
+ // the row untouched.
2041
+ const signedCommits = readBool("signedCommits");
2042
+ if (signedCommits === true) {
2043
+ await agentEnvService.patch(agentId, { SHIPWRIGHT_SIGNED_COMMITS: "1" });
2044
+ } else if (signedCommits === false) {
2045
+ await agentEnvService.deleteKey(agentId, "SHIPWRIGHT_SIGNED_COMMITS");
2046
+ }
2047
+
2048
+ const agent = await agentService.updateFields(agentId, {
2049
+ ...(restrictSlackToMembers !== undefined && { restrictSlackToMembers }),
2050
+ ...(autoPostReviews !== undefined && { autoPostReviews }),
2051
+ ...(allowSelfReview !== undefined && { allowSelfReview }),
2052
+ ...(cleanupMergedWorktrees !== undefined && { cleanupMergedWorktrees }),
2053
+ ...(minConfidence !== undefined && { minConfidence }),
2054
+ ...(maxFindings !== undefined && { maxFindings }),
2055
+ ...(cleanupAfterDays !== undefined && { cleanupAfterDays }),
2056
+ });
2057
+ return redirectWithMembersWarning(
2058
+ c,
2059
+ agentMemberService,
2060
+ agent.id,
2061
+ agent.restrictSlackToMembers,
2062
+ );
2063
+ });
2064
+
2065
+ // ─── Cron job mutations ───────────────────────────────────────────────────
2066
+
2067
+ app.post("/admin/agents/:id/crons", requireAuth, async (c) => {
2068
+ const agentId = c.req.param("id");
2069
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
2070
+ return new Response("Forbidden", { status: 403 });
2071
+ }
2072
+ let schedule: string | undefined;
2073
+ let prompt: string | undefined;
2074
+ let channel: string | null = null;
2075
+ let user: string | null = null;
2076
+ let silent = false;
2077
+ let enabled = true;
2078
+ let name: string | null = null;
2079
+ try {
2080
+ const formData = await c.req.formData();
2081
+ schedule = formData.get("schedule")?.toString();
2082
+ prompt = formData.get("prompt")?.toString();
2083
+ channel = formData.get("channel")?.toString() || null;
2084
+ user = formData.get("user")?.toString() || null;
2085
+ silent =
2086
+ formData.get("silent") === "on" || formData.get("silent") === "true";
2087
+ const enabledVal = formData.get("enabled");
2088
+ // Checkbox: present as "on" when checked, absent (null) when unchecked.
2089
+ // Programmatic callers may send "true"/"false" explicitly.
2090
+ enabled = enabledVal === "on" || enabledVal === "true";
2091
+ name = formData.get("name")?.toString() || null;
2092
+ } catch {
2093
+ return c.redirect(`/admin/agents/${agentId}`, 302);
2094
+ }
2095
+ if (!schedule || !prompt) {
2096
+ return c.redirect(`/admin/agents/${agentId}?error=missing_fields`, 302);
2097
+ }
2098
+ try {
2099
+ await agentCronJobService.create(agentId, {
2100
+ schedule,
2101
+ prompt,
2102
+ channel,
2103
+ user,
2104
+ silent,
2105
+ enabled,
2106
+ name,
2107
+ });
2108
+ } catch (err) {
2109
+ if (err instanceof UnprocessableEntityError) {
2110
+ const msg = err.message.toLowerCase();
2111
+ if (msg.includes("invalid cron")) {
2112
+ return c.redirect(
2113
+ `/admin/agents/${agentId}?error=invalid_schedule`,
2114
+ 302,
2115
+ );
2116
+ }
2117
+ if (
2118
+ msg.includes("channel") ||
2119
+ msg.includes("user") ||
2120
+ msg.includes("target")
2121
+ ) {
2122
+ return c.redirect(
2123
+ `/admin/agents/${agentId}?error=invalid_target`,
2124
+ 302,
2125
+ );
2126
+ }
2127
+ }
2128
+ return c.redirect(`/admin/agents/${agentId}?error=create_failed`, 302);
2129
+ }
2130
+ return c.redirect(`/admin/agents/${agentId}`, 302);
2131
+ });
2132
+
2133
+ app.post("/admin/agents/:id/crons/:cronId/toggle", requireAuth, async (c) => {
2134
+ const agentId = c.req.param("id");
2135
+ const cronId = c.req.param("cronId");
2136
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
2137
+ return new Response("Forbidden", { status: 403 });
2138
+ }
2139
+ let enabled = true;
2140
+ try {
2141
+ const formData = await c.req.formData();
2142
+ enabled = formData.get("enabled") !== "false";
2143
+ } catch {
2144
+ // use default
2145
+ }
2146
+ try {
2147
+ await agentCronJobService.setEnabled(agentId, cronId, enabled);
2148
+ } catch {
2149
+ // ignore errors — redirect back regardless
2150
+ }
2151
+ return c.redirect(`/admin/agents/${agentId}`, 302);
2152
+ });
2153
+
2154
+ app.post("/admin/agents/:id/crons/:cronId/update", requireAuth, async (c) => {
2155
+ const agentId = c.req.param("id");
2156
+ const cronId = c.req.param("cronId");
2157
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
2158
+ return new Response("Forbidden", { status: 403 });
2159
+ }
2160
+ let schedule = "";
2161
+ let prompt = "";
2162
+ let channel: string | null = null;
2163
+ let preCheck: string | null = null;
2164
+ try {
2165
+ const formData = await c.req.formData();
2166
+ schedule = ((formData.get("schedule") as string | null) ?? "").trim();
2167
+ prompt = ((formData.get("prompt") as string | null) ?? "").trim();
2168
+ const ch = ((formData.get("channel") as string | null) ?? "").trim();
2169
+ channel = ch === "" ? null : ch;
2170
+ const pc = ((formData.get("preCheck") as string | null) ?? "").trim();
2171
+ preCheck = pc === "" ? null : pc; // empty clears the preCheck
2172
+ } catch {
2173
+ // fall through to validation
2174
+ }
2175
+ if (!schedule || !prompt) {
2176
+ return c.redirect(
2177
+ `/admin/agents/${agentId}?error=${encodeURIComponent("schedule and prompt are required")}`,
2178
+ 302,
2179
+ );
2180
+ }
2181
+ try {
2182
+ // Fetch the existing cron so we (a) block edits to system crons — their
2183
+ // contents are owned by reconcileSystemCrons and would be reverted — and
2184
+ // (b) forward `user`/`silent`. Without them the service resolves user→null,
2185
+ // silent→false and validateDeliveryTarget throws for any DM-routed cron.
2186
+ const existing = await agentCronJobService.get(agentId, cronId);
2187
+ if (existing.system) {
2188
+ return c.redirect(
2189
+ `/admin/agents/${agentId}?error=${encodeURIComponent("system crons cannot be edited")}`,
2190
+ 302,
2191
+ );
2192
+ }
2193
+ await agentCronJobService.update(agentId, cronId, {
2194
+ schedule,
2195
+ prompt,
2196
+ channel,
2197
+ preCheck,
2198
+ user: existing.user,
2199
+ silent: existing.silent,
2200
+ });
2201
+ } catch (err) {
2202
+ // Surface validation errors (e.g. invalid cron expression) back to the page.
2203
+ return c.redirect(
2204
+ `/admin/agents/${agentId}?error=${encodeURIComponent(
2205
+ err instanceof Error ? err.message : "cron update failed",
2206
+ )}`,
2207
+ 302,
2208
+ );
2209
+ }
2210
+ return c.redirect(`/admin/agents/${agentId}`, 302);
2211
+ });
2212
+
2213
+ app.post("/admin/agents/:id/crons/:cronId/delete", requireAuth, async (c) => {
2214
+ const agentId = c.req.param("id");
2215
+ const cronId = c.req.param("cronId");
2216
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
2217
+ return new Response("Forbidden", { status: 403 });
2218
+ }
2219
+ try {
2220
+ const cron = await agentCronJobService.get(agentId, cronId);
2221
+ if (cron.system) {
2222
+ throw new ForbiddenError("system crons cannot be deleted");
2223
+ }
2224
+ await agentCronJobService.delete(agentId, cronId);
2225
+ } catch (err) {
2226
+ if (err instanceof ForbiddenError) {
2227
+ return c.redirect(
2228
+ `/admin/agents/${agentId}?error=${encodeURIComponent(err.message)}`,
2229
+ 302,
2230
+ );
2231
+ }
2232
+ // other errors (NotFoundError, etc.) — redirect back silently
2233
+ }
2234
+ return c.redirect(`/admin/agents/${agentId}`, 302);
2235
+ });
2236
+
2237
+ // ─── Tool mutations ───────────────────────────────────────────────────────
2238
+
2239
+ app.post("/admin/agents/:id/tools", requireAuth, async (c) => {
2240
+ const agentId = c.req.param("id");
2241
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
2242
+ return new Response("Forbidden", { status: 403 });
2243
+ }
2244
+ let pattern: string | undefined;
2245
+ try {
2246
+ const formData = await c.req.formData();
2247
+ pattern = formData.get("pattern")?.toString();
2248
+ } catch {
2249
+ return c.redirect(`/admin/agents/${agentId}`, 302);
2250
+ }
2251
+ if (!pattern) {
2252
+ return c.redirect(`/admin/agents/${agentId}?error=missing_fields`, 302);
2253
+ }
2254
+ try {
2255
+ await agentToolService.add(agentId, pattern);
2256
+ } catch {
2257
+ // ignore errors — redirect back regardless
2258
+ }
2259
+ return c.redirect(`/admin/agents/${agentId}`, 302);
2260
+ });
2261
+
2262
+ app.post("/admin/agents/:id/tools/:toolId/toggle", requireAuth, async (c) => {
2263
+ const agentId = c.req.param("id");
2264
+ const toolId = c.req.param("toolId");
2265
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
2266
+ return new Response("Forbidden", { status: 403 });
2267
+ }
2268
+ let enabled = true;
2269
+ try {
2270
+ const formData = await c.req.formData();
2271
+ enabled = formData.get("enabled") !== "false";
2272
+ } catch {
2273
+ // use default
2274
+ }
2275
+ try {
2276
+ await agentToolService.toggle(agentId, toolId, enabled);
2277
+ } catch {
2278
+ // ignore errors — redirect back regardless
2279
+ }
2280
+ return c.redirect(`/admin/agents/${agentId}`, 302);
2281
+ });
2282
+
2283
+ app.post("/admin/agents/:id/tools/:toolId/delete", requireAuth, async (c) => {
2284
+ const agentId = c.req.param("id");
2285
+ const toolId = c.req.param("toolId");
2286
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
2287
+ return new Response("Forbidden", { status: 403 });
2288
+ }
2289
+ try {
2290
+ await agentToolService.remove(agentId, toolId);
2291
+ } catch {
2292
+ // ignore errors — redirect back regardless
2293
+ }
2294
+ return c.redirect(`/admin/agents/${agentId}`, 302);
2295
+ });
2296
+
2297
+ // ─── Token mutations ──────────────────────────────────────────────────────
2298
+
2299
+ app.post("/admin/agents/:id/tokens", requireAuth, async (c) => {
2300
+ const agentId = c.req.param("id");
2301
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
2302
+ return new Response("Forbidden", { status: 403 });
2303
+ }
2304
+ let label: string | undefined;
2305
+ try {
2306
+ const formData = await c.req.formData();
2307
+ label = formData.get("label")?.toString() || undefined;
2308
+ } catch {
2309
+ return c.redirect(`/admin/agents/${agentId}`, 302);
2310
+ }
2311
+ const agent = await agentService.getDetail(agentId);
2312
+ if (!agent) {
2313
+ return new Response("Agent not found", { status: 404 });
2314
+ }
2315
+ const agentDetail: AgentDetail = {
2316
+ id: agent.id,
2317
+ name: agent.name,
2318
+ slackId: agent.slackId ?? null,
2319
+ selfHosted: agent.selfHosted,
2320
+ createdAt: agent.createdAt,
2321
+ updatedAt: agent.updatedAt,
2322
+ repos: agent.repos,
2323
+ authorAllowlist: agent.reviewAuthorAllowlist,
2324
+ patchAuthorAllowlist: agent.patchAuthorAllowlist,
2325
+ restrictSlackToMembers: agent.restrictSlackToMembers,
2326
+ typeName: agent.typeName,
2327
+ missingRequiredEnv: agent.missingRequiredEnv,
2328
+ autoPostReviews: agent.autoPostReviews ?? true,
2329
+ allowSelfReview: agent.allowSelfReview ?? false,
2330
+ minConfidence: agent.minConfidence ?? 75,
2331
+ maxFindings: agent.maxFindings ?? 5,
2332
+ cleanupMergedWorktrees: agent.cleanupMergedWorktrees ?? true,
2333
+ cleanupAfterDays: agent.cleanupAfterDays ?? 14,
2334
+ ownerEmail: agent.ownerEmail ?? null,
2335
+ anthropicIdentityName: agent.anthropicIdentityName ?? null,
2336
+ anthropicServiceAccountId: agent.anthropicServiceAccountId ?? null,
2337
+ anthropicFederationRuleId: agent.anthropicFederationRuleId ?? null,
2338
+ };
2339
+ try {
2340
+ const { rawToken } = await agentTokenService.create(agentId, label);
2341
+ // Render the page directly (200) rather than redirecting with the token in the URL.
2342
+ // A redirect would expose the raw token in server access logs and browser history.
2343
+ const [envResult, crons, tools, tokens, plugins, members] =
2344
+ await Promise.all([
2345
+ agentEnvService
2346
+ .getByAgentId(agentId)
2347
+ .then((e) => e ?? { env: {}, secretKeys: [] }),
2348
+ agentCronJobService.listWithRunSummary(agentId),
2349
+ agentToolService.list(agentId),
2350
+ agentTokenService.listForAgent(agentId),
2351
+ agentPluginService.list(agentId),
2352
+ canManageAgents(c.var.isAdmin)
2353
+ ? agentMemberService.listByAgentId(agentId)
2354
+ : Promise.resolve([]),
2355
+ ]);
2356
+ // The token page renders only the Configuration-group extension cards;
2357
+ // header cards keep their place empty, as before the slot split.
2358
+ const cards = await renderAgentDetailCardSlots(
2359
+ deps.agentDetailCards ?? [],
2360
+ {
2361
+ agentId,
2362
+ userEmail: c.var.userEmail,
2363
+ isAdmin: c.var.isAdmin,
2364
+ accountId: agent.accountId ?? null,
2365
+ timezone,
2366
+ },
2367
+ ["configuration"],
2368
+ );
2369
+ return html(
2370
+ renderAgentDetailPage(
2371
+ agentDetail,
2372
+ envResult,
2373
+ crons,
2374
+ tools,
2375
+ tokens,
2376
+ plugins,
2377
+ members,
2378
+ c.var.userEmail,
2379
+ c.var.isAdmin,
2380
+ {
2381
+ canManage: canManageAgents(c.var.isAdmin),
2382
+ newToken: rawToken,
2383
+ timezone,
2384
+ extensionCardsHtml: cards.header,
2385
+ configurationCardsHtml: cards.configuration,
2386
+ queueActivityLinks: deps.queueActivityLinks,
2387
+ },
2388
+ ),
2389
+ );
2390
+ } catch {
2391
+ return c.redirect(`/admin/agents/${agentId}?error=create_failed`, 302);
2392
+ }
2393
+ });
2394
+
2395
+ app.post(
2396
+ "/admin/agents/:id/tokens/:tokenId/revoke",
2397
+ requireAuth,
2398
+ async (c) => {
2399
+ const agentId = c.req.param("id");
2400
+ const tokenId = c.req.param("tokenId");
2401
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
2402
+ return new Response("Forbidden", { status: 403 });
2403
+ }
2404
+ try {
2405
+ await agentTokenService.revoke(tokenId);
2406
+ } catch {
2407
+ // ignore errors — redirect back regardless
2408
+ }
2409
+ return c.redirect(`/admin/agents/${agentId}`, 302);
2410
+ },
2411
+ );
2412
+
2413
+ // ─── Provisioning state constants ────────────────────────────────────────
2414
+ // PROVISION_STATE_COOKIE / PROVISION_STATE_TTL_SECONDS now live in
2415
+ // slack-provisioning-service.ts (UAP-1.1); GITHUB_PROVISION_STATE_COOKIE /
2416
+ // GITHUB_PROVISION_STATE_TTL_SECONDS / GITHUB_ORG_PATTERN now live in
2417
+ // github-provisioning-service.ts (UAP-1.2) — re-imported above so every
2418
+ // route in this file keeps using the same names.
2419
+
2420
+ // ─── Manifest sync ────────────────────────────────────────────────────────
2421
+
2422
+ app.post("/admin/agents/:id/sync-manifest", requireAuth, async (c) => {
2423
+ const agentId = c.req.param("id");
2424
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
2425
+ return new Response("Forbidden", { status: 403 });
2426
+ }
2427
+ const agent = await agentService.getDetail(agentId);
2428
+ if (!agent) return new Response("Agent not found", { status: 404 });
2429
+
2430
+ let xoxpToken: string | undefined;
2431
+ try {
2432
+ const formData = await c.req.formData();
2433
+ xoxpToken = formData.get("xoxpToken")?.toString();
2434
+ } catch {
2435
+ return c.redirect(`/admin/agents/${agentId}?error=missing_fields`, 302);
2436
+ }
2437
+
2438
+ if (!xoxpToken?.startsWith("xoxe.xoxp-")) {
2439
+ return c.redirect(
2440
+ `/admin/agents/${agentId}?error=${encodeURIComponent("Slack app configuration token must start with xoxe.xoxp-")}`,
2441
+ 302,
2442
+ );
2443
+ }
2444
+
2445
+ const envBundle = await agentEnvService.getConfigBundle(agentId);
2446
+ const appId = envBundle?.env.SLACK_APP_ID;
2447
+ if (!appId) {
2448
+ return c.redirect(
2449
+ `/admin/agents/${agentId}?error=${encodeURIComponent("SLACK_APP_ID is not set — provision the agent first.")}`,
2450
+ 302,
2451
+ );
2452
+ }
2453
+
2454
+ try {
2455
+ const redirectUri = `${appBaseUrl}/admin/agents/${agentId}/connect-slack/callback`;
2456
+ const manifest = buildAgentManifest(agent.name, redirectUri);
2457
+ await slackClient.updateAppManifest(xoxpToken, appId, manifest);
2458
+ } catch (err) {
2459
+ const msg =
2460
+ err instanceof Error ? err.message : "Unknown error syncing manifest.";
2461
+ return c.redirect(
2462
+ `/admin/agents/${agentId}?error=${encodeURIComponent(msg)}`,
2463
+ 302,
2464
+ );
2465
+ }
2466
+
2467
+ // If the agent has OAuth credentials stored, trigger a reinstall via Slack OAuth
2468
+ const clientId = envBundle?.env.SLACK_CLIENT_ID;
2469
+ const clientSecret = envBundle?.env.SLACK_CLIENT_SECRET;
2470
+ const signingSecret = envBundle?.env.SLACK_SIGNING_SECRET;
2471
+
2472
+ if (clientId && clientSecret && signingSecret) {
2473
+ // Sign a provision-state cookie so the connect-slack callback can exchange the code
2474
+ const now = Math.floor(Date.now() / 1000);
2475
+ const provisionToken = await sign(
2476
+ {
2477
+ agentId,
2478
+ clientId,
2479
+ clientSecret,
2480
+ signingSecret,
2481
+ appId,
2482
+ iat: now,
2483
+ exp: now + PROVISION_STATE_TTL_SECONDS,
2484
+ },
2485
+ sessionSecret,
2486
+ "HS256",
2487
+ );
2488
+ setCookie(c, PROVISION_STATE_COOKIE, provisionToken, {
2489
+ httpOnly: true,
2490
+ maxAge: PROVISION_STATE_TTL_SECONDS,
2491
+ sameSite: "Lax",
2492
+ path: "/",
2493
+ secure: appBaseUrl.startsWith("https://"),
2494
+ });
2495
+
2496
+ // Build the Slack OAuth v2 authorize URL — use the canonical scope list
2497
+ // exported from slack-provisioning-client.ts so this stays in sync with
2498
+ // what buildAgentManifest declares.
2499
+ const scopes = AGENT_BOT_SCOPES.join(",");
2500
+ const redirectUri = `${appBaseUrl}/admin/agents/${agentId}/connect-slack/callback`;
2501
+ const oauthParams = new URLSearchParams({
2502
+ client_id: clientId,
2503
+ scope: scopes,
2504
+ redirect_uri: redirectUri,
2505
+ });
2506
+ return c.redirect(
2507
+ `https://slack.com/oauth/v2/authorize?${oauthParams.toString()}`,
2508
+ 302,
2509
+ );
2510
+ }
2511
+
2512
+ return c.redirect(`/admin/agents/${agentId}?success=manifest_synced`, 302);
2513
+ });
2514
+
2515
+ // ─── connect-slack (UAP-1.1) ───────────────────────────────────────────────
2516
+ // Per-agent equivalents of the legacy /admin/provision/* Slack wizard,
2517
+ // scoped to an already-existing agent id. All three delegate to the same
2518
+ // SlackProvisioningService the wizard now calls — pure routing/rendering
2519
+ // here, no business logic.
2520
+
2521
+ app.post("/admin/agents/:id/connect-slack", requireAuth, async (c) => {
2522
+ const agentId = c.req.param("id");
2523
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
2524
+ return new Response("Forbidden", { status: 403 });
2525
+ }
2526
+
2527
+ let xoxpToken: string | undefined;
2528
+ try {
2529
+ const formData = await c.req.formData();
2530
+ xoxpToken = formData.get("xoxpToken")?.toString();
2531
+ } catch {
2532
+ return c.redirect(`/admin/agents/${agentId}?error=missing_fields`, 302);
2533
+ }
2534
+
2535
+ const result = await slackProvisioningService.startConnect(
2536
+ agentId,
2537
+ xoxpToken,
2538
+ `${appBaseUrl}/admin/agents/${agentId}/connect-slack/callback`,
2539
+ );
2540
+
2541
+ if (!result.ok) {
2542
+ return c.redirect(
2543
+ `/admin/agents/${agentId}?error=${encodeURIComponent(result.error)}`,
2544
+ 302,
2545
+ );
2546
+ }
2547
+
2548
+ setCookie(c, PROVISION_STATE_COOKIE, result.provisionStateToken, {
2549
+ httpOnly: true,
2550
+ maxAge: PROVISION_STATE_TTL_SECONDS,
2551
+ sameSite: "Lax",
2552
+ path: "/",
2553
+ secure: appBaseUrl.startsWith("https://"),
2554
+ });
2555
+
2556
+ return c.redirect(result.oauthRedirectUrl, 302);
2557
+ });
2558
+
2559
+ app.get(
2560
+ "/admin/agents/:id/connect-slack/callback",
2561
+ requireAuth,
2562
+ async (c) => {
2563
+ const agentId = c.req.param("id");
2564
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
2565
+ return new Response("Forbidden", { status: 403 });
2566
+ }
2567
+ const userEmail = c.var.userEmail;
2568
+
2569
+ const rawStateCookie = getCookie(c, PROVISION_STATE_COOKIE);
2570
+ const code = c.req.query("code");
2571
+ const result = await slackProvisioningService.completeConnect(
2572
+ rawStateCookie,
2573
+ code,
2574
+ `${appBaseUrl}/admin/agents/${agentId}/connect-slack/callback`,
2575
+ );
2576
+
2577
+ if (result.outcome === "invalid_state") {
2578
+ deleteCookie(c, PROVISION_STATE_COOKIE);
2579
+ return html(
2580
+ renderProvisionCompletePage(userEmail, {
2581
+ success: false,
2582
+ error: result.error,
2583
+ }),
2584
+ );
2585
+ }
2586
+
2587
+ if (result.outcome === "missing_code") {
2588
+ // Cookie must remain intact so the user can restart the provision flow.
2589
+ return html(
2590
+ renderProvisionCompletePage(userEmail, {
2591
+ success: false,
2592
+ error: result.error,
2593
+ }),
2594
+ );
2595
+ }
2596
+
2597
+ // Every other outcome consumed the cookie's OAuth code — clear it now.
2598
+ deleteCookie(c, PROVISION_STATE_COOKIE);
2599
+
2600
+ if (result.outcome === "exchange_failed") {
2601
+ return html(
2602
+ renderProvisionCompletePage(userEmail, {
2603
+ success: false,
2604
+ error: result.error,
2605
+ }),
2606
+ );
2607
+ }
2608
+
2609
+ // A non-fatal GitHub PAT storage failure from the combined create+connect
2610
+ // flow (UAP-2.1) rode through Slack's OAuth round trip in the signed
2611
+ // cookie — surface it now so the operator isn't misled into believing
2612
+ // GH_TOKEN was stored.
2613
+ const ghConnectWarning = result.ghConnectError
2614
+ ? `GitHub was not connected — storing the PAT failed: ${result.ghConnectError}`
2615
+ : undefined;
2616
+
2617
+ if (result.outcome === "reinstalled") {
2618
+ const query = ghConnectWarning
2619
+ ? `?success=reinstalled&warning=${encodeURIComponent(ghConnectWarning)}`
2620
+ : "?success=reinstalled";
2621
+ return c.redirect(`/admin/agents/${result.agentId}${query}`, 302);
2622
+ }
2623
+
2624
+ // result.outcome === "needs_app_token"
2625
+ return html(
2626
+ renderProvisionXappTokenPage(userEmail, {
2627
+ agentId: result.agentId,
2628
+ warning: ghConnectWarning,
2629
+ }),
2630
+ );
2631
+ },
2632
+ );
2633
+
2634
+ app.post(
2635
+ "/admin/agents/:id/connect-slack/app-token",
2636
+ requireAuth,
2637
+ async (c) => {
2638
+ const agentId = c.req.param("id");
2639
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
2640
+ return new Response("Forbidden", { status: 403 });
2641
+ }
2642
+ const userEmail = c.var.userEmail;
2643
+
2644
+ let xappToken: string | undefined;
2645
+ try {
2646
+ const formData = await c.req.formData();
2647
+ xappToken = formData.get("xappToken")?.toString();
2648
+ } catch {
2649
+ return html(
2650
+ renderProvisionXappTokenPage(userEmail, {
2651
+ agentId,
2652
+ error: "Invalid form submission.",
2653
+ }),
2654
+ );
2655
+ }
2656
+
2657
+ const result = await slackProvisioningService.saveAppToken(
2658
+ agentId,
2659
+ xappToken,
2660
+ );
2661
+
2662
+ if (!result.ok) {
2663
+ return html(
2664
+ renderProvisionXappTokenPage(userEmail, {
2665
+ agentId: result.agentId,
2666
+ error: result.error,
2667
+ }),
2668
+ );
2669
+ }
2670
+
2671
+ return html(
2672
+ renderProvisionCompletePage(userEmail, {
2673
+ success: true,
2674
+ agentId: result.agentId,
2675
+ }),
2676
+ );
2677
+ },
2678
+ );
2679
+
2680
+ // ─── connect-github (UAP-1.2) ───────────────────────────────────────────────
2681
+ // Per-agent equivalents of the legacy /admin/provision/* GitHub App/PAT
2682
+ // wizard branches, scoped to an already-existing agent id. All three
2683
+ // delegate to the same GithubProvisioningService the wizard now calls —
2684
+ // pure routing/rendering here, no business logic. Supports all three modes
2685
+ // selected via `ghAuthMode`/`ghAppMode` form fields, mirroring the legacy
2686
+ // wizard's form shape: pat, app+manual, app+auto.
2687
+
2688
+ app.post("/admin/agents/:id/connect-github", requireAuth, async (c) => {
2689
+ const agentId = c.req.param("id");
2690
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
2691
+ return new Response("Forbidden", { status: 403 });
2692
+ }
2693
+ const userEmail = c.var.userEmail;
2694
+
2695
+ let ghAuthMode: string | undefined;
2696
+ let ghPat: string | undefined;
2697
+ let ghAppMode: string | undefined;
2698
+ let ghAppId: string | undefined;
2699
+ let ghAppInstallationId: string | undefined;
2700
+ let ghAppPrivateKey: string | undefined;
2701
+ let githubOrg: string | undefined;
2702
+ let ghAppPublic = false;
2703
+ try {
2704
+ const formData = await c.req.formData();
2705
+ ghAuthMode = formData.get("ghAuthMode")?.toString() ?? "pat";
2706
+ ghPat = formData.get("ghPat")?.toString();
2707
+ ghAppMode = formData.get("ghAppMode")?.toString() ?? "manual";
2708
+ ghAppId = formData.get("ghAppId")?.toString();
2709
+ ghAppInstallationId = formData.get("ghAppInstallationId")?.toString();
2710
+ // The detail page's "Use existing GitHub App" action (UAP-5.4) submits
2711
+ // the private key as a file upload rather than pasted text — read its
2712
+ // contents server-side. Falls back to a plain ghAppPrivateKey text
2713
+ // field for back-compat with any other caller still posting it that
2714
+ // way (e.g. API clients).
2715
+ const ghAppPrivateKeyFile = formData.get("ghAppPrivateKeyFile");
2716
+ ghAppPrivateKey =
2717
+ ghAppPrivateKeyFile instanceof File
2718
+ ? await ghAppPrivateKeyFile.text()
2719
+ : formData.get("ghAppPrivateKey")?.toString();
2720
+ githubOrg = formData.get("githubOrg")?.toString()?.trim();
2721
+ ghAppPublic = formData.get("ghAppPublic")?.toString() === "true";
2722
+ } catch {
2723
+ return html(
2724
+ renderProvisionCompletePage(userEmail, {
2725
+ success: false,
2726
+ agentId,
2727
+ error: "Invalid form submission.",
2728
+ }),
2729
+ );
2730
+ }
2731
+
2732
+ if (ghAuthMode === "pat") {
2733
+ const result = await githubProvisioningService.startPatConnect(
2734
+ agentId,
2735
+ ghPat,
2736
+ );
2737
+ if (!result.ok) {
2738
+ return html(
2739
+ renderProvisionCompletePage(userEmail, {
2740
+ success: false,
2741
+ agentId: result.agentId,
2742
+ error: result.error,
2743
+ }),
2744
+ );
2745
+ }
2746
+ return html(
2747
+ renderProvisionCompletePage(userEmail, {
2748
+ success: true,
2749
+ agentId: result.agentId,
2750
+ }),
2751
+ );
2752
+ }
2753
+
2754
+ if (ghAppMode === "auto") {
2755
+ const result = await githubProvisioningService.startAppAutoConnect(
2756
+ agentId,
2757
+ githubOrg,
2758
+ {
2759
+ redirectUri: `${appBaseUrl}/admin/agents/${agentId}/connect-github/callback`,
2760
+ setupUrl: `${appBaseUrl}/admin/agents/${agentId}/connect-github/installed`,
2761
+ },
2762
+ { public: ghAppPublic },
2763
+ );
2764
+ if (!result.ok) {
2765
+ return html(
2766
+ renderProvisionCompletePage(userEmail, {
2767
+ success: false,
2768
+ agentId,
2769
+ error: result.error,
2770
+ }),
2771
+ );
2772
+ }
2773
+ setCookie(c, GITHUB_PROVISION_STATE_COOKIE, result.provisionStateToken, {
2774
+ httpOnly: true,
2775
+ maxAge: GITHUB_PROVISION_STATE_TTL_SECONDS,
2776
+ sameSite: "Lax",
2777
+ path: "/",
2778
+ secure: appBaseUrl.startsWith("https://"),
2779
+ });
2780
+ // Use c.html() so the Set-Cookie header from setCookie() is included
2781
+ return c.html(
2782
+ renderGithubAppManifestRedirectPage(userEmail, {
2783
+ githubOrg: result.githubOrg,
2784
+ manifest: result.manifest,
2785
+ }),
2786
+ );
2787
+ }
2788
+
2789
+ // ghAppMode === "manual" (default)
2790
+ const result = await githubProvisioningService.startAppManualConnect(
2791
+ agentId,
2792
+ { ghAppId, ghAppInstallationId, ghAppPrivateKey },
2793
+ );
2794
+ if (!result.ok) {
2795
+ return html(
2796
+ renderProvisionCompletePage(userEmail, {
2797
+ success: false,
2798
+ agentId: result.agentId,
2799
+ error: result.error,
2800
+ }),
2801
+ );
2802
+ }
2803
+ return html(
2804
+ renderProvisionCompletePage(userEmail, {
2805
+ success: true,
2806
+ agentId: result.agentId,
2807
+ }),
2808
+ );
2809
+ });
2810
+
2811
+ app.get(
2812
+ "/admin/agents/:id/connect-github/callback",
2813
+ requireAuth,
2814
+ async (c) => {
2815
+ const agentId = c.req.param("id");
2816
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
2817
+ return new Response("Forbidden", { status: 403 });
2818
+ }
2819
+ const userEmail = c.var.userEmail;
2820
+
2821
+ const rawStateCookie = getCookie(c, GITHUB_PROVISION_STATE_COOKIE);
2822
+ const code = c.req.query("code");
2823
+ const result = await githubProvisioningService.completeConnect(
2824
+ rawStateCookie,
2825
+ code,
2826
+ agentId,
2827
+ );
2828
+
2829
+ if (result.outcome === "invalid_state") {
2830
+ deleteCookie(c, GITHUB_PROVISION_STATE_COOKIE);
2831
+ return html(
2832
+ renderGithubAppInstalledPage(userEmail, {
2833
+ success: false,
2834
+ error: result.error,
2835
+ }),
2836
+ );
2837
+ }
2838
+
2839
+ if (result.outcome === "agent_mismatch") {
2840
+ deleteCookie(c, GITHUB_PROVISION_STATE_COOKIE);
2841
+ return new Response("Forbidden", { status: 403 });
2842
+ }
2843
+
2844
+ if (result.outcome === "missing_code") {
2845
+ // Cookie must remain intact so the user can restart the provision flow.
2846
+ return html(
2847
+ renderGithubAppInstalledPage(userEmail, {
2848
+ success: false,
2849
+ error: result.error,
2850
+ }),
2851
+ );
2852
+ }
2853
+
2854
+ // Every other outcome consumed the cookie's manifest code — clear it now.
2855
+ deleteCookie(c, GITHUB_PROVISION_STATE_COOKIE);
2856
+
2857
+ if (result.outcome === "exchange_failed") {
2858
+ return html(
2859
+ renderGithubAppInstalledPage(userEmail, {
2860
+ success: false,
2861
+ error: result.error,
2862
+ }),
2863
+ );
2864
+ }
2865
+
2866
+ // result.outcome === "success"
2867
+ return html(
2868
+ renderGithubAppInstallPage(userEmail, {
2869
+ installUrl: result.installUrl,
2870
+ }),
2871
+ );
2872
+ },
2873
+ );
2874
+
2875
+ app.get(
2876
+ "/admin/agents/:id/connect-github/installed",
2877
+ requireAuth,
2878
+ async (c) => {
2879
+ const agentId = c.req.param("id");
2880
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
2881
+ return new Response("Forbidden", { status: 403 });
2882
+ }
2883
+ const userEmail = c.var.userEmail;
2884
+
2885
+ const rawStateCookie = getCookie(c, GITHUB_PROVISION_STATE_COOKIE);
2886
+ const installationId = c.req.query("installation_id");
2887
+ const result = await githubProvisioningService.completeInstalled(
2888
+ rawStateCookie,
2889
+ installationId,
2890
+ agentId,
2891
+ );
2892
+
2893
+ if (result.outcome === "invalid_state") {
2894
+ deleteCookie(c, GITHUB_PROVISION_STATE_COOKIE);
2895
+ return html(
2896
+ renderGithubAppInstalledPage(userEmail, {
2897
+ success: false,
2898
+ error: result.error,
2899
+ }),
2900
+ );
2901
+ }
2902
+
2903
+ if (result.outcome === "agent_mismatch") {
2904
+ deleteCookie(c, GITHUB_PROVISION_STATE_COOKIE);
2905
+ return new Response("Forbidden", { status: 403 });
2906
+ }
2907
+
2908
+ if (result.outcome === "invalid_installation_id") {
2909
+ deleteCookie(c, GITHUB_PROVISION_STATE_COOKIE);
2910
+ return html(
2911
+ renderGithubAppInstalledPage(userEmail, {
2912
+ success: false,
2913
+ error: result.error,
2914
+ }),
2915
+ );
2916
+ }
2917
+
2918
+ // result.outcome === "success"
2919
+ deleteCookie(c, GITHUB_PROVISION_STATE_COOKIE);
2920
+ return html(
2921
+ renderGithubAppInstalledPage(userEmail, {
2922
+ success: true,
2923
+ alreadyStored: result.alreadyStored,
2924
+ }),
2925
+ );
2926
+ },
2927
+ );
2928
+
2929
+ app.get(
2930
+ "/admin/agents/:id/connect-github/add-org",
2931
+ requireAuth,
2932
+ async (c) => {
2933
+ const agentId = c.req.param("id");
2934
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
2935
+ return new Response("Forbidden", { status: 403 });
2936
+ }
2937
+ const result = await githubProvisioningService.getAddOrgUrl(agentId);
2938
+ if (!result.ok) {
2939
+ return c.redirect(
2940
+ `/admin/agents/${agentId}?error=${encodeURIComponent(result.error)}`,
2941
+ 302,
2942
+ );
2943
+ }
2944
+ return c.redirect(result.url, 302);
2945
+ },
2946
+ );
2947
+
2948
+ // ─── Provisioning flow ────────────────────────────────────────────────────
2949
+
2950
+ app.get("/admin/provision", (c) => c.redirect("/admin/agents/new", 302));
2951
+
2952
+ // ─── Member management (admin, or any agent-access holder when self-serve is on) ──────────────────────────────────────
2953
+
2954
+ app.post("/admin/agents/:id/members", requireAuth, async (c) => {
2955
+ const agentId = c.req.param("id");
2956
+ if (
2957
+ !(await assertCanManageAgent(agentId, c.var.userEmail, c.var.isAdmin))
2958
+ ) {
2959
+ return new Response("Forbidden", { status: 403 });
2960
+ }
2961
+ let email: string | undefined;
2962
+ try {
2963
+ const formData = await c.req.formData();
2964
+ email = formData.get("email")?.toString()?.toLowerCase();
2965
+ } catch {
2966
+ return c.redirect(`/admin/agents/${agentId}`, 302);
2967
+ }
2968
+ if (email) {
2969
+ try {
2970
+ await agentMemberService.add(agentId, email);
2971
+ } catch {
2972
+ // unique constraint violation — already a member, ignore
2973
+ }
2974
+ }
2975
+ return c.redirect(`/admin/agents/${agentId}`, 302);
2976
+ });
2977
+
2978
+ app.post("/admin/agents/:id/members/delete", requireAuth, async (c) => {
2979
+ const agentId = c.req.param("id");
2980
+ if (
2981
+ !(await assertCanManageAgent(agentId, c.var.userEmail, c.var.isAdmin))
2982
+ ) {
2983
+ return new Response("Forbidden", { status: 403 });
2984
+ }
2985
+ let memberId: string | undefined;
2986
+ try {
2987
+ const formData = await c.req.formData();
2988
+ memberId = formData.get("memberId")?.toString();
2989
+ } catch {
2990
+ return c.redirect(`/admin/agents/${agentId}`, 302);
2991
+ }
2992
+ if (memberId) {
2993
+ try {
2994
+ await agentMemberService.remove(agentId, memberId);
2995
+ } catch {
2996
+ // already gone, ignore
2997
+ }
2998
+ }
2999
+ return c.redirect(`/admin/agents/${agentId}`, 302);
3000
+ });
3001
+
3002
+ // ─── Account page (SSP-3.2) ───────────────────────────────────────────────
3003
+
3004
+ if (accountServices) {
3005
+ registerAccountRoutes(app, {
3006
+ enabled: accountNavEnabled,
3007
+ requireAuth,
3008
+ ...accountServices,
3009
+ html,
3010
+ });
3011
+ registerAdminAccountsRoutes(app, {
3012
+ enabled: accountNavEnabled,
3013
+ requireAuth,
3014
+ ...accountServices,
3015
+ agents: agentService,
3016
+ provisioner,
3017
+ html,
3018
+ });
3019
+ }
3020
+
3021
+ // ─── Chat routes ─────────────────────────────────────────────────────────
3022
+
3023
+ /**
3024
+ * Chat access gate (admins: always; members: only agents they belong to).
3025
+ * The chat service is reached with a single admin token, so for non-admins
3026
+ * the thread must also be verified to belong to the URL's agent — otherwise a
3027
+ * member could pass their own agentId with another agent's threadId.
3028
+ */
3029
+ async function canAccessChat(
3030
+ userEmail: string,
3031
+ isAdmin: boolean,
3032
+ agentId: string,
3033
+ threadId?: string,
3034
+ ): Promise<boolean> {
3035
+ if (isAdmin) return true;
3036
+ if (!agentId || !(await assertAgentAccess(agentId, userEmail, false))) {
3037
+ return false;
3038
+ }
3039
+ if (!threadId) return true;
3040
+ if (!chatClient) return true;
3041
+ try {
3042
+ const thread = await chatClient.getThread(threadId);
3043
+ return thread.agentId === agentId;
3044
+ } catch {
3045
+ return false;
3046
+ }
3047
+ }
3048
+
3049
+ /**
3050
+ * SSP-8.4: message-send gate. A suspended / trial-expired account's agents
3051
+ * stop accepting chat messages (read-only history stays available). Platform
3052
+ * admins are exempt; agents with no account are unaffected.
3053
+ */
3054
+ async function chatSendBlockedMessage(
3055
+ agentId: string,
3056
+ isAdmin: boolean,
3057
+ ): Promise<string | null> {
3058
+ if (isAdmin || !accountService) return null;
3059
+ const agent = await agentService.getDetail(agentId);
3060
+ if (!agent?.accountId) return null;
3061
+ const account = await accountService.getById(agent.accountId);
3062
+ if (account?.status === "suspended") {
3063
+ return "This account is suspended — chat messages can't be sent. Contact support to reactivate it.";
3064
+ }
3065
+ if (account?.status === "trial_expired") {
3066
+ return "This account's trial has expired — chat messages can't be sent. Upgrade to continue.";
3067
+ }
3068
+ return null;
3069
+ }
3070
+
3071
+ app.get("/admin/chat", requireAuth, async (c) => {
3072
+ const selectedAgentId = c.req.query("agentId") || undefined;
3073
+ const q = c.req.query("q") || undefined;
3074
+ const agents: AgentOption[] = c.var.isAdmin
3075
+ ? await agentService.listOptions()
3076
+ : (await resolveAccessibleAgents(c.var.userEmail, false)).map((a) => ({
3077
+ id: a.id,
3078
+ name: a.name,
3079
+ }));
3080
+ if (
3081
+ selectedAgentId &&
3082
+ !c.var.isAdmin &&
3083
+ !agents.some((a) => a.id === selectedAgentId)
3084
+ ) {
3085
+ return new Response("Forbidden", { status: 403 });
3086
+ }
3087
+
3088
+ if (!chatClient) {
3089
+ return html(
3090
+ renderChatPage(agents, selectedAgentId, null, c.var.userEmail, q),
3091
+ );
3092
+ }
3093
+
3094
+ let threads: ChatThread[] = [];
3095
+ if (selectedAgentId) {
3096
+ try {
3097
+ const result = await chatClient.listThreads(selectedAgentId);
3098
+ threads = result.threads;
3099
+ // Server-side filter by search query
3100
+ if (q) {
3101
+ const lowerQ = q.toLowerCase();
3102
+ threads = threads.filter((t) =>
3103
+ (t.title ?? "").toLowerCase().includes(lowerQ),
3104
+ );
3105
+ }
3106
+ } catch {
3107
+ threads = [];
3108
+ }
3109
+ }
3110
+
3111
+ return html(
3112
+ renderChatPage(agents, selectedAgentId, threads, c.var.userEmail, q),
3113
+ );
3114
+ });
3115
+
3116
+ app.get("/admin/chat/:agentId/threads/:threadId", requireAuth, async (c) => {
3117
+ if (
3118
+ !(await canAccessChat(
3119
+ c.var.userEmail,
3120
+ c.var.isAdmin,
3121
+ c.req.param("agentId") ?? "",
3122
+ c.req.param("threadId"),
3123
+ ))
3124
+ ) {
3125
+ return new Response("Forbidden", { status: 403 });
3126
+ }
3127
+
3128
+ const agentId = c.req.param("agentId");
3129
+ const threadId = c.req.param("threadId");
3130
+
3131
+ // Test-only override for the live-progress stall-warning threshold so e2e
3132
+ // doesn't need a real 2-minute wait. Clamped to a sane range; production
3133
+ // never passes it, so the 120s default stands.
3134
+ const rawStall = c.req.query("stallWarnAfterMs");
3135
+ const stallWarnAfterMs =
3136
+ rawStall !== undefined &&
3137
+ Number.isFinite(Number(rawStall)) &&
3138
+ Number(rawStall) > 0
3139
+ ? Number(rawStall)
3140
+ : undefined;
3141
+
3142
+ if (!chatClient) {
3143
+ return html(
3144
+ renderChatThreadPage(agentId, null, null, null, c.var.userEmail),
3145
+ );
3146
+ }
3147
+
3148
+ try {
3149
+ const [thread, messagesResult, threadListResult, statsResult] =
3150
+ await Promise.all([
3151
+ chatClient.getThread(threadId),
3152
+ chatClient.listMessages(threadId),
3153
+ chatClient.listThreads(agentId).catch(() => null),
3154
+ chatClient.getThreadStats(threadId).catch(() => null),
3155
+ ]);
3156
+ const threadList = threadListResult ? threadListResult.threads : null;
3157
+ return html(
3158
+ renderChatThreadPage(
3159
+ agentId,
3160
+ thread,
3161
+ messagesResult.messages,
3162
+ threadList,
3163
+ c.var.userEmail,
3164
+ statsResult,
3165
+ { stallWarnAfterMs, pushEnabled, vapidPublicKey },
3166
+ ),
3167
+ );
3168
+ } catch {
3169
+ return html(
3170
+ renderChatThreadPage(agentId, null, null, null, c.var.userEmail),
3171
+ );
3172
+ }
3173
+ });
3174
+
3175
+ app.post("/admin/chat/:agentId/threads", requireAuth, async (c) => {
3176
+ if (
3177
+ !(await canAccessChat(
3178
+ c.var.userEmail,
3179
+ c.var.isAdmin,
3180
+ c.req.param("agentId") ?? "",
3181
+ c.req.param("threadId"),
3182
+ ))
3183
+ ) {
3184
+ return new Response("Forbidden", { status: 403 });
3185
+ }
3186
+
3187
+ const agentId = c.req.param("agentId");
3188
+
3189
+ if (!chatClient) {
3190
+ return c.redirect(
3191
+ `/admin/chat?agentId=${encodeURIComponent(agentId)}`,
3192
+ 302,
3193
+ );
3194
+ }
3195
+
3196
+ let title: string | undefined;
3197
+ try {
3198
+ const formData = await c.req.formData();
3199
+ title = formData.get("title")?.toString()?.trim() || undefined;
3200
+ } catch {
3201
+ return c.redirect(
3202
+ `/admin/chat?agentId=${encodeURIComponent(agentId)}`,
3203
+ 302,
3204
+ );
3205
+ }
3206
+
3207
+ try {
3208
+ const thread = await chatClient.createThread(agentId, { title });
3209
+ return c.redirect(
3210
+ `/admin/chat/${encodeURIComponent(agentId)}/threads/${encodeURIComponent(thread.id)}`,
3211
+ 302,
3212
+ );
3213
+ } catch {
3214
+ return c.redirect(
3215
+ `/admin/chat?agentId=${encodeURIComponent(agentId)}`,
3216
+ 302,
3217
+ );
3218
+ }
3219
+ });
3220
+
3221
+ // Upload route — registered BEFORE the form POST /messages route so the more
3222
+ // specific `/messages/upload` segment matches first. Returns JSON so the inline
3223
+ // send flow can surface validation errors without a full-page redirect.
3224
+ app.post(
3225
+ "/admin/chat/:agentId/threads/:threadId/messages/upload",
3226
+ requireAuth,
3227
+ async (c) => {
3228
+ if (
3229
+ !(await canAccessChat(
3230
+ c.var.userEmail,
3231
+ c.var.isAdmin,
3232
+ c.req.param("agentId") ?? "",
3233
+ c.req.param("threadId"),
3234
+ ))
3235
+ ) {
3236
+ return new Response("Forbidden", { status: 403 });
3237
+ }
3238
+
3239
+ const sendBlocked = await chatSendBlockedMessage(
3240
+ c.req.param("agentId") ?? "",
3241
+ c.var.isAdmin,
3242
+ );
3243
+ if (sendBlocked) {
3244
+ return c.json(
3245
+ { error: "account_suspended", message: sendBlocked },
3246
+ 403,
3247
+ );
3248
+ }
3249
+
3250
+ const agentId = c.req.param("agentId");
3251
+ const threadId = c.req.param("threadId");
3252
+
3253
+ if (!chatClient) {
3254
+ return c.json({ error: "chat service not configured" }, 503);
3255
+ }
3256
+
3257
+ let body: string | undefined;
3258
+ let file: File | null = null;
3259
+ try {
3260
+ const formData = await c.req.formData();
3261
+ body = formData.get("body")?.toString()?.trim();
3262
+ const rawFile = formData.get("file");
3263
+ file = rawFile instanceof File && rawFile.size > 0 ? rawFile : null;
3264
+ } catch {
3265
+ return c.json({ error: "invalid form data" }, 400);
3266
+ }
3267
+
3268
+ let attachment:
3269
+ | { filename: string; size: number; bytes: Uint8Array }
3270
+ | undefined;
3271
+ if (file) {
3272
+ const validation = validateAttachment(file.name, file.size, file.type);
3273
+ if (!validation.ok) {
3274
+ return c.json({ error: validation.error }, validation.status);
3275
+ }
3276
+ attachment = {
3277
+ filename: file.name,
3278
+ size: file.size,
3279
+ bytes: new Uint8Array(await file.arrayBuffer()),
3280
+ };
3281
+ }
3282
+
3283
+ // A message needs at least a body or a file.
3284
+ if (!body && !attachment) {
3285
+ return c.json({ error: "message body or file is required" }, 400);
3286
+ }
3287
+
3288
+ try {
3289
+ const message = await chatClient.createMessage(
3290
+ threadId,
3291
+ "user",
3292
+ body ?? "",
3293
+ attachment,
3294
+ );
3295
+ // Record that this user is watching the thread so the agent's reply
3296
+ // can be targeted back to them via push (CFB-4.2). This is the route
3297
+ // the live chat UI's send box actually posts to (see
3298
+ // admin-ui-pages.ts's uploadUrl) — the form POST /messages and
3299
+ // /messages.json routes above are alternate entry points with their
3300
+ // own identical call; all three must stay in sync. Best-effort —
3301
+ // never block the response.
3302
+ if (pushEnabled) {
3303
+ await pushService?.recordWatch(c.var.userEmail, agentId, threadId);
3304
+ }
3305
+ return c.json({ message }, 201);
3306
+ } catch {
3307
+ return c.json({ error: "failed to create message" }, 500);
3308
+ }
3309
+ },
3310
+ );
3311
+
3312
+ // Attachment proxy (VM-3.1) — streams a message's attachment bytes back to
3313
+ // the browser so audio attachments can play inline via `<audio src>`. The
3314
+ // browser never holds the chat service's admin bearer token, so it can't
3315
+ // call the chat service's GET /:id/attachment directly; this route fetches
3316
+ // on its behalf and re-serves with a real audio/* Content-Type (the chat
3317
+ // service itself always streams application/octet-stream, which most
3318
+ // browsers won't play inline in an <audio> element).
3319
+ app.get(
3320
+ "/admin/chat/:agentId/threads/:threadId/messages/:messageId/attachment",
3321
+ requireAuth,
3322
+ async (c) => {
3323
+ if (
3324
+ !(await canAccessChat(
3325
+ c.var.userEmail,
3326
+ c.var.isAdmin,
3327
+ c.req.param("agentId") ?? "",
3328
+ c.req.param("threadId"),
3329
+ ))
3330
+ ) {
3331
+ return new Response("Forbidden", { status: 403 });
3332
+ }
3333
+
3334
+ if (!chatClient) {
3335
+ return c.json({ error: "chat service not configured" }, 503);
3336
+ }
3337
+
3338
+ const threadId = c.req.param("threadId");
3339
+ const messageId = c.req.param("messageId");
3340
+
3341
+ try {
3342
+ const attachment = await chatClient.getAttachment(threadId, messageId);
3343
+ if (!attachment) {
3344
+ return c.json({ error: "attachment not found" }, 404);
3345
+ }
3346
+ const contentType =
3347
+ audioContentTypeForFilename(attachment.filename) ??
3348
+ "application/octet-stream";
3349
+ // .slice() normalizes to Uint8Array<ArrayBuffer> — Hono's Data type
3350
+ // requires that exact generic, not the wider Uint8Array<ArrayBufferLike>
3351
+ // that new Uint8Array(await res.arrayBuffer()) produces.
3352
+ return c.body(attachment.bytes.slice(), 200, {
3353
+ "Content-Type": contentType,
3354
+ "Content-Disposition": `inline; filename="${attachment.filename.replace(/"/g, "")}"`,
3355
+ });
3356
+ } catch {
3357
+ return c.json({ error: "failed to fetch attachment" }, 500);
3358
+ }
3359
+ },
3360
+ );
3361
+
3362
+ app.post(
3363
+ "/admin/chat/:agentId/threads/:threadId/messages",
3364
+ requireAuth,
3365
+ async (c) => {
3366
+ if (
3367
+ !(await canAccessChat(
3368
+ c.var.userEmail,
3369
+ c.var.isAdmin,
3370
+ c.req.param("agentId") ?? "",
3371
+ c.req.param("threadId"),
3372
+ ))
3373
+ ) {
3374
+ return new Response("Forbidden", { status: 403 });
3375
+ }
3376
+
3377
+ const sendBlocked = await chatSendBlockedMessage(
3378
+ c.req.param("agentId") ?? "",
3379
+ c.var.isAdmin,
3380
+ );
3381
+ if (sendBlocked) {
3382
+ return new Response(sendBlocked, { status: 403 });
3383
+ }
3384
+
3385
+ const agentId = c.req.param("agentId");
3386
+ const threadId = c.req.param("threadId");
3387
+
3388
+ const backUrl = `/admin/chat/${encodeURIComponent(agentId)}/threads/${encodeURIComponent(threadId)}`;
3389
+
3390
+ if (!chatClient) {
3391
+ return c.redirect(backUrl, 302);
3392
+ }
3393
+
3394
+ const ALLOWED_ROLES = ["user", "assistant"] as const;
3395
+ type MessageRole = (typeof ALLOWED_ROLES)[number];
3396
+
3397
+ let body: string | undefined;
3398
+ let role: MessageRole = "user";
3399
+ let file: File | null = null;
3400
+ try {
3401
+ const formData = await c.req.formData();
3402
+ body = formData.get("body")?.toString()?.trim();
3403
+ const rawRole = formData.get("role")?.toString() || "user";
3404
+ role = (ALLOWED_ROLES as readonly string[]).includes(rawRole)
3405
+ ? (rawRole as MessageRole)
3406
+ : "user";
3407
+ const rawFile = formData.get("file");
3408
+ file = rawFile instanceof File && rawFile.size > 0 ? rawFile : null;
3409
+ } catch {
3410
+ return c.redirect(backUrl, 302);
3411
+ }
3412
+
3413
+ let attachment:
3414
+ | { filename: string; size: number; bytes: Uint8Array }
3415
+ | undefined;
3416
+ if (file) {
3417
+ const validation = validateAttachment(file.name, file.size, file.type);
3418
+ if (!validation.ok) {
3419
+ // Invalid attachment — bounce back without queuing anything.
3420
+ return c.redirect(backUrl, 302);
3421
+ }
3422
+ attachment = {
3423
+ filename: file.name,
3424
+ size: file.size,
3425
+ bytes: new Uint8Array(await file.arrayBuffer()),
3426
+ };
3427
+ }
3428
+
3429
+ if (!body && !attachment) {
3430
+ return c.redirect(backUrl, 302);
3431
+ }
3432
+
3433
+ try {
3434
+ await chatClient.createMessage(threadId, role, body ?? "", attachment);
3435
+ } catch {
3436
+ // swallow — redirect back regardless
3437
+ }
3438
+
3439
+ // Record that this user is watching the thread so the agent's reply can
3440
+ // be targeted back to them via push (CFB-4.2). Only on user messages, and
3441
+ // only when push is configured. Best-effort — never block the redirect.
3442
+ if (pushEnabled && role === "user") {
3443
+ await pushService?.recordWatch(c.var.userEmail, agentId, threadId);
3444
+ }
3445
+
3446
+ return c.redirect(backUrl, 302);
3447
+ },
3448
+ );
3449
+
3450
+ app.post(
3451
+ "/admin/chat/:agentId/threads/:threadId/rename",
3452
+ requireAuth,
3453
+ async (c) => {
3454
+ if (
3455
+ !(await canAccessChat(
3456
+ c.var.userEmail,
3457
+ c.var.isAdmin,
3458
+ c.req.param("agentId") ?? "",
3459
+ c.req.param("threadId"),
3460
+ ))
3461
+ ) {
3462
+ return new Response("Forbidden", { status: 403 });
3463
+ }
3464
+
3465
+ const agentId = c.req.param("agentId");
3466
+ const threadId = c.req.param("threadId");
3467
+ const backUrl = `/admin/chat/${encodeURIComponent(agentId)}/threads/${encodeURIComponent(threadId)}`;
3468
+
3469
+ if (!chatClient) {
3470
+ return c.redirect(backUrl, 302);
3471
+ }
3472
+
3473
+ let title: string | undefined;
3474
+ try {
3475
+ const formData = await c.req.formData();
3476
+ title = formData.get("title")?.toString()?.trim() || undefined;
3477
+ } catch {
3478
+ return c.redirect(backUrl, 302);
3479
+ }
3480
+
3481
+ // Guard: skip the API call when title is blank to avoid a silent no-op PATCH
3482
+ if (!title) {
3483
+ return c.redirect(backUrl, 302);
3484
+ }
3485
+
3486
+ try {
3487
+ await chatClient.updateThread(threadId, { title });
3488
+ } catch {
3489
+ // swallow — redirect back regardless
3490
+ }
3491
+
3492
+ return c.redirect(backUrl, 302);
3493
+ },
3494
+ );
3495
+
3496
+ app.post(
3497
+ "/admin/chat/:agentId/threads/:threadId/delete",
3498
+ requireAuth,
3499
+ async (c) => {
3500
+ if (
3501
+ !(await canAccessChat(
3502
+ c.var.userEmail,
3503
+ c.var.isAdmin,
3504
+ c.req.param("agentId") ?? "",
3505
+ c.req.param("threadId"),
3506
+ ))
3507
+ ) {
3508
+ return new Response("Forbidden", { status: 403 });
3509
+ }
3510
+
3511
+ const agentId = c.req.param("agentId");
3512
+ const threadId = c.req.param("threadId");
3513
+
3514
+ if (!chatClient) {
3515
+ return c.redirect(
3516
+ `/admin/chat?agentId=${encodeURIComponent(agentId)}`,
3517
+ 302,
3518
+ );
3519
+ }
3520
+
3521
+ try {
3522
+ await chatClient.deleteThread(threadId);
3523
+ } catch {
3524
+ // swallow — redirect back regardless
3525
+ }
3526
+
3527
+ return c.redirect(
3528
+ `/admin/chat?agentId=${encodeURIComponent(agentId)}`,
3529
+ 302,
3530
+ );
3531
+ },
3532
+ );
3533
+
3534
+ // ─── Chat JSON API routes ─────────────────────────────────────────────────
3535
+
3536
+ app.get(
3537
+ "/admin/chat/:agentId/threads/:threadId/messages.json",
3538
+ requireAuth,
3539
+ async (c) => {
3540
+ if (
3541
+ !(await canAccessChat(
3542
+ c.var.userEmail,
3543
+ c.var.isAdmin,
3544
+ c.req.param("agentId") ?? "",
3545
+ c.req.param("threadId"),
3546
+ ))
3547
+ ) {
3548
+ return new Response("Forbidden", { status: 403 });
3549
+ }
3550
+
3551
+ const agentId = c.req.param("agentId");
3552
+ const threadId = c.req.param("threadId");
3553
+ // ?since=<messageId> → incremental poll: only messages after that id.
3554
+ const since = c.req.query("since") || undefined;
3555
+
3556
+ if (!chatClient) {
3557
+ return c.json({ messages: [] });
3558
+ }
3559
+
3560
+ try {
3561
+ const result = await chatClient.listMessages(threadId);
3562
+ // Track retryBody (CFB-2.4) over the FULL ordered list — not the
3563
+ // ?since-filtered slice — so a retry button on a polled-in error
3564
+ // reply still resolves to its originating user message even when
3565
+ // that user message was delivered in an earlier poll response.
3566
+ const retryBodyByMessageId = new Map<string, string | null>();
3567
+ let lastUserBody: string | null = null;
3568
+ for (const m of result.messages) {
3569
+ retryBodyByMessageId.set(m.id, lastUserBody);
3570
+ if (m.role === "user") lastUserBody = m.body;
3571
+ }
3572
+ // ?since filtering is applied here at the admin (server) layer over the
3573
+ // full ordered list, so it works regardless of the chat client impl.
3574
+ const ordered = since
3575
+ ? filterSince(result.messages, since)
3576
+ : result.messages;
3577
+ // Emit an explicit field set rather than spreading the upstream
3578
+ // message: the chat service once leaked its raw `attachmentBytes`
3579
+ // column here, which JSON-expanded a few MB of audio into ~36 MB per
3580
+ // poll and OOM-killed this service. The browser's poll loop reads
3581
+ // id/role/createdAt/repliedAt/progressPhase/progressSeq/bubbleHtml.
3582
+ // bubbleHtml is the server-rendered bubble so polled bubbles are
3583
+ // byte-identical to a full reload's.
3584
+ const messages = ordered.map((m) =>
3585
+ toPolledMessage(m, {
3586
+ bubbleHtml: renderChatMessageBubble(
3587
+ m,
3588
+ retryBodyByMessageId.get(m.id) ?? null,
3589
+ agentId,
3590
+ ),
3591
+ }),
3592
+ );
3593
+ return c.json({ messages });
3594
+ } catch {
3595
+ return c.json({ messages: [] });
3596
+ }
3597
+ },
3598
+ );
3599
+
3600
+ app.post(
3601
+ "/admin/chat/:agentId/threads/:threadId/messages.json",
3602
+ requireAuth,
3603
+ async (c) => {
3604
+ if (
3605
+ !(await canAccessChat(
3606
+ c.var.userEmail,
3607
+ c.var.isAdmin,
3608
+ c.req.param("agentId") ?? "",
3609
+ c.req.param("threadId"),
3610
+ ))
3611
+ ) {
3612
+ return new Response("Forbidden", { status: 403 });
3613
+ }
3614
+
3615
+ const sendBlocked = await chatSendBlockedMessage(
3616
+ c.req.param("agentId") ?? "",
3617
+ c.var.isAdmin,
3618
+ );
3619
+ if (sendBlocked) {
3620
+ return c.json(
3621
+ { error: "account_suspended", message: sendBlocked },
3622
+ 403,
3623
+ );
3624
+ }
3625
+
3626
+ const threadId = c.req.param("threadId");
3627
+
3628
+ if (!chatClient) {
3629
+ return c.json({ message: null });
3630
+ }
3631
+
3632
+ let body: string | undefined;
3633
+ try {
3634
+ const jsonBody = await c.req.json<{ body?: string }>();
3635
+ body = jsonBody.body?.trim();
3636
+ } catch {
3637
+ return c.json({ message: null }, 400);
3638
+ }
3639
+
3640
+ if (!body) {
3641
+ return c.json({ message: null }, 400);
3642
+ }
3643
+
3644
+ try {
3645
+ const message = await chatClient.createMessage(threadId, "user", body);
3646
+ if (pushEnabled) {
3647
+ await pushService?.recordWatch(
3648
+ c.var.userEmail,
3649
+ c.req.param("agentId"),
3650
+ threadId,
3651
+ );
3652
+ }
3653
+ return c.json({ message });
3654
+ } catch {
3655
+ return c.json({ message: null }, 500);
3656
+ }
3657
+ },
3658
+ );
3659
+
3660
+ // ─── Web Push routes (CFB-4.2) ────────────────────────────────────────────
3661
+ // All 503 when push is not fully configured server-side, so the client-side
3662
+ // toggle (which is itself absent in that case) degrades cleanly.
3663
+
3664
+ app.post("/admin/chat/:agentId/push/subscribe", requireAuth, async (c) => {
3665
+ if (
3666
+ !(await canAccessChat(
3667
+ c.var.userEmail,
3668
+ c.var.isAdmin,
3669
+ c.req.param("agentId") ?? "",
3670
+ c.req.param("threadId"),
3671
+ ))
3672
+ ) {
3673
+ return new Response("Forbidden", { status: 403 });
3674
+ }
3675
+ if (!pushEnabled) return c.json({ error: "push_disabled" }, 503);
3676
+
3677
+ let payload: { endpoint?: string; p256dh?: string; auth?: string };
3678
+ try {
3679
+ payload = await c.req.json();
3680
+ } catch {
3681
+ return c.json({ error: "bad_request" }, 400);
3682
+ }
3683
+ const { endpoint, p256dh, auth } = payload;
3684
+ if (!endpoint || !p256dh || !auth) {
3685
+ return c.json({ error: "bad_request" }, 400);
3686
+ }
3687
+ const result = await pushService?.subscribe(
3688
+ c.var.userEmail,
3689
+ endpoint,
3690
+ p256dh,
3691
+ auth,
3692
+ );
3693
+ if (!result?.ok) {
3694
+ if (result?.reason === "store_failed") {
3695
+ return c.json({ error: "store_failed" }, 500);
3696
+ }
3697
+ return c.json({ error: "push_disabled" }, 503);
3698
+ }
3699
+ return c.json({ ok: true });
3700
+ });
3701
+
3702
+ app.post("/admin/chat/:agentId/push/unsubscribe", requireAuth, async (c) => {
3703
+ if (
3704
+ !(await canAccessChat(
3705
+ c.var.userEmail,
3706
+ c.var.isAdmin,
3707
+ c.req.param("agentId") ?? "",
3708
+ c.req.param("threadId"),
3709
+ ))
3710
+ ) {
3711
+ return new Response("Forbidden", { status: 403 });
3712
+ }
3713
+ if (!pushEnabled) return c.json({ error: "push_disabled" }, 503);
3714
+
3715
+ let endpoint: string | undefined;
3716
+ try {
3717
+ endpoint = (await c.req.json<{ endpoint?: string }>()).endpoint;
3718
+ } catch {
3719
+ return c.json({ error: "bad_request" }, 400);
3720
+ }
3721
+ if (!endpoint) return c.json({ error: "bad_request" }, 400);
3722
+ // Scoped to the caller (userEmail) inside PushService.unsubscribe so a
3723
+ // stale endpoint can't be used to prune another user's subscription.
3724
+ const result = await pushService?.unsubscribe(c.var.userEmail, endpoint);
3725
+ if (!result?.ok) {
3726
+ return c.json({ error: "push_disabled" }, 503);
3727
+ }
3728
+ return c.json({ ok: true });
3729
+ });
3730
+
3731
+ // Inbound webhook the chat service calls when an agent posts a reply, so the
3732
+ // watching user(s) get notified. Authenticated by a shared bearer token
3733
+ // (SHIPWRIGHT_ADMIN_PUSH_WEBHOOK_TOKEN) — NOT a user session — since the
3734
+ // caller is a service, not a browser.
3735
+ app.post("/admin/push/notify", async (c) => {
3736
+ if (!pushEnabled || !pushService || !pushWebhookToken) {
3737
+ return c.json({ error: "push_disabled" }, 503);
3738
+ }
3739
+ const auth = c.req.header("authorization") ?? "";
3740
+ const presented = auth.startsWith("Bearer ") ? auth.slice(7) : "";
3741
+ if (!presented || presented !== pushWebhookToken) {
3742
+ return c.json({ error: "unauthorized" }, 401);
3743
+ }
3744
+ let body: {
3745
+ threadId?: string;
3746
+ agentId?: string;
3747
+ title?: string | null;
3748
+ preview?: string | null;
3749
+ };
3750
+ try {
3751
+ body = await c.req.json();
3752
+ } catch {
3753
+ return c.json({ error: "bad_request" }, 400);
3754
+ }
3755
+ if (!body.threadId || !body.agentId) {
3756
+ return c.json({ error: "bad_request" }, 400);
3757
+ }
3758
+ const result = await pushService.notifyThreadReply({
3759
+ threadId: body.threadId,
3760
+ agentId: body.agentId,
3761
+ title: body.title ?? null,
3762
+ preview: body.preview ?? null,
3763
+ });
3764
+ return c.json({ ok: true, ...result });
3765
+ });
3766
+
3767
+ // ─── Agent delete (danger zone) ───────────────────────────────────────────
3768
+
3769
+ app.post("/admin/agents/:id/delete", requireAuth, async (c) => {
3770
+ const agentId = c.req.param("id");
3771
+ if (
3772
+ !(await assertCanManageAgent(agentId, c.var.userEmail, c.var.isAdmin))
3773
+ ) {
3774
+ return new Response("Forbidden", { status: 403 });
3775
+ }
3776
+ try {
3777
+ // Full teardown: K8s workload, task-store + chat-service tokens/threads,
3778
+ // optional Slack app deletion, then the Agent row itself (deleted last,
3779
+ // only if every automatable step succeeded). Same orchestration DELETE
3780
+ // /agents/:id uses (agents-api.ts) — this was previously a separate
3781
+ // inline provisioner.deprovision() + direct-prisma-delete path that had
3782
+ // drifted from it. deleteAgentFully() still takes prisma directly (its
3783
+ // own dependency shape, not routed through AgentService).
3784
+ const result = await deleteAgentFully(agentId, {
3785
+ prisma,
3786
+ provisioner,
3787
+ serviceIntegrations,
3788
+ chatService,
3789
+ slack,
3790
+ decrypt,
3791
+ });
3792
+ if (!result.agentDeleted) {
3793
+ // Cleanup is incomplete — keep the operator on the agent's own page
3794
+ // (NOT the agents list) so the agent doesn't appear deleted while
3795
+ // steps still need a retry.
3796
+ const failedSteps = result.failed.map((f) => f.step).join(", ");
3797
+ return c.redirect(
3798
+ `/admin/agents/${agentId}?error=${encodeURIComponent(
3799
+ `Cleanup incomplete — retry. Failed steps: ${failedSteps}`,
3800
+ )}`,
3801
+ 302,
3802
+ );
3803
+ }
3804
+ const manualStepsParam =
3805
+ result.manualStepsRequired.length > 0
3806
+ ? `&manualSteps=${encodeURIComponent(
3807
+ JSON.stringify(result.manualStepsRequired),
3808
+ )}`
3809
+ : "";
3810
+ return c.redirect(
3811
+ `/admin/agents?success=deleted${manualStepsParam}`,
3812
+ 302,
3813
+ );
3814
+ } catch (err) {
3815
+ const msg =
3816
+ err instanceof Error
3817
+ ? encodeURIComponent(err.message)
3818
+ : "delete_failed";
3819
+ return c.redirect(`/admin/agents/${agentId}?error=${msg}`, 302);
3820
+ }
3821
+ });
3822
+
3823
+ return app;
3824
+ }