@app-vitals/agent-admin 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (325) hide show
  1. package/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,1643 @@
1
+ /**
2
+ * admin/src/admin-local-agent.smoke.test.ts
3
+ * Smoke tests for the "New agent" create flow (/admin/agents/new).
4
+ *
5
+ * Tests:
6
+ * - GET /admin/agents/new — admin session returns 200 with form containing name input
7
+ * - GET /admin/agents/new — non-admin session returns 403
8
+ * - POST /admin/agents — admin creates agent with selfHosted:true → 302 redirect to /admin/agents/:id
9
+ * - POST /admin/agents — non-admin session returns 403
10
+ * - POST /admin/agents with repos — repos are attached to created agent
11
+ * - POST /admin/agents with missing name — stays on form or redirects with error
12
+ */
13
+
14
+ import { beforeAll, describe, expect, it } from "bun:test";
15
+ import { DEFAULT_AGENT_ENV } from "@app-vitals/agent-lib/default-agent-env";
16
+ import { sign } from "hono/jwt";
17
+ import type {
18
+ AdminUIDeps,
19
+ AdminUIGithubAppClient,
20
+ AdminUISlackClient,
21
+ } from "./admin-ui.ts";
22
+ import { createAdminUIApp } from "./admin-ui.ts";
23
+ import { SpecialtyRegistry } from "./specialty-manifest-loader.ts";
24
+ import type { GoogleAuthClient } from "./google-auth-client.ts";
25
+
26
+ // The configured default specialty (the fixture under the platform test run,
27
+ // "coding" in the monorepo).
28
+ const TYPE_META = new SpecialtyRegistry().getManifest(
29
+ process.env.SHIPWRIGHT_DEFAULT_AGENT_TYPE || "coding",
30
+ ).metadata;
31
+ const TYPE = TYPE_META.name;
32
+
33
+ // ─── Constants ────────────────────────────────────────────────────────────────
34
+
35
+ const SESSION_SECRET = "test-admin-session-secret-32-bytes!";
36
+ const ADMIN_ALLOWED_EMAILS = ["admin@example.com"];
37
+ const NEW_AGENT_ID = "agent-new-local-123";
38
+
39
+ // ─── JWT helper ───────────────────────────────────────────────────────────────
40
+
41
+ async function makeSessionCookie(
42
+ isAdmin = true,
43
+ email = "admin@example.com",
44
+ ): Promise<string> {
45
+ return sign(
46
+ {
47
+ userId: "google-sub-123",
48
+ email,
49
+ isAdmin,
50
+ iat: Math.floor(Date.now() / 1000),
51
+ exp: Math.floor(Date.now() / 1000) + 3600,
52
+ },
53
+ SESSION_SECRET,
54
+ "HS256",
55
+ );
56
+ }
57
+
58
+ // ─── Mock Google client ───────────────────────────────────────────────────────
59
+
60
+ function makeGoogleClient(): GoogleAuthClient {
61
+ return {
62
+ exchangeCode: () =>
63
+ Promise.resolve({
64
+ accessToken: "test-access-token",
65
+ refreshToken: "test-refresh-token",
66
+ expiresIn: 3600,
67
+ }),
68
+ getUserInfo: () =>
69
+ Promise.resolve({
70
+ sub: "google-sub-123",
71
+ email: "admin@example.com",
72
+ email_verified: true,
73
+ name: "Admin User",
74
+ }),
75
+ };
76
+ }
77
+
78
+ // ─── Mock deps factory ────────────────────────────────────────────────────────
79
+
80
+ function makeMockDeps(overrides?: Partial<AdminUIDeps>): AdminUIDeps {
81
+ const BASE_SLACK_CLIENT: AdminUISlackClient = {
82
+ createAppManifest: async () => ({
83
+ appId: "A123456",
84
+ oauthRedirectUrl: "https://slack.com/oauth/authorize?client_id=123",
85
+ clientId: "test-client-id",
86
+ clientSecret: "test-client-secret",
87
+ signingSecret: "test-signing-secret",
88
+ }),
89
+ updateAppManifest: async () => {},
90
+ exchangeOAuthCode: async () => ({ botToken: "xoxb-mock-bot-token" }),
91
+ authTest: async () => ({ userId: "U0AALR8M69X" }),
92
+ };
93
+
94
+ const BASE_GITHUB_APP_CLIENT: AdminUIGithubAppClient = {
95
+ exchangeManifestCode: async () => ({
96
+ appId: "999111",
97
+ slug: "test-shipwright-agent",
98
+ pem: "-----BEGIN RSA PRIVATE KEY-----\nmock\n-----END RSA PRIVATE KEY-----",
99
+ clientId: "gh-app-client-id",
100
+ clientSecret: "gh-app-client-secret",
101
+ }),
102
+ };
103
+
104
+ const defaults: AdminUIDeps = {
105
+ prisma: {
106
+ agent: {
107
+ findMany: async () => [],
108
+ findUnique: async () => null,
109
+ create: async () => ({
110
+ id: NEW_AGENT_ID,
111
+ name: "New Local Agent",
112
+ slackId: null,
113
+ createdAt: new Date("2024-01-01"),
114
+ updatedAt: new Date("2024-01-01"),
115
+ repos: [],
116
+ }),
117
+ update: async () => ({
118
+ id: NEW_AGENT_ID,
119
+ name: "New Local Agent",
120
+ slackId: null,
121
+ createdAt: new Date("2024-01-01"),
122
+ updatedAt: new Date("2024-01-01"),
123
+ repos: [],
124
+ }),
125
+ delete: async () => ({
126
+ id: NEW_AGENT_ID,
127
+ name: "New Local Agent",
128
+ slackId: null,
129
+ createdAt: new Date("2024-01-01"),
130
+ updatedAt: new Date("2024-01-01"),
131
+ repos: [],
132
+ }),
133
+ },
134
+ agentEnv: {
135
+ findMany: async () => [],
136
+ },
137
+ agentPlugin: {
138
+ findMany: async () => [],
139
+ },
140
+ agentMember: {
141
+ findMany: async () => [],
142
+ findUnique: async () => null,
143
+ create: async () => ({
144
+ id: "m1",
145
+ agentId: NEW_AGENT_ID,
146
+ email: "admin@example.com",
147
+ }),
148
+ deleteMany: async () => ({ count: 0 }),
149
+ },
150
+ },
151
+ agentEnvService: {
152
+ getByAgentId: async () => ({ env: {}, secretKeys: [] }),
153
+ upsert: async () => {},
154
+ patch: async () => {},
155
+ deleteKey: async () => {},
156
+ getConfigBundle: async () => null,
157
+ },
158
+ agentCronJobService: {
159
+ list: async () => [],
160
+ listWithRunSummary: async () => [],
161
+ get: async () => {
162
+ throw new Error("not found");
163
+ },
164
+ create: async () => {
165
+ throw new Error("not implemented");
166
+ },
167
+ setEnabled: async () => {
168
+ throw new Error("not implemented");
169
+ },
170
+ update: async () => {
171
+ throw new Error("not implemented");
172
+ },
173
+ delete: async () => {},
174
+ reconcileSystemCrons: async () => ({
175
+ created: 0,
176
+ updated: 0,
177
+ deleted: 0,
178
+ }),
179
+ },
180
+ agentToolService: {
181
+ list: async () => [],
182
+ add: async (agentId: string, pattern: string) => ({
183
+ id: "tool1",
184
+ agentId,
185
+ pattern,
186
+ enabled: true,
187
+ createdAt: new Date(),
188
+ updatedAt: new Date(),
189
+ }),
190
+ toggle: async () => {
191
+ throw new Error("not implemented");
192
+ },
193
+ remove: async () => {},
194
+ },
195
+ agentTokenService: {
196
+ listForAgent: async () => [],
197
+ create: async () => ({
198
+ token: {
199
+ id: "t1",
200
+ label: null,
201
+ createdAt: new Date(),
202
+ revokedAt: null,
203
+ agentId: NEW_AGENT_ID,
204
+ token: "hash",
205
+ },
206
+ rawToken: "sw_raw123456",
207
+ }),
208
+ revoke: async () => {
209
+ throw new Error("not implemented");
210
+ },
211
+ },
212
+ agentPluginService: {
213
+ list: async () => [],
214
+ add: async (agentId: string, name: string) => ({
215
+ id: "plugin1",
216
+ agentId,
217
+ name,
218
+ version: null,
219
+ enabled: true,
220
+ createdAt: new Date(),
221
+ updatedAt: new Date(),
222
+ }),
223
+ },
224
+ agentMemberService: {
225
+ listByEmail: async () => [],
226
+ exists: async () => false,
227
+ add: async (agentId: string, email: string) => ({
228
+ id: "m1",
229
+ agentId,
230
+ email,
231
+ createdAt: new Date(),
232
+ }),
233
+ remove: async () => {},
234
+ listByAgentId: async () => [],
235
+ },
236
+ agentService: {
237
+ listAll: async () => [],
238
+ listByIds: async () => [],
239
+ searchByName: async () => [],
240
+ listOptions: async () => [],
241
+ create: async () => ({
242
+ id: NEW_AGENT_ID,
243
+ name: "New Local Agent",
244
+ slackId: null,
245
+ selfHosted: true,
246
+ repos: [],
247
+ reviewAuthorAllowlist: [],
248
+ patchAuthorAllowlist: [],
249
+ restrictSlackToMembers: false,
250
+ typeName: TYPE,
251
+ createdAt: new Date("2024-01-01"),
252
+ updatedAt: new Date("2024-01-01"),
253
+ missingRequiredEnv: [],
254
+ }),
255
+ delete: async () => {},
256
+ getDetail: async () => ({
257
+ id: NEW_AGENT_ID,
258
+ name: "New Local Agent",
259
+ slackId: null,
260
+ selfHosted: true,
261
+ repos: [],
262
+ reviewAuthorAllowlist: [],
263
+ patchAuthorAllowlist: [],
264
+ restrictSlackToMembers: false,
265
+ typeName: TYPE,
266
+ createdAt: new Date("2024-01-01"),
267
+ updatedAt: new Date("2024-01-01"),
268
+ missingRequiredEnv: [],
269
+ }),
270
+ updateFields: async () => ({
271
+ id: NEW_AGENT_ID,
272
+ name: "New Local Agent",
273
+ slackId: null,
274
+ selfHosted: true,
275
+ repos: [],
276
+ reviewAuthorAllowlist: [],
277
+ patchAuthorAllowlist: [],
278
+ restrictSlackToMembers: false,
279
+ typeName: TYPE,
280
+ createdAt: new Date("2024-01-01"),
281
+ updatedAt: new Date("2024-01-01"),
282
+ missingRequiredEnv: [],
283
+ }),
284
+ },
285
+ sessionSecret: SESSION_SECRET,
286
+ googleClientId: "test-google-client-id",
287
+ googleClientSecret: "test-google-client-secret",
288
+ adminAllowedEmails: ADMIN_ALLOWED_EMAILS,
289
+ googleClient: makeGoogleClient(),
290
+ slackClient: BASE_SLACK_CLIENT,
291
+ githubAppClient: BASE_GITHUB_APP_CLIENT,
292
+ provisioner: {
293
+ canProvision: false,
294
+ provision: async () => ({
295
+ resourceName: "r",
296
+ secretName: "s",
297
+ deploymentName: "d",
298
+ }),
299
+ deprovision: async () => {},
300
+ reconcile: async () => ({
301
+ recreated: [],
302
+ migrated: [],
303
+ noOwner: [],
304
+ dryRun: false,
305
+ updated: [],
306
+ orphans: [],
307
+ failed: [],
308
+ }),
309
+ },
310
+ serviceIntegrations: [
311
+ {
312
+ name: "task-store",
313
+ listTokensForAgent: async () => [],
314
+ revokeToken: async () => {},
315
+ },
316
+ ],
317
+ chatService: {
318
+ listTokensForAgent: async () => [],
319
+ revokeToken: async () => {},
320
+ deleteThreadsForAgent: async () => ({ deleted: 0 }),
321
+ },
322
+ slack: {
323
+ deleteApp: async () => {},
324
+ },
325
+ decrypt: (value: string) => value,
326
+ appBaseUrl: "https://example.com",
327
+ };
328
+
329
+ return { ...defaults, ...overrides };
330
+ }
331
+
332
+ // ─── Tests ────────────────────────────────────────────────────────────────────
333
+
334
+ /**
335
+ * New agents are always seeded with the non-secret DEFAULT_AGENT_ENV; a "no env
336
+ * writes" scenario means nothing beyond those defaults (no credentials) landed.
337
+ */
338
+ function expectOnlyDefaultEnv(
339
+ patches: Array<{ env: Record<string, string> }>,
340
+ ): void {
341
+ for (const patch of patches) {
342
+ expect(patch.env).toEqual({ ...DEFAULT_AGENT_ENV });
343
+ }
344
+ }
345
+
346
+ describe("admin UI — new local agent create flow", () => {
347
+ let adminCookie: string;
348
+ let nonAdminCookie: string;
349
+
350
+ beforeAll(async () => {
351
+ adminCookie = await makeSessionCookie(true);
352
+ nonAdminCookie = await makeSessionCookie(false, "member@example.com");
353
+ });
354
+
355
+ // ── GET /admin/agents/new ─────────────────────────────────────────────────
356
+
357
+ it("GET /admin/agents/new — admin session returns 200 with form containing name input", async () => {
358
+ const app = createAdminUIApp(makeMockDeps());
359
+ const res = await app.request("/admin/agents/new", {
360
+ headers: { Cookie: `admin_session=${adminCookie}` },
361
+ });
362
+ expect(res.status).toBe(200);
363
+ const html = await res.text();
364
+ expect(html).toContain('name="name"');
365
+ expect(html).toContain("New Agent");
366
+ });
367
+
368
+ it("GET /admin/agents/new — renders a required type select listing the registry's built-in types", async () => {
369
+ const app = createAdminUIApp(makeMockDeps());
370
+ const res = await app.request("/admin/agents/new", {
371
+ headers: { Cookie: `admin_session=${adminCookie}` },
372
+ });
373
+ expect(res.status).toBe(200);
374
+ const html = await res.text();
375
+ expect(html).toMatch(/<select[^>]*name="type"[^>]*required/);
376
+ expect(html).toContain(
377
+ `<option value="${TYPE}">${TYPE_META.displayName}</option>`,
378
+ );
379
+ });
380
+
381
+ it("GET /admin/agents/new — non-admin session returns 403", async () => {
382
+ const app = createAdminUIApp(makeMockDeps());
383
+ const res = await app.request("/admin/agents/new", {
384
+ headers: { Cookie: `admin_session=${nonAdminCookie}` },
385
+ });
386
+ expect(res.status).toBe(403);
387
+ });
388
+
389
+ it("GET /admin/agents/new — unauthenticated redirects to /admin/login", async () => {
390
+ const app = createAdminUIApp(makeMockDeps());
391
+ const res = await app.request("/admin/agents/new");
392
+ expect(res.status).toBe(302);
393
+ expect(res.headers.get("Location")).toBe("/admin/login");
394
+ });
395
+
396
+ // ── POST /admin/agents ────────────────────────────────────────────────────
397
+
398
+ it("POST /admin/agents — admin creates agent with selfHosted:true → 302 redirect to /admin/agents/:id", async () => {
399
+ let createdArgs: { name: string; selfHosted?: boolean } | null = null;
400
+ const deps = makeMockDeps({
401
+ agentService: {
402
+ ...makeMockDeps().agentService,
403
+ create: async (input: { name: string; selfHosted?: boolean }) => {
404
+ createdArgs = input;
405
+ return {
406
+ id: NEW_AGENT_ID,
407
+ name: input.name,
408
+ slackId: null,
409
+ selfHosted: input.selfHosted ?? false,
410
+ repos: [],
411
+ reviewAuthorAllowlist: [],
412
+ patchAuthorAllowlist: [],
413
+ restrictSlackToMembers: false,
414
+ typeName: TYPE,
415
+ createdAt: new Date("2024-01-01"),
416
+ updatedAt: new Date("2024-01-01"),
417
+ missingRequiredEnv: [],
418
+ };
419
+ },
420
+ },
421
+ });
422
+ const app = createAdminUIApp(deps);
423
+ const body = new URLSearchParams({
424
+ name: "My Local Agent",
425
+ type: TYPE,
426
+ });
427
+ const res = await app.request("/admin/agents", {
428
+ method: "POST",
429
+ body: body.toString(),
430
+ headers: {
431
+ "Content-Type": "application/x-www-form-urlencoded",
432
+ Cookie: `admin_session=${adminCookie}`,
433
+ },
434
+ });
435
+ expect(res.status).toBe(302);
436
+ expect(res.headers.get("Location")).toBe(`/admin/agents/${NEW_AGENT_ID}`);
437
+ expect(createdArgs).not.toBeNull();
438
+ // biome-ignore lint/style/noNonNullAssertion: guarded by expect(createdArgs).not.toBeNull() above
439
+ expect(createdArgs!.selfHosted).toBe(true);
440
+ // biome-ignore lint/style/noNonNullAssertion: guarded by expect(createdArgs).not.toBeNull() above
441
+ expect(createdArgs!.name).toBe("My Local Agent");
442
+ });
443
+
444
+ it("POST /admin/agents — non-admin session returns 403", async () => {
445
+ const app = createAdminUIApp(makeMockDeps());
446
+ const body = new URLSearchParams({
447
+ name: "My Local Agent",
448
+ type: TYPE,
449
+ });
450
+ const res = await app.request("/admin/agents", {
451
+ method: "POST",
452
+ body: body.toString(),
453
+ headers: {
454
+ "Content-Type": "application/x-www-form-urlencoded",
455
+ Cookie: `admin_session=${nonAdminCookie}`,
456
+ },
457
+ });
458
+ expect(res.status).toBe(403);
459
+ });
460
+
461
+ it("POST /admin/agents with repos — repos are attached to created agent via update", async () => {
462
+ let updateArgs: { id: string; repos: string[] } | null = null;
463
+ const deps = makeMockDeps({
464
+ agentService: {
465
+ ...makeMockDeps().agentService,
466
+ create: async (input: { name: string; selfHosted?: boolean }) => ({
467
+ id: NEW_AGENT_ID,
468
+ name: input.name,
469
+ slackId: null,
470
+ selfHosted: input.selfHosted ?? false,
471
+ repos: [],
472
+ reviewAuthorAllowlist: [],
473
+ patchAuthorAllowlist: [],
474
+ restrictSlackToMembers: false,
475
+ typeName: TYPE,
476
+ createdAt: new Date("2024-01-01"),
477
+ updatedAt: new Date("2024-01-01"),
478
+ missingRequiredEnv: [],
479
+ }),
480
+ updateFields: async (id: string, input: { repos?: string[] }) => {
481
+ updateArgs = { id, repos: input.repos ?? [] };
482
+ return {
483
+ id,
484
+ name: "My Local Agent",
485
+ slackId: null,
486
+ selfHosted: true,
487
+ repos: input.repos ?? [],
488
+ reviewAuthorAllowlist: [],
489
+ patchAuthorAllowlist: [],
490
+ restrictSlackToMembers: false,
491
+ typeName: TYPE,
492
+ createdAt: new Date("2024-01-01"),
493
+ updatedAt: new Date("2024-01-01"),
494
+ missingRequiredEnv: [],
495
+ };
496
+ },
497
+ },
498
+ });
499
+ const app = createAdminUIApp(deps);
500
+ const body = new URLSearchParams({
501
+ name: "My Local Agent",
502
+ type: TYPE,
503
+ repos: "my-org/repo-one\nmy-org/repo-two",
504
+ });
505
+ const res = await app.request("/admin/agents", {
506
+ method: "POST",
507
+ body: body.toString(),
508
+ headers: {
509
+ "Content-Type": "application/x-www-form-urlencoded",
510
+ Cookie: `admin_session=${adminCookie}`,
511
+ },
512
+ });
513
+ expect(res.status).toBe(302);
514
+ expect(res.headers.get("Location")).toBe(`/admin/agents/${NEW_AGENT_ID}`);
515
+ expect(updateArgs).not.toBeNull();
516
+ // biome-ignore lint/style/noNonNullAssertion: guarded by expect(updateArgs).not.toBeNull() above
517
+ expect(updateArgs!.repos).toContain("my-org/repo-one");
518
+ // biome-ignore lint/style/noNonNullAssertion: guarded by expect(updateArgs).not.toBeNull() above
519
+ expect(updateArgs!.repos).toContain("my-org/repo-two");
520
+ });
521
+
522
+ // ── UAP-5.2: inline member-email textarea for restrictSlackToMembers ──────
523
+
524
+ it("POST /admin/agents with restrictSlackToMembers=true and memberEmails — creates an AgentMember row for each email", async () => {
525
+ const addCalls: Array<{ agentId: string; email: string }> = [];
526
+ const deps = makeMockDeps({
527
+ agentMemberService: {
528
+ ...makeMockDeps().agentMemberService,
529
+ add: async (agentId: string, email: string) => {
530
+ addCalls.push({ agentId, email });
531
+ return { id: "m1", agentId, email, createdAt: new Date() };
532
+ },
533
+ listByAgentId: async () =>
534
+ addCalls.map((c) => ({
535
+ id: "m1",
536
+ agentId: c.agentId,
537
+ email: c.email,
538
+ createdAt: new Date(),
539
+ })),
540
+ },
541
+ });
542
+ const app = createAdminUIApp(deps);
543
+ const body = new URLSearchParams({
544
+ name: "My Local Agent",
545
+ type: TYPE,
546
+ restrictSlackToMembers: "true",
547
+ memberEmails: "a@example.com\nb@example.com",
548
+ });
549
+ const res = await app.request("/admin/agents", {
550
+ method: "POST",
551
+ body: body.toString(),
552
+ headers: {
553
+ "Content-Type": "application/x-www-form-urlencoded",
554
+ Cookie: `admin_session=${adminCookie}`,
555
+ },
556
+ });
557
+ expect(res.status).toBe(302);
558
+ expect(addCalls.map((c) => c.email)).toContain("a@example.com");
559
+ expect(addCalls.map((c) => c.email)).toContain("b@example.com");
560
+ expect(addCalls.length).toBe(2);
561
+ // Members were created before redirectWithMembersWarning ran, so no
562
+ // zero-members warning should be appended.
563
+ expect(res.headers.get("Location")).toBe(`/admin/agents/${NEW_AGENT_ID}`);
564
+ });
565
+
566
+ it("POST /admin/agents with duplicate memberEmails (or agentMemberService.add throwing) does not 500 — still 302 redirects", async () => {
567
+ const deps = makeMockDeps({
568
+ agentMemberService: {
569
+ ...makeMockDeps().agentMemberService,
570
+ add: async () => {
571
+ throw new Error("unique constraint violation");
572
+ },
573
+ },
574
+ });
575
+ const app = createAdminUIApp(deps);
576
+ const body = new URLSearchParams({
577
+ name: "My Local Agent",
578
+ type: TYPE,
579
+ restrictSlackToMembers: "true",
580
+ memberEmails: "dup@example.com\ndup@example.com",
581
+ });
582
+ const res = await app.request("/admin/agents", {
583
+ method: "POST",
584
+ body: body.toString(),
585
+ headers: {
586
+ "Content-Type": "application/x-www-form-urlencoded",
587
+ Cookie: `admin_session=${adminCookie}`,
588
+ },
589
+ });
590
+ expect(res.status).toBe(302);
591
+ expect(res.headers.get("Location")).toStartWith(
592
+ `/admin/agents/${NEW_AGENT_ID}`,
593
+ );
594
+ });
595
+
596
+ it("POST /admin/agents with restrictSlackToMembers=true and an empty memberEmails textarea — still creates the agent, no regression to the zero-members warning", async () => {
597
+ const deps = makeMockDeps({
598
+ agentMemberService: {
599
+ ...makeMockDeps().agentMemberService,
600
+ listByAgentId: async () => [],
601
+ },
602
+ });
603
+ const app = createAdminUIApp(deps);
604
+ const body = new URLSearchParams({
605
+ name: "My Local Agent",
606
+ type: TYPE,
607
+ restrictSlackToMembers: "true",
608
+ memberEmails: "",
609
+ });
610
+ const res = await app.request("/admin/agents", {
611
+ method: "POST",
612
+ body: body.toString(),
613
+ headers: {
614
+ "Content-Type": "application/x-www-form-urlencoded",
615
+ Cookie: `admin_session=${adminCookie}`,
616
+ },
617
+ });
618
+ expect(res.status).toBe(302);
619
+ expect(res.headers.get("Location")).toBe(
620
+ `/admin/agents/${NEW_AGENT_ID}?warning=restrict_slack_no_members`,
621
+ );
622
+ });
623
+
624
+ it("POST /admin/agents with missing name — returns non-200 or error response", async () => {
625
+ const app = createAdminUIApp(makeMockDeps());
626
+ const body = new URLSearchParams({ name: "" });
627
+ const res = await app.request("/admin/agents", {
628
+ method: "POST",
629
+ body: body.toString(),
630
+ headers: {
631
+ "Content-Type": "application/x-www-form-urlencoded",
632
+ Cookie: `admin_session=${adminCookie}`,
633
+ },
634
+ });
635
+ // Should not succeed — either redirect back with error or re-render form
636
+ // Must not redirect to an agent detail page
637
+ const location = res.headers.get("Location") ?? "";
638
+ const isErrorResponse =
639
+ res.status === 200 ||
640
+ (res.status === 302 && !location.startsWith("/admin/agents/agent-"));
641
+ expect(isErrorResponse).toBe(true);
642
+ });
643
+
644
+ it("POST /admin/agents with missing type — redirects with error, no agent created", async () => {
645
+ let createCalled = false;
646
+ const deps = makeMockDeps({
647
+ agentService: {
648
+ ...makeMockDeps().agentService,
649
+ create: async (input: { name: string; selfHosted?: boolean }) => {
650
+ createCalled = true;
651
+ return {
652
+ id: NEW_AGENT_ID,
653
+ name: input.name,
654
+ slackId: null,
655
+ selfHosted: input.selfHosted ?? false,
656
+ repos: [],
657
+ reviewAuthorAllowlist: [],
658
+ patchAuthorAllowlist: [],
659
+ restrictSlackToMembers: false,
660
+ typeName: TYPE,
661
+ createdAt: new Date("2024-01-01"),
662
+ updatedAt: new Date("2024-01-01"),
663
+ missingRequiredEnv: [],
664
+ };
665
+ },
666
+ },
667
+ });
668
+ const app = createAdminUIApp(deps);
669
+ const body = new URLSearchParams({ name: "My Local Agent" });
670
+ const res = await app.request("/admin/agents", {
671
+ method: "POST",
672
+ body: body.toString(),
673
+ headers: {
674
+ "Content-Type": "application/x-www-form-urlencoded",
675
+ Cookie: `admin_session=${adminCookie}`,
676
+ },
677
+ });
678
+ expect(res.status).toBe(302);
679
+ expect(res.headers.get("Location")).toBe(
680
+ "/admin/agents/new?error=invalid_type",
681
+ );
682
+ expect(createCalled).toBe(false);
683
+ });
684
+
685
+ it("POST /admin/agents with a bogus type — redirects with error, no agent created", async () => {
686
+ let createCalled = false;
687
+ const deps = makeMockDeps({
688
+ agentService: {
689
+ ...makeMockDeps().agentService,
690
+ create: async (input: { name: string; selfHosted?: boolean }) => {
691
+ createCalled = true;
692
+ return {
693
+ id: NEW_AGENT_ID,
694
+ name: input.name,
695
+ slackId: null,
696
+ selfHosted: input.selfHosted ?? false,
697
+ repos: [],
698
+ reviewAuthorAllowlist: [],
699
+ patchAuthorAllowlist: [],
700
+ restrictSlackToMembers: false,
701
+ typeName: TYPE,
702
+ createdAt: new Date("2024-01-01"),
703
+ updatedAt: new Date("2024-01-01"),
704
+ missingRequiredEnv: [],
705
+ };
706
+ },
707
+ },
708
+ });
709
+ const app = createAdminUIApp(deps);
710
+ const body = new URLSearchParams({
711
+ name: "My Local Agent",
712
+ type: "not-a-real-type",
713
+ });
714
+ const res = await app.request("/admin/agents", {
715
+ method: "POST",
716
+ body: body.toString(),
717
+ headers: {
718
+ "Content-Type": "application/x-www-form-urlencoded",
719
+ Cookie: `admin_session=${adminCookie}`,
720
+ },
721
+ });
722
+ expect(res.status).toBe(302);
723
+ expect(res.headers.get("Location")).toBe(
724
+ "/admin/agents/new?error=invalid_type",
725
+ );
726
+ expect(createCalled).toBe(false);
727
+ });
728
+
729
+ it("POST /admin/agents with invalid repo format — deletes agent and redirects to error page", async () => {
730
+ let deleteCalled = false;
731
+ const deps = makeMockDeps({
732
+ agentService: {
733
+ ...makeMockDeps().agentService,
734
+ create: async (input: { name: string; selfHosted?: boolean }) => ({
735
+ id: NEW_AGENT_ID,
736
+ name: input.name,
737
+ slackId: null,
738
+ selfHosted: input.selfHosted ?? false,
739
+ repos: [],
740
+ reviewAuthorAllowlist: [],
741
+ patchAuthorAllowlist: [],
742
+ restrictSlackToMembers: false,
743
+ typeName: TYPE,
744
+ createdAt: new Date("2024-01-01"),
745
+ updatedAt: new Date("2024-01-01"),
746
+ missingRequiredEnv: [],
747
+ }),
748
+ delete: async () => {
749
+ deleteCalled = true;
750
+ },
751
+ },
752
+ });
753
+ const app = createAdminUIApp(deps);
754
+ const body = new URLSearchParams({
755
+ name: "My Local Agent",
756
+ type: TYPE,
757
+ repos: "not-valid-repo",
758
+ });
759
+ const res = await app.request("/admin/agents", {
760
+ method: "POST",
761
+ body: body.toString(),
762
+ headers: {
763
+ "Content-Type": "application/x-www-form-urlencoded",
764
+ Cookie: `admin_session=${adminCookie}`,
765
+ },
766
+ });
767
+ expect(res.status).toBe(302);
768
+ expect(res.headers.get("Location")).toBe(
769
+ "/admin/agents/new?error=invalid_repo_format",
770
+ );
771
+ expect(deleteCalled).toBe(true);
772
+ });
773
+
774
+ it("GET /admin/agents/new with error query param — renders error banner", async () => {
775
+ const app = createAdminUIApp(makeMockDeps());
776
+ const res = await app.request(
777
+ "/admin/agents/new?error=invalid_repo_format",
778
+ {
779
+ headers: { Cookie: `admin_session=${adminCookie}` },
780
+ },
781
+ );
782
+ expect(res.status).toBe(200);
783
+ const html = await res.text();
784
+ expect(html).toContain("alert-error");
785
+ expect(html).toContain("Repo must be in org/repo format");
786
+ });
787
+
788
+ // ── /admin/agents list page has the primary "New agent" button ────────────
789
+
790
+ it("GET /admin/agents — admin sees the '+ New agent' button linking to /admin/agents/new", async () => {
791
+ const app = createAdminUIApp(makeMockDeps());
792
+ const res = await app.request("/admin/agents", {
793
+ headers: { Cookie: `admin_session=${adminCookie}` },
794
+ });
795
+ expect(res.status).toBe(200);
796
+ const html = await res.text();
797
+ expect(html).toContain("+ New agent");
798
+ expect(html).toContain("/admin/agents/new");
799
+ });
800
+
801
+ it("GET /admin/agents — non-admin does NOT see the '+ New agent' button", async () => {
802
+ const app = createAdminUIApp(makeMockDeps());
803
+ const res = await app.request("/admin/agents", {
804
+ headers: { Cookie: `admin_session=${nonAdminCookie}` },
805
+ });
806
+ expect(res.status).toBe(200);
807
+ const html = await res.text();
808
+ expect(html).not.toContain("+ New agent");
809
+ });
810
+
811
+ // ── runtime=in-cluster (Slack-free provisioning) ──────────────────────────
812
+
813
+ /**
814
+ * Builds deps with a recording provisioner / cron service / env service so a
815
+ * test can assert exactly what the create path did. `canProvision` defaults
816
+ * to true because these cases exercise the in-cluster branch.
817
+ */
818
+ function makeProvisioningDeps(opts?: {
819
+ canProvision?: boolean;
820
+ provisionError?: Error;
821
+ reconcileError?: Error;
822
+ deleteError?: Error;
823
+ }) {
824
+ const calls = {
825
+ created: null as { name: string; selfHosted?: boolean } | null,
826
+ provisioned: [] as Array<{ id: string; slug?: string }>,
827
+ reconciled: [] as string[],
828
+ deleted: [] as string[],
829
+ envPatches: [] as Array<{
830
+ agentId: string;
831
+ env: Record<string, string>;
832
+ secretKeys: Set<string> | undefined;
833
+ }>,
834
+ };
835
+ const base = makeMockDeps();
836
+ const deps = makeMockDeps({
837
+ agentService: {
838
+ ...base.agentService,
839
+ create: async (input: { name: string; selfHosted?: boolean }) => {
840
+ calls.created = input;
841
+ return {
842
+ id: NEW_AGENT_ID,
843
+ name: input.name,
844
+ slackId: null,
845
+ selfHosted: input.selfHosted ?? false,
846
+ repos: [],
847
+ reviewAuthorAllowlist: [],
848
+ patchAuthorAllowlist: [],
849
+ restrictSlackToMembers: false,
850
+ typeName: TYPE,
851
+ createdAt: new Date("2024-01-01"),
852
+ updatedAt: new Date("2024-01-01"),
853
+ missingRequiredEnv: [],
854
+ };
855
+ },
856
+ delete: async (id: string) => {
857
+ calls.deleted.push(id);
858
+ if (opts?.deleteError) throw opts.deleteError;
859
+ },
860
+ },
861
+ agentEnvService: {
862
+ ...base.agentEnvService,
863
+ patch: async (
864
+ agentId: string,
865
+ env: Record<string, string>,
866
+ secretKeys?: Set<string>,
867
+ ) => {
868
+ calls.envPatches.push({ agentId, env, secretKeys });
869
+ },
870
+ },
871
+ agentCronJobService: {
872
+ ...base.agentCronJobService,
873
+ reconcileSystemCrons: async (agentId: string) => {
874
+ calls.reconciled.push(agentId);
875
+ if (opts?.reconcileError) throw opts.reconcileError;
876
+ return { created: 3, updated: 0, deleted: 0 };
877
+ },
878
+ },
879
+ provisioner: {
880
+ ...base.provisioner,
881
+ canProvision: opts?.canProvision ?? true,
882
+ provision: async (id: string, o?: { slug?: string }) => {
883
+ calls.provisioned.push({ id, slug: o?.slug });
884
+ if (opts?.provisionError) throw opts.provisionError;
885
+ return {
886
+ resourceName: "r",
887
+ secretName: "s",
888
+ deploymentName: "d",
889
+ };
890
+ },
891
+ },
892
+ });
893
+ return { deps, calls };
894
+ }
895
+
896
+ async function postAgent(
897
+ deps: AdminUIDeps,
898
+ fields: Record<string, string>,
899
+ ): Promise<Response> {
900
+ return createAdminUIApp(deps).request("/admin/agents", {
901
+ method: "POST",
902
+ body: new URLSearchParams(fields).toString(),
903
+ headers: {
904
+ "Content-Type": "application/x-www-form-urlencoded",
905
+ Cookie: `admin_session=${adminCookie}`,
906
+ },
907
+ });
908
+ }
909
+
910
+ it("runtime=in-cluster creates a non-self-hosted agent, provisions it, and seeds system crons", async () => {
911
+ const { deps, calls } = makeProvisioningDeps();
912
+ const res = await postAgent(deps, {
913
+ name: "minikube-agent",
914
+ type: TYPE,
915
+ runtime: "in-cluster",
916
+ });
917
+ expect(res.status).toBe(302);
918
+ expect(res.headers.get("Location")).toBe(`/admin/agents/${NEW_AGENT_ID}`);
919
+ expect(calls.created?.selfHosted).toBe(false);
920
+ expect(calls.provisioned).toEqual([
921
+ { id: NEW_AGENT_ID, slug: "minikube-agent" },
922
+ ]);
923
+ expect(calls.reconciled).toEqual([NEW_AGENT_ID]);
924
+ expect(calls.deleted).toEqual([]);
925
+ });
926
+
927
+ it("runtime=self-hosted keeps the historical behavior — selfHosted:true, provisioner untouched", async () => {
928
+ const { deps, calls } = makeProvisioningDeps();
929
+ const res = await postAgent(deps, {
930
+ name: "docker-agent",
931
+ type: TYPE,
932
+ runtime: "self-hosted",
933
+ });
934
+ expect(res.status).toBe(302);
935
+ expect(calls.created?.selfHosted).toBe(true);
936
+ expect(calls.provisioned).toEqual([]);
937
+ });
938
+
939
+ it("omitted runtime defaults to self-hosted", async () => {
940
+ const { deps, calls } = makeProvisioningDeps();
941
+ await postAgent(deps, { name: "no-runtime-field", type: TYPE });
942
+ expect(calls.created?.selfHosted).toBe(true);
943
+ expect(calls.provisioned).toEqual([]);
944
+ });
945
+
946
+ it("runtime=in-cluster is rejected before any row is created when provisioning is disabled", async () => {
947
+ const { deps, calls } = makeProvisioningDeps({ canProvision: false });
948
+ const res = await postAgent(deps, {
949
+ name: "nope",
950
+ type: TYPE,
951
+ runtime: "in-cluster",
952
+ });
953
+ expect(res.status).toBe(302);
954
+ expect(res.headers.get("Location")).toBe(
955
+ "/admin/agents/new?error=provisioning_disabled",
956
+ );
957
+ expect(calls.created).toBeNull();
958
+ expect(calls.provisioned).toEqual([]);
959
+ });
960
+
961
+ it("provisioning failure rolls the agent row back and redirects with provision_failed", async () => {
962
+ const { deps, calls } = makeProvisioningDeps({
963
+ provisionError: new Error("no cluster"),
964
+ });
965
+ const res = await postAgent(deps, {
966
+ name: "doomed",
967
+ type: TYPE,
968
+ runtime: "in-cluster",
969
+ });
970
+ expect(res.status).toBe(302);
971
+ expect(res.headers.get("Location")).toBe(
972
+ "/admin/agents/new?error=provision_failed",
973
+ );
974
+ expect(calls.deleted).toEqual([NEW_AGENT_ID]);
975
+ expect(calls.reconciled).toEqual([]);
976
+ });
977
+
978
+ it("provisioning failure still redirects with provision_failed even when the rollback delete itself fails", async () => {
979
+ const { deps, calls } = makeProvisioningDeps({
980
+ provisionError: new Error("no cluster"),
981
+ deleteError: new Error("db unreachable"),
982
+ });
983
+ const res = await postAgent(deps, {
984
+ name: "doubly-doomed",
985
+ type: TYPE,
986
+ runtime: "in-cluster",
987
+ });
988
+ expect(res.status).toBe(302);
989
+ expect(res.headers.get("Location")).toBe(
990
+ "/admin/agents/new?error=provision_failed",
991
+ );
992
+ // The rollback was attempted (and failed) but the create flow still
993
+ // reports provision_failed rather than throwing — the cleanup error is
994
+ // swallowed (logged), not propagated.
995
+ expect(calls.deleted).toEqual([NEW_AGENT_ID]);
996
+ expect(calls.reconciled).toEqual([]);
997
+ });
998
+
999
+ it("a cron-seeding failure is non-fatal — the agent survives and the redirect succeeds", async () => {
1000
+ const { deps, calls } = makeProvisioningDeps({
1001
+ reconcileError: new Error("db down"),
1002
+ });
1003
+ const res = await postAgent(deps, {
1004
+ name: "crons-broke",
1005
+ type: TYPE,
1006
+ runtime: "in-cluster",
1007
+ });
1008
+ expect(res.status).toBe(302);
1009
+ expect(res.headers.get("Location")).toBe(`/admin/agents/${NEW_AGENT_ID}`);
1010
+ expect(calls.deleted).toEqual([]);
1011
+ });
1012
+
1013
+ it("a supplied Claude token is stored as a secret env var before provisioning", async () => {
1014
+ const { deps, calls } = makeProvisioningDeps();
1015
+ await postAgent(deps, {
1016
+ name: "with-creds",
1017
+ type: TYPE,
1018
+ runtime: "in-cluster",
1019
+ claudeCodeOauthToken: "sk-ant-oat01-example",
1020
+ });
1021
+ expect(calls.envPatches).toHaveLength(1);
1022
+ const patch = calls.envPatches[0];
1023
+ expect(patch?.agentId).toBe(NEW_AGENT_ID);
1024
+ expect(patch?.env.CLAUDE_CODE_OAUTH_TOKEN).toBe("sk-ant-oat01-example");
1025
+ expect(patch?.secretKeys?.has("CLAUDE_CODE_OAUTH_TOKEN")).toBe(true);
1026
+ });
1027
+
1028
+ it("no Claude token supplied → only the non-secret default env is patched", async () => {
1029
+ const { deps, calls } = makeProvisioningDeps();
1030
+ await postAgent(deps, {
1031
+ name: "no-creds",
1032
+ type: TYPE,
1033
+ runtime: "in-cluster",
1034
+ });
1035
+ expectOnlyDefaultEnv(calls.envPatches);
1036
+ });
1037
+
1038
+ // ── UAP-2.1: unified create page — Anthropic key, Connect Slack, GitHub auth ──
1039
+
1040
+ it("a supplied Anthropic API key is stored as a secret env var alongside the Claude token in a single patch", async () => {
1041
+ const { deps, calls } = makeProvisioningDeps();
1042
+ await postAgent(deps, {
1043
+ name: "with-anthropic-key",
1044
+ type: TYPE,
1045
+ claudeCodeOauthToken: "sk-ant-oat01-example",
1046
+ anthropicApiKey: "sk-ant-api03-example",
1047
+ });
1048
+ expect(calls.envPatches).toHaveLength(1);
1049
+ const patch = calls.envPatches[0];
1050
+ expect(patch?.agentId).toBe(NEW_AGENT_ID);
1051
+ expect(patch?.env.CLAUDE_CODE_OAUTH_TOKEN).toBe("sk-ant-oat01-example");
1052
+ expect(patch?.env.ANTHROPIC_API_KEY).toBe("sk-ant-api03-example");
1053
+ expect(patch?.secretKeys?.has("ANTHROPIC_API_KEY")).toBe(true);
1054
+ });
1055
+
1056
+ it("an Anthropic API key alone (no Claude token) is still patched", async () => {
1057
+ const { deps, calls } = makeProvisioningDeps();
1058
+ await postAgent(deps, {
1059
+ name: "anthropic-only",
1060
+ type: TYPE,
1061
+ anthropicApiKey: "sk-ant-api03-only",
1062
+ });
1063
+ expect(calls.envPatches).toHaveLength(1);
1064
+ expect(calls.envPatches[0]?.env.ANTHROPIC_API_KEY).toBe(
1065
+ "sk-ant-api03-only",
1066
+ );
1067
+ });
1068
+
1069
+ it("all-skipped baseline — no Slack/GitHub/Anthropic fields — redirects to detail page exactly as before (regression guard)", async () => {
1070
+ const { deps, calls } = makeProvisioningDeps();
1071
+ const res = await postAgent(deps, {
1072
+ name: "plain-agent",
1073
+ type: TYPE,
1074
+ });
1075
+ expect(res.status).toBe(302);
1076
+ expect(res.headers.get("Location")).toBe(`/admin/agents/${NEW_AGENT_ID}`);
1077
+ expectOnlyDefaultEnv(calls.envPatches);
1078
+ });
1079
+
1080
+ it("Connect Slack checked redirects into the Slack OAuth URL for the newly created agent", async () => {
1081
+ const base = makeMockDeps();
1082
+ const deps = makeMockDeps({
1083
+ agentService: {
1084
+ ...base.agentService,
1085
+ create: async (input: { name: string; selfHosted?: boolean }) => ({
1086
+ id: NEW_AGENT_ID,
1087
+ name: input.name,
1088
+ slackId: null,
1089
+ selfHosted: input.selfHosted ?? false,
1090
+ repos: [],
1091
+ reviewAuthorAllowlist: [],
1092
+ patchAuthorAllowlist: [],
1093
+ restrictSlackToMembers: false,
1094
+ typeName: TYPE,
1095
+ createdAt: new Date("2024-01-01"),
1096
+ updatedAt: new Date("2024-01-01"),
1097
+ missingRequiredEnv: [],
1098
+ }),
1099
+ },
1100
+ provisioner: { ...base.provisioner, canProvision: true },
1101
+ });
1102
+ const res = await postAgent(deps, {
1103
+ name: "slack-agent",
1104
+ type: TYPE,
1105
+ runtime: "in-cluster",
1106
+ connectSlack: "true",
1107
+ xoxpToken: "xoxe.xoxp-test-token",
1108
+ });
1109
+ expect(res.status).toBe(302);
1110
+ const location = res.headers.get("Location") ?? "";
1111
+ expect(location).toContain("slack.com/oauth/authorize");
1112
+ // Cookie carrying provision state must be set for the OAuth callback.
1113
+ expect(res.headers.get("Set-Cookie") ?? "").toContain(
1114
+ "slack_provision_state=",
1115
+ );
1116
+ });
1117
+
1118
+ it("Connect Slack with a bad/missing token redirects to the agent detail page with an error, not the New Agent form", async () => {
1119
+ const base = makeMockDeps();
1120
+ const deps = makeMockDeps({
1121
+ provisioner: { ...base.provisioner, canProvision: true },
1122
+ });
1123
+ const res = await postAgent(deps, {
1124
+ name: "slack-bad-token",
1125
+ type: TYPE,
1126
+ runtime: "in-cluster",
1127
+ connectSlack: "true",
1128
+ xoxpToken: "not-a-valid-token",
1129
+ });
1130
+ expect(res.status).toBe(302);
1131
+ const location = res.headers.get("Location") ?? "";
1132
+ expect(location).toStartWith(`/admin/agents/${NEW_AGENT_ID}?error=`);
1133
+ });
1134
+
1135
+ it("GitHub App requested renders the manifest auto-submit redirect flow for the newly created agent", async () => {
1136
+ const base = makeMockDeps();
1137
+ const deps = makeMockDeps({
1138
+ provisioner: { ...base.provisioner, canProvision: true },
1139
+ });
1140
+ const res = await postAgent(deps, {
1141
+ name: "gh-app-agent",
1142
+ type: TYPE,
1143
+ runtime: "in-cluster",
1144
+ ghAuthMode: "app",
1145
+ githubOrg: "my-org",
1146
+ });
1147
+ expect(res.status).toBe(200);
1148
+ const html = await res.text();
1149
+ expect(html).toContain("github.com/organizations/my-org/settings/apps/new");
1150
+ expect(res.headers.get("Set-Cookie") ?? "").toContain(
1151
+ "github_provision_state=",
1152
+ );
1153
+ });
1154
+
1155
+ it("GitHub App with an invalid org name redirects to the agent detail page with an error — agent is NOT rolled back", async () => {
1156
+ const { deps, calls } = makeProvisioningDeps();
1157
+ const res = await postAgent(deps, {
1158
+ name: "gh-app-bad-org",
1159
+ type: TYPE,
1160
+ runtime: "in-cluster",
1161
+ ghAuthMode: "app",
1162
+ githubOrg: "not a valid org!!",
1163
+ });
1164
+ expect(res.status).toBe(302);
1165
+ const location = res.headers.get("Location") ?? "";
1166
+ expect(location).toStartWith(`/admin/agents/${NEW_AGENT_ID}?error=`);
1167
+ expect(calls.deleted).toEqual([]);
1168
+ });
1169
+
1170
+ it("GitHub PAT provided stores GH_TOKEN directly and continues to the detail page", async () => {
1171
+ const { deps, calls } = makeProvisioningDeps();
1172
+ const res = await postAgent(deps, {
1173
+ name: "gh-pat-agent",
1174
+ type: TYPE,
1175
+ runtime: "in-cluster",
1176
+ ghAuthMode: "pat",
1177
+ ghPat: "ghp_exampletoken",
1178
+ });
1179
+ expect(res.status).toBe(302);
1180
+ expect(res.headers.get("Location")).toBe(`/admin/agents/${NEW_AGENT_ID}`);
1181
+ const patch = calls.envPatches.find((p) => p.env.GH_TOKEN);
1182
+ expect(patch).toBeDefined();
1183
+ expect(patch?.env.GH_TOKEN).toBe("ghp_exampletoken");
1184
+ expect(patch?.secretKeys?.has("GH_TOKEN")).toBe(true);
1185
+ });
1186
+
1187
+ it("GitHub PAT mode with no token supplied redirects to the detail page with an error — agent is NOT rolled back", async () => {
1188
+ const { deps, calls } = makeProvisioningDeps();
1189
+ const res = await postAgent(deps, {
1190
+ name: "gh-pat-missing",
1191
+ type: TYPE,
1192
+ runtime: "in-cluster",
1193
+ ghAuthMode: "pat",
1194
+ });
1195
+ expect(res.status).toBe(302);
1196
+ const location = res.headers.get("Location") ?? "";
1197
+ expect(location).toStartWith(`/admin/agents/${NEW_AGENT_ID}?error=`);
1198
+ expect(calls.deleted).toEqual([]);
1199
+ });
1200
+
1201
+ // ── UAP-5.3: ghAuthMode=app + ghAppMode=manual ("Use existing GitHub App") ──
1202
+
1203
+ it("ghAuthMode=app&ghAppMode=manual with a valid App ID/Installation ID/uploaded PEM file persists GH_APP_ID/GH_APP_INSTALLATION_ID/GH_APP_PRIVATE_KEY and redirects to the agent detail page", async () => {
1204
+ const { deps, calls } = makeProvisioningDeps();
1205
+ const pemContents =
1206
+ "-----BEGIN RSA PRIVATE KEY-----\nfaketestkeydata\n-----END RSA PRIVATE KEY-----";
1207
+ const form = new FormData();
1208
+ form.append("name", "gh-app-manual-agent");
1209
+ form.append("type", TYPE);
1210
+ form.append("runtime", "in-cluster");
1211
+ form.append("ghAuthMode", "app");
1212
+ form.append("ghAppMode", "manual");
1213
+ form.append("ghAppId", "12345");
1214
+ form.append("ghAppInstallationId", "67890");
1215
+ form.append(
1216
+ "ghAppPrivateKeyFile",
1217
+ new File([pemContents], "private-key.pem", {
1218
+ type: "application/x-pem-file",
1219
+ }),
1220
+ );
1221
+
1222
+ const res = await createAdminUIApp(deps).request("/admin/agents", {
1223
+ method: "POST",
1224
+ body: form,
1225
+ headers: { Cookie: `admin_session=${adminCookie}` },
1226
+ });
1227
+
1228
+ expect(res.status).toBe(302);
1229
+ expect(res.headers.get("Location")).toBe(`/admin/agents/${NEW_AGENT_ID}`);
1230
+ const ghAppIdPatch = calls.envPatches.find((p) => p.env.GH_APP_ID);
1231
+ expect(ghAppIdPatch?.env.GH_APP_ID).toBe("12345");
1232
+ const ghAppInstallationIdPatch = calls.envPatches.find(
1233
+ (p) => p.env.GH_APP_INSTALLATION_ID,
1234
+ );
1235
+ expect(ghAppInstallationIdPatch?.env.GH_APP_INSTALLATION_ID).toBe("67890");
1236
+ const ghAppPrivateKeyPatch = calls.envPatches.find(
1237
+ (p) => p.env.GH_APP_PRIVATE_KEY,
1238
+ );
1239
+ expect(ghAppPrivateKeyPatch?.env.GH_APP_PRIVATE_KEY).toBe(pemContents);
1240
+ });
1241
+
1242
+ it("ghAuthMode=app&ghAppMode=manual with a missing/invalid App ID redirects to the detail page with an error — agent is NOT rolled back", async () => {
1243
+ const { deps, calls } = makeProvisioningDeps();
1244
+ const form = new FormData();
1245
+ form.append("name", "gh-app-manual-bad-id");
1246
+ form.append("type", TYPE);
1247
+ form.append("runtime", "in-cluster");
1248
+ form.append("ghAuthMode", "app");
1249
+ form.append("ghAppMode", "manual");
1250
+ form.append("ghAppInstallationId", "67890");
1251
+ form.append(
1252
+ "ghAppPrivateKeyFile",
1253
+ new File(
1254
+ ["-----BEGIN RSA PRIVATE KEY-----\nx\n-----END RSA PRIVATE KEY-----"],
1255
+ "private-key.pem",
1256
+ ),
1257
+ );
1258
+
1259
+ const res = await createAdminUIApp(deps).request("/admin/agents", {
1260
+ method: "POST",
1261
+ body: form,
1262
+ headers: { Cookie: `admin_session=${adminCookie}` },
1263
+ });
1264
+
1265
+ expect(res.status).toBe(302);
1266
+ const location = res.headers.get("Location") ?? "";
1267
+ expect(location).toStartWith(`/admin/agents/${NEW_AGENT_ID}?error=`);
1268
+ expect(calls.deleted).toEqual([]);
1269
+ expect(calls.envPatches.find((p) => p.env.GH_APP_ID)).toBeUndefined();
1270
+ });
1271
+
1272
+ it("ghAuthMode=app with ghAppMode omitted still runs the auto/manifest flow — no regression to the default", async () => {
1273
+ const base = makeMockDeps();
1274
+ const deps = makeMockDeps({
1275
+ provisioner: { ...base.provisioner, canProvision: true },
1276
+ });
1277
+ const res = await postAgent(deps, {
1278
+ name: "gh-app-default-auto",
1279
+ type: TYPE,
1280
+ runtime: "in-cluster",
1281
+ ghAuthMode: "app",
1282
+ githubOrg: "my-org",
1283
+ });
1284
+ expect(res.status).toBe(200);
1285
+ const html = await res.text();
1286
+ expect(html).toContain("github.com/organizations/my-org/settings/apps/new");
1287
+ });
1288
+
1289
+ it("ghAuthMode=skip (or omitted) with connectSlack unchecked behaves exactly like today — plain redirect to detail page", async () => {
1290
+ const { deps, calls } = makeProvisioningDeps();
1291
+ const res = await postAgent(deps, {
1292
+ name: "gh-skip-agent",
1293
+ type: TYPE,
1294
+ runtime: "in-cluster",
1295
+ ghAuthMode: "skip",
1296
+ });
1297
+ expect(res.status).toBe(302);
1298
+ expect(res.headers.get("Location")).toBe(`/admin/agents/${NEW_AGENT_ID}`);
1299
+ expectOnlyDefaultEnv(calls.envPatches);
1300
+ });
1301
+
1302
+ it("both Connect Slack and GitHub PAT requested — GitHub PAT is stored first, then Slack's OAuth redirect is returned", async () => {
1303
+ const { deps, calls } = makeProvisioningDeps();
1304
+ const res = await postAgent(deps, {
1305
+ name: "slack-and-gh-pat",
1306
+ type: TYPE,
1307
+ runtime: "in-cluster",
1308
+ connectSlack: "true",
1309
+ xoxpToken: "xoxe.xoxp-test-token",
1310
+ ghAuthMode: "pat",
1311
+ ghPat: "ghp_bothflow",
1312
+ });
1313
+ expect(res.status).toBe(302);
1314
+ const location = res.headers.get("Location") ?? "";
1315
+ expect(location).toContain("slack.com/oauth/authorize");
1316
+ const patch = calls.envPatches.find((p) => p.env.GH_TOKEN);
1317
+ expect(patch?.env.GH_TOKEN).toBe("ghp_bothflow");
1318
+ });
1319
+
1320
+ it("both Connect Slack and GitHub PAT requested — GitHub PAT storage fails while Slack succeeds: Slack redirect is preserved and the failure is carried through the signed provision-state cookie", async () => {
1321
+ const { deps, calls } = makeProvisioningDeps();
1322
+ // Omitting ghPat makes startPatConnect() return ok:false
1323
+ // ("GitHub Personal Access Token is required.") — the same failure path a
1324
+ // rejected PAT storage would take. connectSlack=true means the handler
1325
+ // must NOT early-redirect on that failure; it continues into Slack's OAuth
1326
+ // flow, but the GitHub failure must be surfaced rather than swallowed.
1327
+ const res = await postAgent(deps, {
1328
+ name: "slack-ok-gh-pat-fail",
1329
+ type: TYPE,
1330
+ runtime: "in-cluster",
1331
+ connectSlack: "true",
1332
+ xoxpToken: "xoxe.xoxp-test-token",
1333
+ ghAuthMode: "pat",
1334
+ // no ghPat → startPatConnect fails
1335
+ });
1336
+
1337
+ // Existing behavior preserved: still redirects into Slack's OAuth flow.
1338
+ expect(res.status).toBe(302);
1339
+ const location = res.headers.get("Location") ?? "";
1340
+ expect(location).toContain("slack.com/oauth/authorize");
1341
+
1342
+ // GH_TOKEN was never stored.
1343
+ expect(calls.envPatches.find((p) => p.env.GH_TOKEN)).toBeUndefined();
1344
+
1345
+ // The failure is carried through Slack's OAuth round trip via the signed
1346
+ // provision-state cookie so the post-callback landing can surface it —
1347
+ // rather than being silently console.error'd and swallowed.
1348
+ const setCookieHeader = res.headers.get("Set-Cookie") ?? "";
1349
+ expect(setCookieHeader).toContain("slack_provision_state=");
1350
+ const match = setCookieHeader.match(/slack_provision_state=([^;]+)/);
1351
+ expect(match).toBeTruthy();
1352
+ const jwtToken = decodeURIComponent(match?.[1] ?? "");
1353
+ const parts = jwtToken.split(".");
1354
+ expect(parts).toHaveLength(3);
1355
+ const payload = JSON.parse(
1356
+ Buffer.from(parts[1] ?? "", "base64url").toString("utf-8"),
1357
+ );
1358
+ expect(payload.ghConnectError).toBe(
1359
+ "GitHub Personal Access Token is required.",
1360
+ );
1361
+ });
1362
+
1363
+ // ── UAP-5.1: runtime=self-hosted must never trigger Slack/GitHub provisioning ──
1364
+
1365
+ it("runtime=self-hosted with connectSlack=true performs no Slack provisioning — plain redirect, no OAuth redirect, no env writes", async () => {
1366
+ const { deps, calls } = makeProvisioningDeps();
1367
+ const res = await postAgent(deps, {
1368
+ name: "self-hosted-slack",
1369
+ type: TYPE,
1370
+ runtime: "self-hosted",
1371
+ connectSlack: "true",
1372
+ xoxpToken: "xoxe.xoxp-test-token",
1373
+ });
1374
+ expect(res.status).toBe(302);
1375
+ expect(res.headers.get("Location")).toBe(`/admin/agents/${NEW_AGENT_ID}`);
1376
+ const location = res.headers.get("Location") ?? "";
1377
+ expect(location).not.toContain("slack.com/oauth/authorize");
1378
+ expect(res.headers.get("Set-Cookie") ?? "").not.toContain(
1379
+ "slack_provision_state=",
1380
+ );
1381
+ expectOnlyDefaultEnv(calls.envPatches);
1382
+ expect(calls.provisioned).toEqual([]);
1383
+ });
1384
+
1385
+ it("runtime=self-hosted with ghAuthMode=app performs no GitHub App provisioning — plain redirect, no manifest page, no cookie", async () => {
1386
+ const { deps, calls } = makeProvisioningDeps();
1387
+ const res = await postAgent(deps, {
1388
+ name: "self-hosted-gh-app",
1389
+ type: TYPE,
1390
+ runtime: "self-hosted",
1391
+ ghAuthMode: "app",
1392
+ githubOrg: "my-org",
1393
+ });
1394
+ expect(res.status).toBe(302);
1395
+ expect(res.headers.get("Location")).toBe(`/admin/agents/${NEW_AGENT_ID}`);
1396
+ expect(res.headers.get("Set-Cookie") ?? "").not.toContain(
1397
+ "github_provision_state=",
1398
+ );
1399
+ expectOnlyDefaultEnv(calls.envPatches);
1400
+ });
1401
+
1402
+ it("runtime=self-hosted with ghAuthMode=pat performs no GH_TOKEN write", async () => {
1403
+ const { deps, calls } = makeProvisioningDeps();
1404
+ const res = await postAgent(deps, {
1405
+ name: "self-hosted-gh-pat",
1406
+ type: TYPE,
1407
+ runtime: "self-hosted",
1408
+ ghAuthMode: "pat",
1409
+ ghPat: "ghp_shouldnotbestored",
1410
+ });
1411
+ expect(res.status).toBe(302);
1412
+ expect(res.headers.get("Location")).toBe(`/admin/agents/${NEW_AGENT_ID}`);
1413
+ expect(calls.envPatches.find((p) => p.env.GH_TOKEN)).toBeUndefined();
1414
+ expectOnlyDefaultEnv(calls.envPatches);
1415
+ });
1416
+
1417
+ it("runtime=self-hosted with both connectSlack=true and ghAuthMode=app set (raw POST bypassing the UI) performs no provisioning of either kind", async () => {
1418
+ const { deps, calls } = makeProvisioningDeps();
1419
+ const res = await postAgent(deps, {
1420
+ name: "self-hosted-both",
1421
+ type: TYPE,
1422
+ runtime: "self-hosted",
1423
+ connectSlack: "true",
1424
+ xoxpToken: "xoxe.xoxp-test-token",
1425
+ ghAuthMode: "app",
1426
+ githubOrg: "my-org",
1427
+ });
1428
+ expect(res.status).toBe(302);
1429
+ expect(res.headers.get("Location")).toBe(`/admin/agents/${NEW_AGENT_ID}`);
1430
+ const location = res.headers.get("Location") ?? "";
1431
+ expect(location).not.toContain("slack.com/oauth/authorize");
1432
+ expect(res.headers.get("Set-Cookie") ?? "").not.toContain(
1433
+ "slack_provision_state=",
1434
+ );
1435
+ expect(res.headers.get("Set-Cookie") ?? "").not.toContain(
1436
+ "github_provision_state=",
1437
+ );
1438
+ expectOnlyDefaultEnv(calls.envPatches);
1439
+ expect(calls.created?.selfHosted).toBe(true);
1440
+ expect(calls.provisioned).toEqual([]);
1441
+ });
1442
+ });
1443
+
1444
+ // ─── SSP-4.2: account users create and delete their own agents ──────────────
1445
+
1446
+ describe("admin UI — account users create / delete agents (SSP-4.2)", () => {
1447
+ const ACCOUNT_AGENT_ID = "agent-in-acct-a";
1448
+ let accountCookie: string;
1449
+
1450
+ beforeAll(async () => {
1451
+ accountCookie = await makeSessionCookie(false, "owner@acct-a.example.com");
1452
+ });
1453
+
1454
+ const scoped = (accountId: string | null) =>
1455
+ (async () => ({
1456
+ kind: "scoped" as const,
1457
+ accountId,
1458
+ agentIds: accountId ? [ACCOUNT_AGENT_ID] : [],
1459
+ })) satisfies NonNullable<AdminUIDeps["callerScopeResolver"]>;
1460
+
1461
+ function accountServiceStub(
1462
+ over: { maxAgents?: number; count?: number } = {},
1463
+ ): NonNullable<AdminUIDeps["accountService"]> {
1464
+ return {
1465
+ getById: async (id: string) =>
1466
+ ({
1467
+ id,
1468
+ maxAgents: over.maxAgents ?? 3,
1469
+ status: "active",
1470
+ }) as never,
1471
+ countAgents: async () => over.count ?? 1,
1472
+ listAgentIds: async (accountId: string) =>
1473
+ accountId === "acct-a" ? [ACCOUNT_AGENT_ID] : ["someone-elses-agent"],
1474
+ };
1475
+ }
1476
+
1477
+ function depsCapturingCreate(accountId: string | null) {
1478
+ const created: Array<Record<string, unknown>> = [];
1479
+ const base = makeMockDeps();
1480
+ const deps = makeMockDeps({
1481
+ callerScopeResolver: scoped(accountId),
1482
+ accountService: accountServiceStub(),
1483
+ agentService: {
1484
+ ...base.agentService,
1485
+ create: async (input) => {
1486
+ created.push(input as unknown as Record<string, unknown>);
1487
+ return base.agentService.create(input);
1488
+ },
1489
+ },
1490
+ });
1491
+ return { deps, created };
1492
+ }
1493
+
1494
+ const form = (cookie: string, fields: Record<string, string>) => ({
1495
+ method: "POST",
1496
+ body: new URLSearchParams(fields).toString(),
1497
+ headers: {
1498
+ "Content-Type": "application/x-www-form-urlencoded",
1499
+ Cookie: `admin_session=${cookie}`,
1500
+ },
1501
+ });
1502
+
1503
+ it("GET /admin/agents/new — account user sees remaining quota and prerequisites", async () => {
1504
+ const { deps } = depsCapturingCreate("acct-a");
1505
+ const res = await createAdminUIApp(deps).request("/admin/agents/new", {
1506
+ headers: { Cookie: `admin_session=${accountCookie}` },
1507
+ });
1508
+ expect(res.status).toBe(200);
1509
+ const html = await res.text();
1510
+ expect(html).toContain("2 of 3");
1511
+ expect(html).toContain("Slack App Configuration Token");
1512
+ });
1513
+
1514
+ it("GET /admin/agents/new — non-admin without an account gets 403", async () => {
1515
+ const { deps } = depsCapturingCreate(null);
1516
+ const res = await createAdminUIApp(deps).request("/admin/agents/new", {
1517
+ headers: { Cookie: `admin_session=${accountCookie}` },
1518
+ });
1519
+ expect(res.status).toBe(403);
1520
+ });
1521
+
1522
+ it("POST /admin/agents — account user's agent is stamped with their account and forced to coding", async () => {
1523
+ const { deps, created } = depsCapturingCreate("acct-a");
1524
+ const res = await createAdminUIApp(deps).request(
1525
+ "/admin/agents",
1526
+ form(accountCookie, {
1527
+ name: "mine",
1528
+ type: "other",
1529
+ accountId: "acct-b",
1530
+ }),
1531
+ );
1532
+ expect(res.status).toBe(302);
1533
+ expect(created).toHaveLength(1);
1534
+ expect(created[0]).toMatchObject({
1535
+ accountId: "acct-a",
1536
+ typeName: TYPE,
1537
+ });
1538
+ });
1539
+
1540
+ it("POST /admin/agents — non-admin without an account gets 403", async () => {
1541
+ const { deps, created } = depsCapturingCreate(null);
1542
+ const res = await createAdminUIApp(deps).request(
1543
+ "/admin/agents",
1544
+ form(accountCookie, { name: "nope", type: TYPE }),
1545
+ );
1546
+ expect(res.status).toBe(403);
1547
+ expect(created).toHaveLength(0);
1548
+ });
1549
+
1550
+ it("POST /admin/agents — quota_exceeded redirects back to the form with a clear message", async () => {
1551
+ const { deps } = depsCapturingCreate("acct-a");
1552
+ const base = deps.agentService;
1553
+ const res = await createAdminUIApp({
1554
+ ...deps,
1555
+ agentService: {
1556
+ ...base,
1557
+ runTransaction: async () =>
1558
+ ({ ok: false, errorCode: "quota_exceeded" }) as never,
1559
+ },
1560
+ }).request("/admin/agents", form(accountCookie, { name: "x", type: TYPE }));
1561
+ expect(res.status).toBe(302);
1562
+ expect(res.headers.get("Location")).toBe(
1563
+ `/admin/agents/new?error=${encodeURIComponent("quota_exceeded")}`,
1564
+ );
1565
+ });
1566
+
1567
+ function deleteDeps(ownerAccount: string) {
1568
+ const deleted: string[] = [];
1569
+ const base = makeMockDeps();
1570
+ const deps = makeMockDeps({
1571
+ // SSP-4.3: delete is gated by agent access + self-serve on, so the
1572
+ // caller's scope only includes the agent when its account owns it.
1573
+ selfServe: { enabled: true, defaultMaxAgents: 2, contactEmail: "x@y.z" },
1574
+ callerScopeResolver: (async () => ({
1575
+ kind: "scoped" as const,
1576
+ accountId: "acct-a",
1577
+ agentIds: ownerAccount === "acct-a" ? [ACCOUNT_AGENT_ID] : [],
1578
+ })) satisfies NonNullable<AdminUIDeps["callerScopeResolver"]>,
1579
+ accountService: {
1580
+ ...accountServiceStub(),
1581
+ listAgentIds: async (id: string) =>
1582
+ id === ownerAccount ? [ACCOUNT_AGENT_ID] : [],
1583
+ },
1584
+ prisma: {
1585
+ ...base.prisma,
1586
+ agent: {
1587
+ ...base.prisma.agent,
1588
+ findUnique: async () =>
1589
+ ({
1590
+ id: ACCOUNT_AGENT_ID,
1591
+ name: "Mine",
1592
+ slackId: null,
1593
+ selfHosted: true,
1594
+ repos: [],
1595
+ createdAt: new Date("2024-01-01"),
1596
+ updatedAt: new Date("2024-01-01"),
1597
+ }) as never,
1598
+ delete: async ({ where }: { where: { id: string } }) => {
1599
+ deleted.push(where.id);
1600
+ return {} as never;
1601
+ },
1602
+ },
1603
+ agentEnv: { findMany: async () => [] },
1604
+ agentPlugin: { findMany: async () => [] },
1605
+ } as AdminUIDeps["prisma"],
1606
+ });
1607
+ return { deps, deleted };
1608
+ }
1609
+
1610
+ it("POST /admin/agents/:id/delete — account user deletes their own account's agent", async () => {
1611
+ const { deps, deleted } = deleteDeps("acct-a");
1612
+ const res = await createAdminUIApp(deps).request(
1613
+ `/admin/agents/${ACCOUNT_AGENT_ID}/delete`,
1614
+ { method: "POST", headers: { Cookie: `admin_session=${accountCookie}` } },
1615
+ );
1616
+ expect(res.status).toBe(302);
1617
+ expect(res.headers.get("Location")).toBe("/admin/agents?success=deleted");
1618
+ expect(deleted).toEqual([ACCOUNT_AGENT_ID]);
1619
+ });
1620
+
1621
+ it("POST /admin/agents/:id/delete — another account's agent is 403 and untouched", async () => {
1622
+ const { deps, deleted } = deleteDeps("acct-b");
1623
+ const res = await createAdminUIApp(deps).request(
1624
+ `/admin/agents/${ACCOUNT_AGENT_ID}/delete`,
1625
+ { method: "POST", headers: { Cookie: `admin_session=${accountCookie}` } },
1626
+ );
1627
+ expect(res.status).toBe(403);
1628
+ expect(deleted).toEqual([]);
1629
+ });
1630
+
1631
+ it("POST /admin/agents/:id/delete — non-admin without an account still gets 403", async () => {
1632
+ const { deps, deleted } = deleteDeps("acct-a");
1633
+ const res = await createAdminUIApp({
1634
+ ...deps,
1635
+ callerScopeResolver: scoped(null),
1636
+ }).request(`/admin/agents/${ACCOUNT_AGENT_ID}/delete`, {
1637
+ method: "POST",
1638
+ headers: { Cookie: `admin_session=${accountCookie}` },
1639
+ });
1640
+ expect(res.status).toBe(403);
1641
+ expect(deleted).toEqual([]);
1642
+ });
1643
+ });