@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,218 @@
1
+ /**
2
+ * admin/src/attachment-validation.unit.test.ts
3
+ *
4
+ * Unit tests for pure attachment validation logic — no I/O.
5
+ */
6
+
7
+ import { describe, expect, it } from "bun:test";
8
+ import {
9
+ audioContentTypeForFilename,
10
+ isAudioFilename,
11
+ MAX_ATTACHMENT_BYTES,
12
+ validateAttachment,
13
+ } from "./attachment-validation.ts";
14
+
15
+ describe("validateAttachment", () => {
16
+ it("accepts a text/plain file under 10 MB", () => {
17
+ const result = validateAttachment("notes.txt", 1024, "text/plain");
18
+ expect(result.ok).toBe(true);
19
+ if (result.ok) {
20
+ expect(result.filename).toBe("notes.txt");
21
+ expect(result.size).toBe(1024);
22
+ }
23
+ });
24
+
25
+ it("accepts an image/png under the limit", () => {
26
+ const result = validateAttachment("pic.png", 500_000, "image/png");
27
+ expect(result.ok).toBe(true);
28
+ });
29
+
30
+ it("accepts an application/pdf file", () => {
31
+ const result = validateAttachment("doc.pdf", 2_000_000, "application/pdf");
32
+ expect(result.ok).toBe(true);
33
+ });
34
+
35
+ it("accepts a file exactly at the size limit", () => {
36
+ const result = validateAttachment(
37
+ "big.txt",
38
+ MAX_ATTACHMENT_BYTES,
39
+ "text/plain",
40
+ );
41
+ expect(result.ok).toBe(true);
42
+ });
43
+
44
+ it("rejects a file over 10 MB with status 413", () => {
45
+ const result = validateAttachment(
46
+ "huge.txt",
47
+ MAX_ATTACHMENT_BYTES + 1,
48
+ "text/plain",
49
+ );
50
+ expect(result.ok).toBe(false);
51
+ if (!result.ok) {
52
+ expect(result.status).toBe(413);
53
+ expect(result.error).toBeTruthy();
54
+ }
55
+ });
56
+
57
+ it("rejects a disallowed MIME type like video/mp4 with status 415", () => {
58
+ const result = validateAttachment("clip.mp4", 1024, "video/mp4");
59
+ expect(result.ok).toBe(false);
60
+ if (!result.ok) {
61
+ expect(result.status).toBe(415);
62
+ expect(result.error).toBeTruthy();
63
+ }
64
+ });
65
+
66
+ it("rejects application/x-executable with status 415", () => {
67
+ const result = validateAttachment(
68
+ "run.bin",
69
+ 1024,
70
+ "application/x-executable",
71
+ );
72
+ expect(result.ok).toBe(false);
73
+ if (!result.ok) {
74
+ expect(result.status).toBe(415);
75
+ }
76
+ });
77
+
78
+ // CFB-3.1: picking an *existing* photo from an iPhone photo library (not the
79
+ // camera) can hand the browser a HEIC/HEIF file — these were previously
80
+ // missing from the allowlist and got a hard 415 rejection.
81
+ it("accepts an image/heic file under the limit", () => {
82
+ const result = validateAttachment("photo.heic", 2_000_000, "image/heic");
83
+ expect(result.ok).toBe(true);
84
+ });
85
+
86
+ it("accepts an image/heif file under the limit", () => {
87
+ const result = validateAttachment("photo.heif", 2_000_000, "image/heif");
88
+ expect(result.ok).toBe(true);
89
+ });
90
+
91
+ it("rejects application/octet-stream with status 415", () => {
92
+ const result = validateAttachment(
93
+ "unknown.bin",
94
+ 1024,
95
+ "application/octet-stream",
96
+ );
97
+ expect(result.ok).toBe(false);
98
+ if (!result.ok) {
99
+ expect(result.status).toBe(415);
100
+ }
101
+ });
102
+
103
+ it("rejects an empty-string MIME type with status 415", () => {
104
+ const result = validateAttachment("unknown", 1024, "");
105
+ expect(result.ok).toBe(false);
106
+ if (!result.ok) {
107
+ expect(result.status).toBe(415);
108
+ }
109
+ });
110
+
111
+ // VM-3.1: mic-recorded blobs go through the same upload path as file
112
+ // attachments, so MediaRecorder's actual output MIME types must be allowed.
113
+ it("accepts audio/webm (MediaRecorder default in some browsers)", () => {
114
+ const result = validateAttachment("recording.webm", 1024, "audio/webm");
115
+ expect(result.ok).toBe(true);
116
+ });
117
+
118
+ it("accepts audio/webm;codecs=opus (MediaRecorder's Chrome/Firefox default)", () => {
119
+ const result = validateAttachment(
120
+ "recording.webm",
121
+ 1024,
122
+ "audio/webm;codecs=opus",
123
+ );
124
+ expect(result.ok).toBe(true);
125
+ });
126
+
127
+ it("accepts audio/ogg (MediaRecorder's Firefox alternative)", () => {
128
+ const result = validateAttachment("recording.ogg", 1024, "audio/ogg");
129
+ expect(result.ok).toBe(true);
130
+ });
131
+
132
+ it("accepts video/webm;codecs=opus and video/webm for a .webm filename (Chromium audio-only recordings)", () => {
133
+ expect(
134
+ validateAttachment("recording.webm", 1024, "video/webm;codecs=opus").ok,
135
+ ).toBe(true);
136
+ expect(validateAttachment("recording.webm", 1024, "video/webm").ok).toBe(
137
+ true,
138
+ );
139
+ });
140
+
141
+ it("rejects video/webm with a non-.webm filename", () => {
142
+ const result = validateAttachment("clip.mp4", 1024, "video/webm");
143
+ expect(result.ok).toBe(false);
144
+ if (!result.ok) {
145
+ expect(result.status).toBe(415);
146
+ }
147
+ });
148
+
149
+ it("accepts audio/mp4, audio/mpeg and audio/wav", () => {
150
+ expect(validateAttachment("recording.m4a", 1024, "audio/mp4").ok).toBe(
151
+ true,
152
+ );
153
+ expect(validateAttachment("a.mp3", 1024, "audio/mpeg").ok).toBe(true);
154
+ expect(validateAttachment("a.wav", 1024, "audio/wav").ok).toBe(true);
155
+ });
156
+
157
+ it("strips MIME parameters and case before matching", () => {
158
+ expect(
159
+ validateAttachment("a.m4a", 1024, "Audio/MP4;codecs=mp4a.40.2").ok,
160
+ ).toBe(true);
161
+ expect(
162
+ validateAttachment("a.txt", 1024, "TEXT/Plain; charset=utf-8").ok,
163
+ ).toBe(true);
164
+ });
165
+
166
+ it("still rejects an unrelated audio type not in the allowlist", () => {
167
+ const result = validateAttachment("clip.aac", 1024, "audio/aac");
168
+ expect(result.ok).toBe(false);
169
+ if (!result.ok) {
170
+ expect(result.status).toBe(415);
171
+ }
172
+ });
173
+ });
174
+
175
+ describe("isAudioFilename", () => {
176
+ it("returns true for MediaRecorder output extensions (.webm, .ogg)", () => {
177
+ expect(isAudioFilename("recording-123.webm")).toBe(true);
178
+ expect(isAudioFilename("recording-123.ogg")).toBe(true);
179
+ });
180
+
181
+ it("returns true for TTS output extensions (.wav, .mp3) — VM-2.1 assistant replies", () => {
182
+ expect(isAudioFilename("1700000000000-response.wav")).toBe(true);
183
+ expect(isAudioFilename("1700000000000-response.mp3")).toBe(true);
184
+ });
185
+
186
+ it("is case-insensitive", () => {
187
+ expect(isAudioFilename("Recording.WEBM")).toBe(true);
188
+ });
189
+
190
+ it("returns false for non-audio filenames", () => {
191
+ expect(isAudioFilename("notes.txt")).toBe(false);
192
+ expect(isAudioFilename("photo.png")).toBe(false);
193
+ expect(isAudioFilename("doc.pdf")).toBe(false);
194
+ });
195
+
196
+ it("returns false for null/undefined/empty filenames without throwing", () => {
197
+ expect(isAudioFilename(null)).toBe(false);
198
+ expect(isAudioFilename(undefined)).toBe(false);
199
+ expect(isAudioFilename("")).toBe(false);
200
+ });
201
+ });
202
+
203
+ describe("audioContentTypeForFilename", () => {
204
+ it("maps each recognized audio extension to a real audio/* Content-Type", () => {
205
+ expect(audioContentTypeForFilename("a.webm")).toBe("audio/webm");
206
+ expect(audioContentTypeForFilename("a.ogg")).toBe("audio/ogg");
207
+ expect(audioContentTypeForFilename("a.wav")).toBe("audio/wav");
208
+ expect(audioContentTypeForFilename("a.mp3")).toBe("audio/mpeg");
209
+ expect(audioContentTypeForFilename("x.m4a")).toBe("audio/mp4");
210
+ expect(isAudioFilename("x.m4a")).toBe(true);
211
+ });
212
+
213
+ it("returns null for a non-audio or missing filename", () => {
214
+ expect(audioContentTypeForFilename("notes.txt")).toBeNull();
215
+ expect(audioContentTypeForFilename(null)).toBeNull();
216
+ expect(audioContentTypeForFilename(undefined)).toBeNull();
217
+ });
218
+ });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * admin/src/caller-scope.ts
3
+ *
4
+ * Single resolver for "which agents may this caller see/act on" (SSP-2.1),
5
+ * shared by the admin UI (assertAgentAccess, resolveAccessibleAgents), the
6
+ * session-visibility helpers and the JSON API auth middleware.
7
+ *
8
+ * Resolved per request from the caller's email — the account is deliberately
9
+ * NOT stored in the session JWT, so membership changes take effect
10
+ * immediately. Account membership contributes agents only when the self-serve
11
+ * flag is on; with it off the scope is exactly the AgentMember-only scope.
12
+ */
13
+
14
+ import type { AccountService } from "./accounts.ts";
15
+ import type { AgentMemberService } from "./agent-members.ts";
16
+
17
+ export type CallerScope =
18
+ | { kind: "all" }
19
+ | { kind: "scoped"; accountId: string | null; agentIds: string[] };
20
+
21
+ interface CallerScopeInput {
22
+ email: string;
23
+ isAdmin: boolean;
24
+ flagEnabled: boolean;
25
+ }
26
+
27
+ /** I/O boundary of the resolver — injected so the logic stays unit-testable. */
28
+ export interface CallerScopeDeps {
29
+ /** Account the email belongs to, or null. */
30
+ getAccountIdByEmail(email: string): Promise<string | null>;
31
+ /** Ids of every agent whose accountId matches. */
32
+ listAgentIdsByAccount(accountId: string): Promise<string[]>;
33
+ /** Ids of agents the email holds an AgentMember row for. */
34
+ listMemberAgentIds(email: string): Promise<string[]>;
35
+ }
36
+
37
+ export async function resolveCallerScope(
38
+ input: CallerScopeInput,
39
+ deps: CallerScopeDeps,
40
+ ): Promise<CallerScope> {
41
+ if (input.isAdmin) return { kind: "all" };
42
+ const email = input.email.toLowerCase();
43
+
44
+ const accountId = input.flagEnabled
45
+ ? await deps.getAccountIdByEmail(email)
46
+ : null;
47
+ const [accountAgentIds, memberAgentIds] = await Promise.all([
48
+ accountId ? deps.listAgentIdsByAccount(accountId) : [],
49
+ deps.listMemberAgentIds(email),
50
+ ]);
51
+ return {
52
+ kind: "scoped",
53
+ accountId,
54
+ agentIds: [...new Set([...accountAgentIds, ...memberAgentIds])],
55
+ };
56
+ }
57
+
58
+ export function scopeIncludesAgent(
59
+ scope: CallerScope,
60
+ agentId: string,
61
+ ): boolean {
62
+ return scope.kind === "all" || scope.agentIds.includes(agentId);
63
+ }
64
+
65
+ /**
66
+ * How a caller may read the admin UI's task-store views — Tasks, PRs and
67
+ * Sessions (SSP-6.8). Platform admins see every account; an account user
68
+ * (self-serve flag on, so the resolver produced an accountId) sees exactly
69
+ * their account, filtered server-side via the task-store's `?accountId=`;
70
+ * anyone else keeps today's admin-only gate.
71
+ */
72
+ export type TaskStoreViewScope =
73
+ | { kind: "all" }
74
+ | { kind: "account"; accountId: string; agentIds: string[] }
75
+ | { kind: "none" };
76
+
77
+ export function taskStoreViewScope(scope: CallerScope): TaskStoreViewScope {
78
+ if (scope.kind === "all") return { kind: "all" };
79
+ if (scope.accountId === null) return { kind: "none" };
80
+ return {
81
+ kind: "account",
82
+ accountId: scope.accountId,
83
+ agentIds: scope.agentIds,
84
+ };
85
+ }
86
+
87
+ /** Per-request resolver with the flag already bound. */
88
+ export type CallerScopeResolver = (
89
+ email: string,
90
+ isAdmin: boolean,
91
+ ) => Promise<CallerScope>;
92
+
93
+ export function createCallerScopeResolver(
94
+ deps: CallerScopeDeps,
95
+ flagEnabled: boolean,
96
+ ): CallerScopeResolver {
97
+ return (email, isAdmin) =>
98
+ resolveCallerScope({ email, isAdmin, flagEnabled }, deps);
99
+ }
100
+
101
+ /** Wire the resolver's I/O to the real services. */
102
+ export function callerScopeDepsFromServices(services: {
103
+ accountService: Pick<AccountService, "getByMemberEmail" | "listAgentIds">;
104
+ agentMemberService: Pick<AgentMemberService, "listByEmail">;
105
+ }): CallerScopeDeps {
106
+ return {
107
+ getAccountIdByEmail: async (email) =>
108
+ (await services.accountService.getByMemberEmail(email))?.id ?? null,
109
+ listAgentIdsByAccount: (accountId) =>
110
+ services.accountService.listAgentIds(accountId),
111
+ listMemberAgentIds: async (email) =>
112
+ (await services.agentMemberService.listByEmail(email)).map(
113
+ (m) => m.agentId,
114
+ ),
115
+ };
116
+ }
117
+
118
+ /** Flag-off resolver backed only by AgentMember rows (today's behavior). */
119
+ export function memberOnlyCallerScopeResolver(
120
+ agentMemberService: Pick<AgentMemberService, "listByEmail">,
121
+ ): CallerScopeResolver {
122
+ return createCallerScopeResolver(
123
+ {
124
+ getAccountIdByEmail: async () => null,
125
+ listAgentIdsByAccount: async () => [],
126
+ listMemberAgentIds: async (email) =>
127
+ (await agentMemberService.listByEmail(email)).map((m) => m.agentId),
128
+ },
129
+ false,
130
+ );
131
+ }
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ type CallerScope,
4
+ type CallerScopeDeps,
5
+ resolveCallerScope,
6
+ scopeIncludesAgent,
7
+ taskStoreViewScope,
8
+ } from "./caller-scope.ts";
9
+
10
+ function makeDeps(opts: {
11
+ account?: Record<string, string>;
12
+ accountAgents?: Record<string, string[]>;
13
+ members?: Record<string, string[]>;
14
+ }): CallerScopeDeps {
15
+ return {
16
+ getAccountIdByEmail: async (e) => opts.account?.[e] ?? null,
17
+ listAgentIdsByAccount: async (id) => opts.accountAgents?.[id] ?? [],
18
+ listMemberAgentIds: async (e) => opts.members?.[e] ?? [],
19
+ };
20
+ }
21
+
22
+ const deps = makeDeps({
23
+ account: { "a@x.com": "acct-a", "both@x.com": "acct-a" },
24
+ accountAgents: { "acct-a": ["ag-1", "ag-2"] },
25
+ members: { "m@x.com": ["ag-9"], "both@x.com": ["ag-2", "ag-9"] },
26
+ });
27
+
28
+ const run = (email: string, isAdmin = false, flagEnabled = true) =>
29
+ resolveCallerScope({ email, isAdmin, flagEnabled }, deps);
30
+
31
+ describe("resolveCallerScope", () => {
32
+ it("returns all for admins", async () => {
33
+ expect(await run("a@x.com", true)).toEqual({ kind: "all" });
34
+ });
35
+
36
+ it("account member gets every account agent", async () => {
37
+ expect(await run("a@x.com")).toEqual({
38
+ kind: "scoped",
39
+ accountId: "acct-a",
40
+ agentIds: ["ag-1", "ag-2"],
41
+ });
42
+ });
43
+
44
+ it("explicit member gets only AgentMember agents", async () => {
45
+ expect(await run("m@x.com")).toEqual({
46
+ kind: "scoped",
47
+ accountId: null,
48
+ agentIds: ["ag-9"],
49
+ });
50
+ });
51
+
52
+ it("account + explicit membership is unioned and deduplicated", async () => {
53
+ const scope = await run("BOTH@x.com");
54
+ expect(scope.kind === "scoped" && scope.agentIds.sort()).toEqual([
55
+ "ag-1",
56
+ "ag-2",
57
+ "ag-9",
58
+ ]);
59
+ });
60
+
61
+ it("caller with neither gets an empty scope", async () => {
62
+ expect(await run("none@x.com")).toEqual({
63
+ kind: "scoped",
64
+ accountId: null,
65
+ agentIds: [],
66
+ });
67
+ });
68
+
69
+ it("flag off: account membership contributes nothing", async () => {
70
+ expect(await run("both@x.com", false, false)).toEqual({
71
+ kind: "scoped",
72
+ accountId: null,
73
+ agentIds: ["ag-2", "ag-9"],
74
+ });
75
+ expect(await run("a@x.com", false, false)).toEqual({
76
+ kind: "scoped",
77
+ accountId: null,
78
+ agentIds: [],
79
+ });
80
+ });
81
+ });
82
+
83
+ describe("scopeIncludesAgent", () => {
84
+ it("all includes anything; scoped only listed ids", () => {
85
+ expect(scopeIncludesAgent({ kind: "all" }, "x")).toBe(true);
86
+ const s: CallerScope = {
87
+ kind: "scoped",
88
+ accountId: null,
89
+ agentIds: ["a"],
90
+ };
91
+ expect(scopeIncludesAgent(s, "a")).toBe(true);
92
+ expect(scopeIncludesAgent(s, "b")).toBe(false);
93
+ });
94
+ });
95
+
96
+ describe("taskStoreViewScope (SSP-6.8)", () => {
97
+ it("platform admins see every account", () => {
98
+ expect(taskStoreViewScope({ kind: "all" })).toEqual({ kind: "all" });
99
+ });
100
+
101
+ it("an account user is narrowed to their account, keeping their agent ids", () => {
102
+ expect(
103
+ taskStoreViewScope({
104
+ kind: "scoped",
105
+ accountId: "acct-a",
106
+ agentIds: ["a1"],
107
+ }),
108
+ ).toEqual({ kind: "account", accountId: "acct-a", agentIds: ["a1"] });
109
+ });
110
+
111
+ it("a caller with no account (flag off, or no membership) gets no task-store views", () => {
112
+ expect(
113
+ taskStoreViewScope({ kind: "scoped", accountId: null, agentIds: ["a1"] }),
114
+ ).toEqual({ kind: "none" });
115
+ });
116
+ });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * admin/src/chat-credential-config.ts
3
+ *
4
+ * Pure parser for the chat-credential mint flag. Takes an injected env object
5
+ * (no process.env reads) so it is deterministically unit-testable; admin-boot
6
+ * reads it once at startup and injects the resolved config as deps. Same
7
+ * "enabled" convention as the self-serve flag (self-serve-config.ts).
8
+ */
9
+
10
+ /** Structural env shape — accepts `process.env` or a plain test object. */
11
+ type ChatCredentialEnv = Readonly<Record<string, string | undefined>>;
12
+
13
+ export interface ChatCredentialConfig {
14
+ /** True only when SHIPWRIGHT_CHAT_TOKEN_MINT_ENABLED === "enabled". */
15
+ enabled: boolean;
16
+ }
17
+
18
+ export function parseChatCredentialConfig(
19
+ env: ChatCredentialEnv,
20
+ ): ChatCredentialConfig {
21
+ return { enabled: env.SHIPWRIGHT_CHAT_TOKEN_MINT_ENABLED === "enabled" };
22
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Log-capture test for the chat-credentials routes (COS-32.1): the raw token
3
+ * must never reach a log line, and each mint/revoke emits one structured line.
4
+ */
5
+
6
+ import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
7
+ import {
8
+ type AdminDeps,
9
+ createAdminApp,
10
+ parseAdminApiKeys,
11
+ } from "./agents-api.ts";
12
+
13
+ const RAW = "ct_super_secret_raw_token_value";
14
+
15
+ function makeApp() {
16
+ const deps = {
17
+ agentService: { exists: async () => true },
18
+ agentTokenService: { validate: async () => null },
19
+ agentMemberService: { listByEmail: async () => [] },
20
+ adminApiKeys: parseAdminApiKeys("root:admin-key:*"),
21
+ sessionSecret: "s".repeat(32),
22
+ chatCredentialsEnabled: true,
23
+ chatCredentials: {
24
+ mintToken: async () => ({ id: "tok-1", rawToken: RAW }),
25
+ revokeToken: async () => {},
26
+ listTokensForAgent: async () => [{ id: "tok-1", label: "lbl" }],
27
+ },
28
+ } as unknown as AdminDeps;
29
+ return createAdminApp(deps);
30
+ }
31
+
32
+ describe("chat-credentials logging", () => {
33
+ let lines: string[];
34
+ const spies: Array<{ mockRestore: () => void }> = [];
35
+ beforeEach(() => {
36
+ lines = [];
37
+ for (const m of ["log", "info", "warn", "error"] as const) {
38
+ spies.push(
39
+ spyOn(console, m).mockImplementation((...a: unknown[]) => {
40
+ lines.push(a.map(String).join(" "));
41
+ }),
42
+ );
43
+ }
44
+ });
45
+ afterEach(() => {
46
+ for (const s of spies.splice(0)) s.mockRestore();
47
+ });
48
+
49
+ const req = (
50
+ app: ReturnType<typeof makeApp>,
51
+ method: string,
52
+ path: string,
53
+ body?: unknown,
54
+ ) =>
55
+ app.request(path, {
56
+ method,
57
+ body: body === undefined ? undefined : JSON.stringify(body),
58
+ headers: {
59
+ "Content-Type": "application/json",
60
+ Authorization: "Bearer admin-key",
61
+ },
62
+ });
63
+
64
+ it("mint and revoke each log one structured line without the raw token", async () => {
65
+ const app = makeApp();
66
+ const minted = await req(app, "POST", "/agents/a1/chat-credentials", {
67
+ label: "lbl",
68
+ });
69
+ expect(minted.status).toBe(201);
70
+ expect(((await minted.json()) as { rawToken: string }).rawToken).toBe(RAW);
71
+ expect(
72
+ (await req(app, "DELETE", "/agents/a1/chat-credentials/tok-1")).status,
73
+ ).toBe(204);
74
+
75
+ const events = lines
76
+ .filter((l) => l.startsWith("{"))
77
+ .map((l) => JSON.parse(l) as Record<string, unknown>);
78
+ expect(events.map((e) => e.event)).toEqual([
79
+ "chat-credential.mint",
80
+ "chat-credential.revoke",
81
+ ]);
82
+ for (const e of events) {
83
+ expect(e).toMatchObject({
84
+ agentId: "a1",
85
+ tokenId: "tok-1",
86
+ label: "lbl",
87
+ });
88
+ expect(typeof e.callerLabel).toBe("string");
89
+ }
90
+ expect(lines.join("\n")).not.toContain(RAW);
91
+ });
92
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * admin/src/chat-markers.ts
3
+ *
4
+ * Marker parsing for chat message display in admin UI.
5
+ * Strips response markers from displayed text and extracts URLs/paths for rendering.
6
+ *
7
+ * Supported markers:
8
+ * [silent] — stripped (Slack-only)
9
+ * [upload:/path/to/file] — path extracted for artifact badge
10
+ * [speak:text] — stripped (Slack-only)
11
+ * [react:emoji1,emoji2] — stripped (Slack-only)
12
+ * [plan:url] — URL extracted for link badge
13
+ *
14
+ * Note: This is a separate implementation from agent/src/runtime/markers.ts because
15
+ * admin and agent are separate workspace packages with no cross-imports.
16
+ */
17
+
18
+ interface ParseChatMarkersResult {
19
+ /** Message body with all markers removed. */
20
+ cleaned: string;
21
+ /** Extracted upload file paths (in order of occurrence). */
22
+ uploads: string[];
23
+ /** Extracted plan URLs (in order of occurrence). */
24
+ planUrls: string[];
25
+ }
26
+
27
+ // Regex patterns — greedy non-nested matching.
28
+ const SILENT_REGEX = /\[silent\]\s*$/i;
29
+ const UPLOAD_REGEX = /\[upload:([^\]]+)\]/gi;
30
+ const SPEAK_REGEX = /\[speak:([\s\S]*?)\]/gi;
31
+ const REACT_REGEX = /\[react:([^\]]+)\]/gi;
32
+ const PLAN_REGEX = /\[plan:([^\]]+)\]/gi;
33
+
34
+ /**
35
+ * Parse response markers from assistant chat message text.
36
+ * Returns the cleaned text (all markers stripped) and arrays of extracted URLs/paths.
37
+ *
38
+ * @param text - the raw message body potentially containing markers
39
+ * @returns cleaned text, upload paths, and plan URLs (all in order of occurrence)
40
+ */
41
+ export function parseChatMarkers(text: string): ParseChatMarkersResult {
42
+ const uploads: string[] = [];
43
+ const planUrls: string[] = [];
44
+ let cleaned = text;
45
+
46
+ // [silent]
47
+ if (SILENT_REGEX.test(cleaned)) {
48
+ cleaned = cleaned.replace(SILENT_REGEX, "").trim();
49
+ }
50
+
51
+ // [upload:/path]
52
+ cleaned = cleaned.replace(UPLOAD_REGEX, (_match, pathRaw: string) => {
53
+ const path = pathRaw.trim();
54
+ // Note: UPLOAD_REGEX uses [^\]]+ (one-or-more), so `path` is never empty here.
55
+ // This guard is unreachable but kept for defensive consistency.
56
+ if (!path) {
57
+ return _match;
58
+ }
59
+ uploads.push(path);
60
+ return "";
61
+ });
62
+ UPLOAD_REGEX.lastIndex = 0;
63
+ cleaned = cleaned.trim();
64
+
65
+ // [speak:text] — Slack-only, strip silently
66
+ // Note: SPEAK_REGEX uses [\s\S]*? (zero-or-more), so `text` can be empty — this guard is reachable.
67
+ cleaned = cleaned.replace(SPEAK_REGEX, (_match, textRaw: string) => {
68
+ const text = textRaw.trim();
69
+ if (!text) {
70
+ // Malformed: empty speak text. Leave in text.
71
+ return _match;
72
+ }
73
+ return "";
74
+ });
75
+ SPEAK_REGEX.lastIndex = 0;
76
+ cleaned = cleaned.trim();
77
+
78
+ // [react:emoji1,emoji2,...] — Slack-only, strip silently
79
+ // Note: REACT_REGEX uses [^\]]+ (one-or-more), so `raw` is never empty; after split+filter,
80
+ // emojis.length === 0 is unreachable. Guard kept for defensive consistency.
81
+ cleaned = cleaned.replace(REACT_REGEX, (_match, raw: string) => {
82
+ const emojis = raw
83
+ .split(",")
84
+ .map((e) => e.trim())
85
+ .filter(Boolean);
86
+ if (emojis.length === 0) {
87
+ return _match;
88
+ }
89
+ return "";
90
+ });
91
+ REACT_REGEX.lastIndex = 0;
92
+ cleaned = cleaned.trim();
93
+
94
+ // [plan:url]
95
+ // Note: PLAN_REGEX uses [^\]]+ (one-or-more), so `url` is never empty here.
96
+ // Guard kept for defensive consistency. Also filter non-http(s) schemes (e.g. javascript:)
97
+ // as defense-in-depth against injection via agent-generated messages.
98
+ cleaned = cleaned.replace(PLAN_REGEX, (_match, urlRaw: string) => {
99
+ const url = urlRaw.trim();
100
+ if (!url) {
101
+ return _match;
102
+ }
103
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
104
+ // Non-http scheme (e.g. javascript:). Leave in text rather than rendering as a link.
105
+ return _match;
106
+ }
107
+ planUrls.push(url);
108
+ return "";
109
+ });
110
+ PLAN_REGEX.lastIndex = 0;
111
+ cleaned = cleaned.trim();
112
+
113
+ return { cleaned, uploads, planUrls };
114
+ }