@bugmole/cli 0.4.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 (308) hide show
  1. package/.bugmole.env.example +20 -0
  2. package/LICENSE +7 -0
  3. package/README.md +293 -0
  4. package/TESTING.md +117 -0
  5. package/bugmole.config.yaml +39 -0
  6. package/package.json +85 -0
  7. package/scripts/billing/paypal-setup.mjs +121 -0
  8. package/scripts/bugmole-continue.ts +318 -0
  9. package/scripts/bugmole-init.ts +188 -0
  10. package/scripts/bugmole.cjs +17 -0
  11. package/scripts/bugmole.test.ts +344 -0
  12. package/scripts/bugmole.ts +657 -0
  13. package/scripts/ensure-maestro.cjs +79 -0
  14. package/scripts/ios-tunnel-keeper.sh +45 -0
  15. package/scripts/ios-wda-keeper.sh +66 -0
  16. package/scripts/sync-plan-catalog.d.mts +3 -0
  17. package/scripts/sync-plan-catalog.mjs +16 -0
  18. package/scripts/ui-parity-diff.py +65 -0
  19. package/scripts/ui-parity-requirements.txt +1 -0
  20. package/scripts/verify-manage-to-plans.mts +194 -0
  21. package/spec/app-ui-audit.schema.json +176 -0
  22. package/spec/blockers.yaml +79 -0
  23. package/spec/bugs.index.json +42 -0
  24. package/spec/design-dna.schema.json +38 -0
  25. package/spec/domain_rules.yaml +24 -0
  26. package/spec/flows/flow_manage-to-plans-64c3c61b.yaml +8 -0
  27. package/spec/flows/flow_screen-to-evidence-6c3ab309.yaml +7 -0
  28. package/spec/journey_graph.yaml +126 -0
  29. package/spec/journeys.graph.json +2618 -0
  30. package/spec/plans/dashboard-smoke.flow.yaml +20 -0
  31. package/spec/plans/example.flow.yaml +99 -0
  32. package/spec/plans/flow_api-keys-to-logout-7396cd5d.flow.yaml +33 -0
  33. package/spec/plans/flow_api-keys-to-screen-157e10a6.flow.yaml +33 -0
  34. package/spec/plans/flow_manage-to-audits-176c1eb8.flow.yaml +33 -0
  35. package/spec/plans/flow_manage-to-blockers-633282c8.flow.yaml +112 -0
  36. package/spec/plans/flow_manage-to-devices-ad57e202.flow.yaml +33 -0
  37. package/spec/plans/flow_manage-to-logout-86c54656.flow.yaml +33 -0
  38. package/spec/plans/flow_manage-to-manage-resource-action-6325f99a.flow.yaml +185 -0
  39. package/spec/plans/flow_manage-to-parity-issues-01fa2579.flow.yaml +34 -0
  40. package/spec/plans/flow_manage-to-plans-64c3c61b.flow.yaml +123 -0
  41. package/spec/plans/flow_manage-to-screen-6857f915.flow.yaml +106 -0
  42. package/spec/plans/flow_manage-to-tasks-c5791c95.flow.yaml +33 -0
  43. package/spec/plans/flow_profile-to-screen-6045aa3d.flow.yaml +33 -0
  44. package/spec/plans/flow_register-to-api-auth-login-460a9cc8.flow.yaml +34 -0
  45. package/spec/plans/flow_screen-to-audits-07666356.flow.yaml +33 -0
  46. package/spec/plans/flow_screen-to-blockers-091d258b.flow.yaml +33 -0
  47. package/spec/plans/flow_screen-to-devices-cad72f75.flow.yaml +33 -0
  48. package/spec/plans/flow_screen-to-evidence-6c3ab309.flow.yaml +126 -0
  49. package/spec/plans/flow_screen-to-parity-issues-2218e3ad.flow.yaml +34 -0
  50. package/spec/plans/flow_screen-to-plans-8232a94f.flow.yaml +33 -0
  51. package/spec/plans/flow_screen-to-tasks-540671d1.flow.yaml +33 -0
  52. package/spec/plans/login.flow.yaml +22 -0
  53. package/spec/plans/owner-operations.flow.yaml +20 -0
  54. package/spec/project_config.yaml +55 -0
  55. package/spec/roles.yaml +30 -0
  56. package/spec/schema.md +394 -0
  57. package/spec/test-case-results.schema.json +62 -0
  58. package/spec/test-cases.schema.json +85 -0
  59. package/spec/ui-parity-audit.schema.json +194 -0
  60. package/spec/ui-reverse-engineering.schema.json +94 -0
  61. package/src/billing/plan-catalog.test.ts +46 -0
  62. package/src/billing/plan-catalog.ts +199 -0
  63. package/src/integrations/aws-sigv4.test.ts +42 -0
  64. package/src/integrations/aws-sigv4.ts +72 -0
  65. package/src/integrations/device-farm.ts +155 -0
  66. package/src/integrations/github-app.test.ts +57 -0
  67. package/src/integrations/github-app.ts +143 -0
  68. package/src/integrations/gitlab.ts +81 -0
  69. package/src/integrations/temp-email.test.ts +123 -0
  70. package/src/integrations/temp-email.ts +175 -0
  71. package/src/integrations/testflight-feedback.test.ts +51 -0
  72. package/src/integrations/testflight-feedback.ts +173 -0
  73. package/src/integrations/webdriver-client.ts +131 -0
  74. package/src/mcp/server.test.ts +1220 -0
  75. package/src/mcp/server.ts +3064 -0
  76. package/src/mcp/write-test-cases.test.ts +287 -0
  77. package/src/registry/api-key-client.ts +39 -0
  78. package/src/registry/control-plane-client.ts +212 -0
  79. package/src/registry/migrations/0001_registry.sql +47 -0
  80. package/src/registry/migrations/0002_device_authorizations.sql +23 -0
  81. package/src/registry/migrations/0003_project_environments.sql +25 -0
  82. package/src/registry/migrations/0004_device_authorization_email.sql +1 -0
  83. package/src/registry/migrations/0005_testing_control_plane.sql +55 -0
  84. package/src/registry/migrations/0006_workspaces.sql +36 -0
  85. package/src/registry/migrations/0007_project_apps.sql +26 -0
  86. package/src/registry/migrations/0008_agent_tasks.sql +30 -0
  87. package/src/registry/migrations/0009_journey_revisions.sql +17 -0
  88. package/src/registry/migrations/0010_agent_task_journey.sql +2 -0
  89. package/src/registry/migrations/0011_run_journey_revision.sql +1 -0
  90. package/src/registry/migrations/0012_canonical_flow_execution.sql +10 -0
  91. package/src/registry/migrations/0013_device_sessions.sql +22 -0
  92. package/src/registry/migrations/0014_agent_task_step.sql +1 -0
  93. package/src/registry/migrations/0015_agent_task_attempts.sql +6 -0
  94. package/src/registry/migrations/0016_github_issue_tracker.sql +54 -0
  95. package/src/registry/migrations/0017_run_targets.sql +7 -0
  96. package/src/registry/migrations/0018_run_fix_from.sql +4 -0
  97. package/src/registry/migrations/0019_workspace_flags.sql +9 -0
  98. package/src/registry/migrations/0020_orgs.sql +40 -0
  99. package/src/registry/migrations/0021_billing_core.sql +58 -0
  100. package/src/registry/migrations/0022_cloud_runners.sql +19 -0
  101. package/src/registry/migrations/0023_signup.sql +4 -0
  102. package/src/registry/migrations/0024_billing.sql +67 -0
  103. package/src/registry/migrations/0025_notifications.sql +47 -0
  104. package/src/registry/migrations/0026_repo_bindings.sql +28 -0
  105. package/src/registry/migrations/0027_feedback.sql +29 -0
  106. package/src/registry/migrations/0028_devices.sql +48 -0
  107. package/src/registry/migrations/0029_sso.sql +31 -0
  108. package/src/registry/migrations/0030_workspace_domains.sql +18 -0
  109. package/src/registry/migrations/0031_gitlab_and_teams.sql +45 -0
  110. package/src/registry/migrations/0032_personas.sql +15 -0
  111. package/src/registry/migrations/0033_bugmole_rename.sql +11 -0
  112. package/src/registry/task-scheduling.test.ts +100 -0
  113. package/src/registry/task-scheduling.ts +80 -0
  114. package/src/registry-worker/ai/platform-model.ts +77 -0
  115. package/src/registry-worker/ai/routes.test.ts +88 -0
  116. package/src/registry-worker/ai/routes.ts +90 -0
  117. package/src/registry-worker/artifacts.test.ts +98 -0
  118. package/src/registry-worker/artifacts.ts +85 -0
  119. package/src/registry-worker/billing/billing-core.test.ts +175 -0
  120. package/src/registry-worker/billing/checkout-routes.ts +209 -0
  121. package/src/registry-worker/billing/enforcement.ts +69 -0
  122. package/src/registry-worker/billing/entitlements.ts +108 -0
  123. package/src/registry-worker/billing/ledger.ts +186 -0
  124. package/src/registry-worker/billing/paypal/api.ts +259 -0
  125. package/src/registry-worker/billing/paypal/client.ts +91 -0
  126. package/src/registry-worker/billing/paypal/provider.ts +143 -0
  127. package/src/registry-worker/billing/paypal.test.ts +466 -0
  128. package/src/registry-worker/billing/provider.ts +114 -0
  129. package/src/registry-worker/billing/routes.ts +66 -0
  130. package/src/registry-worker/billing/subscriptions.ts +780 -0
  131. package/src/registry-worker/billing/thresholds.ts +107 -0
  132. package/src/registry-worker/core.ts +308 -0
  133. package/src/registry-worker/devices/devices.test.ts +185 -0
  134. package/src/registry-worker/devices/policy.ts +71 -0
  135. package/src/registry-worker/devices/routes.ts +453 -0
  136. package/src/registry-worker/domains/domains.test.ts +210 -0
  137. package/src/registry-worker/domains/routes.ts +139 -0
  138. package/src/registry-worker/domains.ts +88 -0
  139. package/src/registry-worker/email/sender.ts +75 -0
  140. package/src/registry-worker/env.d.ts +14716 -0
  141. package/src/registry-worker/features.ts +20 -0
  142. package/src/registry-worker/feedback/feedback.test.ts +230 -0
  143. package/src/registry-worker/feedback/format.ts +148 -0
  144. package/src/registry-worker/feedback/routes.ts +386 -0
  145. package/src/registry-worker/flags.ts +39 -0
  146. package/src/registry-worker/github/checks.test.ts +177 -0
  147. package/src/registry-worker/github/checks.ts +374 -0
  148. package/src/registry-worker/gitlab/checks.test.ts +141 -0
  149. package/src/registry-worker/gitlab/checks.ts +349 -0
  150. package/src/registry-worker/hooks.ts +54 -0
  151. package/src/registry-worker/index.ts +2077 -0
  152. package/src/registry-worker/jobs/index.ts +29 -0
  153. package/src/registry-worker/jobs/retention.ts +68 -0
  154. package/src/registry-worker/mcp/mcp.test.ts +355 -0
  155. package/src/registry-worker/mcp/routes.ts +215 -0
  156. package/src/registry-worker/mcp/token.ts +126 -0
  157. package/src/registry-worker/mcp/tools.ts +563 -0
  158. package/src/registry-worker/notifications/alerts.ts +212 -0
  159. package/src/registry-worker/notifications/notifications.test.ts +298 -0
  160. package/src/registry-worker/notifications/outbox.ts +83 -0
  161. package/src/registry-worker/notifications/routes.ts +280 -0
  162. package/src/registry-worker/notifications/secrets.ts +49 -0
  163. package/src/registry-worker/notifications/slack.ts +96 -0
  164. package/src/registry-worker/notifications/teams.ts +46 -0
  165. package/src/registry-worker/org/audit.ts +116 -0
  166. package/src/registry-worker/org/routes.test.ts +163 -0
  167. package/src/registry-worker/org/routes.ts +302 -0
  168. package/src/registry-worker/personas/personas.test.ts +78 -0
  169. package/src/registry-worker/personas/routes.ts +100 -0
  170. package/src/registry-worker/repo-triggers.ts +20 -0
  171. package/src/registry-worker/routes/index.ts +74 -0
  172. package/src/registry-worker/run-events.ts +24 -0
  173. package/src/registry-worker/runner/dispatch.ts +219 -0
  174. package/src/registry-worker/runner/jobs.ts +43 -0
  175. package/src/registry-worker/runner/metering.ts +82 -0
  176. package/src/registry-worker/runner/policy.ts +59 -0
  177. package/src/registry-worker/runner/routes.ts +171 -0
  178. package/src/registry-worker/runner/runner.test.ts +358 -0
  179. package/src/registry-worker/runner/tokens.ts +93 -0
  180. package/src/registry-worker/runs.test.ts +60 -0
  181. package/src/registry-worker/signup/policy.ts +57 -0
  182. package/src/registry-worker/signup/routes.ts +106 -0
  183. package/src/registry-worker/signup/signup.test.ts +81 -0
  184. package/src/registry-worker/sso/aegis.ts +141 -0
  185. package/src/registry-worker/sso/membership.ts +157 -0
  186. package/src/registry-worker/sso/routes.ts +458 -0
  187. package/src/registry-worker/sso/sso.test.ts +344 -0
  188. package/src/registry-worker/testing/d1-shim.ts +180 -0
  189. package/src/registry-worker/testing/harness.ts +137 -0
  190. package/src/runner-worker/index.ts +108 -0
  191. package/src/runtime/ai-analysis.ts +97 -0
  192. package/src/runtime/ai-exploration.test.ts +32 -0
  193. package/src/runtime/ai-exploration.ts +69 -0
  194. package/src/runtime/ai-repair.ts +74 -0
  195. package/src/runtime/ai-work.test.ts +99 -0
  196. package/src/runtime/android-screen-record.test.ts +75 -0
  197. package/src/runtime/android-screen-record.ts +192 -0
  198. package/src/runtime/app-understanding.test.ts +123 -0
  199. package/src/runtime/app-understanding.ts +201 -0
  200. package/src/runtime/appium-driver.test.ts +179 -0
  201. package/src/runtime/appium-driver.ts +295 -0
  202. package/src/runtime/blocker-resolution.test.ts +113 -0
  203. package/src/runtime/blocker-resolution.ts +111 -0
  204. package/src/runtime/browser-matrix.integration.test.ts +212 -0
  205. package/src/runtime/browser-matrix.test.ts +143 -0
  206. package/src/runtime/browser-matrix.ts +200 -0
  207. package/src/runtime/canonical-flow.test.ts +52 -0
  208. package/src/runtime/config-validate.ts +185 -0
  209. package/src/runtime/continuous-execution.ts +291 -0
  210. package/src/runtime/cursor-applescript.ts +573 -0
  211. package/src/runtime/cursor-cli-driver.test.ts +78 -0
  212. package/src/runtime/cursor-cli-driver.ts +156 -0
  213. package/src/runtime/cursor-driver-example.ts +117 -0
  214. package/src/runtime/cursor-driver-index.ts +65 -0
  215. package/src/runtime/cursor-driver-init.ts +277 -0
  216. package/src/runtime/cursor-driver-run.test.ts +15 -0
  217. package/src/runtime/cursor-driver-run.ts +323 -0
  218. package/src/runtime/cursor-driver.ts +332 -0
  219. package/src/runtime/cursor-llm-example.ts +90 -0
  220. package/src/runtime/cursor-llm.ts +206 -0
  221. package/src/runtime/cursor-mcp-monitor.ts +386 -0
  222. package/src/runtime/device-clouds/browserstack.ts +73 -0
  223. package/src/runtime/device-clouds/device-farm.ts +52 -0
  224. package/src/runtime/device-clouds/index.ts +92 -0
  225. package/src/runtime/device-clouds/kobiton.ts +70 -0
  226. package/src/runtime/device-clouds/targets.ts +44 -0
  227. package/src/runtime/device-clouds/types.ts +62 -0
  228. package/src/runtime/diff-proposal.ts +84 -0
  229. package/src/runtime/discovery-task.test.ts +29 -0
  230. package/src/runtime/discovery-task.ts +284 -0
  231. package/src/runtime/driver-recovery.ts +69 -0
  232. package/src/runtime/driver.ts +79 -0
  233. package/src/runtime/environment.test.ts +104 -0
  234. package/src/runtime/environment.ts +137 -0
  235. package/src/runtime/executor.test.ts +509 -0
  236. package/src/runtime/executor.ts +921 -0
  237. package/src/runtime/explorer.test.ts +101 -0
  238. package/src/runtime/explorer.ts +1013 -0
  239. package/src/runtime/failure-analysis.test.ts +111 -0
  240. package/src/runtime/failure-analysis.ts +272 -0
  241. package/src/runtime/fixtures/fake-maestro.sh +36 -0
  242. package/src/runtime/flow-language.test.ts +268 -0
  243. package/src/runtime/flow-language.ts +414 -0
  244. package/src/runtime/init-wizard.ts +354 -0
  245. package/src/runtime/ios-screen-record.test.ts +68 -0
  246. package/src/runtime/ios-screen-record.ts +155 -0
  247. package/src/runtime/journey-editor.ts +452 -0
  248. package/src/runtime/journey-evidence.test.ts +161 -0
  249. package/src/runtime/journey-evidence.ts +180 -0
  250. package/src/runtime/journey-graph.test.ts +257 -0
  251. package/src/runtime/journey-graph.ts +170 -0
  252. package/src/runtime/legacy-names.ts +32 -0
  253. package/src/runtime/llm-example.ts +105 -0
  254. package/src/runtime/llm.ts +527 -0
  255. package/src/runtime/local-browser.test.ts +45 -0
  256. package/src/runtime/local-browser.ts +48 -0
  257. package/src/runtime/local-registry-stub.test.ts +325 -0
  258. package/src/runtime/local-registry-stub.ts +803 -0
  259. package/src/runtime/maestro-driver.test.ts +84 -0
  260. package/src/runtime/maestro-driver.ts +209 -0
  261. package/src/runtime/mole-voice.ts +21 -0
  262. package/src/runtime/nav-crawl.test.ts +100 -0
  263. package/src/runtime/nav-crawl.ts +153 -0
  264. package/src/runtime/pipeline.test.ts +405 -0
  265. package/src/runtime/pipeline.ts +833 -0
  266. package/src/runtime/planner.test.ts +37 -0
  267. package/src/runtime/planner.ts +274 -0
  268. package/src/runtime/platform-ai.ts +76 -0
  269. package/src/runtime/playwright-driver.test.ts +93 -0
  270. package/src/runtime/playwright-driver.ts +620 -0
  271. package/src/runtime/project-spec.ts +140 -0
  272. package/src/runtime/record-run-verdicts.ts +68 -0
  273. package/src/runtime/reporter.test.ts +56 -0
  274. package/src/runtime/reporter.ts +158 -0
  275. package/src/runtime/reset.test.ts +44 -0
  276. package/src/runtime/reset.ts +61 -0
  277. package/src/runtime/reviewer.test.ts +73 -0
  278. package/src/runtime/reviewer.ts +158 -0
  279. package/src/runtime/run-job.ts +136 -0
  280. package/src/runtime/run-once.test.ts +207 -0
  281. package/src/runtime/run-once.ts +168 -0
  282. package/src/runtime/run.ts +132 -0
  283. package/src/runtime/screen-recording.ts +34 -0
  284. package/src/runtime/serve-gateway.test.ts +74 -0
  285. package/src/runtime/serve-gateway.ts +164 -0
  286. package/src/runtime/serve-worker.test.ts +23 -0
  287. package/src/runtime/serve-worker.ts +278 -0
  288. package/src/runtime/site-discovery.test.ts +168 -0
  289. package/src/runtime/site-discovery.ts +308 -0
  290. package/src/runtime/target-runner.ts +144 -0
  291. package/src/runtime/test-case-verdicts.test.ts +94 -0
  292. package/src/runtime/test-case-verdicts.ts +120 -0
  293. package/src/runtime/ui-reverse-engineering/coordinator.test.ts +59 -0
  294. package/src/runtime/ui-reverse-engineering/coordinator.ts +391 -0
  295. package/src/runtime/ui-reverse-engineering/types.ts +197 -0
  296. package/src/runtime/web-suite.test.ts +97 -0
  297. package/src/runtime/web-suite.ts +176 -0
  298. package/src/storage/create-object-store.ts +144 -0
  299. package/src/storage/keys.ts +34 -0
  300. package/src/storage/local-artifact-server.test.ts +314 -0
  301. package/src/storage/local-artifact-server.ts +357 -0
  302. package/src/storage/object-store.test.ts +28 -0
  303. package/src/storage/object-store.ts +101 -0
  304. package/src/storage/registry-object-store.ts +88 -0
  305. package/src/storage/remote-object-store.ts +104 -0
  306. package/src/storage/storage-directory.test.ts +43 -0
  307. package/src/storage/storage-directory.ts +24 -0
  308. package/tsconfig.json +24 -0
@@ -0,0 +1,29 @@
1
+ // Cron entry point. wrangler.jsonc schedules:
2
+ // "* * * * *" → frequent work: cloud-run dispatch, notification outbox
3
+ // "17 3 * * *" → daily work: retention, monthly billing close, subscription sync
4
+ import type { Env } from "../core.js";
5
+
6
+ export type Job = (env: Env, ctx: ExecutionContext, now: Date) => Promise<void>;
7
+
8
+ export const MINUTE_CRON = "* * * * *";
9
+ export const DAILY_CRON = "17 3 * * *";
10
+
11
+ const minuteJobs: Array<[string, Job]> = [];
12
+ const dailyJobs: Array<[string, Job]> = [];
13
+
14
+ export function registerJob(schedule: "minute" | "daily", name: string, job: Job): void {
15
+ (schedule === "minute" ? minuteJobs : dailyJobs).push([name, job]);
16
+ }
17
+
18
+ export async function runScheduledJobs(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
19
+ const now = new Date(controller.scheduledTime);
20
+ const jobs = controller.cron === DAILY_CRON ? dailyJobs : minuteJobs;
21
+ // One failing job must not starve the others.
22
+ for (const [name, job] of jobs) {
23
+ try {
24
+ await job(env, ctx, now);
25
+ } catch (error) {
26
+ console.error(`[registry] scheduled job ${name} failed`, error);
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,68 @@
1
+ // Deletes run history older than each workspace's plan allows, including the
2
+ // evidence Bugmole stores for it. Runs in small batches every day.
3
+ import type { Env } from "../core.js";
4
+ import { getEntitlements } from "../billing/entitlements.js";
5
+ import { recordAudit } from "../org/audit.js";
6
+ import { registerJob } from "./index.js";
7
+
8
+ const TERMINAL = "('success', 'partial', 'error', 'cancelled')";
9
+ const MAX_RUNS_PER_TICK = 500;
10
+ /** No plan keeps less history than this, so younger runs are never examined. */
11
+ const MIN_HISTORY_DAYS = 7;
12
+
13
+ export function historyCutoff(historyDays: number | null, now: Date): string | null {
14
+ return historyDays === null ? null : new Date(now.getTime() - historyDays * 86_400_000).toISOString();
15
+ }
16
+
17
+ async function deletePrefix(bucket: R2Bucket, prefix: string): Promise<number> {
18
+ let cursor: string | undefined;
19
+ let deleted = 0;
20
+ do {
21
+ const page = await bucket.list({ prefix, cursor, limit: 1000 });
22
+ const keys = page.objects.map((object) => object.key);
23
+ if (keys.length) {
24
+ await bucket.delete(keys);
25
+ deleted += keys.length;
26
+ }
27
+ cursor = page.truncated ? page.cursor : undefined;
28
+ } while (cursor);
29
+ return deleted;
30
+ }
31
+
32
+ export async function enforceRetention(env: Env, now = new Date()): Promise<{ runs: number; objects: number }> {
33
+ const oldest = new Date(now.getTime() - MIN_HISTORY_DAYS * 86_400_000).toISOString();
34
+ const workspaces = await env.DB.prepare(`
35
+ SELECT DISTINCT workspace_id AS workspaceId FROM runs
36
+ WHERE workspace_id IS NOT NULL AND status IN ${TERMINAL} AND COALESCE(completed_at, requested_at) < ?
37
+ `).bind(oldest).all<{ workspaceId: string }>();
38
+ let runs = 0;
39
+ let objects = 0;
40
+ for (const { workspaceId } of workspaces.results) {
41
+ if (runs >= MAX_RUNS_PER_TICK) break;
42
+ const { historyDays } = await getEntitlements(env, workspaceId);
43
+ const cutoff = historyCutoff(historyDays, now);
44
+ if (!cutoff) continue;
45
+ const expired = await env.DB.prepare(`
46
+ SELECT id, project_id AS projectId FROM runs
47
+ WHERE workspace_id = ? AND status IN ${TERMINAL} AND COALESCE(completed_at, requested_at) < ?
48
+ ORDER BY requested_at LIMIT ?
49
+ `).bind(workspaceId, cutoff, MAX_RUNS_PER_TICK - runs).all<{ id: string; projectId: string }>();
50
+ if (!expired.results.length) continue;
51
+ for (const run of expired.results) {
52
+ if (env.ARTIFACTS) objects += await deletePrefix(env.ARTIFACTS, `projects/${run.projectId}/runs/${run.id}/`);
53
+ await env.DB.batch([
54
+ env.DB.prepare("DELETE FROM run_events WHERE run_id = ?").bind(run.id),
55
+ env.DB.prepare("DELETE FROM runs WHERE id = ?").bind(run.id),
56
+ ]);
57
+ }
58
+ runs += expired.results.length;
59
+ await recordAudit(env, { type: "system", id: "retention" }, {
60
+ workspaceId, action: "runs.expired", data: { count: expired.results.length, historyDays, cutoff },
61
+ });
62
+ }
63
+ return { runs, objects };
64
+ }
65
+
66
+ registerJob("daily", "retention", async (env, _ctx, now) => {
67
+ await enforceRetention(env, now);
68
+ });
@@ -0,0 +1,355 @@
1
+ import assert from "node:assert/strict";
2
+ import { generateKeyPairSync, sign, type KeyObject } from "node:crypto";
3
+ import test from "node:test";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import YAML from "yaml";
8
+ import type { AppDriver, DriverContext, DriverResult } from "../../runtime/driver.js";
9
+ import { parseMaestroStyleFlow } from "../../runtime/flow-language.js";
10
+ import { planDocument } from "./tools.js";
11
+ import type { Env } from "../core.js";
12
+ import { call, createTestEnv, seedProject } from "../testing/harness.js";
13
+ import { resetMcpKeyCache } from "./token.js";
14
+
15
+ const ISSUER = "https://id.test";
16
+ const RESOURCE = "https://registry.test/mcp";
17
+ const KID = "key-1";
18
+
19
+ const keys = generateKeyPairSync("rsa", { modulusLength: 2048 });
20
+ const otherKeys = generateKeyPairSync("rsa", { modulusLength: 2048 });
21
+
22
+ function b64url(value: string | Buffer): string {
23
+ return Buffer.from(value).toString("base64url");
24
+ }
25
+
26
+ function jwt(claims: Record<string, unknown>, options: { key?: KeyObject; kid?: string; typ?: string; alg?: string } = {}): string {
27
+ const header = b64url(JSON.stringify({ alg: options.alg ?? "RS256", kid: options.kid ?? KID, typ: options.typ ?? "at+jwt" }));
28
+ const body = b64url(JSON.stringify(claims));
29
+ const signature = sign("sha256", Buffer.from(`${header}.${body}`), options.key ?? keys.privateKey);
30
+ return `${header}.${body}.${b64url(signature)}`;
31
+ }
32
+
33
+ function accessToken(sub: string, overrides: Record<string, unknown> = {}, options: Parameters<typeof jwt>[1] = {}): string {
34
+ const now = Math.floor(Date.now() / 1000);
35
+ return jwt({
36
+ iss: ISSUER,
37
+ aud: RESOURCE,
38
+ sub,
39
+ client_id: "mcp_0123",
40
+ scope: "openid email offline_access bugmole:mcp",
41
+ email: `${sub}@acme.test`,
42
+ email_verified: true,
43
+ iat: now,
44
+ exp: now + 3600,
45
+ ...overrides,
46
+ }, options);
47
+ }
48
+
49
+ function mcpEnv(overrides: Partial<Env> = {}) {
50
+ resetMcpKeyCache();
51
+ let jwksCalls = 0;
52
+ const aegisFetch = (async (input: RequestInfo | URL) => {
53
+ const url = new URL(String(input));
54
+ if (url.toString() !== `${ISSUER}/jwks`) return new Response("not found", { status: 404 });
55
+ jwksCalls += 1;
56
+ return Response.json({ keys: [{ ...keys.publicKey.export({ format: "jwk" }), kid: KID, alg: "RS256", use: "sig" }] });
57
+ }) as typeof fetch;
58
+ const setup = createTestEnv({
59
+ FEATURE_MCP: "on",
60
+ AEGIS_ISSUER: ISSUER,
61
+ AEGIS_FETCH: aegisFetch,
62
+ REGISTRY_PUBLIC_URL: "https://registry.test",
63
+ DASHBOARD_URL: "https://dash.test",
64
+ ...overrides,
65
+ } as Partial<Env>);
66
+ return { ...setup, jwksCalls: () => jwksCalls };
67
+ }
68
+
69
+ let nextId = 1;
70
+ async function rpc(env: Env, token: string, method: string, params?: unknown) {
71
+ const res = await call(env, "POST", "/mcp", {
72
+ token,
73
+ body: { jsonrpc: "2.0", id: nextId++, method, ...(params === undefined ? {} : { params }) },
74
+ headers: { accept: "application/json, text/event-stream" },
75
+ });
76
+ return res;
77
+ }
78
+
79
+ async function tool(env: Env, token: string, name: string, args: Record<string, unknown> = {}) {
80
+ const res = await rpc(env, token, "tools/call", { name, arguments: args });
81
+ assert.equal(res.status, 200, JSON.stringify(res.json));
82
+ const result = res.json.result;
83
+ return { ...result, data: result.isError ? null : JSON.parse(result.content[0].text), message: result.content[0].text as string };
84
+ }
85
+
86
+ test("the MCP endpoint is off unless enabled", async () => {
87
+ const { env } = mcpEnv({ FEATURE_MCP: undefined } as Partial<Env>);
88
+ assert.equal((await call(env, "POST", "/mcp", { body: {} })).status, 404);
89
+ assert.equal((await call(env, "GET", "/.well-known/oauth-protected-resource/mcp")).status, 404);
90
+ });
91
+
92
+ test("assistants discover Aegis from the resource metadata", async () => {
93
+ const { env } = mcpEnv();
94
+ for (const path of ["/.well-known/oauth-protected-resource/mcp", "/.well-known/oauth-protected-resource"]) {
95
+ const res = await call(env, "GET", path);
96
+ assert.equal(res.status, 200);
97
+ assert.equal(res.json.resource, RESOURCE);
98
+ assert.deepEqual(res.json.authorization_servers, [ISSUER]);
99
+ assert.ok(res.json.scopes_supported.includes("bugmole:mcp"));
100
+ assert.equal(res.headers.get("access-control-allow-origin"), "*");
101
+ }
102
+ const anonymous = await call(env, "POST", "/mcp", { body: { jsonrpc: "2.0", id: 1, method: "initialize" } });
103
+ assert.equal(anonymous.status, 401);
104
+ const challenge = anonymous.headers.get("www-authenticate") ?? "";
105
+ assert.match(challenge, /^Bearer /);
106
+ assert.match(challenge, /resource_metadata="https:\/\/registry\.test\/\.well-known\/oauth-protected-resource\/mcp"/);
107
+ assert.match(challenge, /scope="bugmole:mcp"/);
108
+ assert.equal((await call(env, "OPTIONS", "/mcp")).status, 204);
109
+ });
110
+
111
+ test("only current Aegis access tokens for this endpoint are accepted", async () => {
112
+ const { env, db, jwksCalls } = mcpEnv();
113
+ const now = Math.floor(Date.now() / 1000);
114
+ const rejected: Array<[string, string]> = [
115
+ ["another audience", accessToken("user_a", { aud: "https://other.test/mcp" })],
116
+ ["another issuer", accessToken("user_a", { iss: "https://evil.test" })],
117
+ ["expired", accessToken("user_a", { exp: now - 600 })],
118
+ ["not yet valid", accessToken("user_a", { nbf: now + 600 })],
119
+ ["no MCP scope", accessToken("user_a", { scope: "openid email" })],
120
+ ["no subject", accessToken("user_a", { sub: "" })],
121
+ ["an ID token", accessToken("user_a", {}, { typ: "JWT" })],
122
+ ["signed by someone else", accessToken("user_a", {}, { key: otherKeys.privateKey })],
123
+ ["unknown key", accessToken("user_a", {}, { kid: "key-9" })],
124
+ ["HS256", accessToken("user_a", {}, { alg: "HS256" })],
125
+ ["garbage", "not.a.jwt"],
126
+ ];
127
+ for (const [label, token] of rejected) {
128
+ const res = await rpc(env, token, "ping");
129
+ assert.equal(res.status, 401, label);
130
+ assert.match(res.headers.get("www-authenticate") ?? "", /error="invalid_token"/, label);
131
+ }
132
+ // The key set is cached; an unknown key id refetches it at most once a minute.
133
+ assert.ok(jwksCalls() <= 2, `fetched keys ${jwksCalls()} times`);
134
+
135
+ const ok = await rpc(env, accessToken("user_a", { aud: [RESOURCE, "https://other.test"] }), "ping");
136
+ assert.equal(ok.status, 200);
137
+ assert.deepEqual(ok.json, { jsonrpc: "2.0", id: ok.json.id, result: {} });
138
+
139
+ // Someone their organization removed is refused.
140
+ db.db.prepare("INSERT INTO aegis_users (id, deprovisioned_at) VALUES (?, CURRENT_TIMESTAMP)").run("user_gone");
141
+ assert.equal((await rpc(env, accessToken("user_gone"), "ping")).status, 401);
142
+ });
143
+
144
+ test("initialize, tools/list, notifications and errors follow the protocol", async () => {
145
+ const { env } = mcpEnv();
146
+ const token = accessToken("user_a");
147
+ const init = await rpc(env, token, "initialize", { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "test", version: "1" } });
148
+ assert.equal(init.json.result.protocolVersion, "2025-03-26");
149
+ assert.equal(init.json.result.serverInfo.name, "bugmole");
150
+ assert.ok(init.json.result.capabilities.tools);
151
+ assert.match(init.json.result.instructions, /explore[\s\S]*plan[\s\S]*run[\s\S]*report/);
152
+ const unknownVersion = await rpc(env, token, "initialize", { protocolVersion: "1999-01-01" });
153
+ assert.equal(unknownVersion.json.result.protocolVersion, "2025-06-18");
154
+
155
+ const listed = await rpc(env, token, "tools/list");
156
+ const names = listed.json.result.tools.map((item: { name: string }) => item.name);
157
+ assert.deepEqual(names, [
158
+ "list_projects", "list_test_plans", "get_test_plan", "save_test_plan", "run_tests",
159
+ "get_run", "list_runs", "explore_app", "get_exploration", "list_journeys",
160
+ ]);
161
+ for (const item of listed.json.result.tools) {
162
+ assert.equal(item.inputSchema.type, "object", item.name);
163
+ assert.equal(typeof item.annotations.readOnlyHint, "boolean", item.name);
164
+ assert.equal(item.run, undefined);
165
+ }
166
+
167
+ const notification = await call(env, "POST", "/mcp", { token, body: { jsonrpc: "2.0", method: "notifications/initialized" } });
168
+ assert.equal(notification.status, 202);
169
+ const missing = await rpc(env, token, "sampling/createMessage");
170
+ assert.equal(missing.json.error.code, -32601);
171
+ const parse = await call(env, "POST", "/mcp", { token, body: "{nope", headers: { "content-type": "application/json" } });
172
+ assert.equal(parse.json.error.code, -32700);
173
+ const batch = await call(env, "POST", "/mcp", { token, body: [{ jsonrpc: "2.0", id: 1, method: "ping" }, { jsonrpc: "2.0", method: "notifications/x" }] });
174
+ assert.equal(batch.json.length, 1);
175
+ assert.equal((await call(env, "GET", "/mcp", { token })).status, 405);
176
+ const unknownTool = await tool(env, token, "delete_everything");
177
+ assert.equal(unknownTool.isError, true);
178
+ const missingArg = await tool(env, token, "list_test_plans", {});
179
+ assert.equal(missingArg.isError, true);
180
+ assert.match(missingArg.message, /projectId is required/);
181
+ });
182
+
183
+ const FLOW = `url: https://shop.example.com/cart
184
+ ---
185
+ - launchApp
186
+ - tapOn: "Checkout"
187
+ - inputText: "{{secret.SHOP_PASSWORD}}"
188
+ - assertVisible: "Order placed"
189
+ `;
190
+
191
+ test("an assistant explores, writes a plan, runs it and reads the result as the signed-in person", async () => {
192
+ const { env, artifacts } = mcpEnv();
193
+ const { owner, projectId } = await seedProject(env, { storageProvider: "bugmole" });
194
+ const environment = await call(env, "POST", `/api/projects/${projectId}/environments`, {
195
+ as: owner,
196
+ body: { id: "staging", name: "Staging", baseUrl: "https://staging.shop.test" },
197
+ });
198
+ assert.ok([200, 201].includes(environment.status), JSON.stringify(environment.json));
199
+ const token = accessToken(owner);
200
+
201
+ const projects = await tool(env, token, "list_projects");
202
+ const project = projects.data.projects.find((item: { id: string }) => item.id === projectId);
203
+ assert.equal(project.storage, "Bugmole");
204
+ assert.equal(project.runsGoTo, "your workers");
205
+ assert.ok(project.environments.some((item: { baseUrl: string }) => item.baseUrl === "https://staging.shop.test"));
206
+
207
+ // Explore
208
+ const explore = await tool(env, token, "explore_app", { projectId, url: "https://staging.shop.test", goal: "guest checkout" });
209
+ assert.equal(explore.isError, undefined, explore.message);
210
+ assert.equal(explore.data.status, "queued");
211
+ const exploration = await tool(env, token, "get_exploration", { projectId, taskId: explore.data.taskId });
212
+ assert.equal(exploration.data.status, "queued");
213
+ assert.match(exploration.data.title, /Explore staging\.shop\.test: guest checkout/);
214
+ assert.equal((await tool(env, token, "explore_app", { projectId, url: "javascript:alert(1)" })).isError, true);
215
+ assert.deepEqual((await tool(env, token, "list_journeys", { projectId })).data, { journeys: [] });
216
+
217
+ // Plan
218
+ const invalid = await tool(env, token, "save_test_plan", { projectId, planId: "checkout", name: "Guest checkout", flow: "- launchApp" });
219
+ assert.equal(invalid.isError, true);
220
+ assert.match(invalid.message, /isn't valid/);
221
+ const badId = await tool(env, token, "save_test_plan", { projectId, planId: "../x", name: "x", flow: FLOW });
222
+ assert.equal(badId.isError, true);
223
+ const saved = await tool(env, token, "save_test_plan", { projectId, planId: "checkout-guest", name: "Guest checkout", flow: FLOW, actor: "guest" });
224
+ assert.equal(saved.isError, undefined, saved.message);
225
+ assert.equal(saved.data.steps, 4);
226
+ const storedFlow = artifacts.objects.get(`projects/${projectId}/spec/flows/checkout-guest.yaml`);
227
+ assert.equal(new TextDecoder().decode(storedFlow!.body), FLOW);
228
+ const planText = new TextDecoder().decode(artifacts.objects.get(`projects/${projectId}/plans/checkout-guest.flow.yaml`)!.body);
229
+ const plan = YAML.parse(planText);
230
+ assert.equal(plan.suites[0].journeys[0].flow, "flows/checkout-guest.yaml");
231
+ assert.equal(plan.suites[0].journeys[0].driver, "playwright");
232
+ assert.equal(plan.suites[0].journeys[0].id, "checkout-guest");
233
+ assert.equal(plan.suites[0].journeys[0].actor, "guest");
234
+ assert.equal(plan.suites[0].journeys[0].steps.length, 4);
235
+ assert.doesNotMatch(planText, /SHOP_PASSWORD/, "typed text stays out of the plan's step labels");
236
+
237
+ const plans = await tool(env, token, "list_test_plans", { projectId });
238
+ assert.deepEqual(plans.data.plans.map((item: { planId: string }) => item.planId), ["checkout-guest"]);
239
+ const read = await tool(env, token, "get_test_plan", { projectId, planId: "checkout-guest" });
240
+ assert.equal(read.data.flow, FLOW);
241
+ assert.equal(read.data.name, "Guest checkout");
242
+
243
+ // Run
244
+ const started = await tool(env, token, "run_tests", { projectId, planId: "checkout-guest", environmentId: "staging", browsers: ["chromium", "firefox"] });
245
+ assert.equal(started.isError, undefined, started.message);
246
+ assert.equal(started.data.where, "your workers");
247
+ assert.match(started.data.link, /^https:\/\/dash\.test\/runs\?project=shop&run=run_/);
248
+ const run = await tool(env, token, "get_run", { runId: started.data.runId });
249
+ assert.equal(run.data.status, "queued");
250
+ assert.equal(run.data.environmentBaseUrl, "https://staging.shop.test");
251
+ assert.ok(run.data.recentEvents.some((event: { type: string }) => event.type === "queued"));
252
+ const runs = await tool(env, token, "list_runs", { projectId, planId: "checkout-guest" });
253
+ assert.equal(runs.data.runs.length, 1);
254
+ const missingPlan = await tool(env, token, "run_tests", { projectId, planId: "nope" });
255
+ assert.equal(missingPlan.isError, true);
256
+
257
+ // A worker reports a failure; the assistant sees the step and Bugmole's diagnosis.
258
+ await call(env, "POST", `/api/runs/${started.data.runId}/claim`, { as: owner, body: { workerId: "w1" } });
259
+ const finished = await call(env, "POST", `/api/runs/${started.data.runId}`, {
260
+ as: owner,
261
+ body: {
262
+ status: "partial",
263
+ workerId: "w1",
264
+ stepsExecuted: 4,
265
+ stepsPassed: 3,
266
+ stepsFailed: 1,
267
+ targetResults: [{
268
+ targetId: "chromium", browser: "chromium", status: "failed", stepsPassed: 3, stepsFailed: 1, artifactKeys: [],
269
+ steps: [{ index: 3, status: "failed", label: "See “Order placed”", message: "Not visible after 10s" }],
270
+ analysis: { title: "The confirmation text changed", category: "test", source: "ai", fixSummary: "Assert “Thanks for your order”" },
271
+ }],
272
+ },
273
+ });
274
+ assert.ok([200, 201].includes(finished.status), JSON.stringify(finished.json));
275
+ const report = await tool(env, token, "get_run", { runId: started.data.runId });
276
+ assert.equal(report.data.status, "partial");
277
+ assert.deepEqual(report.data.targets[0].failedStep, { number: 4, label: "See “Order placed”", message: "Not visible after 10s" });
278
+ assert.deepEqual(report.data.targets[0].diagnosis, { title: "The confirmation text changed", cause: "test", fix: "Assert “Thanks for your order”" });
279
+ });
280
+
281
+ test("assistants can only reach what the person can", async () => {
282
+ const { env } = mcpEnv();
283
+ const { owner, projectId } = await seedProject(env, { storageProvider: "bugmole" });
284
+ await call(env, "POST", `/api/projects/${projectId}/plans`, {
285
+ as: owner,
286
+ body: { planId: "checkout", journeyId: "checkout", objectKey: `projects/${projectId}/plans/checkout.flow.yaml` },
287
+ });
288
+ const stranger = accessToken("user_stranger");
289
+ assert.deepEqual((await tool(env, stranger, "list_projects")).data, { projects: [] });
290
+ for (const [name, args] of [
291
+ ["list_test_plans", { projectId }],
292
+ ["get_test_plan", { projectId, planId: "checkout" }],
293
+ ["save_test_plan", { projectId, planId: "x", name: "x", flow: FLOW }],
294
+ ["run_tests", { projectId, planId: "checkout" }],
295
+ ["list_runs", { projectId }],
296
+ ["explore_app", { projectId, url: "https://shop.test" }],
297
+ ["list_journeys", { projectId }],
298
+ ] as const) {
299
+ const result = await tool(env, stranger, name, args);
300
+ assert.equal(result.isError, true, name);
301
+ }
302
+ const ownRun = await tool(env, accessToken(owner), "run_tests", { projectId, planId: "checkout" });
303
+ assert.equal((await tool(env, stranger, "get_run", { runId: ownRun.data.runId })).isError, true);
304
+ });
305
+
306
+ test("projects that keep files in their own storage get a clear answer", async () => {
307
+ const { env } = mcpEnv();
308
+ const { owner, projectId } = await seedProject(env, { storageProvider: "r2" });
309
+ const saved = await tool(env, accessToken(owner), "save_test_plan", { projectId, planId: "x", name: "x", flow: FLOW });
310
+ assert.equal(saved.isError, true);
311
+ assert.match(saved.message, /its own storage/);
312
+ });
313
+
314
+ test("a plan saved through MCP is one the runner can execute", async () => {
315
+ const { execute } = await import("../../runtime/executor.js");
316
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bugmole-mcp-plan-"));
317
+ try {
318
+ const specDir = path.join(dir, "spec");
319
+ fs.mkdirSync(path.join(specDir, "plans"), { recursive: true });
320
+ fs.mkdirSync(path.join(specDir, "flows"), { recursive: true });
321
+ const steps = parseMaestroStyleFlow(FLOW).steps;
322
+ fs.writeFileSync(path.join(specDir, "flows", "checkout-guest.yaml"), FLOW);
323
+ fs.writeFileSync(
324
+ path.join(specDir, "plans", "checkout-guest.flow.yaml"),
325
+ YAML.stringify(planDocument({ planId: "checkout-guest", name: "Guest checkout", journeyId: "checkout-guest", actor: "guest", steps })),
326
+ );
327
+ const contexts: DriverContext[] = [];
328
+ const driver = {
329
+ id: "playwright",
330
+ async run(context: DriverContext): Promise<DriverResult> {
331
+ contexts.push(context);
332
+ return { status: "passed", message: "ok", artifacts: [], steps: steps.map((_step, index) => ({ sequenceNumber: index, status: "passed" as const })) };
333
+ },
334
+ } as unknown as AppDriver;
335
+ let driverId = "";
336
+ const result = await execute(
337
+ { project: { id: "shop" }, runtime: { spec_dir: specDir, artifacts_dir: path.join(dir, "artifacts") } },
338
+ {
339
+ planId: "checkout-guest",
340
+ journeyId: "checkout-guest",
341
+ baseUrl: "https://staging.shop.test",
342
+ driverFactory: (_cfg: unknown, id: string) => {
343
+ driverId = id;
344
+ return driver;
345
+ },
346
+ },
347
+ );
348
+ assert.equal(result.status, "success", result.error ?? "");
349
+ assert.equal(driverId, "playwright");
350
+ assert.equal(fs.readFileSync(contexts[0].flowPath ?? "", "utf8"), FLOW);
351
+ assert.equal(result.stepsPassed, 4);
352
+ } finally {
353
+ fs.rmSync(dir, { recursive: true, force: true });
354
+ }
355
+ });
@@ -0,0 +1,215 @@
1
+ // Hosted MCP endpoint (Streamable HTTP, JSON responses) for ChatGPT, Claude
2
+ // and other assistants. People sign in through Aegis; see docs/mcp-hosted.md.
3
+ import { bearer, response, type Env } from "../core.js";
4
+ import { flagMode } from "../flags.js";
5
+ import { registryDispatch } from "../hooks.js";
6
+ import { isDeprovisioned } from "../sso/membership.js";
7
+ import { registerPublicRoutes } from "../routes/index.js";
8
+ import { aegisIssuer, MCP_SCOPE, verifyMcpToken, type McpIdentity, type McpTokenEnv } from "./token.js";
9
+ import { callTool, FLOW_REFERENCE, toolList, type RegistryCall } from "./tools.js";
10
+
11
+ export const MCP_PATH = "/mcp";
12
+ export const PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
13
+ const MAX_BODY_BYTES = 512 * 1024;
14
+
15
+ type McpEnv = Env & McpTokenEnv & { AEGIS_ISSUER?: string };
16
+
17
+ const INSTRUCTIONS = `Bugmole tests web apps the way a person uses them. Work in this order:
18
+
19
+ 1. explore: list_projects to pick a project and environment. For an app or area you don't know yet, explore_app, then get_exploration until it's done, then list_journeys.
20
+ 2. plan: write a flow for one journey (what a person does and should see), then save_test_plan. Read existing plans with list_test_plans / get_test_plan first and change them rather than duplicating.
21
+ 3. run: run_tests, then get_run until the status is success, partial, error or cancelled. Runs take a minute or two.
22
+ 4. report: summarise for the person: what passed, what failed at which step, Bugmole's diagnosis (test, app or environment) and the run link. If the test is at fault, fix the flow, save it and run again; if the app is at fault, say so and don't change the test to hide it.
23
+
24
+ Never put real passwords in a flow; use {{secret.NAME}}.
25
+
26
+ ${FLOW_REFERENCE}`;
27
+
28
+ export function mcpEnabled(env: Env): boolean {
29
+ return flagMode(env, "mcp") === "on";
30
+ }
31
+
32
+ function publicUrl(env: Env, request: Request): string {
33
+ return (env.REGISTRY_PUBLIC_URL || new URL(request.url).origin).replace(/\/+$/, "");
34
+ }
35
+
36
+ export function mcpResource(env: Env, request: Request): string {
37
+ return `${publicUrl(env, request)}${MCP_PATH}`;
38
+ }
39
+
40
+ function metadataUrl(env: Env, request: Request): string {
41
+ return `${publicUrl(env, request)}/.well-known/oauth-protected-resource${MCP_PATH}`;
42
+ }
43
+
44
+ const cors = {
45
+ "access-control-allow-origin": "*",
46
+ "access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
47
+ "access-control-allow-headers": "authorization, content-type, mcp-protocol-version, mcp-session-id, last-event-id",
48
+ "access-control-expose-headers": "www-authenticate, mcp-session-id",
49
+ "access-control-max-age": "86400",
50
+ };
51
+
52
+ function withCors(res: Response): Response {
53
+ const headers = new Headers(res.headers);
54
+ for (const [name, value] of Object.entries(cors)) headers.set(name, value);
55
+ return new Response(res.body, { status: res.status, headers });
56
+ }
57
+
58
+ function unauthorized(env: Env, request: Request, error?: "invalid_token" | "insufficient_scope"): Response {
59
+ const parts = [`resource_metadata="${metadataUrl(env, request)}"`, `scope="${MCP_SCOPE}"`];
60
+ if (error) parts.unshift(`error="${error}"`);
61
+ return withCors(new Response(JSON.stringify({ error: error ?? "unauthorized", error_description: "Sign in to Bugmole to use these tools" }), {
62
+ status: 401,
63
+ headers: { "content-type": "application/json", "www-authenticate": `Bearer ${parts.join(", ")}` },
64
+ }));
65
+ }
66
+
67
+ function protectedResourceMetadata(env: McpEnv, request: Request): Response {
68
+ return withCors(Response.json({
69
+ resource: mcpResource(env, request),
70
+ authorization_servers: [aegisIssuer(env)],
71
+ scopes_supported: [MCP_SCOPE, "openid", "email", "offline_access"],
72
+ bearer_methods_supported: ["header"],
73
+ resource_name: "Bugmole",
74
+ resource_documentation: "https://bugmole.com/docs/ai-assistants",
75
+ }, { headers: { "cache-control": "public, max-age=300" } }));
76
+ }
77
+
78
+ type JsonRpcRequest = { jsonrpc?: string; id?: string | number | null; method?: string; params?: any };
79
+
80
+ function rpcResult(id: JsonRpcRequest["id"], result: unknown) {
81
+ return { jsonrpc: "2.0", id: id ?? null, result };
82
+ }
83
+
84
+ function rpcError(id: JsonRpcRequest["id"], code: number, message: string) {
85
+ return { jsonrpc: "2.0", id: id ?? null, error: { code, message } };
86
+ }
87
+
88
+ /** Calls the registry's own routes in-process as the signed-in person, like the dashboard does. */
89
+ export function registryCaller(env: Env, identity: McpIdentity, ctx: ExecutionContext, origin: string): RegistryCall {
90
+ return async (method, path, body, init = {}) => {
91
+ const dispatch = registryDispatch.fetch;
92
+ if (!dispatch) throw new Error("registry dispatch is not bound");
93
+ const headers = new Headers({
94
+ "x-aegis-id": identity.aegisId,
95
+ "x-aegis-secret": env.AEGIS_CLIENT_SECRET,
96
+ "x-bugmole-via": "mcp",
97
+ });
98
+ if (identity.email && identity.emailVerified) {
99
+ headers.set("x-aegis-email", identity.email);
100
+ headers.set("x-aegis-email-verified", "true");
101
+ }
102
+ if (identity.orgId) headers.set("x-aegis-org", identity.orgId);
103
+ let payload: string | undefined;
104
+ if (body !== undefined) {
105
+ payload = typeof body === "string" ? body : JSON.stringify(body);
106
+ headers.set("content-type", init.contentType ?? "application/json");
107
+ headers.set("content-length", String(new TextEncoder().encode(payload).byteLength));
108
+ }
109
+ const res = await dispatch(new Request(`${origin}${path}`, { method, headers, body: payload }), env, ctx);
110
+ const text = await res.text();
111
+ let json: any = null;
112
+ if (!init.raw) {
113
+ try {
114
+ json = text ? JSON.parse(text) : null;
115
+ } catch {
116
+ json = null;
117
+ }
118
+ } else if (!res.ok) {
119
+ try {
120
+ json = JSON.parse(text);
121
+ } catch {
122
+ json = null;
123
+ }
124
+ }
125
+ return { status: res.status, json, text };
126
+ };
127
+ }
128
+
129
+ async function handleRpc(message: JsonRpcRequest, env: McpEnv, identity: McpIdentity, ctx: ExecutionContext, request: Request) {
130
+ const isNotification = message.id === undefined;
131
+ if (message.jsonrpc !== "2.0" || typeof message.method !== "string") {
132
+ return isNotification ? null : rpcError(message.id, -32600, "Invalid request");
133
+ }
134
+ switch (message.method) {
135
+ case "initialize": {
136
+ const requested = message.params?.protocolVersion;
137
+ const protocolVersion = PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0];
138
+ return rpcResult(message.id, {
139
+ protocolVersion,
140
+ capabilities: { tools: { listChanged: false } },
141
+ serverInfo: { name: "bugmole", title: "Bugmole", version: "1.0.0" },
142
+ instructions: INSTRUCTIONS,
143
+ });
144
+ }
145
+ case "ping":
146
+ return isNotification ? null : rpcResult(message.id, {});
147
+ case "tools/list":
148
+ return rpcResult(message.id, { tools: toolList() });
149
+ case "tools/call": {
150
+ const name = message.params?.name;
151
+ if (typeof name !== "string") return rpcError(message.id, -32602, "Tool name is required");
152
+ const origin = new URL(request.url).origin;
153
+ const result = await callTool(name, message.params?.arguments, {
154
+ call: registryCaller(env, identity, ctx, origin),
155
+ dashboardUrl: (env.DASHBOARD_URL || "https://app.bugmole.com").replace(/\/+$/, ""),
156
+ });
157
+ return rpcResult(message.id, result);
158
+ }
159
+ case "resources/list":
160
+ return rpcResult(message.id, { resources: [] });
161
+ case "prompts/list":
162
+ return rpcResult(message.id, { prompts: [] });
163
+ default:
164
+ if (message.method.startsWith("notifications/")) return null;
165
+ return isNotification ? null : rpcError(message.id, -32601, `Method not found: ${message.method}`);
166
+ }
167
+ }
168
+
169
+ async function handleMcp(request: Request, env: McpEnv, ctx: ExecutionContext): Promise<Response> {
170
+ if (request.method === "OPTIONS") return withCors(new Response(null, { status: 204 }));
171
+ const token = bearer(request);
172
+ if (!token) return unauthorized(env, request);
173
+ const identity = await verifyMcpToken(env, token, mcpResource(env, request));
174
+ if (!identity) return unauthorized(env, request, "invalid_token");
175
+ if (await isDeprovisioned(env, identity.aegisId)) return unauthorized(env, request, "invalid_token");
176
+ // No server-initiated stream and no sessions: every request stands alone.
177
+ if (request.method !== "POST") {
178
+ return withCors(new Response(null, { status: 405, headers: { allow: "POST, OPTIONS" } }));
179
+ }
180
+ const declared = Number(request.headers.get("content-length") ?? "0");
181
+ if (declared > MAX_BODY_BYTES) return withCors(response(rpcError(null, -32600, "Request too large"), 413));
182
+ const raw = await request.text();
183
+ if (raw.length > MAX_BODY_BYTES) return withCors(response(rpcError(null, -32600, "Request too large"), 413));
184
+ let parsed: unknown;
185
+ try {
186
+ parsed = JSON.parse(raw);
187
+ } catch {
188
+ return withCors(response(rpcError(null, -32700, "Parse error"), 400));
189
+ }
190
+ const batch = Array.isArray(parsed);
191
+ const messages = (batch ? parsed : [parsed]) as JsonRpcRequest[];
192
+ if (messages.length === 0 || messages.length > 20) return withCors(response(rpcError(null, -32600, "Invalid request"), 400));
193
+ const replies = [];
194
+ for (const message of messages) {
195
+ const reply = message && typeof message === "object"
196
+ ? await handleRpc(message, env, identity, ctx, request)
197
+ : rpcError(null, -32600, "Invalid request");
198
+ if (reply) replies.push(reply);
199
+ }
200
+ if (replies.length === 0) return withCors(new Response(null, { status: 202 }));
201
+ return withCors(response(batch ? replies : replies[0]));
202
+ }
203
+
204
+ registerPublicRoutes(async ({ request, env, url, ctx }) => {
205
+ const isMetadata = url.pathname === "/.well-known/oauth-protected-resource"
206
+ || url.pathname === `/.well-known/oauth-protected-resource${MCP_PATH}`;
207
+ const isEndpoint = url.pathname === MCP_PATH || url.pathname === `${MCP_PATH}/`;
208
+ if (!isMetadata && !isEndpoint) return null;
209
+ if (!mcpEnabled(env)) return response({ error: "Not found" }, 404);
210
+ if (isMetadata) {
211
+ if (request.method === "OPTIONS") return withCors(new Response(null, { status: 204 }));
212
+ return request.method === "GET" ? protectedResourceMetadata(env, request) : withCors(response({ error: "Method not allowed" }, 405));
213
+ }
214
+ return handleMcp(request, env, ctx);
215
+ });