@bugmole/cli 0.4.0 → 0.6.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 (173) hide show
  1. package/LICENSE +18 -4
  2. package/package.json +7 -4
  3. package/scripts/bugmole-admin.mjs +194 -0
  4. package/scripts/bugmole-admin.test.mjs +62 -0
  5. package/scripts/bugmole.test.ts +94 -2
  6. package/scripts/bugmole.ts +290 -43
  7. package/scripts/ensure-playwright.cjs +87 -0
  8. package/scripts/ensure-playwright.test.ts +36 -0
  9. package/scripts/sync-byok.d.mts +2 -0
  10. package/scripts/sync-byok.mjs +18 -0
  11. package/scripts/sync-launcher-version.cjs +24 -0
  12. package/scripts/sync-launcher-version.test.ts +28 -0
  13. package/scripts/sync-plan-catalog.d.mts +3 -0
  14. package/scripts/sync-plan-catalog.mjs +16 -6
  15. package/spec/domain_rules.yaml +139 -0
  16. package/spec/roles.yaml +20 -0
  17. package/spec/test-case-results.schema.json +20 -4
  18. package/spec/test-cases.schema.json +131 -16
  19. package/src/billing/plan-catalog.test.ts +82 -2
  20. package/src/billing/plan-catalog.ts +212 -9
  21. package/src/integrations/jira.ts +264 -0
  22. package/src/mcp/roles-and-review.test.ts +111 -0
  23. package/src/mcp/server.ts +358 -22
  24. package/src/mcp/write-test-cases.test.ts +68 -0
  25. package/src/registry/control-plane-client.ts +121 -6
  26. package/src/registry/migrations/0034_signup_attribution.sql +19 -0
  27. package/src/registry/migrations/0035_domain_verify_file.sql +6 -0
  28. package/src/registry/migrations/0036_task_approval.sql +11 -0
  29. package/src/registry/migrations/0037_spec_proposals.sql +27 -0
  30. package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
  31. package/src/registry/migrations/0039_project_secrets.sql +37 -0
  32. package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
  33. package/src/registry/migrations/0041_jira_integration.sql +75 -0
  34. package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
  35. package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
  36. package/src/registry/migrations/0044_project_identity.sql +31 -0
  37. package/src/registry/project-identity.test.ts +99 -0
  38. package/src/registry/project-identity.ts +206 -0
  39. package/src/registry/roles.test.ts +66 -0
  40. package/src/registry/roles.ts +201 -0
  41. package/src/registry/task-scheduling.test.ts +94 -0
  42. package/src/registry/task-scheduling.ts +199 -2
  43. package/src/registry/test-case-revisions.test.ts +57 -0
  44. package/src/registry/test-case-revisions.ts +132 -0
  45. package/src/registry-worker/ai/routes.ts +3 -3
  46. package/src/registry-worker/artifacts.ts +23 -4
  47. package/src/registry-worker/billing/billing-core.test.ts +1 -1
  48. package/src/registry-worker/billing/checkout-routes.ts +53 -4
  49. package/src/registry-worker/billing/enforcement.ts +67 -12
  50. package/src/registry-worker/billing/paypal/api.ts +14 -0
  51. package/src/registry-worker/billing/paypal/client.ts +9 -0
  52. package/src/registry-worker/billing/paypal/provider.ts +10 -1
  53. package/src/registry-worker/billing/paypal.test.ts +108 -1
  54. package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
  55. package/src/registry-worker/billing/provider.ts +8 -0
  56. package/src/registry-worker/billing/routes.ts +2 -1
  57. package/src/registry-worker/billing/subscriptions.ts +114 -4
  58. package/src/registry-worker/core.ts +24 -0
  59. package/src/registry-worker/devices/policy.ts +4 -5
  60. package/src/registry-worker/devices/routes.ts +8 -8
  61. package/src/registry-worker/domains/domains.test.ts +64 -1
  62. package/src/registry-worker/domains/routes.ts +32 -15
  63. package/src/registry-worker/domains.ts +40 -2
  64. package/src/registry-worker/feature-access.ts +165 -0
  65. package/src/registry-worker/feature-flags/admin.ts +300 -0
  66. package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
  67. package/src/registry-worker/feature-flags/routes.ts +102 -0
  68. package/src/registry-worker/features.ts +11 -0
  69. package/src/registry-worker/feedback/feedback.test.ts +47 -0
  70. package/src/registry-worker/feedback/routes.ts +2 -2
  71. package/src/registry-worker/feedback/website.ts +77 -0
  72. package/src/registry-worker/flags.ts +80 -18
  73. package/src/registry-worker/github/checks.ts +4 -4
  74. package/src/registry-worker/hooks.ts +9 -0
  75. package/src/registry-worker/identity/oidc.test.ts +103 -0
  76. package/src/registry-worker/identity/oidc.ts +176 -0
  77. package/src/registry-worker/index.ts +494 -173
  78. package/src/registry-worker/jira/connection.ts +111 -0
  79. package/src/registry-worker/jira/jira.test.ts +404 -0
  80. package/src/registry-worker/jira/routes.ts +432 -0
  81. package/src/registry-worker/jira/workflow.ts +577 -0
  82. package/src/registry-worker/jobs/retention.ts +21 -5
  83. package/src/registry-worker/mcp/tools.ts +2 -1
  84. package/src/registry-worker/notifications/alerts.ts +4 -4
  85. package/src/registry-worker/notifications/notifications.test.ts +11 -0
  86. package/src/registry-worker/notifications/routes.ts +9 -10
  87. package/src/registry-worker/notifications/teams.ts +6 -2
  88. package/src/registry-worker/org/routes.test.ts +24 -1
  89. package/src/registry-worker/org/routes.ts +9 -8
  90. package/src/registry-worker/projects/identity.ts +194 -0
  91. package/src/registry-worker/projects/inactivity.ts +162 -0
  92. package/src/registry-worker/projects/projects.test.ts +283 -0
  93. package/src/registry-worker/proposals/proposals.test.ts +80 -0
  94. package/src/registry-worker/proposals/routes.ts +183 -0
  95. package/src/registry-worker/roles/roles.test.ts +84 -0
  96. package/src/registry-worker/roles/routes.ts +141 -0
  97. package/src/registry-worker/runner/dispatch.ts +1 -0
  98. package/src/registry-worker/runner/routes.ts +22 -5
  99. package/src/registry-worker/runner/runner.test.ts +59 -0
  100. package/src/registry-worker/runner/tokens.ts +12 -1
  101. package/src/registry-worker/secrets/crypto.ts +135 -0
  102. package/src/registry-worker/secrets/routes.ts +296 -0
  103. package/src/registry-worker/secrets/secrets.test.ts +237 -0
  104. package/src/registry-worker/signup/attribution.test.ts +147 -0
  105. package/src/registry-worker/signup/attribution.ts +194 -0
  106. package/src/registry-worker/signup/policy.ts +2 -2
  107. package/src/registry-worker/signup/routes.ts +12 -3
  108. package/src/registry-worker/sso/membership.ts +4 -2
  109. package/src/registry-worker/sso/routes.ts +24 -8
  110. package/src/registry-worker/sso/sso.test.ts +3 -2
  111. package/src/registry-worker/task-approval.test.ts +67 -0
  112. package/src/registry-worker/task-resume.test.ts +196 -0
  113. package/src/registry-worker/task-review.test.ts +121 -0
  114. package/src/runtime/ai-exploration.test.ts +34 -1
  115. package/src/runtime/ai-exploration.ts +97 -3
  116. package/src/runtime/appium-driver.ts +7 -1
  117. package/src/runtime/apply-proposals.test.ts +74 -0
  118. package/src/runtime/apply-proposals.ts +41 -0
  119. package/src/runtime/cloud-secrets.test.ts +201 -0
  120. package/src/runtime/cloud-secrets.ts +210 -0
  121. package/src/runtime/config-validate.ts +12 -9
  122. package/src/runtime/cursor-driver-run.ts +14 -3
  123. package/src/runtime/discovery-task.test.ts +26 -1
  124. package/src/runtime/discovery-task.ts +87 -7
  125. package/src/runtime/executor.ts +18 -10
  126. package/src/runtime/explorer.test.ts +32 -0
  127. package/src/runtime/explorer.ts +48 -0
  128. package/src/runtime/flow-language.ts +43 -8
  129. package/src/runtime/init-wizard.ts +112 -6
  130. package/src/runtime/journey-editor.ts +78 -2
  131. package/src/runtime/journey-graph.test.ts +60 -0
  132. package/src/runtime/local-registry-stub.test.ts +64 -0
  133. package/src/runtime/local-registry-stub.ts +255 -7
  134. package/src/runtime/local-vault.ts +65 -0
  135. package/src/runtime/pipeline.test.ts +23 -0
  136. package/src/runtime/pipeline.ts +61 -20
  137. package/src/runtime/planner.test.ts +24 -1
  138. package/src/runtime/planner.ts +88 -23
  139. package/src/runtime/playwright-driver.test.ts +164 -0
  140. package/src/runtime/playwright-driver.ts +286 -15
  141. package/src/runtime/project-identity.test.ts +161 -0
  142. package/src/runtime/project-identity.ts +232 -0
  143. package/src/runtime/project-roles.test.ts +107 -0
  144. package/src/runtime/project-roles.ts +158 -0
  145. package/src/runtime/propose-cli.ts +66 -0
  146. package/src/runtime/record-run-verdicts.test.ts +58 -0
  147. package/src/runtime/record-run-verdicts.ts +38 -4
  148. package/src/runtime/reporter.ts +1 -1
  149. package/src/runtime/reset.ts +2 -0
  150. package/src/runtime/roles-cli.test.ts +47 -0
  151. package/src/runtime/roles-cli.ts +60 -0
  152. package/src/runtime/run-job.test.ts +21 -0
  153. package/src/runtime/run-job.ts +8 -0
  154. package/src/runtime/run-once.ts +68 -5
  155. package/src/runtime/scenario-matrix.test.ts +41 -0
  156. package/src/runtime/scenario-matrix.ts +98 -0
  157. package/src/runtime/secret-driver.ts +77 -0
  158. package/src/runtime/secret-redaction.ts +69 -0
  159. package/src/runtime/secret-sources.test.ts +442 -0
  160. package/src/runtime/secret-sources.ts +176 -0
  161. package/src/runtime/secrets-cli.ts +146 -0
  162. package/src/runtime/serve-worker.ts +64 -6
  163. package/src/runtime/site-discovery.test.ts +181 -4
  164. package/src/runtime/site-discovery.ts +248 -29
  165. package/src/runtime/vault-federation.test.ts +37 -0
  166. package/src/runtime/vault-federation.ts +128 -0
  167. package/src/runtime/web-suite.test.ts +65 -0
  168. package/src/runtime/web-suite.ts +8 -2
  169. package/src/storage/create-object-store.ts +5 -1
  170. package/src/storage/object-store.ts +12 -1
  171. package/src/storage/test-case-results.test.ts +64 -0
  172. package/src/storage/test-case-results.ts +49 -0
  173. package/src/vendor/byok.ts +371 -0
@@ -14,7 +14,7 @@ import { launcherFor } from "./browser-matrix.js";
14
14
 
15
15
  export type DiscoveryEvent = {
16
16
  at: string;
17
- kind: "visit" | "click" | "form" | "page" | "flows" | "done" | "skip" | "error";
17
+ kind: "visit" | "click" | "form" | "page" | "flows" | "done" | "skip" | "error" | "limit";
18
18
  text: string;
19
19
  route?: string;
20
20
  };
@@ -29,8 +29,15 @@ export type DiscoveredPage = {
29
29
  heading?: string;
30
30
  /** The link labels a visitor clicks, from the home page, to reach this page. */
31
31
  path: string[];
32
- links: Array<{ label: string; route: string }>;
32
+ /** `menu` names the button that must be opened first to see the link. */
33
+ links: Array<{ label: string; route: string; menu?: string }>;
33
34
  forms: DiscoveredForm[];
35
+ /**
36
+ * Visible buttons outside forms ("Continue with Aegis", "Add to cart"), by
37
+ * name. Discovery never presses them; they tell AI exploration what is
38
+ * really on the page, so it doesn't invent controls.
39
+ */
40
+ controls?: string[];
34
41
  };
35
42
 
36
43
  export type DiscoveredFlow = {
@@ -45,13 +52,45 @@ export type DiscoveredFlow = {
45
52
  steps: string[];
46
53
  };
47
54
 
55
+ /**
56
+ * The bounds this exploration ran with and whether they cut it short. A crawl
57
+ * that stops at a limit has seen part of the app, and says how much it left.
58
+ */
59
+ export type DiscoveryLimits = {
60
+ maxPages: number;
61
+ maxDepth: number;
62
+ /** It stopped with pages still queued because it reached maxPages. */
63
+ stoppedAtPageLimit: boolean;
64
+ /** Pages it found links to but never opened: queued past maxPages, or deeper than maxDepth. */
65
+ notFollowed: string[];
66
+ };
67
+
48
68
  export type DiscoveryResult = {
49
69
  baseUrl: string;
50
70
  pages: DiscoveredPage[];
51
71
  flows: DiscoveredFlow[];
52
72
  events: DiscoveryEvent[];
73
+ limits?: DiscoveryLimits;
53
74
  };
54
75
 
76
+ /** Discovery's bounds when nobody sets them: the Free plan's. */
77
+ export const DEFAULT_DISCOVERY_LIMITS = { maxPages: 12, maxDepth: 2 } as const;
78
+
79
+ /**
80
+ * Limits from the environment of the machine that explores
81
+ * (BUGMOLE_DISCOVERY_MAX_PAGES / BUGMOLE_DISCOVERY_MAX_DEPTH), for a project's
82
+ * own worker. Anything not a positive whole number is ignored.
83
+ */
84
+ export function discoveryLimitsFromEnv(env: Record<string, string | undefined> = process.env): { maxPages?: number; maxDepth?: number } {
85
+ const read = (name: string) => {
86
+ const value = Number(env[name]);
87
+ return Number.isInteger(value) && value > 0 ? value : undefined;
88
+ };
89
+ const maxPages = read("BUGMOLE_DISCOVERY_MAX_PAGES");
90
+ const maxDepth = read("BUGMOLE_DISCOVERY_MAX_DEPTH");
91
+ return { ...(maxPages ? { maxPages } : {}), ...(maxDepth ? { maxDepth } : {}) };
92
+ }
93
+
55
94
  export type DiscoveryOptions = {
56
95
  maxPages?: number;
57
96
  maxDepth?: number;
@@ -62,10 +101,21 @@ export type DiscoveryOptions = {
62
101
  * on the app gentle. Defaults to 600ms (BUGMOLE_DISCOVERY_DWELL_MS).
63
102
  */
64
103
  dwellMs?: number;
104
+ /**
105
+ * A Playwright storage-state file to start from, for exploring an app that
106
+ * needs sign-in. Links that sign out are already never followed.
107
+ */
108
+ storageState?: string;
109
+ /**
110
+ * Discovered flows open each page at its address instead of repeating the
111
+ * clicks that reached it. Use it for apps whose navigation hides behind a
112
+ * menu on phones, where the desktop clicks would not exist.
113
+ */
114
+ openEachPage?: boolean;
65
115
  onEvent?: (event: DiscoveryEvent, snapshot: DiscoveryResult) => void | Promise<void>;
66
116
  };
67
117
 
68
- const UNSAFE_LINK = /\b(log ?out|sign ?out|delete|remove|destroy|unsubscribe|deactivate|cancel (?:my )?(?:account|subscription))\b/i;
118
+ export const UNSAFE_LINK = /\b(log ?out|sign ?out|delete|remove|destroy|unsubscribe|deactivate|cancel (?:my )?(?:account|subscription))\b/i;
69
119
 
70
120
  export function slugify(value: string): string {
71
121
  return value.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "home";
@@ -97,7 +147,7 @@ function sampleValue(field: DiscoveredField): string {
97
147
  if (hint.includes("email")) return "qa@example.com";
98
148
  if (hint.includes("phone") || field.type === "tel") return "+1 555 010 0100";
99
149
  if (field.type === "number") return "1";
100
- if (field.type === "url" || hint.includes("website")) return "https://example.com";
150
+ if (field.type === "url" || /\b(url|website|link)\b/.test(hint)) return "https://example.com";
101
151
  if (hint.includes("name")) return "Bugmole";
102
152
  return "Test";
103
153
  }
@@ -105,7 +155,11 @@ function sampleValue(field: DiscoveredField): string {
105
155
  const FILLABLE = new Set(["text", "email", "password", "tel", "number", "url", "search", "textarea"]);
106
156
 
107
157
  /** Flows a visitor could run for each discovered page and form. */
108
- export function flowsFor(baseUrl: string, pages: readonly DiscoveredPage[]): DiscoveredFlow[] {
158
+ export function flowsFor(
159
+ baseUrl: string,
160
+ pages: readonly DiscoveredPage[],
161
+ options: { openEachPage?: boolean } = {},
162
+ ): DiscoveredFlow[] {
109
163
  const flows: DiscoveredFlow[] = [];
110
164
  const used = new Set<string>();
111
165
  const uniqueId = (base: string) => {
@@ -114,22 +168,27 @@ export function flowsFor(baseUrl: string, pages: readonly DiscoveredPage[]): Dis
114
168
  used.add(id);
115
169
  return id;
116
170
  };
117
- const header = (name: string) => `url: ${baseUrl}\nname: ${JSON.stringify(name)}\n---\n`;
118
- const pathSteps = (page: DiscoveredPage) => page.path.map((label) => ({ tapOn: label }));
171
+ // A query on the base URL is app context (a selected project, say) that
172
+ // every page needs, so each opened page carries it.
173
+ const context = new URL(baseUrl).search;
174
+ const header = (name: string, page: DiscoveredPage) =>
175
+ `url: ${options.openEachPage ? `${new URL(page.route, baseUrl).origin}${page.route}${context}` : baseUrl}\nname: ${JSON.stringify(name)}\n---\n`;
176
+ const pathSteps = (page: DiscoveredPage) => (options.openEachPage ? [] : page.path.map((label) => ({ tapOn: label })));
119
177
 
120
178
  for (const page of pages) {
121
179
  const pageName = page.route === "/" ? "Open the home page" : `Visit ${describeRoute(page)}`;
122
180
  const landmark = page.heading ?? page.title;
123
181
  if (landmark) {
124
182
  const id = uniqueId(`discovered-${slugify(page.route === "/" ? "home" : describeRoute(page))}`);
125
- const steps: unknown[] = ["launchApp", ...pathSteps(page), { assertVisible: landmark }];
183
+ // Then the page itself: it fits the screen and its links lead somewhere.
184
+ const steps: unknown[] = ["launchApp", ...pathSteps(page), { assertVisible: landmark }, "assertLayout", "assertLinks"];
126
185
  flows.push({
127
186
  id,
128
187
  name: pageName,
129
188
  kind: "page",
130
189
  route: page.route,
131
190
  file: `flows/discovered/${id}.yaml`,
132
- content: header(pageName) + YAML.stringify(steps),
191
+ content: header(pageName, page) + YAML.stringify(steps),
133
192
  steps: steps.map(stepLabel),
134
193
  });
135
194
  }
@@ -141,7 +200,9 @@ export function flowsFor(baseUrl: string, pages: readonly DiscoveredPage[]): Dis
141
200
  const steps: unknown[] = [
142
201
  "launchApp",
143
202
  ...pathSteps(page),
144
- ...fields.flatMap((field) => [{ tapOn: field.label }, { inputText: sampleValue(field) }]),
203
+ // Cleared first: a field may already hold a value (an app's own URL,
204
+ // say), and typing into the middle of it makes nonsense.
205
+ ...fields.flatMap((field) => [{ tapOn: field.label }, "eraseText", { inputText: sampleValue(field) }]),
145
206
  // Discovery never submits; the flow proves the form is ready to.
146
207
  ...(form.submit ? [{ assertClickable: form.submit }] : []),
147
208
  ];
@@ -151,7 +212,7 @@ export function flowsFor(baseUrl: string, pages: readonly DiscoveredPage[]): Dis
151
212
  kind: "form",
152
213
  route: page.route,
153
214
  file: `flows/discovered/${id}.yaml`,
154
- content: header(name) + YAML.stringify(steps),
215
+ content: header(name, page) + YAML.stringify(steps),
155
216
  steps: steps.map(stepLabel),
156
217
  });
157
218
  }
@@ -160,7 +221,13 @@ export function flowsFor(baseUrl: string, pages: readonly DiscoveredPage[]): Dis
160
221
  }
161
222
 
162
223
  function stepLabel(step: unknown): string {
163
- if (typeof step === "string") return step === "launchApp" ? "Open the app" : step;
224
+ if (typeof step === "string") {
225
+ if (step === "launchApp") return "Open the app";
226
+ if (step === "assertLayout") return "Nothing spills off the screen";
227
+ if (step === "assertLinks") return "Every link on the site works";
228
+ if (step === "eraseText") return "Clear the field";
229
+ return step;
230
+ }
164
231
  const [[key, value]] = Object.entries(step as Record<string, unknown>);
165
232
  const text = String(value);
166
233
  switch (key) {
@@ -186,7 +253,10 @@ const pageProbe = `() => {
186
253
  for (const link of document.querySelectorAll("a[href]")) {
187
254
  if (!visible(link)) continue;
188
255
  const label = text(link) || link.getAttribute("aria-label") || "";
189
- if (label && label.length <= 60) links.push({ label, href: link.href });
256
+ // Card-style links carry a title and a sentence; only whole paragraphs
257
+ // wrapped in a link are left out.
258
+ // A download (an export, a report file) isn't a page a visitor reads.
259
+ if (label && label.length <= 200) links.push({ label, href: link.href, ...(link.hasAttribute("download") ? { download: true } : {}) });
190
260
  }
191
261
  const labelOf = (field) => {
192
262
  const own = field.labels && field.labels[0] ? text(field.labels[0]) : "";
@@ -208,24 +278,106 @@ const pageProbe = `() => {
208
278
  const legend = form.getAttribute("aria-label") || (form.querySelector("legend, h1, h2, h3") ? text(form.querySelector("legend, h1, h2, h3")) : "");
209
279
  forms.push({ name: legend || submitText || "form", fields, submit: submitText || undefined });
210
280
  }
211
- return { title: document.title, heading: heading ? text(heading) : undefined, links, forms };
281
+ const controls = [];
282
+ for (const control of document.querySelectorAll("button, [role=button], input[type=submit], input[type=button]")) {
283
+ if (!visible(control) || control.closest("form") || control.closest("a[href]")) continue;
284
+ const name = (text(control) || control.getAttribute("aria-label") || control.value || "").trim();
285
+ if (name && name.length <= 60 && !controls.includes(name)) controls.push(name);
286
+ if (controls.length >= 30) break;
287
+ }
288
+ return { title: document.title, heading: heading ? text(heading) : undefined, links, forms, controls };
212
289
  }`;
213
290
 
214
- type Probe = { title: string; heading?: string; links: Array<{ label: string; href: string }>; forms: DiscoveredForm[] };
291
+ type Probe = { title: string; heading?: string; links: Array<{ label: string; href: string; download?: boolean }>; forms: DiscoveredForm[]; controls?: string[] };
215
292
 
216
293
  async function readPage(page: Page): Promise<Probe> {
217
294
  return page.evaluate((source: string) => (new Function(`return (${source})`)() as () => Probe)(), pageProbe);
218
295
  }
219
296
 
297
+ /**
298
+ * Menus a person can open — a closed disclosure button (aria-expanded="false")
299
+ * such as an account menu — and the links inside each. Opening one only
300
+ * reveals links; it is closed again before moving on.
301
+ */
302
+ async function linksInMenus(page: Page, known: ReadonlySet<string>): Promise<Array<{ menu: string; label: string; href: string }>> {
303
+ const found: Array<{ menu: string; label: string; href: string }> = [];
304
+ const startUrl = page.url();
305
+ const buttons = page.locator('button[aria-expanded="false"]');
306
+ const count = Math.min(await buttons.count(), 8);
307
+ const names: string[] = [];
308
+ for (let index = 0; index < count; index += 1) {
309
+ const button = buttons.nth(index);
310
+ if (!(await button.isVisible().catch(() => false))) continue;
311
+ // The accessible name, exactly as getByRole matches it, from the
312
+ // accessibility tree itself.
313
+ const snapshot = await button.ariaSnapshot().catch(() => "");
314
+ const quoted = /^- button "((?:[^"\\]|\\.)*)"/.exec(snapshot.trim());
315
+ const name = quoted ? (JSON.parse(`"${quoted[1]}"`) as string) : "";
316
+ if (name && name.length <= 60 && !UNSAFE_LINK.test(name) && !names.includes(name)) names.push(name);
317
+ }
318
+ for (const name of names) {
319
+ const button = page.getByRole("button", { name, exact: true }).first();
320
+ try {
321
+ await button.click({ timeout: 3_000 });
322
+ await page.waitForTimeout(200);
323
+ if (page.url() !== startUrl) {
324
+ // Not a menu after all: put the page back and stop opening things.
325
+ await page.goto(startUrl, { waitUntil: "load" });
326
+ break;
327
+ }
328
+ for (const link of (await readPage(page)).links) {
329
+ if (!known.has(link.href) && !found.some((item) => item.href === link.href)) found.push({ menu: name, ...link });
330
+ }
331
+ } catch {
332
+ // A button that can't be pressed hides nothing we can reach.
333
+ } finally {
334
+ await page.keyboard.press("Escape").catch(() => undefined);
335
+ if ((await button.getAttribute("aria-expanded").catch(() => null)) === "true") await button.click({ timeout: 3_000 }).catch(() => undefined);
336
+ }
337
+ }
338
+ return found;
339
+ }
340
+
341
+ /** The first visible h1/h2, to tell when a client-side navigation has rendered. */
342
+ async function currentHeading(page: Page): Promise<string | undefined> {
343
+ return (await readPage(page).catch(() => undefined))?.heading;
344
+ }
345
+
346
+ const SIGN_IN_WORDS = /\b(sign ?in|log ?in|welcome back)\b/i;
347
+ const SIGN_IN_BUTTON = /^(continue|sign in|log in) with\b/i;
348
+
349
+ function looksLikeSignIn(page: DiscoveredPage): boolean {
350
+ if (page.forms.some((form) => form.fields.some((field) => field.type === "password"))) return true;
351
+ if (SIGN_IN_WORDS.test(page.heading ?? "") || SIGN_IN_WORDS.test(page.title)) return true;
352
+ return Boolean(page.controls?.some((control) => SIGN_IN_BUTTON.test(control)));
353
+ }
354
+
355
+ /**
356
+ * The crawl stopped at a sign-in page: everything it could reach is a sign-in
357
+ * screen, so what lies behind it is unexplored. Discovery doesn't sign in on
358
+ * its own; the caller should say so rather than present the one page it saw
359
+ * as the whole app.
360
+ */
361
+ export function stoppedAtSignIn(result: Pick<DiscoveryResult, "pages">): boolean {
362
+ return result.pages.length > 0 && result.pages.length <= 2 && result.pages.every(looksLikeSignIn);
363
+ }
364
+
220
365
  export async function discoverSite(baseUrl: string, options: DiscoveryOptions = {}): Promise<DiscoveryResult> {
221
366
  const start = new URL(baseUrl);
222
367
  const root = `${start.origin}${start.pathname}`;
223
- const maxPages = options.maxPages ?? 12;
224
- const maxDepth = options.maxDepth ?? 2;
368
+ const maxPages = options.maxPages ?? DEFAULT_DISCOVERY_LIMITS.maxPages;
369
+ const maxDepth = options.maxDepth ?? DEFAULT_DISCOVERY_LIMITS.maxDepth;
225
370
  const timeout = options.navigationTimeoutMs ?? 15_000;
226
371
  const envDwell = Number(process.env.BUGMOLE_DISCOVERY_DWELL_MS);
227
372
  const dwellMs = options.dwellMs ?? (Number.isFinite(envDwell) && envDwell >= 0 ? envDwell : 600);
228
373
  const result: DiscoveryResult = { baseUrl: root, pages: [], flows: [], events: [] };
374
+ // The same link in a shared menu is skipped on every page; say so once.
375
+ const skipped = new Set<string>();
376
+ const skipOnce = async (text: string, route: string) => {
377
+ if (skipped.has(text)) return;
378
+ skipped.add(text);
379
+ await emit("skip", text, route);
380
+ };
229
381
  const emit = async (kind: DiscoveryEvent["kind"], text: string, route?: string) => {
230
382
  const event: DiscoveryEvent = { at: new Date().toISOString(), kind, text, ...(route ? { route } : {}) };
231
383
  result.events.push(event);
@@ -235,12 +387,17 @@ export async function discoverSite(baseUrl: string, options: DiscoveryOptions =
235
387
  const { type, channel } = launcherFor("chromium");
236
388
  const browser = await type.launch({ headless: options.headless ?? true, ...(channel ? { channel } : {}) });
237
389
  try {
238
- const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
390
+ const context = await browser.newContext({
391
+ viewport: { width: 1280, height: 800 },
392
+ ...(options.storageState ? { storageState: options.storageState } : {}),
393
+ });
239
394
  const page = await context.newPage();
240
395
  page.setDefaultTimeout(timeout);
241
396
  const seen = new Set<string>();
397
+ // Links seen on a page at the depth limit, so never queued.
398
+ const tooDeep = new Set<string>();
242
399
  // Breadth-first, so pages close to home are found first.
243
- const queue: Array<{ route: string; path: string[]; parent?: string }> = [{ route: discoverableRoute(root, root) ?? "/", path: [] }];
400
+ const queue: Array<{ route: string; path: string[]; parent?: string; menu?: string }> = [{ route: discoverableRoute(root, root) ?? "/", path: [] }];
244
401
 
245
402
  while (queue.length > 0 && result.pages.length < maxPages) {
246
403
  const next = queue.shift()!;
@@ -249,30 +406,68 @@ export async function discoverSite(baseUrl: string, options: DiscoveryOptions =
249
406
  try {
250
407
  if (next.path.length === 0) {
251
408
  await emit("visit", "Visiting home page", next.route);
252
- await page.goto(root, { waitUntil: "load" });
409
+ await page.goto(`${root}${start.search}`, { waitUntil: "load" });
253
410
  } else {
254
411
  // Reach the page the way a visitor would: from its parent, by clicking.
255
- const parentUrl = new URL(next.parent ?? "/", root).toString();
412
+ const parentUrl = `${new URL(next.parent ?? "/", root).toString()}${start.search}`;
256
413
  if (discoverableRoute(page.url(), root) !== next.parent) await page.goto(parentUrl, { waitUntil: "load" });
257
414
  const label = next.path[next.path.length - 1];
415
+ const before = await currentHeading(page);
416
+ if (next.menu) {
417
+ await emit("click", `Opening “${next.menu}”`, next.route);
418
+ // A disclosure can remember being open (the dashboard's Advanced
419
+ // group does); clicking it then would close it and hide the link.
420
+ const menuButton = page.getByRole("button", { name: next.menu, exact: true }).first();
421
+ if ((await menuButton.getAttribute("aria-expanded").catch(() => null)) !== "true") await menuButton.click();
422
+ }
258
423
  await emit("click", `Clicking “${label}”`, next.route);
259
424
  await page.getByRole("link", { name: label, exact: true }).first().click();
425
+ // Apps that route on the client change the address and then render,
426
+ // some time after the click returns: wait for both, bounded.
427
+ await page.waitForURL((url) => discoverableRoute(url.toString(), root) !== next.parent, { timeout }).catch(() => undefined);
260
428
  await page.waitForLoadState("load").catch(() => undefined);
429
+ await page.waitForFunction(
430
+ ([source, previous]) => ((new Function(`return (${source})`)() as () => { heading?: string })().heading ?? "") !== previous,
431
+ [pageProbe, before ?? ""] as const,
432
+ { timeout: 5_000 },
433
+ ).catch(() => undefined);
261
434
  await page.waitForTimeout(250);
262
435
  }
263
- const route = discoverableRoute(page.url(), root) ?? next.route;
436
+ const landed = discoverableRoute(page.url(), root);
437
+ if (!landed) {
438
+ // A same-origin link that hands off elsewhere (an OAuth or install
439
+ // redirect, say) is not a page of this app.
440
+ await emit("skip", `“${next.path[next.path.length - 1] ?? next.route}” leaves the app`, next.route);
441
+ continue;
442
+ }
443
+ const route = landed;
444
+ if (next.path.length > 0 && route === next.parent) {
445
+ await emit("skip", `“${next.path[next.path.length - 1]}” did not lead anywhere`, next.route);
446
+ continue;
447
+ }
264
448
  if (route !== next.route && seen.has(route)) continue;
265
449
  seen.add(route);
450
+ // A link to an API endpoint or a file opens JSON or a viewer, not a page of the app.
451
+ const contentType = await page.evaluate("document.contentType").then(String).catch(() => "text/html");
452
+ if (!/html/i.test(contentType)) {
453
+ await emit("skip", `“${next.path[next.path.length - 1] ?? route}” opens a file (${contentType}), not a page`, route);
454
+ continue;
455
+ }
266
456
  const probe = await readPage(page);
457
+ const inMenus = await linksInMenus(page, new Set(probe.links.map((link) => link.href)));
267
458
  const links: DiscoveredPage["links"] = [];
268
- for (const link of probe.links) {
459
+ for (const link of [...probe.links, ...inMenus] as Array<{ label: string; href: string; menu?: string; download?: boolean }>) {
269
460
  const target = discoverableRoute(link.href, root);
270
461
  if (!target || target === route || links.some((existing) => existing.route === target)) continue;
271
- if (UNSAFE_LINK.test(link.label)) {
272
- await emit("skip", `Skipping “${link.label}” (it could change data)`, target);
462
+ if (UNSAFE_LINK.test(link.label) || UNSAFE_LINK.test(target.replace(/[/_-]+/g, " "))) {
463
+ await skipOnce(`Skipping “${link.label}” (it could change data)`, target);
464
+ continue;
465
+ }
466
+ if (link.download || /^\/api(\/|$)/.test(target)) {
467
+ await skipOnce(`Skipping “${link.label}” (it downloads a file or calls the API)`, target);
273
468
  continue;
274
469
  }
275
- links.push({ label: link.label, route: target });
470
+ links.push({ label: link.label, route: target, ...(link.menu ? { menu: link.menu } : {}) });
276
471
  }
277
472
  const discovered: DiscoveredPage = {
278
473
  route,
@@ -282,6 +477,7 @@ export async function discoverSite(baseUrl: string, options: DiscoveryOptions =
282
477
  path: next.path,
283
478
  links,
284
479
  forms: probe.forms,
480
+ ...(probe.controls?.length ? { controls: probe.controls } : {}),
285
481
  };
286
482
  result.pages.push(discovered);
287
483
  await emit("page", `Found ${describeRoute(discovered)} (${links.length} link${links.length === 1 ? "" : "s"})`, route);
@@ -289,16 +485,39 @@ export async function discoverSite(baseUrl: string, options: DiscoveryOptions =
289
485
  await emit("form", `Found a form: ${form.name} (${form.fields.map((field) => field.label).join(", ")})`, route);
290
486
  }
291
487
  if (dwellMs > 0) await page.waitForTimeout(dwellMs);
292
- if (next.path.length < maxDepth) {
488
+ if (next.path.length >= maxDepth) {
489
+ for (const link of links) tooDeep.add(link.route);
490
+ } else {
293
491
  for (const link of links) {
294
- if (!seen.has(link.route)) queue.push({ route: link.route, path: [...next.path, link.label], parent: route });
492
+ if (!seen.has(link.route)) {
493
+ queue.push({
494
+ route: link.route,
495
+ // A flow replays the path as taps, so a menu's button is a step too.
496
+ path: [...next.path, ...(link.menu ? [link.menu] : []), link.label],
497
+ parent: route,
498
+ ...(link.menu ? { menu: link.menu } : {}),
499
+ });
500
+ }
295
501
  }
296
502
  }
297
503
  } catch (error) {
298
504
  await emit("error", `Could not open ${next.route}: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`, next.route);
299
505
  }
300
506
  }
301
- result.flows = flowsFor(root, result.pages);
507
+ const opened = new Set(result.pages.map((found) => found.route));
508
+ const queued = [...new Set(queue.map((item) => item.route))].filter((route) => !seen.has(route) && !opened.has(route));
509
+ const notFollowed = [...new Set([...queued, ...tooDeep])].filter((route) => !opened.has(route) && !seen.has(route));
510
+ const stoppedAtPageLimit = result.pages.length >= maxPages && queued.length > 0;
511
+ result.limits = { maxPages, maxDepth, stoppedAtPageLimit, notFollowed };
512
+ if (notFollowed.length) {
513
+ await emit(
514
+ "limit",
515
+ stoppedAtPageLimit
516
+ ? `Stopped at the ${maxPages}-page limit; ${notFollowed.length} linked page${notFollowed.length === 1 ? " was" : "s were"} not opened`
517
+ : `${notFollowed.length} linked page${notFollowed.length === 1 ? " is" : "s are"} deeper than ${maxDepth} click${maxDepth === 1 ? "" : "s"} from home and ${notFollowed.length === 1 ? "was" : "were"} not opened`,
518
+ );
519
+ }
520
+ result.flows = flowsFor(`${root}${start.search}`, result.pages, { openEachPage: options.openEachPage });
302
521
  await emit("flows", `Discovered ${result.flows.length} flow${result.flows.length === 1 ? "" : "s"} across ${result.pages.length} page${result.pages.length === 1 ? "" : "s"}`);
303
522
  await emit("done", "Exploration complete");
304
523
  return result;
@@ -0,0 +1,37 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { awsWebIdentityCredentials, googleFederatedToken, registryIdentityTokens } from "./vault-federation.js";
4
+
5
+ test("asks the registry for a token for the runner's own job, with the audience given", async () => {
6
+ const seen: Array<{ url: string; auth: string | null }> = [];
7
+ const source = registryIdentityTokens((async (url: string, init?: RequestInit) => {
8
+ seen.push({ url, auth: new Headers(init?.headers).get("authorization") });
9
+ return new Response(JSON.stringify({ token: "jwt" }), { status: 200 });
10
+ }) as typeof fetch, "https://api.bugmole.com", "vrt_runner", { kind: "task", id: "task 1" });
11
+ assert.equal(await source("sts.amazonaws.com"), "jwt");
12
+ assert.deepEqual(seen, [{ url: "https://api.bugmole.com/api/runner/tasks/task%201/identity-token?audience=sts.amazonaws.com", auth: "Bearer vrt_runner" }]);
13
+ const off = registryIdentityTokens((async () => new Response("{}", { status: 503 })) as unknown as typeof fetch, "https://r", "t", { kind: "run", id: "r" });
14
+ await assert.rejects(off("x"), /doesn't issue identity tokens/);
15
+ });
16
+
17
+ test("refuses settings that aren't a Google provider or an IAM role, before asking anyone", async () => {
18
+ const never = async () => { throw new Error("not called"); };
19
+ await assert.rejects(googleFederatedToken({ provider: "my-pool", identityToken: never }), /must look like projects\/NUMBER/);
20
+ await assert.rejects(awsWebIdentityCredentials({ roleArn: "bugmole-reader", sessionName: "s", identityToken: never }), /must be an IAM role ARN/);
21
+ });
22
+
23
+ test("passes on what the cloud said when it refuses", async () => {
24
+ const google = googleFederatedToken({
25
+ provider: "projects/1/locations/global/workloadIdentityPools/p/providers/q",
26
+ identityToken: async () => "jwt",
27
+ fetch: (async () => new Response(JSON.stringify({ error: "invalid_grant", error_description: "The audience in ID Token does not match" }), { status: 400 })) as unknown as typeof fetch,
28
+ });
29
+ await assert.rejects(google, /refused Bugmole's token \(400\): The audience in ID Token does not match/);
30
+ const aws = awsWebIdentityCredentials({
31
+ roleArn: "arn:aws:iam::123456789012:role/r",
32
+ sessionName: "bugmole run/1",
33
+ identityToken: async () => "jwt",
34
+ fetch: (async () => new Response("<ErrorResponse><Error><Message>Not authorized to perform sts:AssumeRoleWithWebIdentity</Message></Error></ErrorResponse>", { status: 403 })) as unknown as typeof fetch,
35
+ });
36
+ await assert.rejects(aws, /\(403\): Not authorized to perform sts:AssumeRoleWithWebIdentity/);
37
+ });
@@ -0,0 +1,128 @@
1
+ // A Bugmole Cloud runner reading a project's own vault without a stored key.
2
+ // The registry signs a short-lived identity token for the runner's job
3
+ // (registry-worker/identity/oidc.ts); the project's cloud trusts Bugmole as an
4
+ // identity provider and trades that token for credentials limited to what
5
+ // the project allowed:
6
+ //
7
+ // Google: Workload Identity Federation (STS token exchange), optionally
8
+ // impersonating a service account
9
+ // AWS: an IAM role assumed with AssumeRoleWithWebIdentity
10
+ //
11
+ // What to trust is configured with three non-secret project settings, kept
12
+ // with the project's secrets: VAULT_GCP_WORKLOAD_PROVIDER (the provider's
13
+ // resource name), VAULT_GCP_SERVICE_ACCOUNT (optional) and VAULT_AWS_ROLE_ARN.
14
+
15
+ import type { AwsCredentials } from "../vendor/byok.js";
16
+
17
+ /** Asks the registry for an identity token with this audience, for the runner's job. */
18
+ export type IdentityTokenSource = (audience: string) => Promise<string>;
19
+
20
+ export function registryIdentityTokens(doFetch: typeof fetch, registryUrl: string, token: string, job: { kind: "run" | "task"; id: string }): IdentityTokenSource {
21
+ return async (audience) => {
22
+ const reply = await doFetch(`${registryUrl}/api/runner/${job.kind}s/${encodeURIComponent(job.id)}/identity-token?audience=${encodeURIComponent(audience)}`, {
23
+ headers: { authorization: `Bearer ${token}` },
24
+ });
25
+ if (reply.status === 503) throw new Error("This Bugmole registry doesn't issue identity tokens");
26
+ if (!reply.ok) throw new Error(`The registry refused an identity token (${reply.status})`);
27
+ const body = await reply.json() as { token?: unknown };
28
+ if (typeof body.token !== "string") throw new Error("The registry returned no identity token");
29
+ return body.token;
30
+ };
31
+ }
32
+
33
+ async function failure(reply: Response, what: string): Promise<Error> {
34
+ const text = (await reply.text().catch(() => "")).slice(0, 400);
35
+ let detail = text;
36
+ try {
37
+ const parsed = JSON.parse(text) as { error_description?: string; error?: { message?: string } | string };
38
+ detail = parsed.error_description ?? (typeof parsed.error === "string" ? parsed.error : parsed.error?.message) ?? text;
39
+ } catch {
40
+ // Not JSON (AWS answers in XML).
41
+ detail = /<Message>([^<]*)<\/Message>/.exec(text)?.[1] ?? text;
42
+ }
43
+ return new Error(`${what} (${reply.status}): ${detail}`);
44
+ }
45
+
46
+ /** A Workload Identity provider's resource name, as Google shows it. */
47
+ const PROVIDER = /^projects\/\d+\/locations\/global\/workloadIdentityPools\/[a-z0-9-]+\/providers\/[a-z0-9-]+$/;
48
+
49
+ /**
50
+ * A Google access token for the project's Workload Identity provider: the
51
+ * federated token itself, or a service account's when one is named.
52
+ */
53
+ export async function googleFederatedToken(options: {
54
+ provider: string;
55
+ serviceAccount?: string;
56
+ identityToken: IdentityTokenSource;
57
+ fetch?: typeof fetch;
58
+ }): Promise<string> {
59
+ const doFetch = options.fetch ?? fetch;
60
+ const provider = options.provider.replace(/^\/\/iam\.googleapis\.com\//, "");
61
+ if (!PROVIDER.test(provider)) {
62
+ throw new Error("VAULT_GCP_WORKLOAD_PROVIDER must look like projects/NUMBER/locations/global/workloadIdentityPools/POOL/providers/PROVIDER");
63
+ }
64
+ const audience = `//iam.googleapis.com/${provider}`;
65
+ const subjectToken = await options.identityToken(audience);
66
+ const exchanged = await doFetch("https://sts.googleapis.com/v1/token", {
67
+ method: "POST",
68
+ headers: { "content-type": "application/json" },
69
+ body: JSON.stringify({
70
+ grantType: "urn:ietf:params:oauth:grant-type:token-exchange",
71
+ audience,
72
+ scope: "https://www.googleapis.com/auth/cloud-platform",
73
+ requestedTokenType: "urn:ietf:params:oauth:token-type:access_token",
74
+ subjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
75
+ subjectToken,
76
+ }),
77
+ });
78
+ if (!exchanged.ok) throw await failure(exchanged, "Google's Workload Identity Federation refused Bugmole's token");
79
+ const federated = (await exchanged.json() as { access_token?: string }).access_token;
80
+ if (!federated) throw new Error("Google's token exchange returned no access token");
81
+ if (!options.serviceAccount) return federated;
82
+ const impersonated = await doFetch(
83
+ `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${encodeURIComponent(options.serviceAccount)}:generateAccessToken`,
84
+ {
85
+ method: "POST",
86
+ headers: { authorization: `Bearer ${federated}`, "content-type": "application/json" },
87
+ body: JSON.stringify({ scope: ["https://www.googleapis.com/auth/cloud-platform"], lifetime: "600s" }),
88
+ },
89
+ );
90
+ if (!impersonated.ok) throw await failure(impersonated, `Google refused impersonating ${options.serviceAccount}`);
91
+ const token = (await impersonated.json() as { accessToken?: string }).accessToken;
92
+ if (!token) throw new Error("Google returned no service account token");
93
+ return token;
94
+ }
95
+
96
+ /** Temporary AWS credentials for the project's role, assumed with Bugmole's token. */
97
+ export async function awsWebIdentityCredentials(options: {
98
+ roleArn: string;
99
+ sessionName: string;
100
+ identityToken: IdentityTokenSource;
101
+ fetch?: typeof fetch;
102
+ }): Promise<AwsCredentials> {
103
+ if (!/^arn:aws[a-z-]*:iam::\d{12}:role\/[\w+=,.@/-]+$/.test(options.roleArn)) {
104
+ throw new Error("VAULT_AWS_ROLE_ARN must be an IAM role ARN (arn:aws:iam::ACCOUNT:role/NAME)");
105
+ }
106
+ const webIdentityToken = await options.identityToken("sts.amazonaws.com");
107
+ // AssumeRoleWithWebIdentity is authorized by the token itself: no signature.
108
+ const reply = await (options.fetch ?? fetch)("https://sts.amazonaws.com/", {
109
+ method: "POST",
110
+ headers: { "content-type": "application/x-www-form-urlencoded" },
111
+ body: new URLSearchParams({
112
+ Action: "AssumeRoleWithWebIdentity",
113
+ Version: "2011-06-15",
114
+ RoleArn: options.roleArn,
115
+ RoleSessionName: options.sessionName.replace(/[^\w+=,.@-]/g, "-").slice(0, 64),
116
+ WebIdentityToken: webIdentityToken,
117
+ DurationSeconds: "900",
118
+ }).toString(),
119
+ });
120
+ if (!reply.ok) throw await failure(reply, `AWS refused assuming ${options.roleArn} with Bugmole's token`);
121
+ const xml = await reply.text();
122
+ const field = (name: string) => new RegExp(`<${name}>([^<]+)</${name}>`).exec(xml)?.[1];
123
+ const accessKeyId = field("AccessKeyId");
124
+ const secretAccessKey = field("SecretAccessKey");
125
+ const sessionToken = field("SessionToken");
126
+ if (!accessKeyId || !secretAccessKey || !sessionToken) throw new Error("AWS returned no credentials for the role");
127
+ return { accessKeyId, secretAccessKey, sessionToken };
128
+ }