@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,1650 @@
1
+ /**
2
+ * admin/src/agent-provisioner.ts
3
+ *
4
+ * Provisions (and tears down) the Kubernetes resources backing a single
5
+ * Shipwright agent. This module COMPOSES three lower-level pieces:
6
+ *
7
+ * - AgentTokenService (./agent-tokens.ts) — mints the scoped per-agent token.
8
+ * - the pure manifest builders (./agent-manifest.ts) — shape the Secret +
9
+ * Deployment wire objects (RFC1123 name derivation, owner references, env).
10
+ * - a KubernetesClient (./kubernetes-client.ts) — performs the actual
11
+ * namespace-scoped create/get/delete API calls.
12
+ *
13
+ * Provisioning order is token → Secret → Deployment: the Deployment references
14
+ * the Secret by name (`secretKeyRef`), so the Secret must exist first.
15
+ *
16
+ * In perAgent identity mode (config.perAgentIdentity) each agent also gets its
17
+ * own Anthropic identity, created between the PVC and the Secret: Kubernetes
18
+ * ServiceAccount (named like the Deployment) → Anthropic service account
19
+ * (added to the workspace) → federation rule trusting exactly that
20
+ * ServiceAccount's token subject. Each ID is saved on the Agent row as soon as
21
+ * it exists; a retry reuses saved IDs, and find-by-name is only used when a
22
+ * create conflicts because an earlier ID write was lost.
23
+ *
24
+ * Both operations are SAFE TO RETRY:
25
+ * - provision() treats an already-existing Secret/Deployment (ConflictError /
26
+ * 409) as already-provisioned rather than failing, and if Deployment
27
+ * creation fails after the Secret was created it best-effort deletes the
28
+ * Secret so a retry starts from a clean slate. In perAgent mode any later
29
+ * failure also undoes, in reverse, the identity pieces THIS call created
30
+ * (rule, Anthropic service account, ServiceAccount) and clears their IDs.
31
+ * - deprovision() deletes the Deployment, then the Secret, then the PVC
32
+ * (unlike provision()'s rollback path, a deliberate full deprovision DOES
33
+ * remove the PVC) and swallows NotFoundError (404) so tearing down an
34
+ * absent / half-absent agent is a no-op rather than an error.
35
+ * - removeAnthropicIdentity() (perAgent mode, called by agent deletion after
36
+ * deprovision()) archives the federation rule, then the Anthropic service
37
+ * account, then deletes the ServiceAccount — strictly in that order,
38
+ * throwing at the first failure (Anthropic refuses to archive a service
39
+ * account a live rule references) and clearing each saved ID as its piece
40
+ * goes, so a retry only redoes what is left.
41
+ */
42
+
43
+ import type {
44
+ AgentAnthropicIdentity,
45
+ AgentAnthropicIdentityService,
46
+ } from "./agent-anthropic-identity.ts";
47
+ import {
48
+ type AgentAnthropicFederation,
49
+ type AgentContainerResourceOverrides,
50
+ type AgentVoiceEnv,
51
+ ANTHROPIC_FEDERATION_ENV_NAMES,
52
+ ANTHROPIC_TOKEN_MOUNT_PATH,
53
+ ANTHROPIC_TOKEN_VOLUME,
54
+ buildAgentDeploymentManifest,
55
+ buildAgentSecretManifest,
56
+ CLAUDE_POLICY_HASH_ANNOTATION,
57
+ CLAUDE_POLICY_MOUNT_PATH,
58
+ CLAUDE_POLICY_VOLUME,
59
+ sanitizeAgentName,
60
+ TENANT_LABEL,
61
+ } from "./agent-manifest.ts";
62
+ import type { AgentTokenService } from "./agent-tokens.ts";
63
+ import {
64
+ AnthropicApiError,
65
+ AnthropicConflictError,
66
+ type AnthropicIdentityClient,
67
+ } from "./anthropic-identity-client.ts";
68
+ import type { AnthropicIdentityNameAllocator } from "./anthropic-identity-name.ts";
69
+ import type { ChatServiceProvisioningClient } from "./chat-service-provisioning-client.ts";
70
+ import {
71
+ ConflictError,
72
+ NotFoundError,
73
+ UnprocessableEntityError,
74
+ } from "./errors.ts";
75
+ import type {
76
+ KubernetesClient,
77
+ KubernetesContainer,
78
+ KubernetesDeployment,
79
+ KubernetesEnvVar,
80
+ PvcSpec,
81
+ SecretSpec,
82
+ } from "./kubernetes-client.ts";
83
+ import {
84
+ provisionableIntegrations,
85
+ type ServiceIntegration,
86
+ } from "./service-integration.ts";
87
+
88
+ // ─── Interface ──────────────────────────────────────────────────────────────
89
+
90
+ /** Outcome of a successful (or already-satisfied) provision. */
91
+ export interface ProvisionResult {
92
+ /** RFC1123 Kubernetes object name shared by the Secret + Deployment. */
93
+ resourceName: string;
94
+ /** Name of the per-agent Secret carrying the token. */
95
+ secretName: string;
96
+ /** Name of the per-agent Deployment. */
97
+ deploymentName: string;
98
+ /**
99
+ * The raw token minted for this provision, or `undefined` when the resources
100
+ * already existed (ConflictError) and no new token was issued.
101
+ */
102
+ rawToken?: string;
103
+ }
104
+
105
+ /** Outcome of a reconcile pass. */
106
+ export interface ReconcileResult {
107
+ /** Agent IDs whose Deployments were missing and have been re-provisioned. */
108
+ recreated: string[];
109
+ /** K8s Deployment names that exist but are not tied to any known agent ID. */
110
+ orphans: string[];
111
+ /**
112
+ * Agent IDs whose re-provisioning failed with a transient or permanent error.
113
+ * These are not counted in `recreated`. The caller should retry or alert on
114
+ * non-empty `failed` arrays.
115
+ */
116
+ failed: Array<{ agentId: string; error: string }>;
117
+ /**
118
+ * Agent IDs whose Deployments had drifted from the desired container spec
119
+ * (image, env, resources), Claude policy wiring or Anthropic federation
120
+ * wiring and have been patched back to it. In a dry run: the ones that
121
+ * would be.
122
+ */
123
+ updated: string[];
124
+ /**
125
+ * perAgent mode: agent IDs moved off the shared ServiceAccount onto their own
126
+ * newly created identity (also listed in `updated`, as their Deployment was
127
+ * patched). In a dry run: the ones that would be. Always empty in shared mode
128
+ * and under `policyOnly`.
129
+ */
130
+ migrated: string[];
131
+ /**
132
+ * perAgent mode: agent IDs skipped because they have no owner to name their
133
+ * identity after. Their federation wiring is left as it is, and a missing
134
+ * Deployment is not created. Not counted in `failed`.
135
+ */
136
+ noOwner: string[];
137
+ /** True when the pass only reported: nothing was created, patched or saved. */
138
+ dryRun: boolean;
139
+ }
140
+
141
+ /**
142
+ * An agent as reconcile sees it. The identity fields (the Agent row's owner and
143
+ * per-agent identity IDs) are optional: a target that carries them (`ownerEmail`
144
+ * defined, null when unset) is used as read; a bare `{ id }` target makes
145
+ * reconcile read them from the identity store itself.
146
+ */
147
+ export interface ReconcileTarget extends Partial<AgentAnthropicIdentity> {
148
+ id: string;
149
+ slug?: string;
150
+ accountId?: string | null;
151
+ }
152
+
153
+ export interface AgentProvisioner {
154
+ /**
155
+ * Whether this provisioner actually creates cluster resources. `false` on the
156
+ * no-op implementation (Kubernetes provisioning disabled), where provision()
157
+ * returns plausible names without touching a cluster. Callers that offer an
158
+ * "in-cluster" runtime choice must check this first — otherwise an operator
159
+ * picks it, provision() silently succeeds, and they get an agent row with no
160
+ * pod behind it.
161
+ */
162
+ readonly canProvision: boolean;
163
+ /** Mint a token and create the agent's Secret + Deployment. Idempotent. */
164
+ provision(
165
+ agentId: string,
166
+ opts?: { slug?: string; accountId?: string | null },
167
+ ): Promise<ProvisionResult>;
168
+ /**
169
+ * Delete the agent's Deployment, Secret, and PVC. Tolerates already-absent
170
+ * resources (idempotent). `opts.slug` mirrors provision()'s shape and is
171
+ * used to resolve the PVC name when a `pvcName` template is configured.
172
+ */
173
+ deprovision(agentId: string, opts?: { slug?: string }): Promise<void>;
174
+ /**
175
+ * Remove the agent's per-agent Anthropic identity: archive the federation
176
+ * rule, then the Anthropic service account, then delete the Kubernetes
177
+ * ServiceAccount, clearing each saved ID as soon as its piece is gone.
178
+ * Strictly in that order and stopping at the first failure (it throws):
179
+ * Anthropic refuses to archive a service account while a live rule
180
+ * references it. Already-absent pieces count as removed, so a retry only
181
+ * redoes what is left. Optional: provisioners without per-agent identities
182
+ * (the no-op one, test doubles) need not implement it.
183
+ */
184
+ removeAnthropicIdentity?(agentId: string): Promise<void>;
185
+ /**
186
+ * Reconcile K8s Deployment state against a list of known agent IDs.
187
+ * Re-provisions agents whose Deployments are missing; surfaces orphaned
188
+ * Deployments that have no corresponding agent.
189
+ *
190
+ * `opts.policyOnly` restricts drift correction of existing Deployments to the
191
+ * Claude policy wiring (volume, mount, hash annotation) and the Anthropic
192
+ * federation wiring (ServiceAccount, token volume, mount, federation env
193
+ * entries); image, other env and resource drift is left untouched. Missing
194
+ * Deployments are still recreated.
195
+ *
196
+ * In perAgent identity mode a full pass (not `policyOnly`) also moves each
197
+ * existing agent that has an owner but no identity of its own onto a new one:
198
+ * it creates the identity and patches the Deployment off the shared
199
+ * ServiceAccount. The startup pass is `policyOnly` and so never creates
200
+ * Anthropic resources on a boot; an operator runs the full pass (first with
201
+ * `dryRun`) through POST /agents/reconcile. Agents without an owner are
202
+ * reported in `noOwner` and left alone.
203
+ *
204
+ * `opts.dryRun` makes the pass report only: nothing is created in Anthropic or
205
+ * Kubernetes, nothing is saved and no Deployment is patched; the result lists
206
+ * what would be recreated, updated and migrated.
207
+ */
208
+ reconcile(
209
+ agents: ReconcileTarget[],
210
+ opts?: { policyOnly?: boolean; dryRun?: boolean },
211
+ ): Promise<ReconcileResult>;
212
+ }
213
+
214
+ // ─── Configuration ──────────────────────────────────────────────────────────
215
+
216
+ export interface KubernetesAgentProvisionerConfig {
217
+ /** Namespace the agent resources live in. */
218
+ namespace: string;
219
+ /** Agent container image (without tag). */
220
+ image: string;
221
+ /** Image tag, joined as `image:tag`. */
222
+ imageTag: string;
223
+ /** In-cluster admin/API base URL the agent calls home to. */
224
+ apiUrl: string;
225
+ /**
226
+ * Build the PVC name for an agent from a single pre-resolved, RFC1123-safe
227
+ * name string. When a slug is provided (via `provision(agentId, { slug })`),
228
+ * `pvcNameFor` sanitizes it via `sanitizeAgentName` and passes the result as
229
+ * the sole argument; otherwise the sanitized `resourceName` (derived from
230
+ * `agentId`) is passed. Defaults to `<resourceName>-home`.
231
+ */
232
+ pvcName?: (resourceName: string) => string;
233
+ /**
234
+ * Build the Secret name from the sanitized resource name.
235
+ * Defaults to `<name>-token`.
236
+ */
237
+ secretName?: (resourceName: string) => string;
238
+ /** Key under which the token is stored in the Secret. Defaults to "token". */
239
+ tokenSecretKey?: string;
240
+ /** Replica count for the agent Deployment. Defaults to 1. */
241
+ replicas?: number;
242
+ /** Storage size in Gi for the agent home PVC. Defaults to 40. */
243
+ pvcStorageGi?: number;
244
+ /**
245
+ * Optional container resource overrides (cpuRequest, memoryRequest,
246
+ * memoryLimit, ephemeralStorage), merged field-by-field over the defaults in
247
+ * `resolveAgentContainerResources` (agent-manifest.ts). Sourced from the
248
+ * SHIPWRIGHT_K8S_AGENT_* env vars in main.ts's buildProvisioner. Omit (or
249
+ * leave individual fields unset) to keep today's defaults for those fields.
250
+ */
251
+ resources?: AgentContainerResourceOverrides;
252
+ /**
253
+ * Optional agent-voice env flowed into provisioned agent pods. The admin reads
254
+ * these from its OWN env (sourced from the chart's voice Secret + the in-cluster
255
+ * Whisper Service URL). Omitted/empty → voice disabled (no voice env injected).
256
+ */
257
+ voice?: AgentVoiceEnv;
258
+ /**
259
+ * Optional service integrations (see service-integration.ts), supplied by the
260
+ * composition root (e.g. task-store, registered by the coding extension).
261
+ * For each integration with a `url`, provision() mints a per-agent token
262
+ * (in list order, before the built-in chat-service token), stores it in the
263
+ * agent Secret under the integration's `secretKey`, and the Deployment
264
+ * manifest injects its token/URL env. On rollback each minted token is
265
+ * revoked via revokeToken(). Integrations without a `url` are skipped.
266
+ */
267
+ serviceIntegrations?: readonly ServiceIntegration[];
268
+ /**
269
+ * When set, the provisioner will mint a per-agent chat-service token on
270
+ * provision() and store it in the agent Secret under "chat-service-token".
271
+ * The Deployment manifest will include SHIPWRIGHT_CHAT_SERVICE_TOKEN (from the
272
+ * Secret) and SHIPWRIGHT_CHAT_SERVICE_URL (from chatServiceUrl). On rollback,
273
+ * the minted token is revoked via revokeToken().
274
+ *
275
+ * Omit to skip chat-service wiring (for agents that don't need it or when
276
+ * SHIPWRIGHT_CHAT_SERVICE_URL / SHIPWRIGHT_CHAT_SERVICE_ADMIN_TOKEN are not set).
277
+ */
278
+ chatService?: ChatServiceProvisioningClient;
279
+ /**
280
+ * In-cluster base URL of the chat service. Injected into the agent
281
+ * Deployment as SHIPWRIGHT_CHAT_SERVICE_URL. Required when chatService is set.
282
+ */
283
+ chatServiceUrl?: string;
284
+ /**
285
+ * Claude Code managed-policy ConfigMap to mount into provisioned agent pods
286
+ * (see AgentDeploymentOpts.claudePolicy). Sourced from
287
+ * SHIPWRIGHT_K8S_AGENT_CLAUDE_POLICY_CONFIGMAP / _HASH in main.ts. Omit to
288
+ * leave the manifest unchanged.
289
+ */
290
+ claudePolicy?: { configMapName: string; hash?: string };
291
+ /**
292
+ * Anthropic Workload Identity Federation wiring for provisioned agent pods
293
+ * (see AgentDeploymentOpts.anthropicFederation). Sourced from
294
+ * SHIPWRIGHT_K8S_AGENT_SERVICE_ACCOUNT and
295
+ * SHIPWRIGHT_K8S_AGENT_ANTHROPIC_FEDERATION_* in main.ts. Omit to leave the
296
+ * manifest unchanged.
297
+ */
298
+ anthropicFederation?: AgentAnthropicFederation;
299
+ /**
300
+ * perAgent identity mode: give every agent its own Kubernetes ServiceAccount,
301
+ * Anthropic service account and federation rule at provision time, and build
302
+ * its Deployment from those IDs. Wired in main.ts's buildProvisioner when
303
+ * SHIPWRIGHT_K8S_AGENT_ANTHROPIC_FEDERATION_IDENTITY_MODE=perAgent; mutually
304
+ * exclusive with `anthropicFederation` (shared mode).
305
+ */
306
+ perAgentIdentity?: PerAgentIdentityConfig;
307
+ }
308
+
309
+ // ─── perAgent identity ──────────────────────────────────────────────────────
310
+
311
+ /** Read/save of an agent's owner and identity IDs (AgentAnthropicIdentityService). */
312
+ export type AgentIdentityStore = Pick<
313
+ AgentAnthropicIdentityService,
314
+ "get" | "set"
315
+ >;
316
+
317
+ /** Idempotent identity-name allocation (AnthropicIdentityNameAllocator). */
318
+ export type AgentIdentityNameSource = Pick<
319
+ AnthropicIdentityNameAllocator,
320
+ "allocate"
321
+ >;
322
+
323
+ export interface PerAgentIdentityConfig {
324
+ /** Anthropic Admin API client (the admin pod's own federated sign-in). */
325
+ client: AnthropicIdentityClient;
326
+ identities: AgentIdentityStore;
327
+ names: AgentIdentityNameSource;
328
+ /** → ANTHROPIC_ORGANIZATION_ID */
329
+ organizationId: string;
330
+ /**
331
+ * Workspace each service account is added to and each rule is scoped to
332
+ * (→ ANTHROPIC_WORKSPACE_ID). Unset: no workspace step, and the rule and pod
333
+ * carry no workspace.
334
+ */
335
+ workspaceId?: string;
336
+ /** This cluster's Anthropic federation issuer (the rules' issuer). */
337
+ issuerId: string;
338
+ /** Audience of the agent pods' projected token (also matched by the rule). */
339
+ audience: string;
340
+ /** Lifetime of the agent pods' projected token. */
341
+ expirationSeconds: number;
342
+ }
343
+
344
+ /** OAuth scope of every per-agent federation rule. */
345
+ export const PER_AGENT_RULE_SCOPE = "workspace:inference";
346
+ /** Lifetime of the Anthropic access tokens a per-agent rule mints. */
347
+ export const PER_AGENT_RULE_TOKEN_LIFETIME_SECONDS = 600;
348
+
349
+ /** The token subject Kubernetes stamps on a pod's projected ServiceAccount token. */
350
+ export function serviceAccountSubject(
351
+ namespace: string,
352
+ serviceAccountName: string,
353
+ ): string {
354
+ return `system:serviceaccount:${namespace}:${serviceAccountName}`;
355
+ }
356
+
357
+ /** True when the agent has an owner and every per-agent identity ID saved. */
358
+ export function hasPerAgentIdentity(
359
+ identity: AgentAnthropicIdentity | null,
360
+ ): boolean {
361
+ return Boolean(
362
+ identity?.ownerEmail &&
363
+ identity.k8sServiceAccountName &&
364
+ identity.anthropicServiceAccountId &&
365
+ identity.anthropicFederationRuleId,
366
+ );
367
+ }
368
+
369
+ /**
370
+ * The Deployment federation wiring for an agent's OWN identity.
371
+ * @throws NotFoundError when the agent row is missing
372
+ * @throws UnprocessableEntityError when the agent has no owner, or an ID is
373
+ * not provisioned yet
374
+ */
375
+ export function perAgentFederation(
376
+ config: Pick<
377
+ PerAgentIdentityConfig,
378
+ "organizationId" | "workspaceId" | "audience" | "expirationSeconds"
379
+ >,
380
+ agentId: string,
381
+ identity: AgentAnthropicIdentity | null,
382
+ ): AgentAnthropicFederation {
383
+ const ids = requireOwner(agentId, identity);
384
+ const {
385
+ k8sServiceAccountName,
386
+ anthropicServiceAccountId,
387
+ anthropicFederationRuleId,
388
+ } = ids;
389
+ if (
390
+ !k8sServiceAccountName ||
391
+ !anthropicServiceAccountId ||
392
+ !anthropicFederationRuleId
393
+ ) {
394
+ const missing = (
395
+ [
396
+ "k8sServiceAccountName",
397
+ "anthropicServiceAccountId",
398
+ "anthropicFederationRuleId",
399
+ ] as const
400
+ ).filter((key) => !ids[key]);
401
+ throw new UnprocessableEntityError(
402
+ `Agent ${agentId} has no per-agent Anthropic identity yet (missing ${missing.join(", ")}); provision it first`,
403
+ );
404
+ }
405
+ return {
406
+ serviceAccountName: k8sServiceAccountName,
407
+ ruleId: anthropicFederationRuleId,
408
+ organizationId: config.organizationId,
409
+ serviceAccountId: anthropicServiceAccountId,
410
+ ...(config.workspaceId ? { workspaceId: config.workspaceId } : {}),
411
+ audience: config.audience,
412
+ expirationSeconds: config.expirationSeconds,
413
+ };
414
+ }
415
+
416
+ function requireOwner(
417
+ agentId: string,
418
+ identity: AgentAnthropicIdentity | null,
419
+ ): AgentAnthropicIdentity & { ownerEmail: string } {
420
+ if (!identity) throw new NotFoundError(`Agent ${agentId} not found`);
421
+ const { ownerEmail } = identity;
422
+ if (!ownerEmail) {
423
+ throw new UnprocessableEntityError(
424
+ `Agent ${agentId} has no owner; perAgent identity mode needs an owner email to name the agent's Anthropic identity`,
425
+ );
426
+ }
427
+ return { ...identity, ownerEmail };
428
+ }
429
+
430
+ /** Identity pieces created by THIS provision() call — the rollback set. */
431
+ interface CreatedIdentity {
432
+ k8sServiceAccount?: string;
433
+ serviceAccountId?: string;
434
+ ruleId?: string;
435
+ }
436
+
437
+ function envEntryEqual(a: KubernetesEnvVar, b: KubernetesEnvVar): boolean {
438
+ return (
439
+ a.value === b.value &&
440
+ a.valueFrom?.secretKeyRef?.name === b.valueFrom?.secretKeyRef?.name &&
441
+ a.valueFrom?.secretKeyRef?.key === b.valueFrom?.secretKeyRef?.key
442
+ );
443
+ }
444
+
445
+ /**
446
+ * True when the live container is missing anything the desired container
447
+ * specifies (image, env entries, resource values).
448
+ *
449
+ * Subset semantics: extra live env vars (added manually via kubectl) and extra
450
+ * resource keys (injected by cluster autoscalers like GKE Autopilot) are NOT
451
+ * drift — reconcile must not fight other actors. Only a desired value that is
452
+ * absent or different counts.
453
+ */
454
+ export function containerDrifted(
455
+ current: KubernetesContainer,
456
+ desired: KubernetesContainer,
457
+ ): boolean {
458
+ if (current.image !== desired.image) return true;
459
+
460
+ const currentEnv = new Map((current.env ?? []).map((e) => [e.name, e]));
461
+ for (const entry of desired.env ?? []) {
462
+ const live = currentEnv.get(entry.name);
463
+ if (!live || !envEntryEqual(live, entry)) return true;
464
+ }
465
+
466
+ const cur = current.resources ?? {};
467
+ for (const bucket of ["requests", "limits"] as const) {
468
+ for (const [key, value] of Object.entries(
469
+ desired.resources?.[bucket] ?? {},
470
+ )) {
471
+ if (cur[bucket]?.[key] !== value) return true;
472
+ }
473
+ }
474
+
475
+ return false;
476
+ }
477
+
478
+ type PolicyVolume = NonNullable<
479
+ KubernetesDeployment["spec"]["template"]["spec"]["volumes"]
480
+ >[number];
481
+ type PolicyMount = NonNullable<KubernetesContainer["volumeMounts"]>[number];
482
+
483
+ /** The policy-relevant slice of a Deployment; parts are undefined when absent. */
484
+ function policyParts(dep: KubernetesDeployment): {
485
+ volume?: PolicyVolume;
486
+ mount?: PolicyMount;
487
+ hash?: string;
488
+ } {
489
+ return {
490
+ volume: dep.spec.template.spec.volumes?.find(
491
+ (v) => v.name === CLAUDE_POLICY_VOLUME,
492
+ ),
493
+ // Keyed by mountPath, the strategic-merge key the patch operates on, so a
494
+ // stray same-named mount elsewhere can't make every pass look drifted.
495
+ mount: dep.spec.template.spec.containers[0]?.volumeMounts?.find(
496
+ (m) => m.mountPath === CLAUDE_POLICY_MOUNT_PATH,
497
+ ),
498
+ hash: dep.spec.template.metadata?.annotations?.[
499
+ CLAUDE_POLICY_HASH_ANNOTATION
500
+ ],
501
+ };
502
+ }
503
+
504
+ /**
505
+ * Strategic-merge fragments that bring the live Claude policy wiring (pod-level
506
+ * volume, container mount, hash annotation) to the desired state. A field is
507
+ * omitted when that part is already in sync.
508
+ *
509
+ * Removal needs explicit directives: strategic merge keys volumes by `name` and
510
+ * volumeMounts by `mountPath`, so omitting an entry would leave it in place.
511
+ * `$patch: delete` removes it, and a null annotation value deletes the key.
512
+ */
513
+ interface PolicyPatch {
514
+ volumes?: object[];
515
+ volumeMounts?: object[];
516
+ annotations?: Record<string, string | null>;
517
+ }
518
+
519
+ /** The policy patch for a live Deployment, or undefined when already in sync. */
520
+ function policyPatch(
521
+ live: KubernetesDeployment,
522
+ desired: KubernetesDeployment,
523
+ ): PolicyPatch | undefined {
524
+ const cur = policyParts(live);
525
+ const want = policyParts(desired);
526
+ const patch: PolicyPatch = {};
527
+
528
+ if (want.volume) {
529
+ if (cur.volume?.configMap?.name !== want.volume.configMap?.name) {
530
+ patch.volumes = [want.volume];
531
+ }
532
+ } else if (cur.volume) {
533
+ patch.volumes = [{ name: CLAUDE_POLICY_VOLUME, $patch: "delete" }];
534
+ }
535
+
536
+ if (want.mount) {
537
+ if (
538
+ cur.mount?.name !== want.mount.name ||
539
+ cur.mount.readOnly !== want.mount.readOnly
540
+ ) {
541
+ patch.volumeMounts = [want.mount];
542
+ }
543
+ } else if (cur.mount) {
544
+ patch.volumeMounts = [{ mountPath: cur.mount.mountPath, $patch: "delete" }];
545
+ }
546
+
547
+ if (cur.hash !== want.hash) {
548
+ patch.annotations = { [CLAUDE_POLICY_HASH_ANNOTATION]: want.hash ?? null };
549
+ }
550
+
551
+ return Object.keys(patch).length > 0 ? patch : undefined;
552
+ }
553
+
554
+ /**
555
+ * True when the live Deployment's Claude policy wiring differs from the desired
556
+ * one: a missing, extra or differently-pointed volume, a missing or extra
557
+ * mount, or a differing hash annotation. Covers "policy disabled but still
558
+ * present" too.
559
+ */
560
+ export function policyDrifted(
561
+ live: KubernetesDeployment,
562
+ desired: KubernetesDeployment,
563
+ ): boolean {
564
+ return policyPatch(live, desired) !== undefined;
565
+ }
566
+
567
+ /** The federation-relevant slice of a Deployment; parts are undefined when absent. */
568
+ function federationParts(dep: KubernetesDeployment): {
569
+ serviceAccountName?: string;
570
+ volume?: PolicyVolume;
571
+ mount?: PolicyMount;
572
+ env: Map<string, KubernetesEnvVar>;
573
+ } {
574
+ const podSpec = dep.spec.template.spec;
575
+ const container = podSpec.containers[0];
576
+ const names = new Set<string>(ANTHROPIC_FEDERATION_ENV_NAMES);
577
+ return {
578
+ serviceAccountName:
579
+ typeof podSpec.serviceAccountName === "string"
580
+ ? podSpec.serviceAccountName
581
+ : undefined,
582
+ volume: podSpec.volumes?.find((v) => v.name === ANTHROPIC_TOKEN_VOLUME),
583
+ mount: container?.volumeMounts?.find(
584
+ (m) => m.mountPath === ANTHROPIC_TOKEN_MOUNT_PATH,
585
+ ),
586
+ env: new Map(
587
+ (container?.env ?? [])
588
+ .filter((e) => names.has(e.name))
589
+ .map((e) => [e.name, e]),
590
+ ),
591
+ };
592
+ }
593
+
594
+ interface TokenSource {
595
+ audience?: string;
596
+ expirationSeconds?: number;
597
+ path?: string;
598
+ }
599
+
600
+ /**
601
+ * True when two projected token volumes request the same token. Compares only
602
+ * the fields the builder sets, so server-defaulted fields (projected
603
+ * defaultMode) on the live volume are not drift.
604
+ */
605
+ function tokenSourceEqual(a: PolicyVolume, b: PolicyVolume): boolean {
606
+ const src = (v: PolicyVolume): TokenSource | undefined =>
607
+ (v.projected as { sources?: Array<{ serviceAccountToken?: TokenSource }> })
608
+ ?.sources?.[0]?.serviceAccountToken;
609
+ const x = src(a);
610
+ const y = src(b);
611
+ return (
612
+ x?.audience === y?.audience &&
613
+ x?.expirationSeconds === y?.expirationSeconds &&
614
+ x?.path === y?.path
615
+ );
616
+ }
617
+
618
+ /**
619
+ * Strategic-merge fragments that bring the live federation wiring (pod
620
+ * ServiceAccount, projected token volume, container mount, federation env) to
621
+ * the desired state. A field is omitted when that part is already in sync.
622
+ *
623
+ * Removal uses explicit directives like policyPatch: `$patch: delete` on the
624
+ * volume (by name), mount (by mountPath) and each env entry (by name), and a
625
+ * null serviceAccountName. The deprecated `serviceAccount` alias is nulled too:
626
+ * the API server mirrors serviceAccountName into it and falls back to it when
627
+ * serviceAccountName is empty, so leaving it would resurrect the old name.
628
+ */
629
+ interface FederationPatch {
630
+ serviceAccountName?: string | null;
631
+ serviceAccount?: null;
632
+ volumes?: object[];
633
+ volumeMounts?: object[];
634
+ env?: object[];
635
+ }
636
+
637
+ /** The federation patch for a live Deployment, or undefined when already in sync. */
638
+ export function federationPatch(
639
+ live: KubernetesDeployment,
640
+ desired: KubernetesDeployment,
641
+ ): FederationPatch | undefined {
642
+ const cur = federationParts(live);
643
+ const want = federationParts(desired);
644
+ const patch: FederationPatch = {};
645
+
646
+ if (want.serviceAccountName) {
647
+ if (cur.serviceAccountName !== want.serviceAccountName) {
648
+ patch.serviceAccountName = want.serviceAccountName;
649
+ }
650
+ } else if (cur.serviceAccountName && (cur.volume || cur.env.size > 0)) {
651
+ // Only unset a ServiceAccount that came with federation wiring; one set on
652
+ // the Deployment by another actor is left alone.
653
+ patch.serviceAccountName = null;
654
+ patch.serviceAccount = null;
655
+ }
656
+
657
+ if (want.volume) {
658
+ if (!cur.volume || !tokenSourceEqual(cur.volume, want.volume)) {
659
+ patch.volumes = [want.volume];
660
+ }
661
+ } else if (cur.volume) {
662
+ patch.volumes = [{ name: ANTHROPIC_TOKEN_VOLUME, $patch: "delete" }];
663
+ }
664
+
665
+ if (want.mount) {
666
+ if (
667
+ cur.mount?.name !== want.mount.name ||
668
+ cur.mount.readOnly !== want.mount.readOnly
669
+ ) {
670
+ patch.volumeMounts = [want.mount];
671
+ }
672
+ } else if (cur.mount) {
673
+ patch.volumeMounts = [{ mountPath: cur.mount.mountPath, $patch: "delete" }];
674
+ }
675
+
676
+ const env: object[] = [];
677
+ for (const name of ANTHROPIC_FEDERATION_ENV_NAMES) {
678
+ const wantEntry = want.env.get(name);
679
+ const curEntry = cur.env.get(name);
680
+ if (wantEntry) {
681
+ if (!curEntry || !envEntryEqual(curEntry, wantEntry)) env.push(wantEntry);
682
+ } else if (curEntry) {
683
+ env.push({ name, $patch: "delete" });
684
+ }
685
+ }
686
+ if (env.length > 0) patch.env = env;
687
+
688
+ return Object.keys(patch).length > 0 ? patch : undefined;
689
+ }
690
+
691
+ function isConflict(err: unknown): boolean {
692
+ return err instanceof ConflictError;
693
+ }
694
+
695
+ function isNotFound(err: unknown): boolean {
696
+ return err instanceof NotFoundError;
697
+ }
698
+
699
+ /** An Anthropic archive that 404s means the resource is already gone. */
700
+ function isAnthropicNotFound(err: unknown): boolean {
701
+ return err instanceof AnthropicApiError && err.status === 404;
702
+ }
703
+
704
+ /**
705
+ * Create an Anthropic resource and return its ID (reporting it via
706
+ * `onCreated`). A name conflict means an earlier call created it but its ID
707
+ * write was lost: recover the live resource by name. If nothing by that name
708
+ * is live, the conflict is rethrown.
709
+ */
710
+ async function createOrRecover(
711
+ create: () => Promise<{ id: string }>,
712
+ findByName: () => Promise<{ id: string } | null>,
713
+ onCreated: (id: string) => void,
714
+ ): Promise<string> {
715
+ try {
716
+ const { id } = await create();
717
+ onCreated(id);
718
+ return id;
719
+ } catch (err) {
720
+ if (!(err instanceof AnthropicConflictError)) throw err;
721
+ const found = await findByName();
722
+ if (!found) throw err;
723
+ return found.id;
724
+ }
725
+ }
726
+
727
+ async function tolerateAnthropicNotFound(
728
+ step: () => Promise<unknown>,
729
+ ): Promise<void> {
730
+ try {
731
+ await step();
732
+ } catch (err) {
733
+ if (!isAnthropicNotFound(err)) throw err;
734
+ }
735
+ }
736
+
737
+ /** Run a rollback step; log (never throw) on failure so the original error surfaces. */
738
+ async function bestEffort(
739
+ what: string,
740
+ step: () => Promise<unknown>,
741
+ ): Promise<void> {
742
+ try {
743
+ await step();
744
+ } catch (err) {
745
+ console.warn(
746
+ `[provisioner] rollback: failed to ${what}: ${err instanceof Error ? err.message : String(err)}`,
747
+ );
748
+ }
749
+ }
750
+
751
+ // ─── Kubernetes implementation ──────────────────────────────────────────────
752
+
753
+ export class KubernetesAgentProvisioner implements AgentProvisioner {
754
+ readonly canProvision = true;
755
+
756
+ private readonly tokenKey: string;
757
+
758
+ constructor(
759
+ private readonly k8s: KubernetesClient,
760
+ private readonly tokens: AgentTokenService,
761
+ private readonly config: KubernetesAgentProvisionerConfig,
762
+ ) {
763
+ this.tokenKey = config.tokenSecretKey ?? "token";
764
+ }
765
+
766
+ private resourceName(agentId: string): string {
767
+ return sanitizeAgentName(agentId);
768
+ }
769
+
770
+ private secretNameFor(resourceName: string): string {
771
+ return this.config.secretName
772
+ ? this.config.secretName(resourceName)
773
+ : `${resourceName}-token`;
774
+ }
775
+
776
+ private pvcNameFor(resourceName: string, slug?: string): string {
777
+ return this.config.pvcName
778
+ ? this.config.pvcName(slug ? sanitizeAgentName(slug) : resourceName)
779
+ : `${resourceName}-home`;
780
+ }
781
+
782
+ /**
783
+ * The agent's owner and identity IDs for reconcile: the target's own fields
784
+ * when it carries them, else the identity store's row (null: no such agent).
785
+ */
786
+ private async identityOf(
787
+ agent: ReconcileTarget,
788
+ ): Promise<AgentAnthropicIdentity | null> {
789
+ if (agent.ownerEmail !== undefined) {
790
+ return {
791
+ ownerEmail: agent.ownerEmail,
792
+ anthropicIdentityName: agent.anthropicIdentityName ?? null,
793
+ anthropicServiceAccountId: agent.anthropicServiceAccountId ?? null,
794
+ anthropicFederationRuleId: agent.anthropicFederationRuleId ?? null,
795
+ k8sServiceAccountName: agent.k8sServiceAccountName ?? null,
796
+ };
797
+ }
798
+ const perAgent = this.config.perAgentIdentity;
799
+ return perAgent ? perAgent.identities.get(agent.id) : null;
800
+ }
801
+
802
+ /**
803
+ * The federation wiring reconcile drift-corrects an existing Deployment to.
804
+ * `manage: false` means leave the live federation wiring as it is: in
805
+ * perAgent mode an agent without its own identity keeps whatever it runs with
806
+ * until it is migrated (`move` set: it has an owner, so a full pass can
807
+ * create the identity) or for good (`noOwner` set: it has none).
808
+ */
809
+ private async reconcileFederationFor(agent: ReconcileTarget): Promise<{
810
+ federation?: AgentAnthropicFederation;
811
+ manage: boolean;
812
+ move?: AgentAnthropicIdentity & { ownerEmail: string };
813
+ noOwner?: boolean;
814
+ }> {
815
+ const perAgent = this.config.perAgentIdentity;
816
+ if (!perAgent) {
817
+ return { federation: this.config.anthropicFederation, manage: true };
818
+ }
819
+ const identity = await this.identityOf(agent);
820
+ if (!identity) return { manage: false };
821
+ if (hasPerAgentIdentity(identity)) {
822
+ return {
823
+ federation: perAgentFederation(perAgent, agent.id, identity),
824
+ manage: true,
825
+ };
826
+ }
827
+ const { ownerEmail } = identity;
828
+ return ownerEmail
829
+ ? { manage: false, move: { ...identity, ownerEmail } }
830
+ : { manage: false, noOwner: true };
831
+ }
832
+
833
+ /**
834
+ * The desired Deployment manifest for an agent — the single source of truth
835
+ * used both to create (provision) and to drift-correct (reconcile).
836
+ * `federation` is the agent's resolved wiring: the shared identity, its own
837
+ * per-agent identity (provision / reconcileFederationFor), or none.
838
+ */
839
+ private deploymentManifestFor(
840
+ agentId: string,
841
+ slug: string | undefined,
842
+ accountId: string | null | undefined,
843
+ federation: AgentAnthropicFederation | undefined,
844
+ ) {
845
+ const resourceName = this.resourceName(agentId);
846
+ return buildAgentDeploymentManifest({
847
+ agentId,
848
+ namespace: this.config.namespace,
849
+ image: this.config.image,
850
+ imageTag: this.config.imageTag,
851
+ apiUrl: this.config.apiUrl,
852
+ pvcName: this.pvcNameFor(resourceName, slug),
853
+ secretName: this.secretNameFor(resourceName),
854
+ tokenSecretKey: this.tokenKey,
855
+ replicas: this.config.replicas,
856
+ resources: this.config.resources,
857
+ voice: this.config.voice,
858
+ serviceIntegrations: provisionableIntegrations(
859
+ this.config.serviceIntegrations,
860
+ ),
861
+ chatServiceUrl: this.config.chatServiceUrl,
862
+ claudePolicy: this.config.claudePolicy,
863
+ anthropicFederation: federation,
864
+ accountId,
865
+ });
866
+ }
867
+
868
+ async provision(
869
+ agentId: string,
870
+ opts?: { slug?: string; accountId?: string | null },
871
+ ): Promise<ProvisionResult> {
872
+ const resourceName = this.resourceName(agentId);
873
+ const secretName = this.secretNameFor(resourceName);
874
+ const pvcName = this.pvcNameFor(resourceName, opts?.slug);
875
+ const result: ProvisionResult = {
876
+ resourceName,
877
+ secretName,
878
+ deploymentName: resourceName,
879
+ };
880
+
881
+ // perAgent mode needs an owner to name the identity: check it BEFORE
882
+ // creating anything, so an ownerless agent fails cleanly.
883
+ const perAgent = this.config.perAgentIdentity;
884
+ const identity = perAgent
885
+ ? requireOwner(agentId, await perAgent.identities.get(agentId))
886
+ : undefined;
887
+
888
+ // 1. PVC — must exist before the Deployment that mounts it. A 409 means the
889
+ // PVC already exists from a prior provision; treat as idempotent success.
890
+ // On any subsequent failure, do NOT delete the PVC (data safety policy).
891
+ try {
892
+ await this.k8s.createPvc(this.config.namespace, this.pvcSpec(pvcName));
893
+ } catch (err) {
894
+ if (!isConflict(err)) throw err;
895
+ }
896
+
897
+ if (!perAgent || !identity) {
898
+ return this.provisionSecretAndDeployment(
899
+ agentId,
900
+ opts,
901
+ result,
902
+ this.config.anthropicFederation,
903
+ );
904
+ }
905
+
906
+ // perAgent: the identity steps, then Secret + Deployment with the agent's
907
+ // own IDs. Any failure undoes (best-effort, in reverse) the identity pieces
908
+ // THIS call created; the Secret step rolls itself back first.
909
+ const created: CreatedIdentity = {};
910
+ try {
911
+ const federation = perAgentFederation(
912
+ perAgent,
913
+ agentId,
914
+ await this.provisionIdentity(
915
+ perAgent,
916
+ agentId,
917
+ resourceName,
918
+ identity,
919
+ created,
920
+ ),
921
+ );
922
+ return await this.provisionSecretAndDeployment(
923
+ agentId,
924
+ opts,
925
+ result,
926
+ federation,
927
+ );
928
+ } catch (err) {
929
+ await this.rollbackIdentity(perAgent, agentId, created);
930
+ throw err;
931
+ }
932
+ }
933
+
934
+ /**
935
+ * Steps 1a–1d of perAgent provisioning. Each ID already saved on the agent is
936
+ * reused; each new one is saved right after its create call. Returns the
937
+ * agent's identity with every ID set. Records what it creates in `created`.
938
+ */
939
+ private async provisionIdentity(
940
+ perAgent: PerAgentIdentityConfig,
941
+ agentId: string,
942
+ resourceName: string,
943
+ initial: AgentAnthropicIdentity & { ownerEmail: string },
944
+ created: CreatedIdentity,
945
+ ): Promise<AgentAnthropicIdentity> {
946
+ const { client, identities, workspaceId } = perAgent;
947
+ let identity: AgentAnthropicIdentity = initial;
948
+
949
+ // 1a. Kubernetes ServiceAccount, named like the Deployment. 409 = it already
950
+ // exists (an earlier call created it), so it is not ours to roll back.
951
+ try {
952
+ await this.k8s.createServiceAccount(this.config.namespace, {
953
+ name: resourceName,
954
+ });
955
+ created.k8sServiceAccount = resourceName;
956
+ } catch (err) {
957
+ if (!isConflict(err)) throw err;
958
+ }
959
+ if (identity.k8sServiceAccountName !== resourceName) {
960
+ identity = await identities.set(agentId, {
961
+ k8sServiceAccountName: resourceName,
962
+ });
963
+ }
964
+
965
+ // The Anthropic service account and rule share the agent's identity name.
966
+ const name = await perAgent.names.allocate(agentId, initial.ownerEmail);
967
+ const description = `Shipwright agent ${agentId}`;
968
+
969
+ // 1b. Anthropic service account.
970
+ let serviceAccountId = identity.anthropicServiceAccountId;
971
+ if (!serviceAccountId) {
972
+ serviceAccountId = await createOrRecover(
973
+ () => client.createServiceAccount({ name, description }),
974
+ () => client.findServiceAccountByName(name),
975
+ (id) => {
976
+ created.serviceAccountId = id;
977
+ },
978
+ );
979
+ identity = await identities.set(agentId, {
980
+ anthropicServiceAccountId: serviceAccountId,
981
+ });
982
+ }
983
+
984
+ // 1c. Workspace membership. 409 = already a member (an earlier call).
985
+ if (workspaceId) {
986
+ try {
987
+ await client.addServiceAccountToWorkspace({
988
+ serviceAccountId,
989
+ workspaceId,
990
+ });
991
+ } catch (err) {
992
+ if (!(err instanceof AnthropicConflictError)) throw err;
993
+ }
994
+ }
995
+
996
+ // 1d. Federation rule: trusts exactly this pod's ServiceAccount token
997
+ // (no trailing `*`, so subject_prefix is an exact match).
998
+ if (!identity.anthropicFederationRuleId) {
999
+ const ruleId = await createOrRecover(
1000
+ () =>
1001
+ client.createFederationRule({
1002
+ name,
1003
+ description,
1004
+ issuerId: perAgent.issuerId,
1005
+ serviceAccountId,
1006
+ ...(workspaceId ? { workspaceId } : {}),
1007
+ oauthScope: PER_AGENT_RULE_SCOPE,
1008
+ match: {
1009
+ subjectPrefix: serviceAccountSubject(
1010
+ this.config.namespace,
1011
+ resourceName,
1012
+ ),
1013
+ audience: perAgent.audience,
1014
+ },
1015
+ tokenLifetimeSeconds: PER_AGENT_RULE_TOKEN_LIFETIME_SECONDS,
1016
+ }),
1017
+ () => client.findFederationRuleByName(name),
1018
+ (id) => {
1019
+ created.ruleId = id;
1020
+ },
1021
+ );
1022
+ identity = await identities.set(agentId, {
1023
+ anthropicFederationRuleId: ruleId,
1024
+ });
1025
+ }
1026
+
1027
+ return identity;
1028
+ }
1029
+
1030
+ /**
1031
+ * Best-effort undo of the identity pieces one provision() call created, in
1032
+ * reverse creation order, then clear their saved IDs. Never throws: the
1033
+ * caller's original error is what surfaces. A piece whose undo fails is left
1034
+ * live under the agent's identity name, so a retry recovers it by name.
1035
+ */
1036
+ private async rollbackIdentity(
1037
+ perAgent: PerAgentIdentityConfig,
1038
+ agentId: string,
1039
+ created: CreatedIdentity,
1040
+ ): Promise<void> {
1041
+ const { client, identities } = perAgent;
1042
+ const clear: Partial<AgentAnthropicIdentity> = {};
1043
+ if (created.ruleId) {
1044
+ await bestEffort(`archive federation rule ${created.ruleId}`, () =>
1045
+ client.archiveFederationRule(created.ruleId as string),
1046
+ );
1047
+ clear.anthropicFederationRuleId = null;
1048
+ }
1049
+ if (created.serviceAccountId) {
1050
+ await bestEffort(
1051
+ `archive Anthropic service account ${created.serviceAccountId}`,
1052
+ () => client.archiveServiceAccount(created.serviceAccountId as string),
1053
+ );
1054
+ clear.anthropicServiceAccountId = null;
1055
+ }
1056
+ if (created.k8sServiceAccount) {
1057
+ await bestEffort(
1058
+ `delete ServiceAccount ${created.k8sServiceAccount}`,
1059
+ () =>
1060
+ this.k8s.deleteServiceAccount(
1061
+ this.config.namespace,
1062
+ created.k8sServiceAccount as string,
1063
+ ),
1064
+ );
1065
+ clear.k8sServiceAccountName = null;
1066
+ }
1067
+ if (Object.keys(clear).length > 0) {
1068
+ await bestEffort(`clear identity IDs of agent ${agentId}`, () =>
1069
+ identities.set(agentId, clear),
1070
+ );
1071
+ }
1072
+ }
1073
+
1074
+ /** Steps 2–4: token(s) → Secret → Deployment (the original provision flow). */
1075
+ private async provisionSecretAndDeployment(
1076
+ agentId: string,
1077
+ opts: { slug?: string; accountId?: string | null } | undefined,
1078
+ result: ProvisionResult,
1079
+ federation: AgentAnthropicFederation | undefined,
1080
+ ): Promise<ProvisionResult> {
1081
+ const { secretName } = result;
1082
+
1083
+ // 2. Token — mint only when the Secret does NOT already exist. If the Secret
1084
+ // is already present (a prior provision completed or partially ran), we
1085
+ // skip minting so no orphaned token rows accumulate in the DB and the
1086
+ // returned rawToken correctly signals "use the existing credential" via
1087
+ // undefined. Only mint a fresh token when we're about to write a new Secret.
1088
+ let secretCreated = false;
1089
+ let secretAlreadyExists = false;
1090
+ try {
1091
+ await this.k8s.getSecret(this.config.namespace, secretName);
1092
+ secretAlreadyExists = true;
1093
+ } catch (err) {
1094
+ if (!isNotFound(err)) throw err;
1095
+ }
1096
+
1097
+ // Track the integration and chat-service tokens minted in THIS call so we
1098
+ // can revoke them on rollback. Only set when the respective config is provided
1099
+ // and we actually minted.
1100
+ const integrations = provisionableIntegrations(
1101
+ this.config.serviceIntegrations,
1102
+ );
1103
+ const integrationTokens: {
1104
+ integration: ServiceIntegration;
1105
+ id: string;
1106
+ rawToken: string;
1107
+ }[] = [];
1108
+ let csTokenId: string | undefined;
1109
+
1110
+ if (!secretAlreadyExists) {
1111
+ // Optionally mint a token per service integration (list order, before
1112
+ // chat) to co-locate in the same Secret write.
1113
+ for (const integration of integrations) {
1114
+ const { id, rawToken } = await integration.mintToken(
1115
+ `agent:${agentId}`,
1116
+ agentId,
1117
+ );
1118
+ integrationTokens.push({ integration, id, rawToken });
1119
+ }
1120
+
1121
+ // Optionally mint a chat-service token to co-locate in the same Secret write.
1122
+ let csRawToken: string | undefined;
1123
+ if (this.config.chatService) {
1124
+ const { id, rawToken } = await this.config.chatService.mintToken(
1125
+ `agent:${agentId}`,
1126
+ agentId,
1127
+ );
1128
+ csTokenId = id;
1129
+ csRawToken = rawToken;
1130
+ }
1131
+
1132
+ const { rawToken } = await this.tokens.create(agentId, "k8s-provision");
1133
+ result.rawToken = rawToken;
1134
+
1135
+ // 3. Secret — must exist before the Deployment that references it.
1136
+ // A 409 here is unexpected (we just confirmed it was absent), but treat
1137
+ // it as already-present and continue to the Deployment step.
1138
+ try {
1139
+ await this.k8s.createSecret(
1140
+ this.config.namespace,
1141
+ this.secretSpec(
1142
+ secretName,
1143
+ rawToken,
1144
+ integrationTokens.map((t) => ({
1145
+ secretKey: t.integration.secretKey,
1146
+ rawToken: t.rawToken,
1147
+ })),
1148
+ csRawToken,
1149
+ ),
1150
+ );
1151
+ secretCreated = true;
1152
+ } catch (err) {
1153
+ if (!isConflict(err)) throw err;
1154
+ }
1155
+ }
1156
+
1157
+ // 4. Deployment. If creation fails AFTER we created the Secret in THIS call,
1158
+ // roll the Secret back (best-effort) so a retry starts clean and never
1159
+ // leaks a half-provisioned state. The PVC is NOT rolled back (data safety).
1160
+ // Also revoke any integration and chat-service tokens minted in THIS call.
1161
+ try {
1162
+ await this.k8s.createDeploymentManifest(
1163
+ this.config.namespace,
1164
+ this.deploymentManifestFor(
1165
+ agentId,
1166
+ opts?.slug,
1167
+ opts?.accountId,
1168
+ federation,
1169
+ ),
1170
+ );
1171
+ } catch (err) {
1172
+ if (isConflict(err)) {
1173
+ // Deployment already exists — idempotent success. But if we minted and
1174
+ // created a NEW Secret in THIS call, the pre-existing Deployment isn't
1175
+ // using it; roll the orphaned Secret (and its fresh tokens) back so we
1176
+ // never leak it. Only ever delete a Secret THIS call created.
1177
+ if (secretCreated) {
1178
+ await this.deleteSecretBestEffort(secretName);
1179
+ await this.revokeIntegrationTokensBestEffort(integrationTokens);
1180
+ await this.revokeChatServiceTokenBestEffort(csTokenId);
1181
+ }
1182
+ return result;
1183
+ }
1184
+ if (secretCreated) {
1185
+ await this.deleteSecretBestEffort(secretName);
1186
+ await this.revokeIntegrationTokensBestEffort(integrationTokens);
1187
+ await this.revokeChatServiceTokenBestEffort(csTokenId);
1188
+ }
1189
+ throw err;
1190
+ }
1191
+
1192
+ return result;
1193
+ }
1194
+
1195
+ async removeAnthropicIdentity(agentId: string): Promise<void> {
1196
+ const perAgent = this.config.perAgentIdentity;
1197
+ if (!perAgent) {
1198
+ throw new Error(
1199
+ "per-agent Anthropic identity is not configured on this provisioner, so the agent's identity cannot be removed automatically",
1200
+ );
1201
+ }
1202
+ const { client, identities } = perAgent;
1203
+ const identity = await identities.get(agentId);
1204
+ if (!identity) return;
1205
+
1206
+ // Strict order, no best-effort: a failure throws so the caller keeps the
1207
+ // Agent row. Each ID is cleared right after its piece is gone, so a retry
1208
+ // only redoes what is left.
1209
+ if (identity.anthropicFederationRuleId) {
1210
+ await tolerateAnthropicNotFound(() =>
1211
+ client.archiveFederationRule(
1212
+ identity.anthropicFederationRuleId as string,
1213
+ ),
1214
+ );
1215
+ await identities.set(agentId, { anthropicFederationRuleId: null });
1216
+ }
1217
+ if (identity.anthropicServiceAccountId) {
1218
+ await tolerateAnthropicNotFound(() =>
1219
+ client.archiveServiceAccount(
1220
+ identity.anthropicServiceAccountId as string,
1221
+ ),
1222
+ );
1223
+ await identities.set(agentId, { anthropicServiceAccountId: null });
1224
+ }
1225
+ if (identity.k8sServiceAccountName) {
1226
+ try {
1227
+ await this.k8s.deleteServiceAccount(
1228
+ this.config.namespace,
1229
+ identity.k8sServiceAccountName,
1230
+ );
1231
+ } catch (err) {
1232
+ if (!isNotFound(err)) throw err;
1233
+ }
1234
+ await identities.set(agentId, { k8sServiceAccountName: null });
1235
+ }
1236
+ }
1237
+
1238
+ async deprovision(agentId: string, opts?: { slug?: string }): Promise<void> {
1239
+ const resourceName = this.resourceName(agentId);
1240
+ const secretName = this.secretNameFor(resourceName);
1241
+ const pvcName = this.pvcNameFor(resourceName, opts?.slug);
1242
+
1243
+ // Delete the Deployment first (it depends on the Secret), then the Secret,
1244
+ // then the PVC. Already-absent resources (404) are swallowed so teardown
1245
+ // is idempotent. Unlike provision()'s rollback path (which deliberately
1246
+ // never deletes the PVC — see the "data safety policy" note above), a
1247
+ // deliberate full agent deprovision also removes the PVC so deleted
1248
+ // agents don't leak storage indefinitely.
1249
+ await this.deleteDeploymentBestEffort(resourceName);
1250
+ await this.deleteSecretBestEffort(secretName);
1251
+ await this.deletePvcBestEffort(pvcName);
1252
+ }
1253
+
1254
+ async reconcile(
1255
+ agents: ReconcileTarget[],
1256
+ opts?: { policyOnly?: boolean; dryRun?: boolean },
1257
+ ): Promise<ReconcileResult> {
1258
+ const policyOnly = opts?.policyOnly === true;
1259
+ const dryRun = opts?.dryRun === true;
1260
+ const labelSelector =
1261
+ "app.kubernetes.io/name=shipwright-agent,app.kubernetes.io/managed-by=shipwright-admin";
1262
+
1263
+ // List all agent Deployments currently in k8s.
1264
+ const k8sNames = await this.k8s.listDeployments(
1265
+ this.config.namespace,
1266
+ labelSelector,
1267
+ );
1268
+ const k8sNameSet = new Set(k8sNames);
1269
+
1270
+ // Build a map from sanitized resource name → original agent entry, and track
1271
+ // the full set of expected k8s names.
1272
+ const expectedNames = new Map<string, ReconcileTarget>(); // resourceName → agent
1273
+ for (const agent of agents) {
1274
+ expectedNames.set(this.resourceName(agent.id), agent);
1275
+ }
1276
+
1277
+ const recreated: string[] = [];
1278
+ const updated: string[] = [];
1279
+ const migrated: string[] = [];
1280
+ const noOwner: string[] = [];
1281
+ const orphans: string[] = [];
1282
+ const failed: Array<{ agentId: string; error: string }> = [];
1283
+
1284
+ // Recreate Deployments that should exist but are missing in k8s.
1285
+ // For Deployments that already exist, check whether the image is stale and
1286
+ // patch it if so. Each operation is wrapped in try/catch so a single transient
1287
+ // K8s error does not abort the loop — the remaining agents are always checked.
1288
+ for (const [resourceName, agent] of expectedNames) {
1289
+ if (!k8sNameSet.has(resourceName)) {
1290
+ // provision() is idempotent — it handles ConflictError on Secret/Deployment.
1291
+ if (this.config.pvcName && !agent.slug) {
1292
+ // When a pvcName template is active, the PVC name is derived from the slug.
1293
+ // If slug is absent, reconcile falls back to resourceName — which may not
1294
+ // match the slug-based PVC created by the original provision() call, risking
1295
+ // a mount to an empty volume. Callers should populate slug in the agents array.
1296
+ console.warn(
1297
+ `[reconcile] pvcName template is set but agent ${agent.id} has no slug; PVC will be named from resourceName (${resourceName}) — verify this matches the PVC created at provision time`,
1298
+ );
1299
+ }
1300
+ try {
1301
+ // perAgent mode: provision() needs an owner, so an ownerless agent is
1302
+ // reported rather than counted as a failure. A missing agent row
1303
+ // (null identity) still fails in provision() with NotFound.
1304
+ if (this.config.perAgentIdentity) {
1305
+ const identity = await this.identityOf(agent);
1306
+ if (identity && !identity.ownerEmail) {
1307
+ noOwner.push(agent.id);
1308
+ continue;
1309
+ }
1310
+ }
1311
+ if (!dryRun) {
1312
+ await this.provision(agent.id, {
1313
+ slug: agent.slug,
1314
+ accountId: agent.accountId,
1315
+ });
1316
+ }
1317
+ recreated.push(agent.id);
1318
+ } catch (err) {
1319
+ failed.push({
1320
+ agentId: agent.id,
1321
+ error: err instanceof Error ? err.message : String(err),
1322
+ });
1323
+ }
1324
+ } else {
1325
+ // Deployment already exists — re-apply the desired container spec when
1326
+ // it has drifted. The manifest builder is the single source of truth
1327
+ // for image, env, and resources; the strategic-merge patch upserts env
1328
+ // entries by name, so manually-added extra vars survive. (Patching only
1329
+ // the image here would mean existing Deployments never pick up env or
1330
+ // resource changes shipped in a new admin version.)
1331
+ try {
1332
+ const deployment = await this.k8s.getDeployment(
1333
+ this.config.namespace,
1334
+ resourceName,
1335
+ );
1336
+ const current = deployment.spec.template.spec.containers[0];
1337
+ const reconciled = await this.reconcileFederationFor(agent);
1338
+ if (reconciled.noOwner) noOwner.push(agent.id);
1339
+ // Move an owned agent off the shared ServiceAccount: create its
1340
+ // identity (rolling back what this call created if that fails), then
1341
+ // fall through to the normal drift correction, which now sees the
1342
+ // agent's own federation wiring and patches the ServiceAccount, token
1343
+ // volume and federation env to it. policyOnly passes (admin startup)
1344
+ // never create identities; a dry run only reports the move.
1345
+ let { federation: desiredFederation, manage: manageFederation } =
1346
+ reconciled;
1347
+ const perAgent = this.config.perAgentIdentity;
1348
+ if (reconciled.move && perAgent && !policyOnly) {
1349
+ if (dryRun) {
1350
+ migrated.push(agent.id);
1351
+ updated.push(agent.id);
1352
+ continue;
1353
+ }
1354
+ const created: CreatedIdentity = {};
1355
+ try {
1356
+ desiredFederation = perAgentFederation(
1357
+ perAgent,
1358
+ agent.id,
1359
+ await this.provisionIdentity(
1360
+ perAgent,
1361
+ agent.id,
1362
+ resourceName,
1363
+ reconciled.move,
1364
+ created,
1365
+ ),
1366
+ );
1367
+ manageFederation = true;
1368
+ } catch (err) {
1369
+ await this.rollbackIdentity(perAgent, agent.id, created);
1370
+ throw err;
1371
+ }
1372
+ migrated.push(agent.id);
1373
+ }
1374
+ const desiredDeployment = this.deploymentManifestFor(
1375
+ agent.id,
1376
+ agent.slug,
1377
+ agent.accountId,
1378
+ desiredFederation,
1379
+ );
1380
+ const desired = desiredDeployment.spec.template.spec.containers[0];
1381
+ const desiredLabels = desiredDeployment.spec.template.metadata.labels;
1382
+ const labelsDrifted =
1383
+ !policyOnly &&
1384
+ desiredLabels[TENANT_LABEL] !== undefined &&
1385
+ deployment.spec.template.metadata?.labels?.[TENANT_LABEL] !==
1386
+ desiredLabels[TENANT_LABEL];
1387
+ const policy = policyPatch(deployment, desiredDeployment);
1388
+ // Unmanaged federation (see reconcileFederationFor): no patch, so
1389
+ // the live ServiceAccount, token volume and env are left alone.
1390
+ const federation = manageFederation
1391
+ ? federationPatch(deployment, desiredDeployment)
1392
+ : undefined;
1393
+ const containerDrift =
1394
+ !policyOnly &&
1395
+ current !== undefined &&
1396
+ desired !== undefined &&
1397
+ containerDrifted(current, desired);
1398
+ if (
1399
+ desired &&
1400
+ (labelsDrifted || containerDrift || policy || federation)
1401
+ ) {
1402
+ // Under policyOnly the container entry carries only the policy and
1403
+ // federation mount/env changes (if any); image, the rest of env,
1404
+ // and resources are left to other actors.
1405
+ const volumeMounts = [
1406
+ ...(policy?.volumeMounts ?? []),
1407
+ ...(federation?.volumeMounts ?? []),
1408
+ ];
1409
+ const desiredEnvNames = new Set(
1410
+ (desired.env ?? []).map((e) => e.name),
1411
+ );
1412
+ const env = policyOnly
1413
+ ? (federation?.env ?? [])
1414
+ : [
1415
+ ...(desired.env ?? []),
1416
+ // Full env already upserts desired federation entries; only
1417
+ // the delete directives need appending.
1418
+ ...(federation?.env ?? []).filter(
1419
+ (e) => !desiredEnvNames.has((e as { name: string }).name),
1420
+ ),
1421
+ ];
1422
+ const container = {
1423
+ name: desired.name,
1424
+ ...(policyOnly
1425
+ ? {}
1426
+ : {
1427
+ image: desired.image,
1428
+ resources: desired.resources,
1429
+ }),
1430
+ ...(volumeMounts.length > 0 ? { volumeMounts } : {}),
1431
+ ...(env.length > 0 || !policyOnly ? { env } : {}),
1432
+ };
1433
+ const includeContainer =
1434
+ !policyOnly || volumeMounts.length > 0 || env.length > 0;
1435
+ const volumes = [
1436
+ ...(policy?.volumes ?? []),
1437
+ ...(federation?.volumes ?? []),
1438
+ ];
1439
+ const serviceAccount =
1440
+ federation?.serviceAccountName !== undefined
1441
+ ? {
1442
+ serviceAccountName: federation.serviceAccountName,
1443
+ ...(federation.serviceAccount === null
1444
+ ? { serviceAccount: null }
1445
+ : {}),
1446
+ }
1447
+ : {};
1448
+ const podMetadata = {
1449
+ ...(labelsDrifted
1450
+ ? { labels: { [TENANT_LABEL]: desiredLabels[TENANT_LABEL] } }
1451
+ : {}),
1452
+ ...(policy?.annotations
1453
+ ? { annotations: policy.annotations }
1454
+ : {}),
1455
+ };
1456
+ if (!dryRun)
1457
+ await this.k8s.patchDeployment(
1458
+ this.config.namespace,
1459
+ resourceName,
1460
+ {
1461
+ ...(labelsDrifted
1462
+ ? {
1463
+ metadata: {
1464
+ labels: {
1465
+ [TENANT_LABEL]: desiredLabels[TENANT_LABEL],
1466
+ },
1467
+ },
1468
+ }
1469
+ : {}),
1470
+ spec: {
1471
+ template: {
1472
+ ...(Object.keys(podMetadata).length > 0
1473
+ ? { metadata: podMetadata }
1474
+ : {}),
1475
+ spec: {
1476
+ ...serviceAccount,
1477
+ ...(includeContainer
1478
+ ? { containers: [container] }
1479
+ : {}),
1480
+ ...(volumes.length > 0 ? { volumes } : {}),
1481
+ },
1482
+ },
1483
+ },
1484
+ },
1485
+ );
1486
+ updated.push(agent.id);
1487
+ }
1488
+ } catch (err) {
1489
+ failed.push({
1490
+ agentId: agent.id,
1491
+ error: err instanceof Error ? err.message : String(err),
1492
+ });
1493
+ }
1494
+ }
1495
+ }
1496
+
1497
+ // Collect k8s Deployments that have no known agent.
1498
+ for (const k8sName of k8sNames) {
1499
+ if (!expectedNames.has(k8sName)) {
1500
+ orphans.push(k8sName);
1501
+ }
1502
+ }
1503
+
1504
+ return { recreated, updated, migrated, noOwner, orphans, failed, dryRun };
1505
+ }
1506
+
1507
+ // ─── Spec derivation (via the pure manifest builders) ─────────────────────
1508
+
1509
+ private pvcSpec(pvcName: string): PvcSpec {
1510
+ return {
1511
+ name: pvcName,
1512
+ namespace: this.config.namespace,
1513
+ storageGi: this.config.pvcStorageGi ?? 40,
1514
+ };
1515
+ }
1516
+
1517
+ private secretSpec(
1518
+ secretName: string,
1519
+ rawToken: string,
1520
+ integrationSecrets: { secretKey: string; rawToken: string }[],
1521
+ chatServiceToken?: string,
1522
+ ): SecretSpec {
1523
+ // The pure builder is the single source of truth for the Secret body (name,
1524
+ // token key, base64 encoding). Derive the KubernetesClient SecretSpec from
1525
+ // its output so the builder actually drives the secret — decoding `data`
1526
+ // back to plain `stringData` since createSecret re-encodes it itself.
1527
+ const manifest = buildAgentSecretManifest({
1528
+ name: secretName,
1529
+ namespace: this.config.namespace,
1530
+ token: rawToken,
1531
+ tokenKey: this.tokenKey,
1532
+ });
1533
+ const stringData: Record<string, string> = {};
1534
+ for (const [key, value] of Object.entries(manifest.data)) {
1535
+ stringData[key] = Buffer.from(value, "base64").toString("utf-8");
1536
+ }
1537
+ // Tokens minted for this agent's service integrations go in the same
1538
+ // Secret (list order) so a single Secret holds all per-agent credentials.
1539
+ for (const {
1540
+ secretKey,
1541
+ rawToken: integrationToken,
1542
+ } of integrationSecrets) {
1543
+ stringData[secretKey] = integrationToken;
1544
+ }
1545
+ // When a chat-service token was minted for this agent, include it alongside
1546
+ // the other per-agent credentials in the same Secret.
1547
+ if (chatServiceToken !== undefined) {
1548
+ stringData["chat-service-token"] = chatServiceToken;
1549
+ }
1550
+ return { name: manifest.metadata.name, stringData };
1551
+ }
1552
+
1553
+ private async revokeIntegrationTokensBestEffort(
1554
+ tokens: { integration: ServiceIntegration; id: string }[],
1555
+ ): Promise<void> {
1556
+ for (const { integration, id } of tokens) {
1557
+ if (!id) continue;
1558
+ try {
1559
+ await integration.revokeToken(id);
1560
+ } catch {
1561
+ // Best-effort — log but don't propagate so the original error surfaces.
1562
+ console.warn(
1563
+ `[provisioner] failed to revoke ${integration.name} token ${id} during rollback`,
1564
+ );
1565
+ }
1566
+ }
1567
+ }
1568
+
1569
+ private async revokeChatServiceTokenBestEffort(
1570
+ csTokenId: string | undefined,
1571
+ ): Promise<void> {
1572
+ if (!csTokenId || !this.config.chatService) return;
1573
+ try {
1574
+ await this.config.chatService.revokeToken(csTokenId);
1575
+ } catch {
1576
+ // Best-effort — log but don't propagate so the original error surfaces.
1577
+ console.warn(
1578
+ `[provisioner] failed to revoke chat-service token ${csTokenId} during rollback`,
1579
+ );
1580
+ }
1581
+ }
1582
+
1583
+ private async deleteSecretBestEffort(name: string): Promise<void> {
1584
+ try {
1585
+ await this.k8s.deleteSecret(this.config.namespace, name);
1586
+ } catch (err) {
1587
+ if (!isNotFound(err)) throw err;
1588
+ }
1589
+ }
1590
+
1591
+ private async deleteDeploymentBestEffort(name: string): Promise<void> {
1592
+ try {
1593
+ await this.k8s.deleteDeployment(this.config.namespace, name);
1594
+ } catch (err) {
1595
+ if (!isNotFound(err)) throw err;
1596
+ }
1597
+ }
1598
+
1599
+ private async deletePvcBestEffort(name: string): Promise<void> {
1600
+ try {
1601
+ await this.k8s.deletePvc(this.config.namespace, name);
1602
+ } catch (err) {
1603
+ if (!isNotFound(err)) throw err;
1604
+ }
1605
+ }
1606
+ }
1607
+
1608
+ // ─── No-op implementation ─────────────────────────────────────────────────────
1609
+
1610
+ /**
1611
+ * No-op provisioner used when Kubernetes provisioning is disabled. Lets the
1612
+ * admin service construct and run unchanged without a cluster.
1613
+ */
1614
+ export class NoopAgentProvisioner implements AgentProvisioner {
1615
+ readonly canProvision = false;
1616
+
1617
+ async provision(
1618
+ agentId: string,
1619
+ _opts?: { slug?: string },
1620
+ ): Promise<ProvisionResult> {
1621
+ const resourceName = sanitizeAgentName(agentId);
1622
+ return {
1623
+ resourceName,
1624
+ secretName: `${resourceName}-token`,
1625
+ deploymentName: resourceName,
1626
+ };
1627
+ }
1628
+
1629
+ async deprovision(
1630
+ _agentId: string,
1631
+ _opts?: { slug?: string },
1632
+ ): Promise<void> {
1633
+ // intentionally a no-op
1634
+ }
1635
+
1636
+ async reconcile(
1637
+ _agents: ReconcileTarget[],
1638
+ opts?: { policyOnly?: boolean; dryRun?: boolean },
1639
+ ): Promise<ReconcileResult> {
1640
+ return {
1641
+ recreated: [],
1642
+ updated: [],
1643
+ migrated: [],
1644
+ noOwner: [],
1645
+ orphans: [],
1646
+ failed: [],
1647
+ dryRun: opts?.dryRun === true,
1648
+ };
1649
+ }
1650
+ }