@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,1852 @@
1
+ /**
2
+ * admin/src/agent-provisioner.unit.test.ts
3
+ * Unit tests for KubernetesAgentProvisioner PVC name template feature and
4
+ * task-store token minting/rollback.
5
+ *
6
+ * Uses RecordedKubernetesClient (in-memory K8s double) and a stub AgentTokenService
7
+ * so no real DB or cluster is required.
8
+ */
9
+
10
+ import { describe, expect, it } from "bun:test";
11
+ import {
12
+ type AgentAnthropicFederation,
13
+ buildAgentDeploymentManifest,
14
+ sanitizeAgentName,
15
+ } from "./agent-manifest.ts";
16
+ import {
17
+ containerDrifted,
18
+ federationPatch,
19
+ hasPerAgentIdentity,
20
+ KubernetesAgentProvisioner,
21
+ type KubernetesAgentProvisionerConfig,
22
+ NoopAgentProvisioner,
23
+ perAgentFederation,
24
+ policyDrifted,
25
+ } from "./agent-provisioner.ts";
26
+ import type { AgentTokenService } from "./agent-tokens.ts";
27
+ import type { ChatServiceProvisioningClient } from "./chat-service-provisioning-client.ts";
28
+ import {
29
+ type KubernetesClient,
30
+ RecordedKubernetesClient,
31
+ } from "./kubernetes-client.ts";
32
+ import type { ServiceIntegration } from "./service-integration.ts";
33
+
34
+ const NAMESPACE = "shipwright";
35
+
36
+ const BASE_CONFIG: KubernetesAgentProvisionerConfig = {
37
+ namespace: NAMESPACE,
38
+ image: "ghcr.io/app-vitals/shipwright-agent",
39
+ imageTag: "v1.0.0",
40
+ apiUrl: "http://shipwright-admin.shipwright.svc:3001",
41
+ };
42
+
43
+ /** Stub token service — no DB required. */
44
+ function stubTokens(): Pick<
45
+ AgentTokenService,
46
+ "create" | "validate" | "listForAgent" | "revoke"
47
+ > {
48
+ return {
49
+ create: async (agentId: string, _label: string) => ({
50
+ id: `tok-${agentId}`,
51
+ rawToken: `raw-${agentId}`,
52
+ agentId,
53
+ label: _label,
54
+ createdAt: new Date(),
55
+ expiresAt: null,
56
+ }),
57
+ validate: async () => null,
58
+ listForAgent: async () => [],
59
+ revoke: async () => {},
60
+ } as unknown as Pick<
61
+ AgentTokenService,
62
+ "create" | "validate" | "listForAgent" | "revoke"
63
+ >;
64
+ }
65
+
66
+ function emptyClient(): RecordedKubernetesClient {
67
+ return new RecordedKubernetesClient({
68
+ deployments: {},
69
+ secrets: {},
70
+ pvcs: {},
71
+ });
72
+ }
73
+
74
+ // ─── AC 1: Default behaviour unchanged ────────────────────────────────────────
75
+
76
+ describe("KubernetesAgentProvisioner.provision() — default PVC naming", () => {
77
+ it("creates a PVC named {sanitizedAgentId}-home when no pvcName config is set", async () => {
78
+ const agentId = "cmqalfjcm000m4101iharq28k";
79
+ const k8s = emptyClient();
80
+ const provisioner = new KubernetesAgentProvisioner(
81
+ k8s,
82
+ stubTokens() as AgentTokenService,
83
+ BASE_CONFIG,
84
+ );
85
+
86
+ await provisioner.provision(agentId);
87
+
88
+ const resourceName = sanitizeAgentName(agentId);
89
+ const expectedPvcName = `${resourceName}-home`;
90
+ await expect(k8s.getPvc(NAMESPACE, expectedPvcName)).resolves.toBeDefined();
91
+ });
92
+
93
+ it("passes undefined slug → falls back to resourceName in pvcName function", async () => {
94
+ const agentId = "cmqalfjcm000m4101iharq28k";
95
+ const resourceName = sanitizeAgentName(agentId);
96
+ let capturedName: string | null = null;
97
+
98
+ const config: KubernetesAgentProvisionerConfig = {
99
+ ...BASE_CONFIG,
100
+ pvcName: (name) => {
101
+ capturedName = name;
102
+ return `${name}-home`;
103
+ },
104
+ };
105
+
106
+ const k8s = emptyClient();
107
+ const provisioner = new KubernetesAgentProvisioner(
108
+ k8s,
109
+ stubTokens() as AgentTokenService,
110
+ config,
111
+ );
112
+
113
+ await provisioner.provision(agentId);
114
+
115
+ expect(capturedName).not.toBeNull();
116
+ // Without a slug, pvcNameFor passes the sanitized resourceName directly.
117
+ // biome-ignore lint/style/noNonNullAssertion: guarded by the not.toBeNull() assertion above
118
+ expect(capturedName!).toBe(resourceName);
119
+ });
120
+ });
121
+
122
+ // ─── AC 2: Templated PVC name with slug ───────────────────────────────────────
123
+
124
+ describe("KubernetesAgentProvisioner.provision() — templated PVC naming", () => {
125
+ it("creates a PVC named by the template when slug is provided via opts", async () => {
126
+ const agentId = "cmqalfjcm000m4101iharq28k";
127
+ const slug = "okwow";
128
+
129
+ const config: KubernetesAgentProvisionerConfig = {
130
+ ...BASE_CONFIG,
131
+ pvcName: (name) => `acme-agent-${name}-home`,
132
+ };
133
+
134
+ const k8s = emptyClient();
135
+ const provisioner = new KubernetesAgentProvisioner(
136
+ k8s,
137
+ stubTokens() as AgentTokenService,
138
+ config,
139
+ );
140
+
141
+ await provisioner.provision(agentId, { slug });
142
+
143
+ // PVC must use the slug, not the sanitized agentId
144
+ await expect(
145
+ k8s.getPvc(NAMESPACE, "acme-agent-okwow-home"),
146
+ ).resolves.toBeDefined();
147
+
148
+ // The default name must NOT exist
149
+ const resourceName = sanitizeAgentName(agentId);
150
+ await expect(
151
+ k8s.getPvc(NAMESPACE, `acme-agent-${resourceName}-home`),
152
+ ).rejects.toThrow();
153
+ });
154
+
155
+ it("Deployment is created alongside the template-derived PVC", async () => {
156
+ const agentId = "cmqalfjcm000m4101iharq28k";
157
+ const slug = "okwow";
158
+ const expectedPvcName = "acme-agent-okwow-home";
159
+
160
+ const config: KubernetesAgentProvisionerConfig = {
161
+ ...BASE_CONFIG,
162
+ pvcName: (name) => `acme-agent-${name}-home`,
163
+ };
164
+
165
+ const k8s = emptyClient();
166
+ const provisioner = new KubernetesAgentProvisioner(
167
+ k8s,
168
+ stubTokens() as AgentTokenService,
169
+ config,
170
+ );
171
+
172
+ await provisioner.provision(agentId, { slug });
173
+
174
+ // Both the PVC and the Deployment must exist.
175
+ // The PVC name confirms the slug was threaded through pvcNameFor().
176
+ // (The in-memory RecordedKubernetesClient does not capture volume mounts,
177
+ // so this is tested at the manifest level in agent-manifest tests.)
178
+ const resourceName = sanitizeAgentName(agentId);
179
+ await expect(k8s.getPvc(NAMESPACE, expectedPvcName)).resolves.toBeDefined();
180
+ await expect(
181
+ k8s.getDeployment(NAMESPACE, resourceName),
182
+ ).resolves.toBeDefined();
183
+ });
184
+ });
185
+
186
+ // ─── AC 2b: Reconcile image-update detection ──────────────────────────────────
187
+
188
+ describe("KubernetesAgentProvisioner.reconcile() — image-update detection", () => {
189
+ it("patches a stale image and adds agent to updated[]", async () => {
190
+ const agentId = "cmqalfjcm000m4101iharq28k";
191
+ const resourceName = sanitizeAgentName(agentId);
192
+
193
+ // Pre-seed a deployment with a stale image tag.
194
+ const k8s = new RecordedKubernetesClient({
195
+ deployments: {
196
+ [`${NAMESPACE}/${resourceName}`]: {
197
+ apiVersion: "apps/v1",
198
+ kind: "Deployment",
199
+ metadata: { name: resourceName, namespace: NAMESPACE },
200
+ spec: {
201
+ replicas: 1,
202
+ selector: { matchLabels: { app: resourceName } },
203
+ template: {
204
+ metadata: { labels: { app: resourceName } },
205
+ spec: {
206
+ containers: [
207
+ {
208
+ name: "shipwright-agent",
209
+ image: "ghcr.io/app-vitals/shipwright-agent:v0.9.0", // stale
210
+ },
211
+ ],
212
+ },
213
+ },
214
+ },
215
+ },
216
+ },
217
+ secrets: {},
218
+ pvcs: {},
219
+ });
220
+
221
+ const provisioner = new KubernetesAgentProvisioner(
222
+ k8s,
223
+ stubTokens() as AgentTokenService,
224
+ BASE_CONFIG, // imageTag: "v1.0.0"
225
+ );
226
+
227
+ const result = await provisioner.reconcile([{ id: agentId }]);
228
+
229
+ expect(result.updated).toEqual([agentId]);
230
+ expect(result.recreated).toEqual([]);
231
+ expect(result.failed).toEqual([]);
232
+
233
+ // Verify image was actually patched in the recorded client.
234
+ const dep = await k8s.getDeployment(NAMESPACE, resourceName);
235
+ expect(dep.spec.template.spec.containers[0].image).toBe(
236
+ "ghcr.io/app-vitals/shipwright-agent:v1.0.0",
237
+ );
238
+ });
239
+
240
+ it("skips patch when the container already matches the manifest", async () => {
241
+ const agentId = "cmqalfjcm000m4101iharq28k";
242
+ const resourceName = sanitizeAgentName(agentId);
243
+
244
+ let patchCalled = false;
245
+ const recorded = new RecordedKubernetesClient({
246
+ deployments: {
247
+ // Seed the exact desired manifest (image, env, resources) so there is
248
+ // no drift of any kind.
249
+ [`${NAMESPACE}/${resourceName}`]: buildAgentDeploymentManifest({
250
+ agentId,
251
+ namespace: NAMESPACE,
252
+ image: BASE_CONFIG.image,
253
+ imageTag: BASE_CONFIG.imageTag,
254
+ apiUrl: BASE_CONFIG.apiUrl,
255
+ pvcName: `${resourceName}-home`,
256
+ secretName: `${resourceName}-token`,
257
+ tokenSecretKey: "token",
258
+ }),
259
+ },
260
+ secrets: {},
261
+ pvcs: {},
262
+ });
263
+
264
+ // Build a full KubernetesClient that delegates everything to `recorded`
265
+ // but intercepts patchDeployment to track whether it was called.
266
+ const spied: KubernetesClient = {
267
+ createDeployment: (ns, spec) => recorded.createDeployment(ns, spec),
268
+ createDeploymentManifest: (ns, manifest) =>
269
+ recorded.createDeploymentManifest(ns, manifest),
270
+ getDeployment: (ns, name) => recorded.getDeployment(ns, name),
271
+ deploymentExists: (ns, name) => recorded.deploymentExists(ns, name),
272
+ listDeployments: (ns, sel) => recorded.listDeployments(ns, sel),
273
+ deleteDeployment: (ns, name) => recorded.deleteDeployment(ns, name),
274
+ patchDeployment: async (ns, name, patch) => {
275
+ patchCalled = true;
276
+ return recorded.patchDeployment(ns, name, patch);
277
+ },
278
+ createSecret: (ns, spec) => recorded.createSecret(ns, spec),
279
+ getSecret: (ns, name) => recorded.getSecret(ns, name),
280
+ deleteSecret: (ns, name) => recorded.deleteSecret(ns, name),
281
+ createPvc: (ns, spec) => recorded.createPvc(ns, spec),
282
+ getPvc: (ns, name) => recorded.getPvc(ns, name),
283
+ deletePvc: (ns, name) => recorded.deletePvc(ns, name),
284
+ createServiceAccount: (ns, spec) =>
285
+ recorded.createServiceAccount(ns, spec),
286
+ getServiceAccount: (ns, name) => recorded.getServiceAccount(ns, name),
287
+ deleteServiceAccount: (ns, name) =>
288
+ recorded.deleteServiceAccount(ns, name),
289
+ };
290
+
291
+ const provisioner = new KubernetesAgentProvisioner(
292
+ spied,
293
+ stubTokens() as AgentTokenService,
294
+ BASE_CONFIG,
295
+ );
296
+
297
+ const result = await provisioner.reconcile([{ id: agentId }]);
298
+
299
+ expect(result.updated).toEqual([]);
300
+ expect(result.recreated).toEqual([]);
301
+ expect(result.failed).toEqual([]);
302
+ expect(patchCalled).toBe(false);
303
+ });
304
+
305
+ it("adds agent to failed[] (not updated[]) when patchDeployment throws", async () => {
306
+ const agentId = "cmqalfjcm000m4101iharq28k";
307
+ const resourceName = sanitizeAgentName(agentId);
308
+
309
+ const recorded = new RecordedKubernetesClient({
310
+ deployments: {
311
+ [`${NAMESPACE}/${resourceName}`]: {
312
+ apiVersion: "apps/v1",
313
+ kind: "Deployment",
314
+ metadata: { name: resourceName, namespace: NAMESPACE },
315
+ spec: {
316
+ replicas: 1,
317
+ selector: { matchLabels: { app: resourceName } },
318
+ template: {
319
+ metadata: { labels: { app: resourceName } },
320
+ spec: {
321
+ containers: [
322
+ {
323
+ name: "shipwright-agent",
324
+ image: "ghcr.io/app-vitals/shipwright-agent:v0.9.0", // stale
325
+ },
326
+ ],
327
+ },
328
+ },
329
+ },
330
+ },
331
+ },
332
+ secrets: {},
333
+ pvcs: {},
334
+ });
335
+
336
+ // Full KubernetesClient that delegates everything except patchDeployment.
337
+ const failing: KubernetesClient = {
338
+ createDeployment: (ns, spec) => recorded.createDeployment(ns, spec),
339
+ createDeploymentManifest: (ns, manifest) =>
340
+ recorded.createDeploymentManifest(ns, manifest),
341
+ getDeployment: (ns, name) => recorded.getDeployment(ns, name),
342
+ deploymentExists: (ns, name) => recorded.deploymentExists(ns, name),
343
+ listDeployments: (ns, sel) => recorded.listDeployments(ns, sel),
344
+ deleteDeployment: (ns, name) => recorded.deleteDeployment(ns, name),
345
+ patchDeployment: async (_ns, _name, _patch) => {
346
+ throw new Error("patch failed: server error");
347
+ },
348
+ createSecret: (ns, spec) => recorded.createSecret(ns, spec),
349
+ getSecret: (ns, name) => recorded.getSecret(ns, name),
350
+ deleteSecret: (ns, name) => recorded.deleteSecret(ns, name),
351
+ createPvc: (ns, spec) => recorded.createPvc(ns, spec),
352
+ getPvc: (ns, name) => recorded.getPvc(ns, name),
353
+ deletePvc: (ns, name) => recorded.deletePvc(ns, name),
354
+ createServiceAccount: (ns, spec) =>
355
+ recorded.createServiceAccount(ns, spec),
356
+ getServiceAccount: (ns, name) => recorded.getServiceAccount(ns, name),
357
+ deleteServiceAccount: (ns, name) =>
358
+ recorded.deleteServiceAccount(ns, name),
359
+ };
360
+
361
+ const provisioner = new KubernetesAgentProvisioner(
362
+ failing,
363
+ stubTokens() as AgentTokenService,
364
+ BASE_CONFIG,
365
+ );
366
+
367
+ const result = await provisioner.reconcile([{ id: agentId }]);
368
+
369
+ expect(result.updated).toEqual([]);
370
+ expect(result.recreated).toEqual([]);
371
+ expect(result.failed).toEqual([
372
+ { agentId, error: "patch failed: server error" },
373
+ ]);
374
+ });
375
+
376
+ it("patches a Deployment whose env is missing manifest vars, preserving manual extras", async () => {
377
+ const agentId = "cmqalfjcm000m4101iharq28k";
378
+ const resourceName = sanitizeAgentName(agentId);
379
+
380
+ // Seed the desired manifest, then simulate a Deployment provisioned by an
381
+ // older admin: strip the workspace-dir vars and add a manual kubectl var.
382
+ const stale = buildAgentDeploymentManifest({
383
+ agentId,
384
+ namespace: NAMESPACE,
385
+ image: BASE_CONFIG.image,
386
+ imageTag: BASE_CONFIG.imageTag,
387
+ apiUrl: BASE_CONFIG.apiUrl,
388
+ pvcName: `${resourceName}-home`,
389
+ secretName: `${resourceName}-token`,
390
+ tokenSecretKey: "token",
391
+ });
392
+ const container = stale.spec.template.spec.containers[0];
393
+ container.env = [
394
+ ...(container.env ?? []).filter(
395
+ (e) =>
396
+ e.name !== "SHIPWRIGHT_REPO_DIR" &&
397
+ e.name !== "SHIPWRIGHT_WORKTREE_DIR",
398
+ ),
399
+ { name: "STARTUP_TIMEOUT_MS", value: "300000" },
400
+ ];
401
+
402
+ const k8s = new RecordedKubernetesClient({
403
+ deployments: { [`${NAMESPACE}/${resourceName}`]: stale },
404
+ secrets: {},
405
+ pvcs: {},
406
+ });
407
+ const provisioner = new KubernetesAgentProvisioner(
408
+ k8s,
409
+ stubTokens() as AgentTokenService,
410
+ BASE_CONFIG,
411
+ );
412
+
413
+ const result = await provisioner.reconcile([{ id: agentId }]);
414
+
415
+ expect(result.updated).toEqual([agentId]);
416
+ expect(result.failed).toEqual([]);
417
+
418
+ const dep = await k8s.getDeployment(NAMESPACE, resourceName);
419
+ const env = dep.spec.template.spec.containers[0].env ?? [];
420
+ const names = env.map((e) => e.name);
421
+ expect(names).toContain("SHIPWRIGHT_REPO_DIR");
422
+ expect(names).toContain("SHIPWRIGHT_WORKTREE_DIR");
423
+ // The manually-added var survives the strategic-merge patch.
424
+ expect(names).toContain("STARTUP_TIMEOUT_MS");
425
+ });
426
+
427
+ it("patches a Deployment with drifted resources, preserving Autopilot-injected resource keys", async () => {
428
+ const agentId = "cmqalfjcm000m4101iharq28k";
429
+ const resourceName = sanitizeAgentName(agentId);
430
+
431
+ // Seed the desired manifest, then simulate GKE Autopilot having injected a
432
+ // cpu key into requests and limits that the manifest doesn't specify.
433
+ const stale = buildAgentDeploymentManifest({
434
+ agentId,
435
+ namespace: NAMESPACE,
436
+ image: BASE_CONFIG.image,
437
+ imageTag: BASE_CONFIG.imageTag,
438
+ apiUrl: BASE_CONFIG.apiUrl,
439
+ pvcName: `${resourceName}-home`,
440
+ secretName: `${resourceName}-token`,
441
+ tokenSecretKey: "token",
442
+ });
443
+ const container = stale.spec.template.spec.containers[0];
444
+ // Simulate Autopilot injecting cpu — and also strip the memory limit so
445
+ // containerDrifted() detects drift and triggers a patch.
446
+ container.resources = {
447
+ requests: { ...container.resources?.requests, cpu: "500m" },
448
+ limits: { cpu: "2" }, // missing memory limit → drift
449
+ };
450
+
451
+ const k8s = new RecordedKubernetesClient({
452
+ deployments: { [`${NAMESPACE}/${resourceName}`]: stale },
453
+ secrets: {},
454
+ pvcs: {},
455
+ });
456
+ const provisioner = new KubernetesAgentProvisioner(
457
+ k8s,
458
+ stubTokens() as AgentTokenService,
459
+ BASE_CONFIG,
460
+ );
461
+
462
+ const result = await provisioner.reconcile([{ id: agentId }]);
463
+
464
+ expect(result.updated).toEqual([agentId]);
465
+ expect(result.failed).toEqual([]);
466
+
467
+ const dep = await k8s.getDeployment(NAMESPACE, resourceName);
468
+ const resources = dep.spec.template.spec.containers[0].resources;
469
+ // Desired manifest values are applied.
470
+ expect(resources?.requests?.memory).toBe("2Gi");
471
+ expect(resources?.limits?.memory).toBe("8Gi");
472
+ // Autopilot-injected cpu key survives the strategic-merge patch.
473
+ expect(resources?.requests?.cpu).toBe("500m");
474
+ expect(resources?.limits?.cpu).toBe("2");
475
+ });
476
+ });
477
+
478
+ // ─── containerDrifted ─────────────────────────────────────────────────────────
479
+
480
+ describe("containerDrifted", () => {
481
+ const desired = () =>
482
+ buildAgentDeploymentManifest({
483
+ agentId: "agent-1",
484
+ namespace: NAMESPACE,
485
+ image: BASE_CONFIG.image,
486
+ imageTag: BASE_CONFIG.imageTag,
487
+ apiUrl: BASE_CONFIG.apiUrl,
488
+ pvcName: "agent-1-home",
489
+ secretName: "agent-1-token",
490
+ tokenSecretKey: "token",
491
+ }).spec.template.spec.containers[0];
492
+
493
+ it("no drift when current equals desired", () => {
494
+ expect(containerDrifted(desired(), desired())).toBe(false);
495
+ });
496
+
497
+ it("drifts on image change", () => {
498
+ const current = { ...desired(), image: "other:v0" };
499
+ expect(containerDrifted(current, desired())).toBe(true);
500
+ });
501
+
502
+ it("drifts when a desired env var is missing", () => {
503
+ const current = desired();
504
+ current.env = (current.env ?? []).filter(
505
+ (e) => e.name !== "SHIPWRIGHT_WORKTREE_DIR",
506
+ );
507
+ expect(containerDrifted(current, desired())).toBe(true);
508
+ });
509
+
510
+ it("drifts when a desired env value differs", () => {
511
+ const current = desired();
512
+ current.env = (current.env ?? []).map((e) =>
513
+ e.name === "SHIPWRIGHT_WORKTREE_DIR" ? { ...e, value: "/elsewhere" } : e,
514
+ );
515
+ expect(containerDrifted(current, desired())).toBe(true);
516
+ });
517
+
518
+ it("extra live env vars are not drift", () => {
519
+ const current = desired();
520
+ current.env = [
521
+ ...(current.env ?? []),
522
+ { name: "STARTUP_TIMEOUT_MS", value: "300000" },
523
+ ];
524
+ expect(containerDrifted(current, desired())).toBe(false);
525
+ });
526
+
527
+ it("extra live resource keys (e.g. Autopilot-injected) are not drift", () => {
528
+ const current = desired();
529
+ current.resources = {
530
+ requests: { ...current.resources?.requests, cpu: "500m" },
531
+ limits: { ...current.resources?.limits, cpu: "2" },
532
+ };
533
+ expect(containerDrifted(current, desired())).toBe(false);
534
+ });
535
+
536
+ it("drifts when a desired resource value is missing or different", () => {
537
+ const current = desired();
538
+ current.resources = { requests: current.resources?.requests }; // no limits
539
+ expect(containerDrifted(current, desired())).toBe(true);
540
+ });
541
+ });
542
+
543
+ // ─── AC 3: Reconcile respects the template ────────────────────────────────────
544
+
545
+ describe("KubernetesAgentProvisioner.reconcile() — template respected", () => {
546
+ it("re-provisions using the pvcName function when a deployment is missing", async () => {
547
+ const agentId = "cmqalfjcm000m4101iharq28k";
548
+
549
+ const config: KubernetesAgentProvisionerConfig = {
550
+ ...BASE_CONFIG,
551
+ // Template with no slug falls back to resourceName (resolved by pvcNameFor)
552
+ pvcName: (name) => `acme-agent-${name}-home`,
553
+ };
554
+
555
+ const k8s = emptyClient();
556
+ const provisioner = new KubernetesAgentProvisioner(
557
+ k8s,
558
+ stubTokens() as AgentTokenService,
559
+ config,
560
+ );
561
+
562
+ // Reconcile with a missing deployment — should provision and use the template
563
+ const result = await provisioner.reconcile([{ id: agentId }]);
564
+
565
+ expect(result.recreated).toEqual([agentId]);
566
+ expect(result.failed).toEqual([]);
567
+
568
+ // PVC was created using the template (with resourceName as fallback for slug)
569
+ const resourceName = sanitizeAgentName(agentId);
570
+ const expectedPvcName = `acme-agent-${resourceName}-home`;
571
+ await expect(k8s.getPvc(NAMESPACE, expectedPvcName)).resolves.toBeDefined();
572
+ });
573
+ });
574
+
575
+ // ─── Task-store token minting ─────────────────────────────────────────────────
576
+
577
+ /**
578
+ * Build a spy task-store ServiceIntegration. Records all mintToken and
579
+ * revokeToken calls for assertion in tests.
580
+ */
581
+ function stubTaskStore(opts?: { throwOnMint?: boolean }): {
582
+ client: ServiceIntegration;
583
+ minted: Array<{ label: string; agentId: string | undefined; id: string }>;
584
+ revoked: string[];
585
+ } {
586
+ const minted: Array<{
587
+ label: string;
588
+ agentId: string | undefined;
589
+ id: string;
590
+ }> = [];
591
+ const revoked: string[] = [];
592
+ let seq = 0;
593
+
594
+ const client: ServiceIntegration = {
595
+ name: "task-store",
596
+ tokenEnv: "SHIPWRIGHT_TASK_STORE_TOKEN",
597
+ urlEnv: "SHIPWRIGHT_TASK_STORE_URL",
598
+ secretKey: "task-store-token",
599
+ url: "http://task-store.svc:3002",
600
+ async mintToken(label: string, agentId?: string) {
601
+ if (opts?.throwOnMint) throw new Error("mint failed");
602
+ seq++;
603
+ const id = `ts-tok-${seq}`;
604
+ minted.push({ label, agentId, id });
605
+ return { id, rawToken: `raw-ts-token-${seq}` };
606
+ },
607
+ async revokeToken(id: string) {
608
+ revoked.push(id);
609
+ },
610
+ async listTokensForAgent(_agentId: string) {
611
+ return [];
612
+ },
613
+ };
614
+
615
+ return { client, minted, revoked };
616
+ }
617
+
618
+ describe("KubernetesAgentProvisioner — task-store token minting", () => {
619
+ const AGENT_ID = "test-agent-001";
620
+
621
+ it("provision() mints a task-store token when a task-store integration is configured", async () => {
622
+ const { client, minted } = stubTaskStore();
623
+ const config: KubernetesAgentProvisionerConfig = {
624
+ ...BASE_CONFIG,
625
+ serviceIntegrations: [client],
626
+ };
627
+ const k8s = emptyClient();
628
+ const provisioner = new KubernetesAgentProvisioner(
629
+ k8s,
630
+ stubTokens() as AgentTokenService,
631
+ config,
632
+ );
633
+
634
+ await provisioner.provision(AGENT_ID);
635
+
636
+ expect(minted).toHaveLength(1);
637
+ expect(minted[0].label).toBe(`agent:${AGENT_ID}`);
638
+ expect(minted[0].agentId).toBe(AGENT_ID);
639
+ });
640
+
641
+ it("provision() does NOT mint a task-store token when no integration is configured", async () => {
642
+ // BASE_CONFIG has no serviceIntegrations
643
+ const { client, minted } = stubTaskStore();
644
+ const k8s = emptyClient();
645
+ const provisioner = new KubernetesAgentProvisioner(
646
+ k8s,
647
+ stubTokens() as AgentTokenService,
648
+ BASE_CONFIG,
649
+ );
650
+
651
+ await provisioner.provision(AGENT_ID);
652
+
653
+ // client is not injected so mintToken is never called
654
+ expect(minted).toHaveLength(0);
655
+ // Suppress "client is unused" lint warning by referencing it
656
+ void client;
657
+ });
658
+
659
+ it("provision() revokes the task-store token when Deployment creation fails", async () => {
660
+ const { client, minted, revoked } = stubTaskStore();
661
+ const config: KubernetesAgentProvisionerConfig = {
662
+ ...BASE_CONFIG,
663
+ serviceIntegrations: [client],
664
+ };
665
+
666
+ const recorded = emptyClient();
667
+ const _failingK8s = new RecordedKubernetesClient({
668
+ deployments: {},
669
+ secrets: {},
670
+ pvcs: {},
671
+ });
672
+ // Wrap to throw on createDeploymentManifest
673
+ const failingClient = Object.assign(
674
+ Object.create(Object.getPrototypeOf(recorded)),
675
+ recorded,
676
+ {
677
+ createDeploymentManifest: async () => {
678
+ throw new Error("simulated deploy failure");
679
+ },
680
+ },
681
+ );
682
+
683
+ const provisioner = new KubernetesAgentProvisioner(
684
+ failingClient,
685
+ stubTokens() as AgentTokenService,
686
+ config,
687
+ );
688
+
689
+ await expect(provisioner.provision(AGENT_ID)).rejects.toThrow(
690
+ "simulated deploy failure",
691
+ );
692
+
693
+ expect(minted).toHaveLength(1);
694
+ expect(revoked).toEqual([minted[0].id]);
695
+ });
696
+
697
+ it("provision() does NOT revoke the task-store token when Deployment already exists (409 conflict)", async () => {
698
+ const { client, minted, revoked } = stubTaskStore();
699
+ const config: KubernetesAgentProvisionerConfig = {
700
+ ...BASE_CONFIG,
701
+ serviceIntegrations: [client],
702
+ };
703
+
704
+ // Pre-seed the Deployment so the createDeploymentManifest call hits a 409.
705
+ // Do NOT pre-seed the Secret, so the provisioner mints a fresh token and
706
+ // creates a new Secret — then hits a conflict on the Deployment.
707
+ const recorded = emptyClient();
708
+ const agentResourceName = sanitizeAgentName(AGENT_ID);
709
+ await recorded.createDeployment(NAMESPACE, {
710
+ name: agentResourceName,
711
+ image: `${BASE_CONFIG.image}:${BASE_CONFIG.imageTag}`,
712
+ });
713
+
714
+ const provisioner = new KubernetesAgentProvisioner(
715
+ recorded,
716
+ stubTokens() as AgentTokenService,
717
+ config,
718
+ );
719
+
720
+ // Should NOT throw — ConflictError on Deployment is idempotent success.
721
+ await expect(provisioner.provision(AGENT_ID)).resolves.toBeDefined();
722
+
723
+ // Token WAS minted (Secret was new in this call).
724
+ expect(minted).toHaveLength(1);
725
+ // But because it was a conflict (deployment already existed), we rolled back
726
+ // the Secret — the task-store token should also be revoked.
727
+ expect(revoked).toEqual([minted[0].id]);
728
+ });
729
+ });
730
+
731
+ describe("KubernetesAgentProvisioner — multiple service integrations", () => {
732
+ const AGENT_ID = "multi-agent";
733
+
734
+ function svc(name: string, log: string[], url?: string): ServiceIntegration {
735
+ return {
736
+ name,
737
+ url,
738
+ tokenEnv: `${name}_TOKEN`,
739
+ urlEnv: `${name}_URL`,
740
+ secretKey: `${name}-key`,
741
+ mintToken: async () => {
742
+ log.push(`mint:${name}`);
743
+ return { id: `${name}-id`, rawToken: `${name}-raw` };
744
+ },
745
+ revokeToken: async (id) => {
746
+ log.push(`revoke:${id}`);
747
+ },
748
+ listTokensForAgent: async () => [],
749
+ };
750
+ }
751
+
752
+ it("mints in list order before chat, skips url-less integrations, and stores each token under its secretKey", async () => {
753
+ const log: string[] = [];
754
+ const k8s = emptyClient();
755
+ const provisioner = new KubernetesAgentProvisioner(
756
+ k8s,
757
+ stubTokens() as AgentTokenService,
758
+ {
759
+ ...BASE_CONFIG,
760
+ serviceIntegrations: [
761
+ svc("a", log, "http://a"),
762
+ svc("skipped", log),
763
+ svc("b", log, "http://b"),
764
+ ],
765
+ chatService: {
766
+ mintToken: async () => {
767
+ log.push("mint:chat");
768
+ return { id: "chat-id", rawToken: "chat-raw" };
769
+ },
770
+ revokeToken: async () => {},
771
+ listTokensForAgent: async () => [],
772
+ deleteThreadsForAgent: async () => ({ deleted: 0 }),
773
+ },
774
+ chatServiceUrl: "http://chat",
775
+ },
776
+ );
777
+ await provisioner.provision(AGENT_ID);
778
+ expect(log).toEqual(["mint:a", "mint:b", "mint:chat"]);
779
+ const secret = await k8s.getSecret(
780
+ NAMESPACE,
781
+ `${sanitizeAgentName(AGENT_ID)}-token`,
782
+ );
783
+ expect(Object.keys(secret.data)).toEqual([
784
+ "token",
785
+ "a-key",
786
+ "b-key",
787
+ "chat-service-token",
788
+ ]);
789
+ });
790
+
791
+ it("rolls back every minted integration token when Deployment creation fails", async () => {
792
+ const log: string[] = [];
793
+ const recorded = emptyClient();
794
+ const failing = Object.assign(
795
+ Object.create(Object.getPrototypeOf(recorded)),
796
+ recorded,
797
+ {
798
+ createDeploymentManifest: async () => {
799
+ throw new Error("boom");
800
+ },
801
+ },
802
+ );
803
+ const provisioner = new KubernetesAgentProvisioner(
804
+ failing,
805
+ stubTokens() as AgentTokenService,
806
+ {
807
+ ...BASE_CONFIG,
808
+ serviceIntegrations: [
809
+ svc("a", log, "http://a"),
810
+ svc("b", log, "http://b"),
811
+ ],
812
+ },
813
+ );
814
+ await expect(provisioner.provision(AGENT_ID)).rejects.toThrow("boom");
815
+ expect(log).toEqual(["mint:a", "mint:b", "revoke:a-id", "revoke:b-id"]);
816
+ });
817
+ });
818
+
819
+ // ─── deprovision() deletes the PVC ────────────────────────────────────────────
820
+
821
+ /**
822
+ * Wraps a RecordedKubernetesClient, delegating every call through to it while
823
+ * recording each deletePvc invocation for assertion.
824
+ */
825
+ function spyOnDeletePvc(recorded: RecordedKubernetesClient): {
826
+ client: KubernetesClient;
827
+ deletedPvcs: Array<{ namespace: string; name: string }>;
828
+ } {
829
+ const deletedPvcs: Array<{ namespace: string; name: string }> = [];
830
+ const client: KubernetesClient = {
831
+ createDeployment: (ns, spec) => recorded.createDeployment(ns, spec),
832
+ createDeploymentManifest: (ns, manifest) =>
833
+ recorded.createDeploymentManifest(ns, manifest),
834
+ getDeployment: (ns, name) => recorded.getDeployment(ns, name),
835
+ deploymentExists: (ns, name) => recorded.deploymentExists(ns, name),
836
+ listDeployments: (ns, sel) => recorded.listDeployments(ns, sel),
837
+ deleteDeployment: (ns, name) => recorded.deleteDeployment(ns, name),
838
+ patchDeployment: (ns, name, patch) =>
839
+ recorded.patchDeployment(ns, name, patch),
840
+ createSecret: (ns, spec) => recorded.createSecret(ns, spec),
841
+ getSecret: (ns, name) => recorded.getSecret(ns, name),
842
+ deleteSecret: (ns, name) => recorded.deleteSecret(ns, name),
843
+ createPvc: (ns, spec) => recorded.createPvc(ns, spec),
844
+ getPvc: (ns, name) => recorded.getPvc(ns, name),
845
+ deletePvc: (ns, name) => {
846
+ deletedPvcs.push({ namespace: ns, name });
847
+ return recorded.deletePvc(ns, name);
848
+ },
849
+ createServiceAccount: (ns, spec) => recorded.createServiceAccount(ns, spec),
850
+ getServiceAccount: (ns, name) => recorded.getServiceAccount(ns, name),
851
+ deleteServiceAccount: (ns, name) => recorded.deleteServiceAccount(ns, name),
852
+ };
853
+ return { client, deletedPvcs };
854
+ }
855
+
856
+ describe("KubernetesAgentProvisioner.deprovision() — PVC deletion", () => {
857
+ it("calls deletePvc with the default {resourceName}-home name", async () => {
858
+ const agentId = "cmqalfjcm000m4101iharq28k";
859
+ const resourceName = sanitizeAgentName(agentId);
860
+ const expectedPvcName = `${resourceName}-home`;
861
+
862
+ const { client, deletedPvcs } = spyOnDeletePvc(emptyClient());
863
+ const provisioner = new KubernetesAgentProvisioner(
864
+ client,
865
+ stubTokens() as AgentTokenService,
866
+ BASE_CONFIG,
867
+ );
868
+
869
+ await provisioner.provision(agentId);
870
+ await provisioner.deprovision(agentId);
871
+
872
+ expect(deletedPvcs).toEqual([
873
+ { namespace: NAMESPACE, name: expectedPvcName },
874
+ ]);
875
+ });
876
+
877
+ it("calls deletePvc with the templated name derived from slug", async () => {
878
+ const agentId = "cmqalfjcm000m4101iharq28k";
879
+ const slug = "okwow";
880
+ const expectedPvcName = "acme-agent-okwow-home";
881
+
882
+ const config: KubernetesAgentProvisionerConfig = {
883
+ ...BASE_CONFIG,
884
+ pvcName: (name) => `acme-agent-${name}-home`,
885
+ };
886
+ const { client, deletedPvcs } = spyOnDeletePvc(emptyClient());
887
+ const provisioner = new KubernetesAgentProvisioner(
888
+ client,
889
+ stubTokens() as AgentTokenService,
890
+ config,
891
+ );
892
+
893
+ await provisioner.provision(agentId, { slug });
894
+ await provisioner.deprovision(agentId, { slug });
895
+
896
+ expect(deletedPvcs).toEqual([
897
+ { namespace: NAMESPACE, name: expectedPvcName },
898
+ ]);
899
+ });
900
+ });
901
+
902
+ // ─── Chat-service token minting ───────────────────────────────────────────────
903
+
904
+ /**
905
+ * Build a spy ChatServiceProvisioningClient. Records all mintToken and
906
+ * revokeToken calls for assertion in tests.
907
+ */
908
+ function stubChatService(opts?: { throwOnMint?: boolean }): {
909
+ client: ChatServiceProvisioningClient;
910
+ minted: Array<{ label: string; agentId: string | undefined; id: string }>;
911
+ revoked: string[];
912
+ } {
913
+ const minted: Array<{
914
+ label: string;
915
+ agentId: string | undefined;
916
+ id: string;
917
+ }> = [];
918
+ const revoked: string[] = [];
919
+ let seq = 0;
920
+
921
+ const client: ChatServiceProvisioningClient = {
922
+ async mintToken(label: string, agentId?: string) {
923
+ if (opts?.throwOnMint) throw new Error("mint failed");
924
+ seq++;
925
+ const id = `cs-tok-${seq}`;
926
+ minted.push({ label, agentId, id });
927
+ return { id, rawToken: `raw-cs-token-${seq}` };
928
+ },
929
+ async revokeToken(id: string) {
930
+ revoked.push(id);
931
+ },
932
+ async listTokensForAgent(_agentId: string) {
933
+ return [];
934
+ },
935
+ async deleteThreadsForAgent(_agentId: string) {
936
+ return { deleted: 0 };
937
+ },
938
+ };
939
+
940
+ return { client, minted, revoked };
941
+ }
942
+
943
+ describe("KubernetesAgentProvisioner — chat-service token minting", () => {
944
+ const AGENT_ID = "test-agent-001";
945
+
946
+ it("provision() mints a chat-service token when chatService client is configured", async () => {
947
+ const { client, minted } = stubChatService();
948
+ const config: KubernetesAgentProvisionerConfig = {
949
+ ...BASE_CONFIG,
950
+ chatService: client,
951
+ };
952
+ const k8s = emptyClient();
953
+ const provisioner = new KubernetesAgentProvisioner(
954
+ k8s,
955
+ stubTokens() as AgentTokenService,
956
+ config,
957
+ );
958
+
959
+ await provisioner.provision(AGENT_ID);
960
+
961
+ expect(minted).toHaveLength(1);
962
+ expect(minted[0].label).toBe(`agent:${AGENT_ID}`);
963
+ expect(minted[0].agentId).toBe(AGENT_ID);
964
+
965
+ // Verify the Secret contains the chat-service-token key (stored as base64 data).
966
+ const resourceName = sanitizeAgentName(AGENT_ID);
967
+ const secretName = `${resourceName}-token`;
968
+ const secret = await k8s.getSecret(NAMESPACE, secretName);
969
+ expect(secret.data["chat-service-token"]).toBeDefined();
970
+ });
971
+
972
+ it("provision() does NOT mint a chat-service token when chatService is not configured", async () => {
973
+ // BASE_CONFIG has no chatService
974
+ const { client, minted } = stubChatService();
975
+ const k8s = emptyClient();
976
+ const provisioner = new KubernetesAgentProvisioner(
977
+ k8s,
978
+ stubTokens() as AgentTokenService,
979
+ BASE_CONFIG,
980
+ );
981
+
982
+ await provisioner.provision(AGENT_ID);
983
+
984
+ // client is not injected so mintToken is never called
985
+ expect(minted).toHaveLength(0);
986
+ // Suppress "client is unused" lint warning by referencing it
987
+ void client;
988
+ });
989
+
990
+ it("provision() revokes the chat-service token when Deployment creation fails", async () => {
991
+ const { client, minted, revoked } = stubChatService();
992
+ const config: KubernetesAgentProvisionerConfig = {
993
+ ...BASE_CONFIG,
994
+ chatService: client,
995
+ };
996
+
997
+ const recorded = emptyClient();
998
+ const _failingK8s = new RecordedKubernetesClient({
999
+ deployments: {},
1000
+ secrets: {},
1001
+ pvcs: {},
1002
+ });
1003
+ // Wrap to throw on createDeploymentManifest
1004
+ const failingClient = Object.assign(
1005
+ Object.create(Object.getPrototypeOf(recorded)),
1006
+ recorded,
1007
+ {
1008
+ createDeploymentManifest: async () => {
1009
+ throw new Error("simulated deploy failure");
1010
+ },
1011
+ },
1012
+ );
1013
+
1014
+ const provisioner = new KubernetesAgentProvisioner(
1015
+ failingClient,
1016
+ stubTokens() as AgentTokenService,
1017
+ config,
1018
+ );
1019
+
1020
+ await expect(provisioner.provision(AGENT_ID)).rejects.toThrow(
1021
+ "simulated deploy failure",
1022
+ );
1023
+
1024
+ expect(minted).toHaveLength(1);
1025
+ expect(revoked).toEqual([minted[0].id]);
1026
+ });
1027
+
1028
+ it("provision() revokes the chat-service token when Deployment already exists (409 conflict)", async () => {
1029
+ const { client, minted, revoked } = stubChatService();
1030
+ const config: KubernetesAgentProvisionerConfig = {
1031
+ ...BASE_CONFIG,
1032
+ chatService: client,
1033
+ };
1034
+
1035
+ // Pre-seed the Deployment so the createDeploymentManifest call hits a 409.
1036
+ // Do NOT pre-seed the Secret, so the provisioner mints a fresh token and
1037
+ // creates a new Secret — then hits a conflict on the Deployment.
1038
+ const recorded = emptyClient();
1039
+ const agentResourceName = sanitizeAgentName(AGENT_ID);
1040
+ await recorded.createDeployment(NAMESPACE, {
1041
+ name: agentResourceName,
1042
+ image: `${BASE_CONFIG.image}:${BASE_CONFIG.imageTag}`,
1043
+ });
1044
+
1045
+ const provisioner = new KubernetesAgentProvisioner(
1046
+ recorded,
1047
+ stubTokens() as AgentTokenService,
1048
+ config,
1049
+ );
1050
+
1051
+ // Should NOT throw — ConflictError on Deployment is idempotent success.
1052
+ await expect(provisioner.provision(AGENT_ID)).resolves.toBeDefined();
1053
+
1054
+ // Token WAS minted (Secret was new in this call).
1055
+ expect(minted).toHaveLength(1);
1056
+ // Deployment conflict rolled back the Secret — chat-service token must also be revoked.
1057
+ expect(revoked).toEqual([minted[0].id]);
1058
+ });
1059
+ });
1060
+
1061
+ // ─── canProvision ─────────────────────────────────────────────────────────────
1062
+
1063
+ describe("canProvision", () => {
1064
+ it("is false on the no-op provisioner — it creates no cluster resources", () => {
1065
+ expect(new NoopAgentProvisioner().canProvision).toBe(false);
1066
+ });
1067
+
1068
+ it("is true on the Kubernetes provisioner", () => {
1069
+ const provisioner = new KubernetesAgentProvisioner(
1070
+ emptyClient(),
1071
+ stubTokens() as AgentTokenService,
1072
+ BASE_CONFIG,
1073
+ );
1074
+ expect(provisioner.canProvision).toBe(true);
1075
+ });
1076
+ });
1077
+
1078
+ describe("KubernetesAgentProvisioner.reconcile() — tenant drift (SSP-7.1)", () => {
1079
+ const agentId = "cmqalfjcm000m4101iharq28k";
1080
+ const resourceName = sanitizeAgentName(agentId);
1081
+
1082
+ async function seededWithPlatformManifest() {
1083
+ const k8s = new RecordedKubernetesClient({
1084
+ deployments: {},
1085
+ secrets: {},
1086
+ pvcs: {},
1087
+ });
1088
+ const provisioner = new KubernetesAgentProvisioner(
1089
+ k8s,
1090
+ stubTokens() as AgentTokenService,
1091
+ BASE_CONFIG,
1092
+ );
1093
+ await provisioner.provision(agentId); // created as a platform agent
1094
+ return { k8s, provisioner };
1095
+ }
1096
+
1097
+ it("detects a missing tenant label + CPU limit as drift and patches", async () => {
1098
+ const { k8s, provisioner } = await seededWithPlatformManifest();
1099
+
1100
+ const result = await provisioner.reconcile([
1101
+ { id: agentId, accountId: "acct_1" },
1102
+ ]);
1103
+
1104
+ expect(result.updated).toEqual([agentId]);
1105
+ const dep = await k8s.getDeployment(NAMESPACE, resourceName);
1106
+ expect(dep.spec.template.metadata.labels["shipwright.dev/tenant"]).toBe(
1107
+ "true",
1108
+ );
1109
+ expect(dep.spec.template.spec.containers[0]?.resources?.limits?.cpu).toBe(
1110
+ "2",
1111
+ );
1112
+ });
1113
+
1114
+ it("is a no-op once the tenant Deployment matches", async () => {
1115
+ const { provisioner } = await seededWithPlatformManifest();
1116
+ await provisioner.reconcile([{ id: agentId, accountId: "acct_1" }]);
1117
+ const second = await provisioner.reconcile([
1118
+ { id: agentId, accountId: "acct_1" },
1119
+ ]);
1120
+ expect(second.updated).toEqual([]);
1121
+ });
1122
+
1123
+ it("does not touch platform agents (no accountId)", async () => {
1124
+ const { provisioner } = await seededWithPlatformManifest();
1125
+ const result = await provisioner.reconcile([{ id: agentId }]);
1126
+ expect(result.updated).toEqual([]);
1127
+ });
1128
+ });
1129
+
1130
+ // ─── Claude policy drift + reconcile patch ──────────────────────────────────
1131
+
1132
+ const POLICY_AGENT_ID = "cmqalfjcm000m4101iharq28k";
1133
+ const POLICY_RESOURCE = sanitizeAgentName(POLICY_AGENT_ID);
1134
+ const POLICY_VOLUME_PATH = "claude-policy";
1135
+
1136
+ function manifestWith(
1137
+ claudePolicy?: { configMapName: string; hash?: string },
1138
+ overrides: {
1139
+ imageTag?: string;
1140
+ anthropicFederation?: AgentAnthropicFederation;
1141
+ } = {},
1142
+ ) {
1143
+ return buildAgentDeploymentManifest({
1144
+ agentId: POLICY_AGENT_ID,
1145
+ namespace: NAMESPACE,
1146
+ image: BASE_CONFIG.image,
1147
+ imageTag: overrides.imageTag ?? BASE_CONFIG.imageTag,
1148
+ apiUrl: BASE_CONFIG.apiUrl,
1149
+ pvcName: `${POLICY_RESOURCE}-home`,
1150
+ secretName: `${POLICY_RESOURCE}-token`,
1151
+ tokenSecretKey: "token",
1152
+ claudePolicy,
1153
+ anthropicFederation: overrides.anthropicFederation,
1154
+ });
1155
+ }
1156
+
1157
+ /** Records every patch body sent through the client. */
1158
+ function patchSpy(rec: RecordedKubernetesClient): {
1159
+ client: KubernetesClient;
1160
+ patches: object[];
1161
+ } {
1162
+ const patches: object[] = [];
1163
+ const client: KubernetesClient = {
1164
+ createDeployment: (ns, spec) => rec.createDeployment(ns, spec),
1165
+ createDeploymentManifest: (ns, m) => rec.createDeploymentManifest(ns, m),
1166
+ getDeployment: (ns, n) => rec.getDeployment(ns, n),
1167
+ deploymentExists: (ns, n) => rec.deploymentExists(ns, n),
1168
+ listDeployments: (ns, s) => rec.listDeployments(ns, s),
1169
+ deleteDeployment: (ns, n) => rec.deleteDeployment(ns, n),
1170
+ patchDeployment: async (ns, n, patch) => {
1171
+ patches.push(patch);
1172
+ return rec.patchDeployment(ns, n, patch);
1173
+ },
1174
+ createSecret: (ns, s) => rec.createSecret(ns, s),
1175
+ getSecret: (ns, n) => rec.getSecret(ns, n),
1176
+ deleteSecret: (ns, n) => rec.deleteSecret(ns, n),
1177
+ createPvc: (ns, s) => rec.createPvc(ns, s),
1178
+ getPvc: (ns, n) => rec.getPvc(ns, n),
1179
+ deletePvc: (ns, n) => rec.deletePvc(ns, n),
1180
+ createServiceAccount: (ns, spec) => rec.createServiceAccount(ns, spec),
1181
+ getServiceAccount: (ns, n) => rec.getServiceAccount(ns, n),
1182
+ deleteServiceAccount: (ns, n) => rec.deleteServiceAccount(ns, n),
1183
+ };
1184
+ return { client, patches };
1185
+ }
1186
+
1187
+ function seeded(live: ReturnType<typeof manifestWith>) {
1188
+ return new RecordedKubernetesClient({
1189
+ deployments: { [`${NAMESPACE}/${POLICY_RESOURCE}`]: live },
1190
+ secrets: {},
1191
+ pvcs: {},
1192
+ });
1193
+ }
1194
+
1195
+ const POLICY_A = { configMapName: "claude-policy-cm", hash: "hash-a" };
1196
+
1197
+ describe("policyDrifted()", () => {
1198
+ it("is drifted when the policy is desired but the live Deployment lacks it", () => {
1199
+ expect(policyDrifted(manifestWith(), manifestWith(POLICY_A))).toBe(true);
1200
+ });
1201
+
1202
+ it("is drifted when the hash annotation differs", () => {
1203
+ expect(
1204
+ policyDrifted(
1205
+ manifestWith({ ...POLICY_A, hash: "old" }),
1206
+ manifestWith(POLICY_A),
1207
+ ),
1208
+ ).toBe(true);
1209
+ });
1210
+
1211
+ it("is not drifted when the hash matches", () => {
1212
+ expect(policyDrifted(manifestWith(POLICY_A), manifestWith(POLICY_A))).toBe(
1213
+ false,
1214
+ );
1215
+ });
1216
+
1217
+ it("is drifted when the volume points at a different ConfigMap", () => {
1218
+ expect(
1219
+ policyDrifted(
1220
+ manifestWith({ ...POLICY_A, configMapName: "other" }),
1221
+ manifestWith(POLICY_A),
1222
+ ),
1223
+ ).toBe(true);
1224
+ });
1225
+
1226
+ it("is drifted when only the mount is missing", () => {
1227
+ const live = manifestWith(POLICY_A);
1228
+ const c = live.spec.template.spec.containers[0];
1229
+ if (c) {
1230
+ c.volumeMounts = c.volumeMounts?.filter(
1231
+ (m) => m.mountPath !== "/etc/claude-code",
1232
+ );
1233
+ }
1234
+ expect(policyDrifted(live, manifestWith(POLICY_A))).toBe(true);
1235
+ });
1236
+
1237
+ it("is drifted when the policy mount is not read-only", () => {
1238
+ const live = manifestWith(POLICY_A);
1239
+ const mount = live.spec.template.spec.containers[0]?.volumeMounts?.find(
1240
+ (m) => m.mountPath === "/etc/claude-code",
1241
+ );
1242
+ if (mount) mount.readOnly = false;
1243
+ expect(policyDrifted(live, manifestWith(POLICY_A))).toBe(true);
1244
+ });
1245
+
1246
+ it("ignores a same-named mount at another path once the policy mount is in sync", () => {
1247
+ const live = manifestWith(POLICY_A);
1248
+ live.spec.template.spec.containers[0]?.volumeMounts?.unshift({
1249
+ name: "claude-policy",
1250
+ mountPath: "/etc/other",
1251
+ });
1252
+ expect(policyDrifted(live, manifestWith(POLICY_A))).toBe(false);
1253
+ });
1254
+
1255
+ it("is drifted when a live hash annotation is extra (desired hash absent)", () => {
1256
+ expect(
1257
+ policyDrifted(
1258
+ manifestWith(POLICY_A),
1259
+ manifestWith({ configMapName: POLICY_A.configMapName }),
1260
+ ),
1261
+ ).toBe(true);
1262
+ });
1263
+
1264
+ it("is drifted when the policy is disabled but the live Deployment still has it", () => {
1265
+ expect(policyDrifted(manifestWith(POLICY_A), manifestWith())).toBe(true);
1266
+ });
1267
+
1268
+ it("is not drifted when the policy is disabled and absent", () => {
1269
+ expect(policyDrifted(manifestWith(), manifestWith())).toBe(false);
1270
+ });
1271
+ });
1272
+
1273
+ describe("KubernetesAgentProvisioner.reconcile() — claude policy", () => {
1274
+ function provisionerFor(
1275
+ client: KubernetesClient,
1276
+ claudePolicy?: { configMapName: string; hash?: string },
1277
+ ) {
1278
+ return new KubernetesAgentProvisioner(
1279
+ client,
1280
+ stubTokens() as AgentTokenService,
1281
+ { ...BASE_CONFIG, claudePolicy },
1282
+ );
1283
+ }
1284
+
1285
+ it("adds the volume, mount and annotation when the policy is configured", async () => {
1286
+ const rec = seeded(manifestWith());
1287
+ const { client, patches } = patchSpy(rec);
1288
+ const result = await provisionerFor(client, POLICY_A).reconcile([
1289
+ { id: POLICY_AGENT_ID },
1290
+ ]);
1291
+
1292
+ expect(result.updated).toEqual([POLICY_AGENT_ID]);
1293
+ const desired = manifestWith(POLICY_A);
1294
+ const volume = desired.spec.template.spec.volumes?.find(
1295
+ (v) => v.name === POLICY_VOLUME_PATH,
1296
+ );
1297
+ const mount = desired.spec.template.spec.containers[0]?.volumeMounts?.find(
1298
+ (m) => m.name === POLICY_VOLUME_PATH,
1299
+ );
1300
+ expect(patches).toHaveLength(1);
1301
+ expect(patches[0]).toMatchObject({
1302
+ spec: {
1303
+ template: {
1304
+ metadata: {
1305
+ annotations: { "shipwright.dev/claude-policy-hash": "hash-a" },
1306
+ },
1307
+ spec: {
1308
+ volumes: [volume],
1309
+ containers: [{ volumeMounts: [mount] }],
1310
+ },
1311
+ },
1312
+ },
1313
+ });
1314
+
1315
+ // Converged: a second pass is a no-op.
1316
+ const again = await provisionerFor(client, POLICY_A).reconcile([
1317
+ { id: POLICY_AGENT_ID },
1318
+ ]);
1319
+ expect(again.updated).toEqual([]);
1320
+ expect(patches).toHaveLength(1);
1321
+ });
1322
+
1323
+ it("updates the hash annotation without touching the volume or mount", async () => {
1324
+ const rec = seeded(manifestWith({ ...POLICY_A, hash: "old" }));
1325
+ const { client, patches } = patchSpy(rec);
1326
+ await provisionerFor(client, POLICY_A).reconcile([{ id: POLICY_AGENT_ID }]);
1327
+
1328
+ expect(patches).toHaveLength(1);
1329
+ const tpl = (
1330
+ patches[0] as {
1331
+ spec: { template: { spec: Record<string, unknown> } };
1332
+ }
1333
+ ).spec.template;
1334
+ expect(tpl).toMatchObject({
1335
+ metadata: {
1336
+ annotations: { "shipwright.dev/claude-policy-hash": "hash-a" },
1337
+ },
1338
+ });
1339
+ expect(tpl.spec.volumes).toBeUndefined();
1340
+ const live = await rec.getDeployment(NAMESPACE, POLICY_RESOURCE);
1341
+ expect(
1342
+ live.spec.template.metadata.annotations?.[
1343
+ "shipwright.dev/claude-policy-hash"
1344
+ ],
1345
+ ).toBe("hash-a");
1346
+ });
1347
+
1348
+ it("removes the volume, mount and annotation when the policy is disabled", async () => {
1349
+ const rec = seeded(manifestWith(POLICY_A));
1350
+ const { client, patches } = patchSpy(rec);
1351
+ const result = await provisionerFor(client, undefined).reconcile([
1352
+ { id: POLICY_AGENT_ID },
1353
+ ]);
1354
+
1355
+ expect(result.updated).toEqual([POLICY_AGENT_ID]);
1356
+ expect(patches).toHaveLength(1);
1357
+ expect(patches[0]).toMatchObject({
1358
+ spec: {
1359
+ template: {
1360
+ metadata: {
1361
+ annotations: { "shipwright.dev/claude-policy-hash": null },
1362
+ },
1363
+ spec: {
1364
+ volumes: [{ name: "claude-policy", $patch: "delete" }],
1365
+ containers: [
1366
+ {
1367
+ volumeMounts: [
1368
+ { mountPath: "/etc/claude-code", $patch: "delete" },
1369
+ ],
1370
+ },
1371
+ ],
1372
+ },
1373
+ },
1374
+ },
1375
+ });
1376
+
1377
+ // The delete directives actually remove the entries.
1378
+ const live = await rec.getDeployment(NAMESPACE, POLICY_RESOURCE);
1379
+ expect(
1380
+ live.spec.template.spec.volumes?.some((v) => v.name === "claude-policy"),
1381
+ ).toBe(false);
1382
+ expect(
1383
+ live.spec.template.spec.containers[0]?.volumeMounts?.some(
1384
+ (m) => m.mountPath === "/etc/claude-code",
1385
+ ),
1386
+ ).toBe(false);
1387
+ expect(
1388
+ live.spec.template.metadata.annotations?.[
1389
+ "shipwright.dev/claude-policy-hash"
1390
+ ],
1391
+ ).toBeUndefined();
1392
+ // The PVC volume and home mount survive.
1393
+ expect(live.spec.template.spec.volumes?.length).toBe(1);
1394
+ expect(live.spec.template.spec.containers[0]?.volumeMounts?.length).toBe(1);
1395
+ });
1396
+
1397
+ it("does not patch when nothing drifted", async () => {
1398
+ const rec = seeded(manifestWith(POLICY_A));
1399
+ const { client, patches } = patchSpy(rec);
1400
+ await provisionerFor(client, POLICY_A).reconcile([{ id: POLICY_AGENT_ID }]);
1401
+ expect(patches).toEqual([]);
1402
+ });
1403
+
1404
+ it("policyOnly leaves image drift alone", async () => {
1405
+ const rec = seeded(manifestWith(POLICY_A, { imageTag: "v0.0.1" }));
1406
+ const { client, patches } = patchSpy(rec);
1407
+ const result = await provisionerFor(client, POLICY_A).reconcile(
1408
+ [{ id: POLICY_AGENT_ID }],
1409
+ { policyOnly: true },
1410
+ );
1411
+ expect(result.updated).toEqual([]);
1412
+ expect(patches).toEqual([]);
1413
+ });
1414
+
1415
+ it("policyOnly leaves env and resource drift alone", async () => {
1416
+ const live = manifestWith(POLICY_A);
1417
+ const c = live.spec.template.spec.containers[0];
1418
+ if (c) {
1419
+ c.env = (c.env ?? []).map((e) =>
1420
+ e.value !== undefined ? { ...e, value: `${e.value}-stale` } : e,
1421
+ );
1422
+ c.resources = { requests: { memory: "1Mi" }, limits: { memory: "1Mi" } };
1423
+ }
1424
+ const { client, patches } = patchSpy(seeded(live));
1425
+ const result = await provisionerFor(client, POLICY_A).reconcile(
1426
+ [{ id: POLICY_AGENT_ID }],
1427
+ { policyOnly: true },
1428
+ );
1429
+ expect(result.updated).toEqual([]);
1430
+ expect(patches).toEqual([]);
1431
+
1432
+ // Sanity: without policyOnly the same live state is drifted.
1433
+ const full = await provisionerFor(client, POLICY_A).reconcile([
1434
+ { id: POLICY_AGENT_ID },
1435
+ ]);
1436
+ expect(full.updated).toEqual([POLICY_AGENT_ID]);
1437
+ });
1438
+
1439
+ it("policyOnly patches only policy fields when both kinds drifted", async () => {
1440
+ const rec = seeded(manifestWith(undefined, { imageTag: "v0.0.1" }));
1441
+ const { client, patches } = patchSpy(rec);
1442
+ const result = await provisionerFor(client, POLICY_A).reconcile(
1443
+ [{ id: POLICY_AGENT_ID }],
1444
+ { policyOnly: true },
1445
+ );
1446
+ expect(result.updated).toEqual([POLICY_AGENT_ID]);
1447
+ expect(patches).toHaveLength(1);
1448
+ const container = (
1449
+ patches[0] as {
1450
+ spec: { template: { spec: { containers: Array<object> } } };
1451
+ }
1452
+ ).spec.template.spec.containers[0];
1453
+ expect(Object.keys(container ?? {}).sort()).toEqual([
1454
+ "name",
1455
+ "volumeMounts",
1456
+ ]);
1457
+ const live = await rec.getDeployment(NAMESPACE, POLICY_RESOURCE);
1458
+ expect(live.spec.template.spec.containers[0]?.image).toBe(
1459
+ `${BASE_CONFIG.image}:v0.0.1`,
1460
+ );
1461
+ });
1462
+
1463
+ it("without policyOnly, image and policy drift go in one patch", async () => {
1464
+ const rec = seeded(manifestWith(undefined, { imageTag: "v0.0.1" }));
1465
+ const { client, patches } = patchSpy(rec);
1466
+ await provisionerFor(client, POLICY_A).reconcile([{ id: POLICY_AGENT_ID }]);
1467
+ expect(patches).toHaveLength(1);
1468
+ const live = await rec.getDeployment(NAMESPACE, POLICY_RESOURCE);
1469
+ expect(live.spec.template.spec.containers[0]?.image).toBe(
1470
+ `${BASE_CONFIG.image}:${BASE_CONFIG.imageTag}`,
1471
+ );
1472
+ expect(
1473
+ live.spec.template.spec.volumes?.some((v) => v.name === "claude-policy"),
1474
+ ).toBe(true);
1475
+ });
1476
+ });
1477
+
1478
+ describe("NoopAgentProvisioner.reconcile()", () => {
1479
+ it("accepts the policyOnly option", async () => {
1480
+ const result = await new NoopAgentProvisioner().reconcile([{ id: "a" }], {
1481
+ policyOnly: true,
1482
+ });
1483
+ expect(result).toEqual({
1484
+ recreated: [],
1485
+ migrated: [],
1486
+ noOwner: [],
1487
+ dryRun: false,
1488
+ updated: [],
1489
+ orphans: [],
1490
+ failed: [],
1491
+ });
1492
+ });
1493
+
1494
+ it("accepts a target carrying owner and identity fields, and reports a dry run", async () => {
1495
+ const result = await new NoopAgentProvisioner().reconcile(
1496
+ [
1497
+ {
1498
+ id: "a",
1499
+ ownerEmail: "jane.doe@example.com",
1500
+ anthropicIdentityName: null,
1501
+ anthropicServiceAccountId: null,
1502
+ anthropicFederationRuleId: null,
1503
+ k8sServiceAccountName: null,
1504
+ },
1505
+ ],
1506
+ { dryRun: true },
1507
+ );
1508
+ expect(result.dryRun).toBe(true);
1509
+ expect(result.migrated).toEqual([]);
1510
+ expect(result.noOwner).toEqual([]);
1511
+ });
1512
+ });
1513
+
1514
+ // ─── Anthropic workload identity federation drift + reconcile patch ─────────
1515
+
1516
+ const FED_A: AgentAnthropicFederation = {
1517
+ serviceAccountName: "shipwright-agent",
1518
+ ruleId: "fdrl_a",
1519
+ organizationId: "org-a",
1520
+ serviceAccountId: "svac_a",
1521
+ audience: "https://api.anthropic.com",
1522
+ expirationSeconds: 600,
1523
+ };
1524
+
1525
+ const FED_ENV_NAMES = [
1526
+ "ANTHROPIC_IDENTITY_TOKEN_FILE",
1527
+ "ANTHROPIC_FEDERATION_RULE_ID",
1528
+ "ANTHROPIC_ORGANIZATION_ID",
1529
+ "ANTHROPIC_SERVICE_ACCOUNT_ID",
1530
+ ];
1531
+
1532
+ function fedManifest(fed?: AgentAnthropicFederation) {
1533
+ return manifestWith(undefined, { anthropicFederation: fed });
1534
+ }
1535
+
1536
+ function fedVolume(dep: ReturnType<typeof manifestWith>) {
1537
+ const vol = dep.spec.template.spec.volumes?.find(
1538
+ (v) => v.name === "anthropic-token",
1539
+ );
1540
+ if (!vol) throw new Error("anthropic-token volume missing");
1541
+ return vol;
1542
+ }
1543
+
1544
+ describe("federationPatch()", () => {
1545
+ it("adds the service account, volume, mount and env when enabled", () => {
1546
+ const desired = fedManifest(FED_A);
1547
+ expect(federationPatch(fedManifest(), desired)).toEqual({
1548
+ serviceAccountName: "shipwright-agent",
1549
+ volumes: [fedVolume(desired)],
1550
+ volumeMounts: [
1551
+ {
1552
+ name: "anthropic-token",
1553
+ mountPath: "/var/run/secrets/anthropic.com",
1554
+ readOnly: true,
1555
+ },
1556
+ ],
1557
+ env: [
1558
+ {
1559
+ name: "ANTHROPIC_IDENTITY_TOKEN_FILE",
1560
+ value: "/var/run/secrets/anthropic.com/token",
1561
+ },
1562
+ { name: "ANTHROPIC_FEDERATION_RULE_ID", value: "fdrl_a" },
1563
+ { name: "ANTHROPIC_ORGANIZATION_ID", value: "org-a" },
1564
+ { name: "ANTHROPIC_SERVICE_ACCOUNT_ID", value: "svac_a" },
1565
+ ],
1566
+ });
1567
+ });
1568
+
1569
+ it("is undefined when the live wiring is in sync", () => {
1570
+ expect(
1571
+ federationPatch(fedManifest(FED_A), fedManifest(FED_A)),
1572
+ ).toBeUndefined();
1573
+ });
1574
+
1575
+ it("is undefined when federation is disabled and absent", () => {
1576
+ expect(federationPatch(fedManifest(), fedManifest())).toBeUndefined();
1577
+ });
1578
+
1579
+ it("leaves a ServiceAccount without federation wiring alone when disabled", () => {
1580
+ const live = fedManifest();
1581
+ live.spec.template.spec.serviceAccountName = "hand-set-sa";
1582
+ expect(federationPatch(live, fedManifest())).toBeUndefined();
1583
+ });
1584
+
1585
+ it("ignores server-defaulted fields on the live projected volume", () => {
1586
+ const live = fedManifest(FED_A);
1587
+ const vol = fedVolume(live);
1588
+ if (vol) (vol.projected as Record<string, unknown>).defaultMode = 420;
1589
+ expect(federationPatch(live, fedManifest(FED_A))).toBeUndefined();
1590
+ });
1591
+
1592
+ it("updates only the changed parts", () => {
1593
+ const desired = fedManifest({
1594
+ ...FED_A,
1595
+ ruleId: "fdrl_b",
1596
+ expirationSeconds: 1200,
1597
+ workspaceId: "wrkspc_1",
1598
+ });
1599
+ expect(federationPatch(fedManifest(FED_A), desired)).toEqual({
1600
+ volumes: [fedVolume(desired)],
1601
+ env: [
1602
+ { name: "ANTHROPIC_FEDERATION_RULE_ID", value: "fdrl_b" },
1603
+ { name: "ANTHROPIC_WORKSPACE_ID", value: "wrkspc_1" },
1604
+ ],
1605
+ });
1606
+ });
1607
+
1608
+ it("updates the service account name when it changed", () => {
1609
+ expect(
1610
+ federationPatch(
1611
+ fedManifest(FED_A),
1612
+ fedManifest({ ...FED_A, serviceAccountName: "other-sa" }),
1613
+ ),
1614
+ ).toEqual({ serviceAccountName: "other-sa" });
1615
+ });
1616
+
1617
+ it("deletes a workspace id that is no longer configured", () => {
1618
+ expect(
1619
+ federationPatch(
1620
+ fedManifest({ ...FED_A, workspaceId: "wrkspc_1" }),
1621
+ fedManifest(FED_A),
1622
+ ),
1623
+ ).toEqual({
1624
+ env: [{ name: "ANTHROPIC_WORKSPACE_ID", $patch: "delete" }],
1625
+ });
1626
+ });
1627
+
1628
+ it("removes every part with delete directives when disabled", () => {
1629
+ expect(
1630
+ federationPatch(
1631
+ fedManifest({ ...FED_A, workspaceId: "wrkspc_1" }),
1632
+ fedManifest(),
1633
+ ),
1634
+ ).toEqual({
1635
+ serviceAccountName: null,
1636
+ serviceAccount: null,
1637
+ volumes: [{ name: "anthropic-token", $patch: "delete" }],
1638
+ volumeMounts: [
1639
+ { mountPath: "/var/run/secrets/anthropic.com", $patch: "delete" },
1640
+ ],
1641
+ env: [...FED_ENV_NAMES, "ANTHROPIC_WORKSPACE_ID"].map((name) => ({
1642
+ name,
1643
+ $patch: "delete",
1644
+ })),
1645
+ });
1646
+ });
1647
+ });
1648
+
1649
+ describe("KubernetesAgentProvisioner.reconcile() — anthropic federation", () => {
1650
+ function provisionerFor(
1651
+ client: KubernetesClient,
1652
+ anthropicFederation?: AgentAnthropicFederation,
1653
+ ) {
1654
+ return new KubernetesAgentProvisioner(
1655
+ client,
1656
+ stubTokens() as AgentTokenService,
1657
+ { ...BASE_CONFIG, anthropicFederation },
1658
+ );
1659
+ }
1660
+
1661
+ for (const policyOnly of [false, true]) {
1662
+ it(`adds the federation wiring when enabled (policyOnly=${policyOnly})`, async () => {
1663
+ const rec = seeded(fedManifest());
1664
+ const { client, patches } = patchSpy(rec);
1665
+ const result = await provisionerFor(client, FED_A).reconcile(
1666
+ [{ id: POLICY_AGENT_ID }],
1667
+ { policyOnly },
1668
+ );
1669
+
1670
+ expect(result.updated).toEqual([POLICY_AGENT_ID]);
1671
+ expect(result.failed).toEqual([]);
1672
+ expect(patches).toHaveLength(1);
1673
+ const live = await rec.getDeployment(NAMESPACE, POLICY_RESOURCE);
1674
+ expect(live).toEqual(fedManifest(FED_A));
1675
+
1676
+ // Converged: a second pass is a no-op.
1677
+ const again = await provisionerFor(client, FED_A).reconcile(
1678
+ [{ id: POLICY_AGENT_ID }],
1679
+ { policyOnly },
1680
+ );
1681
+ expect(again.updated).toEqual([]);
1682
+ expect(patches).toHaveLength(1);
1683
+ });
1684
+
1685
+ it(`removes all federation wiring when disabled (policyOnly=${policyOnly})`, async () => {
1686
+ const rec = seeded(fedManifest({ ...FED_A, workspaceId: "wrkspc_1" }));
1687
+ const { client, patches } = patchSpy(rec);
1688
+ const result = await provisionerFor(client, undefined).reconcile(
1689
+ [{ id: POLICY_AGENT_ID }],
1690
+ { policyOnly },
1691
+ );
1692
+
1693
+ expect(result.updated).toEqual([POLICY_AGENT_ID]);
1694
+ expect(patches).toHaveLength(1);
1695
+ expect(patches[0]).toMatchObject({
1696
+ spec: {
1697
+ template: {
1698
+ spec: {
1699
+ serviceAccountName: null,
1700
+ serviceAccount: null,
1701
+ volumes: [{ name: "anthropic-token", $patch: "delete" }],
1702
+ containers: [
1703
+ {
1704
+ volumeMounts: [
1705
+ {
1706
+ mountPath: "/var/run/secrets/anthropic.com",
1707
+ $patch: "delete",
1708
+ },
1709
+ ],
1710
+ },
1711
+ ],
1712
+ },
1713
+ },
1714
+ },
1715
+ });
1716
+ const live = await rec.getDeployment(NAMESPACE, POLICY_RESOURCE);
1717
+ expect(live).toEqual(fedManifest());
1718
+
1719
+ const again = await provisionerFor(client, undefined).reconcile(
1720
+ [{ id: POLICY_AGENT_ID }],
1721
+ { policyOnly },
1722
+ );
1723
+ expect(again.updated).toEqual([]);
1724
+ });
1725
+
1726
+ it(`does not patch an in-sync Deployment (policyOnly=${policyOnly})`, async () => {
1727
+ const { client, patches } = patchSpy(seeded(fedManifest(FED_A)));
1728
+ const result = await provisionerFor(client, FED_A).reconcile(
1729
+ [{ id: POLICY_AGENT_ID }],
1730
+ { policyOnly },
1731
+ );
1732
+ expect(result.updated).toEqual([]);
1733
+ expect(patches).toEqual([]);
1734
+ });
1735
+ }
1736
+
1737
+ it("policyOnly sends only the federation env entries, not the full env", async () => {
1738
+ const { client, patches } = patchSpy(seeded(fedManifest()));
1739
+ await provisionerFor(client, FED_A).reconcile([{ id: POLICY_AGENT_ID }], {
1740
+ policyOnly: true,
1741
+ });
1742
+ const container = (
1743
+ patches[0] as {
1744
+ spec: {
1745
+ template: {
1746
+ spec: { containers: Array<{ env?: Array<{ name: string }> }> };
1747
+ };
1748
+ };
1749
+ }
1750
+ ).spec.template.spec.containers[0];
1751
+ expect(Object.keys(container ?? {}).sort()).toEqual([
1752
+ "env",
1753
+ "name",
1754
+ "volumeMounts",
1755
+ ]);
1756
+ expect(container?.env?.map((e) => e.name)).toEqual(FED_ENV_NAMES);
1757
+ });
1758
+ });
1759
+
1760
+ // ─── perAgent identity: the Deployment's federation values ──────────────────
1761
+
1762
+ describe("perAgentFederation()", () => {
1763
+ const CFG = {
1764
+ organizationId: "org-1",
1765
+ workspaceId: "wrkspc_1",
1766
+ audience: "https://api.anthropic.com",
1767
+ expirationSeconds: 900,
1768
+ };
1769
+ const OWNED = {
1770
+ ownerEmail: "jane.doe@example.com",
1771
+ anthropicIdentityName: "jane-doe-a1",
1772
+ anthropicServiceAccountId: "svac_own",
1773
+ anthropicFederationRuleId: "fdrl_own",
1774
+ k8sServiceAccountName: "agent-sa",
1775
+ };
1776
+
1777
+ it("maps the agent's own SA name, rule ID and service account ID", () => {
1778
+ expect(perAgentFederation(CFG, "agent-1", OWNED)).toEqual({
1779
+ serviceAccountName: "agent-sa",
1780
+ ruleId: "fdrl_own",
1781
+ organizationId: "org-1",
1782
+ serviceAccountId: "svac_own",
1783
+ workspaceId: "wrkspc_1",
1784
+ audience: "https://api.anthropic.com",
1785
+ expirationSeconds: 900,
1786
+ });
1787
+ });
1788
+
1789
+ it("omits the workspace when none is configured", () => {
1790
+ const { workspaceId: _, ...noWs } = CFG;
1791
+ const fed = perAgentFederation(noWs, "agent-1", OWNED);
1792
+ expect("workspaceId" in fed).toBe(false);
1793
+ });
1794
+
1795
+ it("produces per-agent env and ServiceAccount in the Deployment manifest", () => {
1796
+ const dep = buildAgentDeploymentManifest({
1797
+ agentId: "agent-1",
1798
+ namespace: NAMESPACE,
1799
+ image: "img",
1800
+ imageTag: "t",
1801
+ apiUrl: "http://api",
1802
+ pvcName: "pvc",
1803
+ secretName: "sec",
1804
+ tokenSecretKey: "token",
1805
+ anthropicFederation: perAgentFederation(CFG, "agent-1", OWNED),
1806
+ });
1807
+ expect(dep.spec.template.spec.serviceAccountName).toBe("agent-sa");
1808
+ const env = Object.fromEntries(
1809
+ (dep.spec.template.spec.containers[0]?.env ?? []).map((e) => [
1810
+ e.name,
1811
+ e.value,
1812
+ ]),
1813
+ );
1814
+ expect(env.ANTHROPIC_FEDERATION_RULE_ID).toBe("fdrl_own");
1815
+ expect(env.ANTHROPIC_SERVICE_ACCOUNT_ID).toBe("svac_own");
1816
+ expect(env.ANTHROPIC_ORGANIZATION_ID).toBe("org-1");
1817
+ expect(env.ANTHROPIC_WORKSPACE_ID).toBe("wrkspc_1");
1818
+ });
1819
+
1820
+ it("fails with a clear error for an agent with no owner", () => {
1821
+ expect(() =>
1822
+ perAgentFederation(CFG, "agent-1", { ...OWNED, ownerEmail: null }),
1823
+ ).toThrow(/agent-1 has no owner/);
1824
+ });
1825
+
1826
+ it("fails with NotFound for a missing agent", () => {
1827
+ expect(() => perAgentFederation(CFG, "agent-1", null)).toThrow(
1828
+ /agent-1 not found/,
1829
+ );
1830
+ });
1831
+
1832
+ for (const field of [
1833
+ "k8sServiceAccountName",
1834
+ "anthropicServiceAccountId",
1835
+ "anthropicFederationRuleId",
1836
+ ] as const) {
1837
+ it(`fails naming ${field} when it is not provisioned yet`, () => {
1838
+ expect(() =>
1839
+ perAgentFederation(CFG, "agent-1", { ...OWNED, [field]: null }),
1840
+ ).toThrow(field);
1841
+ });
1842
+ }
1843
+
1844
+ it("hasPerAgentIdentity is true only with an owner and every ID", () => {
1845
+ expect(hasPerAgentIdentity(OWNED)).toBe(true);
1846
+ expect(hasPerAgentIdentity(null)).toBe(false);
1847
+ expect(hasPerAgentIdentity({ ...OWNED, ownerEmail: null })).toBe(false);
1848
+ expect(
1849
+ hasPerAgentIdentity({ ...OWNED, anthropicFederationRuleId: null }),
1850
+ ).toBe(false);
1851
+ });
1852
+ });