@app-vitals/agent-admin 0.0.0-stage → 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 +64 -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,949 @@
1
+ /**
2
+ * admin/src/agent-provisioner-per-agent.integration.test.ts
3
+ * Integration tests for KubernetesAgentProvisioner in perAgent identity mode:
4
+ * PVC → Kubernetes ServiceAccount → Anthropic service account (+ workspace) →
5
+ * federation rule → Secret → Deployment, with each ID saved as soon as it
6
+ * exists, idempotent retries and best-effort rollback.
7
+ *
8
+ * Doubles are injected via the constructor — RecordedKubernetesClient (wrapped
9
+ * to fail on demand), FakeAnthropicIdentityClient and an in-memory identity
10
+ * store — so no DB, cluster or network is needed and no globals are touched.
11
+ */
12
+
13
+ import { beforeEach, describe, expect, it } from "bun:test";
14
+ import type { AgentAnthropicIdentity } from "./agent-anthropic-identity.ts";
15
+ import {
16
+ buildAgentDeploymentManifest,
17
+ sanitizeAgentName,
18
+ } from "./agent-manifest.ts";
19
+ import {
20
+ KubernetesAgentProvisioner,
21
+ type KubernetesAgentProvisionerConfig,
22
+ } from "./agent-provisioner.ts";
23
+ import type { AgentTokenService } from "./agent-tokens.ts";
24
+ import type { KubernetesClient } from "./kubernetes-client.ts";
25
+ import {
26
+ type KubernetesDeployment,
27
+ RecordedKubernetesClient,
28
+ } from "./kubernetes-client.ts";
29
+ import {
30
+ FakeAnthropicIdentityClient,
31
+ faultyKubernetesClient,
32
+ InMemoryAgentIdentityStore,
33
+ } from "./per-agent-identity.fixtures.ts";
34
+
35
+ const NAMESPACE = "agents";
36
+ const AGENT_ID = "cmqalfjcm000m4101iharq28k";
37
+ const RESOURCE = sanitizeAgentName(AGENT_ID);
38
+ const OWNER = "jane.doe@example.com";
39
+ const IDENTITY_NAME = "jane-doe-a1";
40
+ const SUBJECT = `system:serviceaccount:${NAMESPACE}:${RESOURCE}`;
41
+
42
+ const BASE_CONFIG: KubernetesAgentProvisionerConfig = {
43
+ namespace: NAMESPACE,
44
+ image: "ghcr.io/example/shipwright-agent",
45
+ imageTag: "v1.0.0",
46
+ apiUrl: "http://shipwright-admin.shipwright.svc:3001",
47
+ };
48
+
49
+ function stubTokens(): AgentTokenService {
50
+ return {
51
+ create: async (agentId: string) => ({
52
+ id: `tok-${agentId}`,
53
+ rawToken: `raw-${agentId}`,
54
+ }),
55
+ } as unknown as AgentTokenService;
56
+ }
57
+
58
+ interface Harness {
59
+ events: string[];
60
+ recorded: RecordedKubernetesClient;
61
+ k8s: ReturnType<typeof faultyKubernetesClient>;
62
+ anthropic: FakeAnthropicIdentityClient;
63
+ store: InMemoryAgentIdentityStore;
64
+ provisioner: KubernetesAgentProvisioner;
65
+ }
66
+
67
+ function harness(opts: { workspaceId?: string | null } = {}): Harness {
68
+ const events: string[] = [];
69
+ const recorded = new RecordedKubernetesClient({
70
+ deployments: {},
71
+ secrets: {},
72
+ pvcs: {},
73
+ serviceAccounts: {},
74
+ });
75
+ const k8s = faultyKubernetesClient(recorded, events);
76
+ const anthropic = new FakeAnthropicIdentityClient(events);
77
+ const store = new InMemoryAgentIdentityStore(events);
78
+ const workspaceId =
79
+ opts.workspaceId === undefined ? "wrkspc_1" : opts.workspaceId;
80
+ const provisioner = new KubernetesAgentProvisioner(k8s, stubTokens(), {
81
+ ...BASE_CONFIG,
82
+ perAgentIdentity: {
83
+ client: anthropic,
84
+ identities: store,
85
+ names: store,
86
+ organizationId: "org-1",
87
+ ...(workspaceId ? { workspaceId } : {}),
88
+ issuerId: "fdis_cluster",
89
+ audience: "https://api.anthropic.com",
90
+ expirationSeconds: 600,
91
+ },
92
+ });
93
+ return { events, recorded, k8s, anthropic, store, provisioner };
94
+ }
95
+
96
+ function envOf(dep: KubernetesDeployment): Record<string, string | undefined> {
97
+ return Object.fromEntries(
98
+ (dep.spec.template.spec.containers[0]?.env ?? []).map((e) => [
99
+ e.name,
100
+ e.value,
101
+ ]),
102
+ );
103
+ }
104
+
105
+ async function exists(p: Promise<unknown>): Promise<boolean> {
106
+ try {
107
+ await p;
108
+ return true;
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ /** The full set of side effects a completed provision leaves behind. */
115
+ async function expectFullyProvisioned(h: Harness): Promise<void> {
116
+ const [sa] = h.anthropic.liveServiceAccounts();
117
+ const [rule] = h.anthropic.liveRules();
118
+ expect(h.anthropic.liveServiceAccounts()).toHaveLength(1);
119
+ expect(h.anthropic.liveRules()).toHaveLength(1);
120
+ expect(sa?.name).toBe(IDENTITY_NAME);
121
+ expect(rule?.name).toBe(IDENTITY_NAME);
122
+ expect(await h.store.get(AGENT_ID)).toEqual({
123
+ ownerEmail: OWNER,
124
+ anthropicIdentityName: IDENTITY_NAME,
125
+ anthropicServiceAccountId: sa?.id ?? "missing",
126
+ anthropicFederationRuleId: rule?.id ?? "missing",
127
+ k8sServiceAccountName: RESOURCE,
128
+ });
129
+ expect(await exists(h.recorded.getServiceAccount(NAMESPACE, RESOURCE))).toBe(
130
+ true,
131
+ );
132
+ const dep = await h.recorded.getDeployment(NAMESPACE, RESOURCE);
133
+ expect(dep.spec.template.spec.serviceAccountName).toBe(RESOURCE);
134
+ const env = envOf(dep);
135
+ expect(env.ANTHROPIC_FEDERATION_RULE_ID).toBe(rule?.id);
136
+ expect(env.ANTHROPIC_SERVICE_ACCOUNT_ID).toBe(sa?.id);
137
+ }
138
+
139
+ /** Nothing but the PVC (and the reserved identity name) survives a rollback. */
140
+ async function expectRolledBack(h: Harness): Promise<void> {
141
+ expect(h.anthropic.liveServiceAccounts()).toHaveLength(0);
142
+ expect(h.anthropic.liveRules()).toHaveLength(0);
143
+ const identity = (await h.store.get(AGENT_ID)) as AgentAnthropicIdentity;
144
+ expect(identity.anthropicServiceAccountId).toBeNull();
145
+ expect(identity.anthropicFederationRuleId).toBeNull();
146
+ expect(identity.k8sServiceAccountName).toBeNull();
147
+ expect(await exists(h.recorded.getServiceAccount(NAMESPACE, RESOURCE))).toBe(
148
+ false,
149
+ );
150
+ expect(
151
+ await exists(h.recorded.getSecret(NAMESPACE, `${RESOURCE}-token`)),
152
+ ).toBe(false);
153
+ expect(await exists(h.recorded.getDeployment(NAMESPACE, RESOURCE))).toBe(
154
+ false,
155
+ );
156
+ // Data safety: the PVC is never rolled back.
157
+ expect(await exists(h.recorded.getPvc(NAMESPACE, `${RESOURCE}-home`))).toBe(
158
+ true,
159
+ );
160
+ }
161
+
162
+ describe("KubernetesAgentProvisioner.provision() — perAgent identity", () => {
163
+ let h: Harness;
164
+
165
+ beforeEach(() => {
166
+ h = harness();
167
+ h.store.addAgent(AGENT_ID, { ownerEmail: OWNER });
168
+ });
169
+
170
+ it("runs PVC → K8s SA → Anthropic SA → workspace → rule → Secret → Deployment, saving each ID right after its create", async () => {
171
+ await h.provisioner.provision(AGENT_ID);
172
+
173
+ expect(h.events).toEqual([
174
+ "k8s.createPvc",
175
+ "k8s.createServiceAccount",
176
+ "store.set(k8sServiceAccountName)",
177
+ "store.allocate",
178
+ "anthropic.createServiceAccount",
179
+ "store.set(anthropicServiceAccountId)",
180
+ "anthropic.addServiceAccountToWorkspace",
181
+ "anthropic.createFederationRule",
182
+ "store.set(anthropicFederationRuleId)",
183
+ "k8s.getSecret",
184
+ "k8s.createSecret",
185
+ "k8s.createDeploymentManifest",
186
+ ]);
187
+ await expectFullyProvisioned(h);
188
+ });
189
+
190
+ it("creates the K8s ServiceAccount with the Deployment's name", async () => {
191
+ await h.provisioner.provision(AGENT_ID);
192
+ const sa = await h.recorded.getServiceAccount(NAMESPACE, RESOURCE);
193
+ expect(sa.metadata.name).toBe(RESOURCE);
194
+ });
195
+
196
+ it("adds the Anthropic service account to the configured workspace", async () => {
197
+ await h.provisioner.provision(AGENT_ID);
198
+ const [sa] = h.anthropic.liveServiceAccounts();
199
+ expect(h.anthropic.callsTo("addServiceAccountToWorkspace")).toEqual([
200
+ { serviceAccountId: sa?.id, workspaceId: "wrkspc_1" },
201
+ ]);
202
+ expect(sa?.workspaces).toEqual(["wrkspc_1"]);
203
+ });
204
+
205
+ it("creates the rule for the exact pod subject, workspace:inference, 600s, on this cluster's issuer", async () => {
206
+ await h.provisioner.provision(AGENT_ID);
207
+ const [sa] = h.anthropic.liveServiceAccounts();
208
+ const [rule] = h.anthropic.liveRules();
209
+ expect(rule?.input).toMatchObject({
210
+ name: IDENTITY_NAME,
211
+ issuerId: "fdis_cluster",
212
+ serviceAccountId: sa?.id,
213
+ workspaceId: "wrkspc_1",
214
+ oauthScope: "workspace:inference",
215
+ match: { subjectPrefix: SUBJECT, audience: "https://api.anthropic.com" },
216
+ tokenLifetimeSeconds: 600,
217
+ });
218
+ // No trailing `*`: the API treats subject_prefix as an exact match then.
219
+ expect(rule?.input.match.subjectPrefix?.endsWith("*")).toBe(false);
220
+ });
221
+
222
+ it("builds the Deployment from this agent's own identity", async () => {
223
+ await h.provisioner.provision(AGENT_ID);
224
+ const [sa] = h.anthropic.liveServiceAccounts();
225
+ const [rule] = h.anthropic.liveRules();
226
+ const dep = await h.recorded.getDeployment(NAMESPACE, RESOURCE);
227
+ expect(dep.spec.template.spec.serviceAccountName).toBe(RESOURCE);
228
+ expect(envOf(dep)).toMatchObject({
229
+ ANTHROPIC_IDENTITY_TOKEN_FILE: "/var/run/secrets/anthropic.com/token",
230
+ ANTHROPIC_FEDERATION_RULE_ID: rule?.id,
231
+ ANTHROPIC_ORGANIZATION_ID: "org-1",
232
+ ANTHROPIC_SERVICE_ACCOUNT_ID: sa?.id,
233
+ ANTHROPIC_WORKSPACE_ID: "wrkspc_1",
234
+ });
235
+ });
236
+
237
+ it("never uses find-by-name on the happy path", async () => {
238
+ await h.provisioner.provision(AGENT_ID);
239
+ expect(h.anthropic.callsTo("findServiceAccountByName")).toHaveLength(0);
240
+ expect(h.anthropic.callsTo("findFederationRuleByName")).toHaveLength(0);
241
+ });
242
+
243
+ it("is a no-op for the identity on a second provision (stored IDs are reused)", async () => {
244
+ await h.provisioner.provision(AGENT_ID);
245
+ await h.provisioner.provision(AGENT_ID);
246
+ expect(h.anthropic.callsTo("createServiceAccount")).toHaveLength(1);
247
+ expect(h.anthropic.callsTo("createFederationRule")).toHaveLength(1);
248
+ await expectFullyProvisioned(h);
249
+ });
250
+
251
+ it("fails with a clear error before creating anything when the agent has no owner", async () => {
252
+ const noOwner = harness();
253
+ noOwner.store.addAgent(AGENT_ID);
254
+ await expect(noOwner.provisioner.provision(AGENT_ID)).rejects.toThrow(
255
+ /no owner/i,
256
+ );
257
+ expect(noOwner.events).toEqual([]);
258
+ });
259
+
260
+ it("fails with NotFound before creating anything when the agent row is missing", async () => {
261
+ const missing = harness();
262
+ await expect(missing.provisioner.provision(AGENT_ID)).rejects.toThrow(
263
+ /not found/i,
264
+ );
265
+ expect(missing.events).toEqual([]);
266
+ });
267
+
268
+ it("skips the workspace step and omits the workspace when none is configured", async () => {
269
+ const noWs = harness({ workspaceId: null });
270
+ noWs.store.addAgent(AGENT_ID, { ownerEmail: OWNER });
271
+ await noWs.provisioner.provision(AGENT_ID);
272
+ expect(noWs.anthropic.callsTo("addServiceAccountToWorkspace")).toEqual([]);
273
+ const [rule] = noWs.anthropic.liveRules();
274
+ expect(rule?.input.workspaceId).toBeUndefined();
275
+ const dep = await noWs.recorded.getDeployment(NAMESPACE, RESOURCE);
276
+ expect(envOf(dep).ANTHROPIC_WORKSPACE_ID).toBeUndefined();
277
+ });
278
+
279
+ it("treats an existing K8s ServiceAccount (409) as already created and keeps it on rollback", async () => {
280
+ await h.recorded.createServiceAccount(NAMESPACE, { name: RESOURCE });
281
+ h.anthropic.failNext("createFederationRule");
282
+ await expect(h.provisioner.provision(AGENT_ID)).rejects.toThrow();
283
+ // Not created by this call, so not deleted by its rollback.
284
+ expect(
285
+ await exists(h.recorded.getServiceAccount(NAMESPACE, RESOURCE)),
286
+ ).toBe(true);
287
+ await h.provisioner.provision(AGENT_ID);
288
+ await expectFullyProvisioned(h);
289
+ });
290
+
291
+ it("treats an existing workspace membership (409) as done", async () => {
292
+ await h.provisioner.provision(AGENT_ID);
293
+ // Simulate a retry that lost the rule ID but kept the SA ID.
294
+ const [rule] = h.anthropic.liveRules();
295
+ await h.anthropic.archiveFederationRule(rule?.id ?? "");
296
+ await h.store.set(AGENT_ID, { anthropicFederationRuleId: null });
297
+ await h.recorded.deleteDeployment(NAMESPACE, RESOURCE);
298
+ await h.provisioner.provision(AGENT_ID);
299
+ expect(h.anthropic.liveServiceAccounts()).toHaveLength(1);
300
+ expect(h.anthropic.liveRules()).toHaveLength(1);
301
+ });
302
+
303
+ it("keeps the identity when the Deployment already exists (409)", async () => {
304
+ await h.recorded.createDeploymentManifest(
305
+ NAMESPACE,
306
+ buildAgentDeploymentManifest({
307
+ agentId: AGENT_ID,
308
+ namespace: NAMESPACE,
309
+ image: BASE_CONFIG.image,
310
+ imageTag: BASE_CONFIG.imageTag,
311
+ apiUrl: BASE_CONFIG.apiUrl,
312
+ pvcName: `${RESOURCE}-home`,
313
+ secretName: `${RESOURCE}-token`,
314
+ tokenSecretKey: "token",
315
+ }),
316
+ );
317
+ await h.provisioner.provision(AGENT_ID);
318
+ expect(h.anthropic.liveServiceAccounts()).toHaveLength(1);
319
+ expect(h.anthropic.liveRules()).toHaveLength(1);
320
+ expect((await h.store.get(AGENT_ID))?.anthropicFederationRuleId).not.toBe(
321
+ null,
322
+ );
323
+ });
324
+ });
325
+
326
+ // ─── Failure at each step, then retry ─────────────────────────────────────────
327
+
328
+ type Step = {
329
+ name: string;
330
+ inject: (h: Harness) => void;
331
+ };
332
+
333
+ const STEPS: Step[] = [
334
+ {
335
+ name: "K8s ServiceAccount create",
336
+ inject: (h) => h.k8s.failNext("createServiceAccount"),
337
+ },
338
+ {
339
+ name: "k8sServiceAccountName save",
340
+ inject: (h) => h.store.failNextSetOf("k8sServiceAccountName"),
341
+ },
342
+ {
343
+ name: "Anthropic service account create",
344
+ inject: (h) => h.anthropic.failNext("createServiceAccount"),
345
+ },
346
+ {
347
+ name: "anthropicServiceAccountId save",
348
+ inject: (h) => h.store.failNextSetOf("anthropicServiceAccountId"),
349
+ },
350
+ {
351
+ name: "add to workspace",
352
+ inject: (h) => h.anthropic.failNext("addServiceAccountToWorkspace"),
353
+ },
354
+ {
355
+ name: "federation rule create",
356
+ inject: (h) => h.anthropic.failNext("createFederationRule"),
357
+ },
358
+ {
359
+ name: "anthropicFederationRuleId save",
360
+ inject: (h) => h.store.failNextSetOf("anthropicFederationRuleId"),
361
+ },
362
+ {
363
+ name: "Secret create",
364
+ inject: (h) => h.k8s.failNext("createSecret"),
365
+ },
366
+ {
367
+ name: "Deployment create",
368
+ inject: (h) => h.k8s.failNext("createDeploymentManifest"),
369
+ },
370
+ ];
371
+
372
+ describe("KubernetesAgentProvisioner.provision() — perAgent failure, rollback and retry", () => {
373
+ for (const step of STEPS) {
374
+ it(`rolls back after a ${step.name} failure, and a retry creates no duplicates`, async () => {
375
+ const h = harness();
376
+ h.store.addAgent(AGENT_ID, { ownerEmail: OWNER });
377
+ step.inject(h);
378
+
379
+ await expect(h.provisioner.provision(AGENT_ID)).rejects.toThrow();
380
+ await expectRolledBack(h);
381
+
382
+ await h.provisioner.provision(AGENT_ID);
383
+ await expectFullyProvisioned(h);
384
+ });
385
+ }
386
+
387
+ it("rolls back in reverse order: rule, Anthropic SA, K8s SA", async () => {
388
+ const h = harness();
389
+ h.store.addAgent(AGENT_ID, { ownerEmail: OWNER });
390
+ h.k8s.failNext("createDeploymentManifest");
391
+ await expect(h.provisioner.provision(AGENT_ID)).rejects.toThrow();
392
+ const after = h.events.slice(
393
+ h.events.indexOf("k8s.createDeploymentManifest") + 1,
394
+ );
395
+ const order = after.filter((e) =>
396
+ [
397
+ "k8s.deleteSecret",
398
+ "anthropic.archiveFederationRule",
399
+ "anthropic.archiveServiceAccount",
400
+ "k8s.deleteServiceAccount",
401
+ ].includes(e),
402
+ );
403
+ expect(order).toEqual([
404
+ "k8s.deleteSecret",
405
+ "anthropic.archiveFederationRule",
406
+ "anthropic.archiveServiceAccount",
407
+ "k8s.deleteServiceAccount",
408
+ ]);
409
+ });
410
+
411
+ it("surfaces the original error when a rollback step fails too", async () => {
412
+ const h = harness();
413
+ h.store.addAgent(AGENT_ID, { ownerEmail: OWNER });
414
+ const original = new Error("deployment create failed");
415
+ h.k8s.failNext("createDeploymentManifest", original);
416
+ h.anthropic.failNext("archiveFederationRule");
417
+ h.k8s.failNext("deleteServiceAccount");
418
+ await expect(h.provisioner.provision(AGENT_ID)).rejects.toBe(original);
419
+ // The other rollback steps still ran.
420
+ expect(h.anthropic.liveServiceAccounts()).toHaveLength(0);
421
+ });
422
+ });
423
+
424
+ // ─── Lost-write recovery (find-by-name) ───────────────────────────────────────
425
+
426
+ describe("KubernetesAgentProvisioner.provision() — perAgent lost-write recovery", () => {
427
+ it("recovers an Anthropic service account whose ID write was lost", async () => {
428
+ const h = harness();
429
+ h.store.addAgent(AGENT_ID, {
430
+ ownerEmail: OWNER,
431
+ anthropicIdentityName: IDENTITY_NAME,
432
+ });
433
+ const orphan = h.anthropic.seedServiceAccount(IDENTITY_NAME);
434
+
435
+ await h.provisioner.provision(AGENT_ID);
436
+
437
+ expect(h.anthropic.callsTo("findServiceAccountByName")).toEqual([
438
+ IDENTITY_NAME,
439
+ ]);
440
+ expect((await h.store.get(AGENT_ID))?.anthropicServiceAccountId).toBe(
441
+ orphan.id,
442
+ );
443
+ await expectFullyProvisioned(h);
444
+ });
445
+
446
+ it("recovers a federation rule whose ID write was lost", async () => {
447
+ const h = harness();
448
+ h.store.addAgent(AGENT_ID, {
449
+ ownerEmail: OWNER,
450
+ anthropicIdentityName: IDENTITY_NAME,
451
+ });
452
+ const sa = h.anthropic.seedServiceAccount(IDENTITY_NAME);
453
+ await h.store.set(AGENT_ID, { anthropicServiceAccountId: sa.id });
454
+ const orphan = h.anthropic.seedRule({
455
+ name: IDENTITY_NAME,
456
+ issuerId: "fdis_cluster",
457
+ serviceAccountId: sa.id,
458
+ match: { subjectPrefix: SUBJECT },
459
+ });
460
+
461
+ await h.provisioner.provision(AGENT_ID);
462
+
463
+ expect(h.anthropic.callsTo("findFederationRuleByName")).toEqual([
464
+ IDENTITY_NAME,
465
+ ]);
466
+ expect((await h.store.get(AGENT_ID))?.anthropicFederationRuleId).toBe(
467
+ orphan.id,
468
+ );
469
+ await expectFullyProvisioned(h);
470
+ });
471
+
472
+ it("does not archive a recovered service account when a later step fails", async () => {
473
+ const h = harness();
474
+ h.store.addAgent(AGENT_ID, {
475
+ ownerEmail: OWNER,
476
+ anthropicIdentityName: IDENTITY_NAME,
477
+ });
478
+ const orphan = h.anthropic.seedServiceAccount(IDENTITY_NAME);
479
+ h.anthropic.failNext("createFederationRule");
480
+
481
+ await expect(h.provisioner.provision(AGENT_ID)).rejects.toThrow();
482
+
483
+ expect(h.anthropic.callsTo("archiveServiceAccount")).toEqual([]);
484
+ expect((await h.store.get(AGENT_ID))?.anthropicServiceAccountId).toBe(
485
+ orphan.id,
486
+ );
487
+ await h.provisioner.provision(AGENT_ID);
488
+ await expectFullyProvisioned(h);
489
+ });
490
+
491
+ it("rethrows the conflict when find-by-name finds nothing", async () => {
492
+ const h = harness();
493
+ h.store.addAgent(AGENT_ID, { ownerEmail: OWNER });
494
+ const { AnthropicConflictError } = await import(
495
+ "./anthropic-identity-client.ts"
496
+ );
497
+ const conflict = new AnthropicConflictError("createServiceAccount");
498
+ h.anthropic.failNext("createServiceAccount", conflict);
499
+ await expect(h.provisioner.provision(AGENT_ID)).rejects.toBe(conflict);
500
+ });
501
+ });
502
+
503
+ // ─── reconcile ────────────────────────────────────────────────────────────────
504
+
505
+ describe("KubernetesAgentProvisioner.reconcile() — perAgent identity", () => {
506
+ const SHARED_FED = {
507
+ serviceAccountName: "shipwright-agent",
508
+ ruleId: "fdrl_shared",
509
+ organizationId: "org-1",
510
+ serviceAccountId: "svac_shared",
511
+ audience: "https://api.anthropic.com",
512
+ expirationSeconds: 600,
513
+ };
514
+
515
+ function liveDeployment(fed?: typeof SHARED_FED) {
516
+ return buildAgentDeploymentManifest({
517
+ agentId: AGENT_ID,
518
+ namespace: NAMESPACE,
519
+ image: BASE_CONFIG.image,
520
+ imageTag: BASE_CONFIG.imageTag,
521
+ apiUrl: BASE_CONFIG.apiUrl,
522
+ pvcName: `${RESOURCE}-home`,
523
+ secretName: `${RESOURCE}-token`,
524
+ tokenSecretKey: "token",
525
+ ...(fed ? { anthropicFederation: fed } : {}),
526
+ });
527
+ }
528
+
529
+ it("leaves federation untouched on an existing ownerless agent (nothing to name its identity after)", async () => {
530
+ const h = harness();
531
+ h.store.addAgent(AGENT_ID);
532
+ await h.recorded.createDeploymentManifest(
533
+ NAMESPACE,
534
+ liveDeployment(SHARED_FED),
535
+ );
536
+
537
+ const result = await h.provisioner.reconcile([{ id: AGENT_ID }]);
538
+
539
+ expect(result.failed).toEqual([]);
540
+ expect(result.updated).toEqual([]);
541
+ const dep = await h.recorded.getDeployment(NAMESPACE, RESOURCE);
542
+ expect(dep.spec.template.spec.serviceAccountName).toBe("shipwright-agent");
543
+ expect(envOf(dep).ANTHROPIC_FEDERATION_RULE_ID).toBe("fdrl_shared");
544
+ expect(h.anthropic.calls).toEqual([]);
545
+ });
546
+
547
+ it("does not fail an existing ownerless agent", async () => {
548
+ const h = harness();
549
+ h.store.addAgent(AGENT_ID);
550
+ await h.recorded.createDeploymentManifest(NAMESPACE, liveDeployment());
551
+ const result = await h.provisioner.reconcile([{ id: AGENT_ID }]);
552
+ expect(result.failed).toEqual([]);
553
+ });
554
+
555
+ it("drift-corrects an existing Deployment to the agent's own identity", async () => {
556
+ const h = harness();
557
+ h.store.addAgent(AGENT_ID, {
558
+ ownerEmail: OWNER,
559
+ anthropicIdentityName: IDENTITY_NAME,
560
+ anthropicServiceAccountId: "svac_own",
561
+ anthropicFederationRuleId: "fdrl_own",
562
+ k8sServiceAccountName: RESOURCE,
563
+ });
564
+ await h.recorded.createDeploymentManifest(
565
+ NAMESPACE,
566
+ liveDeployment(SHARED_FED),
567
+ );
568
+
569
+ const result = await h.provisioner.reconcile([{ id: AGENT_ID }], {
570
+ policyOnly: true,
571
+ });
572
+
573
+ expect(result.updated).toEqual([AGENT_ID]);
574
+ const dep = await h.recorded.getDeployment(NAMESPACE, RESOURCE);
575
+ expect(dep.spec.template.spec.serviceAccountName).toBe(RESOURCE);
576
+ expect(envOf(dep)).toMatchObject({
577
+ ANTHROPIC_FEDERATION_RULE_ID: "fdrl_own",
578
+ ANTHROPIC_SERVICE_ACCOUNT_ID: "svac_own",
579
+ ANTHROPIC_WORKSPACE_ID: "wrkspc_1",
580
+ });
581
+ });
582
+
583
+ it("provisions a missing Deployment with a new per-agent identity", async () => {
584
+ const h = harness();
585
+ h.store.addAgent(AGENT_ID, { ownerEmail: OWNER });
586
+ const result = await h.provisioner.reconcile([{ id: AGENT_ID }]);
587
+ expect(result.recreated).toEqual([AGENT_ID]);
588
+ await expectFullyProvisioned(h);
589
+ });
590
+
591
+ it("reports a missing Deployment for an ownerless agent as noOwner, not failed, and creates nothing", async () => {
592
+ const h = harness();
593
+ h.store.addAgent(AGENT_ID);
594
+ const result = await h.provisioner.reconcile([{ id: AGENT_ID }]);
595
+ expect(result.failed).toEqual([]);
596
+ expect(result.recreated).toEqual([]);
597
+ expect(result.noOwner).toEqual([AGENT_ID]);
598
+ expect(h.anthropic.calls).toEqual([]);
599
+ expect(h.events.filter((e) => /create|patch/.test(e))).toEqual([]);
600
+ });
601
+ });
602
+
603
+ // ─── shared mode is untouched ─────────────────────────────────────────────────
604
+
605
+ describe("KubernetesAgentProvisioner.provision() — shared mode does not touch per-agent identity", () => {
606
+ it("creates no ServiceAccount without perAgentIdentity config", async () => {
607
+ const events: string[] = [];
608
+ const recorded = new RecordedKubernetesClient({
609
+ deployments: {},
610
+ secrets: {},
611
+ pvcs: {},
612
+ });
613
+ const k8s: KubernetesClient = faultyKubernetesClient(recorded, events);
614
+ const provisioner = new KubernetesAgentProvisioner(
615
+ k8s,
616
+ stubTokens(),
617
+ BASE_CONFIG,
618
+ );
619
+ await provisioner.provision(AGENT_ID);
620
+ expect(events).toEqual([
621
+ "k8s.createPvc",
622
+ "k8s.getSecret",
623
+ "k8s.createSecret",
624
+ "k8s.createDeploymentManifest",
625
+ ]);
626
+ });
627
+ });
628
+
629
+ // ─── reconcile: move existing agents to per-agent identity (PAI-2.3) ──────────
630
+
631
+ describe("KubernetesAgentProvisioner.reconcile() — move existing agents", () => {
632
+ const SHARED_SA = "shipwright-agent";
633
+ // A different token source than the per-agent one (audience, lifetime), so
634
+ // the move has to patch the projected token volume as well.
635
+ const SHARED_FED = {
636
+ serviceAccountName: SHARED_SA,
637
+ ruleId: "fdrl_shared",
638
+ organizationId: "org-1",
639
+ serviceAccountId: "svac_shared",
640
+ audience: "https://shared.example.com",
641
+ expirationSeconds: 3600,
642
+ };
643
+
644
+ function sharedDeployment(agentId = AGENT_ID) {
645
+ const dep = buildAgentDeploymentManifest({
646
+ agentId,
647
+ namespace: NAMESPACE,
648
+ image: BASE_CONFIG.image,
649
+ imageTag: BASE_CONFIG.imageTag,
650
+ apiUrl: BASE_CONFIG.apiUrl,
651
+ pvcName: `${sanitizeAgentName(agentId)}-home`,
652
+ secretName: `${sanitizeAgentName(agentId)}-token`,
653
+ tokenSecretKey: "token",
654
+ anthropicFederation: SHARED_FED,
655
+ });
656
+ // The API server mirrors serviceAccountName into the deprecated alias.
657
+ dep.spec.template.spec.serviceAccount = SHARED_SA;
658
+ return dep;
659
+ }
660
+
661
+ /** A reconcile target as agentService.list() would build it from the row. */
662
+ async function target(h: Harness, agentId = AGENT_ID) {
663
+ return { id: agentId, ...(await h.store.get(agentId)) };
664
+ }
665
+
666
+ async function seeded(opts: { owner?: string } = {}): Promise<Harness> {
667
+ const h = harness();
668
+ h.store.addAgent(AGENT_ID, { ownerEmail: opts.owner ?? OWNER });
669
+ await h.recorded.createDeploymentManifest(NAMESPACE, sharedDeployment());
670
+ return h;
671
+ }
672
+
673
+ it("creates the missing identity and moves the Deployment off the shared ServiceAccount", async () => {
674
+ const h = await seeded();
675
+
676
+ const result = await h.provisioner.reconcile([await target(h)]);
677
+
678
+ expect(result.failed).toEqual([]);
679
+ expect(result.migrated).toEqual([AGENT_ID]);
680
+ expect(result.updated).toEqual([AGENT_ID]);
681
+ expect(result.noOwner).toEqual([]);
682
+ expect(result.dryRun).toBe(false);
683
+ const [sa] = h.anthropic.liveServiceAccounts();
684
+ const [rule] = h.anthropic.liveRules();
685
+ expect(h.anthropic.liveServiceAccounts()).toHaveLength(1);
686
+ expect(h.anthropic.liveRules()).toHaveLength(1);
687
+ expect(rule?.input.match.subjectPrefix).toBe(SUBJECT);
688
+ expect(await h.store.get(AGENT_ID)).toEqual({
689
+ ownerEmail: OWNER,
690
+ anthropicIdentityName: IDENTITY_NAME,
691
+ anthropicServiceAccountId: sa?.id ?? "missing",
692
+ anthropicFederationRuleId: rule?.id ?? "missing",
693
+ k8sServiceAccountName: RESOURCE,
694
+ });
695
+ expect(
696
+ await exists(h.recorded.getServiceAccount(NAMESPACE, RESOURCE)),
697
+ ).toBe(true);
698
+ });
699
+
700
+ it("patches serviceAccountName, the token volume and the federation env", async () => {
701
+ const h = await seeded();
702
+ await h.provisioner.reconcile([await target(h)]);
703
+
704
+ const [sa] = h.anthropic.liveServiceAccounts();
705
+ const [rule] = h.anthropic.liveRules();
706
+ const dep = await h.recorded.getDeployment(NAMESPACE, RESOURCE);
707
+ expect(dep.spec.template.spec.serviceAccountName).toBe(RESOURCE);
708
+ const volume = dep.spec.template.spec.volumes?.find(
709
+ (v) => v.name === "anthropic-token",
710
+ );
711
+ expect(JSON.stringify(volume)).toContain("https://api.anthropic.com");
712
+ expect(JSON.stringify(volume)).not.toContain("shared.example.com");
713
+ expect(envOf(dep)).toMatchObject({
714
+ ANTHROPIC_FEDERATION_RULE_ID: rule?.id,
715
+ ANTHROPIC_SERVICE_ACCOUNT_ID: sa?.id,
716
+ ANTHROPIC_WORKSPACE_ID: "wrkspc_1",
717
+ });
718
+ // Converged: the live Deployment now equals the agent's desired manifest.
719
+ expect(dep.spec.template.spec.containers[0]?.image).toBe(
720
+ `${BASE_CONFIG.image}:${BASE_CONFIG.imageTag}`,
721
+ );
722
+ });
723
+
724
+ it("sets serviceAccountName without nulling the deprecated serviceAccount alias (the name wins; the API server re-mirrors it)", async () => {
725
+ const h = await seeded();
726
+ const patches: Array<{ spec: { template: { spec: object } } }> = [];
727
+ const original = h.recorded.patchDeployment.bind(h.recorded);
728
+ h.recorded.patchDeployment = async (ns, name, patch) => {
729
+ patches.push(patch as (typeof patches)[number]);
730
+ return original(ns, name, patch);
731
+ };
732
+
733
+ await h.provisioner.reconcile([await target(h)]);
734
+
735
+ expect(patches).toHaveLength(1);
736
+ const podSpec = patches[0]?.spec.template.spec;
737
+ expect(podSpec).toMatchObject({ serviceAccountName: RESOURCE });
738
+ // Nulling the alias is only for UNSETTING the account: with a non-empty
739
+ // serviceAccountName the alias cannot resurrect the old shared name.
740
+ expect(podSpec).not.toHaveProperty("serviceAccount");
741
+ });
742
+
743
+ it("still nulls the alias together with serviceAccountName when federation is unset", async () => {
744
+ const h = await seeded();
745
+ // Shared-mode provisioner with federation off, same cluster state.
746
+ const off = new KubernetesAgentProvisioner(
747
+ h.k8s,
748
+ stubTokens(),
749
+ BASE_CONFIG,
750
+ );
751
+ const result = await off.reconcile([{ id: AGENT_ID }]);
752
+ expect(result.updated).toEqual([AGENT_ID]);
753
+ const dep = await h.recorded.getDeployment(NAMESPACE, RESOURCE);
754
+ expect(dep.spec.template.spec.serviceAccountName).toBeUndefined();
755
+ expect(dep.spec.template.spec.serviceAccount).toBeUndefined();
756
+ });
757
+
758
+ it("reads the identity from the store when the target carries no identity fields", async () => {
759
+ const h = await seeded();
760
+ const result = await h.provisioner.reconcile([{ id: AGENT_ID }]);
761
+ expect(result.migrated).toEqual([AGENT_ID]);
762
+ });
763
+
764
+ it("is idempotent: a second reconcile creates no identity and patches nothing", async () => {
765
+ const h = await seeded();
766
+ await h.provisioner.reconcile([await target(h)]);
767
+ h.events.length = 0;
768
+ h.anthropic.calls.length = 0;
769
+
770
+ const again = await h.provisioner.reconcile([await target(h)]);
771
+
772
+ expect(again.migrated).toEqual([]);
773
+ expect(again.updated).toEqual([]);
774
+ expect(again.failed).toEqual([]);
775
+ expect(h.anthropic.calls).toEqual([]);
776
+ expect(h.events.filter((e) => /create|patch|store\./.test(e))).toEqual([]);
777
+ expect(h.anthropic.liveServiceAccounts()).toHaveLength(1);
778
+ expect(h.anthropic.liveRules()).toHaveLength(1);
779
+ });
780
+
781
+ it("leaves an ownerless agent alone and lists it in noOwner", async () => {
782
+ const h = harness();
783
+ h.store.addAgent(AGENT_ID);
784
+ await h.recorded.createDeploymentManifest(NAMESPACE, sharedDeployment());
785
+ const before = JSON.stringify(
786
+ await h.recorded.getDeployment(NAMESPACE, RESOURCE),
787
+ );
788
+
789
+ const result = await h.provisioner.reconcile([await target(h)]);
790
+
791
+ expect(result.noOwner).toEqual([AGENT_ID]);
792
+ expect(result.migrated).toEqual([]);
793
+ expect(result.updated).toEqual([]);
794
+ expect(result.failed).toEqual([]);
795
+ expect(
796
+ JSON.stringify(await h.recorded.getDeployment(NAMESPACE, RESOURCE)),
797
+ ).toBe(before);
798
+ expect(h.anthropic.calls).toEqual([]);
799
+ expect(
800
+ h.events.filter((e) => /createServiceAccount|store\./.test(e)),
801
+ ).toEqual([]);
802
+ });
803
+
804
+ it("moves the agents that have an owner and reports the one that has none, in one pass", async () => {
805
+ const h = await seeded();
806
+ const OTHER = "cmqother000000000000000000";
807
+ h.store.addAgent(OTHER);
808
+ await h.recorded.createDeploymentManifest(
809
+ NAMESPACE,
810
+ sharedDeployment(OTHER),
811
+ );
812
+
813
+ const result = await h.provisioner.reconcile([
814
+ await target(h),
815
+ await target(h, OTHER),
816
+ ]);
817
+
818
+ expect(result.migrated).toEqual([AGENT_ID]);
819
+ expect(result.noOwner).toEqual([OTHER]);
820
+ const other = await h.recorded.getDeployment(
821
+ NAMESPACE,
822
+ sanitizeAgentName(OTHER),
823
+ );
824
+ expect(other.spec.template.spec.serviceAccountName).toBe(SHARED_SA);
825
+ });
826
+
827
+ it("dry run reports what would move and changes nothing in Kubernetes, Anthropic or the database", async () => {
828
+ const h = await seeded();
829
+ const OTHER = "cmqother000000000000000000";
830
+ h.store.addAgent(OTHER);
831
+ await h.recorded.createDeploymentManifest(
832
+ NAMESPACE,
833
+ sharedDeployment(OTHER),
834
+ );
835
+ const rowsBefore = JSON.stringify([...h.store.rows]);
836
+ const depBefore = JSON.stringify(
837
+ await h.recorded.getDeployment(NAMESPACE, RESOURCE),
838
+ );
839
+ h.events.length = 0;
840
+
841
+ const result = await h.provisioner.reconcile(
842
+ [await target(h), await target(h, OTHER)],
843
+ { dryRun: true },
844
+ );
845
+
846
+ expect(result.dryRun).toBe(true);
847
+ expect(result.migrated).toEqual([AGENT_ID]);
848
+ expect(result.noOwner).toEqual([OTHER]);
849
+ expect(result.failed).toEqual([]);
850
+ expect(h.anthropic.calls).toEqual([]);
851
+ expect(h.anthropic.liveServiceAccounts()).toHaveLength(0);
852
+ expect(JSON.stringify([...h.store.rows])).toBe(rowsBefore);
853
+ expect(
854
+ JSON.stringify(await h.recorded.getDeployment(NAMESPACE, RESOURCE)),
855
+ ).toBe(depBefore);
856
+ expect(
857
+ h.events.filter((e) => /create|patch|delete|store\./.test(e)),
858
+ ).toEqual([]);
859
+ });
860
+
861
+ it("dry run does not provision a missing Deployment but reports it as recreated", async () => {
862
+ const h = harness();
863
+ h.store.addAgent(AGENT_ID, { ownerEmail: OWNER });
864
+ const result = await h.provisioner.reconcile([await target(h)], {
865
+ dryRun: true,
866
+ });
867
+ expect(result.recreated).toEqual([AGENT_ID]);
868
+ expect(result.dryRun).toBe(true);
869
+ expect(h.events.filter((e) => /create|store\./.test(e))).toEqual([]);
870
+ expect(h.anthropic.calls).toEqual([]);
871
+ });
872
+
873
+ it("does not move agents under a policy-only pass (startup), but still reports ownerless ones", async () => {
874
+ const h = await seeded();
875
+ const OTHER = "cmqother000000000000000000";
876
+ h.store.addAgent(OTHER);
877
+ await h.recorded.createDeploymentManifest(
878
+ NAMESPACE,
879
+ sharedDeployment(OTHER),
880
+ );
881
+
882
+ const result = await h.provisioner.reconcile(
883
+ [await target(h), await target(h, OTHER)],
884
+ { policyOnly: true },
885
+ );
886
+
887
+ expect(result.migrated).toEqual([]);
888
+ expect(result.noOwner).toEqual([OTHER]);
889
+ expect(h.anthropic.calls).toEqual([]);
890
+ const dep = await h.recorded.getDeployment(NAMESPACE, RESOURCE);
891
+ expect(dep.spec.template.spec.serviceAccountName).toBe(SHARED_SA);
892
+ });
893
+
894
+ it("rolls back the identity pieces it created and reports failed when identity creation fails", async () => {
895
+ const h = await seeded();
896
+ h.anthropic.failNext("createFederationRule");
897
+
898
+ const result = await h.provisioner.reconcile([await target(h)]);
899
+
900
+ expect(result.migrated).toEqual([]);
901
+ expect(result.failed).toHaveLength(1);
902
+ expect(result.failed[0]?.agentId).toBe(AGENT_ID);
903
+ expect(h.anthropic.liveServiceAccounts()).toHaveLength(0);
904
+ const dep = await h.recorded.getDeployment(NAMESPACE, RESOURCE);
905
+ expect(dep.spec.template.spec.serviceAccountName).toBe(SHARED_SA);
906
+
907
+ // A later pass recovers.
908
+ const retry = await h.provisioner.reconcile([await target(h)]);
909
+ expect(retry.migrated).toEqual([AGENT_ID]);
910
+ });
911
+
912
+ it("keeps the new identity when only the Deployment patch fails, and a retry completes the move", async () => {
913
+ const h = await seeded();
914
+ h.k8s.failNext("patchDeployment");
915
+
916
+ const first = await h.provisioner.reconcile([await target(h)]);
917
+ expect(first.failed).toHaveLength(1);
918
+ expect(h.anthropic.liveRules()).toHaveLength(1);
919
+
920
+ const retry = await h.provisioner.reconcile([await target(h)]);
921
+ expect(retry.failed).toEqual([]);
922
+ expect(retry.updated).toEqual([AGENT_ID]);
923
+ expect(h.anthropic.callsTo("createServiceAccount")).toHaveLength(1);
924
+ const dep = await h.recorded.getDeployment(NAMESPACE, RESOURCE);
925
+ expect(dep.spec.template.spec.serviceAccountName).toBe(RESOURCE);
926
+ });
927
+ });
928
+
929
+ describe("KubernetesAgentProvisioner.reconcile() — shared mode reports nothing to move", () => {
930
+ it("returns empty migrated/noOwner and dryRun false", async () => {
931
+ const recorded = new RecordedKubernetesClient({
932
+ deployments: {},
933
+ secrets: {},
934
+ pvcs: {},
935
+ });
936
+ const provisioner = new KubernetesAgentProvisioner(
937
+ faultyKubernetesClient(recorded),
938
+ stubTokens(),
939
+ BASE_CONFIG,
940
+ );
941
+ const result = await provisioner.reconcile([
942
+ { id: AGENT_ID, ownerEmail: null },
943
+ ]);
944
+ expect(result.noOwner).toEqual([]);
945
+ expect(result.migrated).toEqual([]);
946
+ expect(result.dryRun).toBe(false);
947
+ expect(result.recreated).toEqual([AGENT_ID]);
948
+ });
949
+ });