@bugmole/cli 0.5.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.
- package/package.json +5 -2
- package/scripts/bugmole-admin.mjs +194 -0
- package/scripts/bugmole-admin.test.mjs +62 -0
- package/scripts/bugmole.test.ts +94 -2
- package/scripts/bugmole.ts +233 -46
- package/scripts/sync-byok.d.mts +2 -0
- package/scripts/sync-byok.mjs +18 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -6
- package/spec/domain_rules.yaml +139 -0
- package/spec/roles.yaml +20 -0
- package/spec/test-case-results.schema.json +20 -4
- package/spec/test-cases.schema.json +131 -16
- package/src/billing/plan-catalog.test.ts +82 -2
- package/src/billing/plan-catalog.ts +212 -9
- package/src/integrations/jira.ts +264 -0
- package/src/mcp/roles-and-review.test.ts +111 -0
- package/src/mcp/server.ts +358 -22
- package/src/mcp/write-test-cases.test.ts +68 -0
- package/src/registry/control-plane-client.ts +121 -6
- package/src/registry/migrations/0036_task_approval.sql +11 -0
- package/src/registry/migrations/0037_spec_proposals.sql +27 -0
- package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
- package/src/registry/migrations/0039_project_secrets.sql +37 -0
- package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
- package/src/registry/migrations/0041_jira_integration.sql +75 -0
- package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
- package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
- package/src/registry/migrations/0044_project_identity.sql +31 -0
- package/src/registry/project-identity.test.ts +99 -0
- package/src/registry/project-identity.ts +206 -0
- package/src/registry/roles.test.ts +66 -0
- package/src/registry/roles.ts +201 -0
- package/src/registry/task-scheduling.test.ts +94 -0
- package/src/registry/task-scheduling.ts +199 -2
- package/src/registry/test-case-revisions.test.ts +57 -0
- package/src/registry/test-case-revisions.ts +132 -0
- package/src/registry-worker/ai/routes.ts +3 -3
- package/src/registry-worker/artifacts.ts +23 -4
- package/src/registry-worker/billing/billing-core.test.ts +1 -1
- package/src/registry-worker/billing/checkout-routes.ts +53 -4
- package/src/registry-worker/billing/enforcement.ts +67 -12
- package/src/registry-worker/billing/paypal/api.ts +14 -0
- package/src/registry-worker/billing/paypal/client.ts +9 -0
- package/src/registry-worker/billing/paypal/provider.ts +10 -1
- package/src/registry-worker/billing/paypal.test.ts +108 -1
- package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
- package/src/registry-worker/billing/provider.ts +8 -0
- package/src/registry-worker/billing/routes.ts +2 -1
- package/src/registry-worker/billing/subscriptions.ts +114 -4
- package/src/registry-worker/core.ts +22 -0
- package/src/registry-worker/devices/policy.ts +4 -5
- package/src/registry-worker/devices/routes.ts +8 -8
- package/src/registry-worker/feature-access.ts +165 -0
- package/src/registry-worker/feature-flags/admin.ts +300 -0
- package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
- package/src/registry-worker/feature-flags/routes.ts +102 -0
- package/src/registry-worker/features.ts +9 -0
- package/src/registry-worker/feedback/routes.ts +2 -2
- package/src/registry-worker/flags.ts +80 -18
- package/src/registry-worker/github/checks.ts +4 -4
- package/src/registry-worker/hooks.ts +9 -0
- package/src/registry-worker/identity/oidc.test.ts +103 -0
- package/src/registry-worker/identity/oidc.ts +176 -0
- package/src/registry-worker/index.ts +477 -174
- package/src/registry-worker/jira/connection.ts +111 -0
- package/src/registry-worker/jira/jira.test.ts +404 -0
- package/src/registry-worker/jira/routes.ts +432 -0
- package/src/registry-worker/jira/workflow.ts +577 -0
- package/src/registry-worker/jobs/retention.ts +21 -5
- package/src/registry-worker/mcp/tools.ts +2 -1
- package/src/registry-worker/notifications/alerts.ts +4 -4
- package/src/registry-worker/notifications/notifications.test.ts +11 -0
- package/src/registry-worker/notifications/routes.ts +9 -10
- package/src/registry-worker/notifications/teams.ts +6 -2
- package/src/registry-worker/org/routes.ts +4 -4
- package/src/registry-worker/projects/identity.ts +194 -0
- package/src/registry-worker/projects/inactivity.ts +162 -0
- package/src/registry-worker/projects/projects.test.ts +283 -0
- package/src/registry-worker/proposals/proposals.test.ts +80 -0
- package/src/registry-worker/proposals/routes.ts +183 -0
- package/src/registry-worker/roles/roles.test.ts +84 -0
- package/src/registry-worker/roles/routes.ts +141 -0
- package/src/registry-worker/runner/dispatch.ts +1 -0
- package/src/registry-worker/runner/routes.ts +22 -5
- package/src/registry-worker/runner/runner.test.ts +21 -0
- package/src/registry-worker/runner/tokens.ts +8 -0
- package/src/registry-worker/secrets/crypto.ts +135 -0
- package/src/registry-worker/secrets/routes.ts +296 -0
- package/src/registry-worker/secrets/secrets.test.ts +237 -0
- package/src/registry-worker/signup/policy.ts +2 -2
- package/src/registry-worker/signup/routes.ts +12 -3
- package/src/registry-worker/sso/membership.ts +4 -2
- package/src/registry-worker/sso/routes.ts +24 -8
- package/src/registry-worker/sso/sso.test.ts +3 -2
- package/src/registry-worker/task-approval.test.ts +67 -0
- package/src/registry-worker/task-resume.test.ts +196 -0
- package/src/registry-worker/task-review.test.ts +121 -0
- package/src/runtime/appium-driver.ts +1 -1
- package/src/runtime/apply-proposals.test.ts +74 -0
- package/src/runtime/apply-proposals.ts +41 -0
- package/src/runtime/cloud-secrets.test.ts +201 -0
- package/src/runtime/cloud-secrets.ts +210 -0
- package/src/runtime/config-validate.ts +12 -9
- package/src/runtime/cursor-driver-run.ts +14 -3
- package/src/runtime/discovery-task.test.ts +26 -1
- package/src/runtime/discovery-task.ts +67 -6
- package/src/runtime/executor.ts +18 -10
- package/src/runtime/explorer.test.ts +32 -0
- package/src/runtime/explorer.ts +48 -0
- package/src/runtime/flow-language.ts +22 -8
- package/src/runtime/init-wizard.ts +112 -6
- package/src/runtime/journey-editor.ts +39 -2
- package/src/runtime/journey-graph.test.ts +18 -0
- package/src/runtime/local-registry-stub.test.ts +64 -0
- package/src/runtime/local-registry-stub.ts +255 -7
- package/src/runtime/local-vault.ts +65 -0
- package/src/runtime/pipeline.test.ts +23 -0
- package/src/runtime/pipeline.ts +61 -20
- package/src/runtime/planner.test.ts +24 -1
- package/src/runtime/planner.ts +88 -23
- package/src/runtime/playwright-driver.test.ts +21 -2
- package/src/runtime/playwright-driver.ts +49 -7
- package/src/runtime/project-identity.test.ts +161 -0
- package/src/runtime/project-identity.ts +232 -0
- package/src/runtime/project-roles.test.ts +107 -0
- package/src/runtime/project-roles.ts +158 -0
- package/src/runtime/propose-cli.ts +66 -0
- package/src/runtime/record-run-verdicts.test.ts +58 -0
- package/src/runtime/record-run-verdicts.ts +38 -4
- package/src/runtime/reporter.ts +1 -1
- package/src/runtime/reset.ts +2 -0
- package/src/runtime/roles-cli.test.ts +47 -0
- package/src/runtime/roles-cli.ts +60 -0
- package/src/runtime/run-once.ts +68 -5
- package/src/runtime/scenario-matrix.test.ts +41 -0
- package/src/runtime/scenario-matrix.ts +98 -0
- package/src/runtime/secret-driver.ts +77 -0
- package/src/runtime/secret-redaction.ts +69 -0
- package/src/runtime/secret-sources.test.ts +442 -0
- package/src/runtime/secret-sources.ts +176 -0
- package/src/runtime/secrets-cli.ts +146 -0
- package/src/runtime/serve-worker.ts +64 -6
- package/src/runtime/site-discovery.test.ts +47 -4
- package/src/runtime/site-discovery.ts +79 -9
- package/src/runtime/vault-federation.test.ts +37 -0
- package/src/runtime/vault-federation.ts +128 -0
- package/src/runtime/web-suite.ts +5 -2
- package/src/storage/create-object-store.ts +5 -1
- package/src/storage/object-store.ts +12 -1
- package/src/storage/test-case-results.test.ts +64 -0
- package/src/storage/test-case-results.ts +49 -0
- 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
|
};
|
|
@@ -52,13 +52,45 @@ export type DiscoveredFlow = {
|
|
|
52
52
|
steps: string[];
|
|
53
53
|
};
|
|
54
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
|
+
|
|
55
68
|
export type DiscoveryResult = {
|
|
56
69
|
baseUrl: string;
|
|
57
70
|
pages: DiscoveredPage[];
|
|
58
71
|
flows: DiscoveredFlow[];
|
|
59
72
|
events: DiscoveryEvent[];
|
|
73
|
+
limits?: DiscoveryLimits;
|
|
60
74
|
};
|
|
61
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
|
+
|
|
62
94
|
export type DiscoveryOptions = {
|
|
63
95
|
maxPages?: number;
|
|
64
96
|
maxDepth?: number;
|
|
@@ -223,7 +255,8 @@ const pageProbe = `() => {
|
|
|
223
255
|
const label = text(link) || link.getAttribute("aria-label") || "";
|
|
224
256
|
// Card-style links carry a title and a sentence; only whole paragraphs
|
|
225
257
|
// wrapped in a link are left out.
|
|
226
|
-
|
|
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 } : {}) });
|
|
227
260
|
}
|
|
228
261
|
const labelOf = (field) => {
|
|
229
262
|
const own = field.labels && field.labels[0] ? text(field.labels[0]) : "";
|
|
@@ -255,7 +288,7 @@ const pageProbe = `() => {
|
|
|
255
288
|
return { title: document.title, heading: heading ? text(heading) : undefined, links, forms, controls };
|
|
256
289
|
}`;
|
|
257
290
|
|
|
258
|
-
type Probe = { title: string; heading?: string; links: Array<{ label: string; href: string }>; forms: DiscoveredForm[]; controls?: string[] };
|
|
291
|
+
type Probe = { title: string; heading?: string; links: Array<{ label: string; href: string; download?: boolean }>; forms: DiscoveredForm[]; controls?: string[] };
|
|
259
292
|
|
|
260
293
|
async function readPage(page: Page): Promise<Probe> {
|
|
261
294
|
return page.evaluate((source: string) => (new Function(`return (${source})`)() as () => Probe)(), pageProbe);
|
|
@@ -332,12 +365,19 @@ export function stoppedAtSignIn(result: Pick<DiscoveryResult, "pages">): boolean
|
|
|
332
365
|
export async function discoverSite(baseUrl: string, options: DiscoveryOptions = {}): Promise<DiscoveryResult> {
|
|
333
366
|
const start = new URL(baseUrl);
|
|
334
367
|
const root = `${start.origin}${start.pathname}`;
|
|
335
|
-
const maxPages = options.maxPages ??
|
|
336
|
-
const maxDepth = options.maxDepth ??
|
|
368
|
+
const maxPages = options.maxPages ?? DEFAULT_DISCOVERY_LIMITS.maxPages;
|
|
369
|
+
const maxDepth = options.maxDepth ?? DEFAULT_DISCOVERY_LIMITS.maxDepth;
|
|
337
370
|
const timeout = options.navigationTimeoutMs ?? 15_000;
|
|
338
371
|
const envDwell = Number(process.env.BUGMOLE_DISCOVERY_DWELL_MS);
|
|
339
372
|
const dwellMs = options.dwellMs ?? (Number.isFinite(envDwell) && envDwell >= 0 ? envDwell : 600);
|
|
340
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
|
+
};
|
|
341
381
|
const emit = async (kind: DiscoveryEvent["kind"], text: string, route?: string) => {
|
|
342
382
|
const event: DiscoveryEvent = { at: new Date().toISOString(), kind, text, ...(route ? { route } : {}) };
|
|
343
383
|
result.events.push(event);
|
|
@@ -354,6 +394,8 @@ export async function discoverSite(baseUrl: string, options: DiscoveryOptions =
|
|
|
354
394
|
const page = await context.newPage();
|
|
355
395
|
page.setDefaultTimeout(timeout);
|
|
356
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>();
|
|
357
399
|
// Breadth-first, so pages close to home are found first.
|
|
358
400
|
const queue: Array<{ route: string; path: string[]; parent?: string; menu?: string }> = [{ route: discoverableRoute(root, root) ?? "/", path: [] }];
|
|
359
401
|
|
|
@@ -373,7 +415,10 @@ export async function discoverSite(baseUrl: string, options: DiscoveryOptions =
|
|
|
373
415
|
const before = await currentHeading(page);
|
|
374
416
|
if (next.menu) {
|
|
375
417
|
await emit("click", `Opening “${next.menu}”`, next.route);
|
|
376
|
-
|
|
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();
|
|
377
422
|
}
|
|
378
423
|
await emit("click", `Clicking “${label}”`, next.route);
|
|
379
424
|
await page.getByRole("link", { name: label, exact: true }).first().click();
|
|
@@ -402,14 +447,24 @@ export async function discoverSite(baseUrl: string, options: DiscoveryOptions =
|
|
|
402
447
|
}
|
|
403
448
|
if (route !== next.route && seen.has(route)) continue;
|
|
404
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
|
+
}
|
|
405
456
|
const probe = await readPage(page);
|
|
406
457
|
const inMenus = await linksInMenus(page, new Set(probe.links.map((link) => link.href)));
|
|
407
458
|
const links: DiscoveredPage["links"] = [];
|
|
408
|
-
for (const link of [...probe.links, ...inMenus] as Array<{ label: string; href: string; menu?: string }>) {
|
|
459
|
+
for (const link of [...probe.links, ...inMenus] as Array<{ label: string; href: string; menu?: string; download?: boolean }>) {
|
|
409
460
|
const target = discoverableRoute(link.href, root);
|
|
410
461
|
if (!target || target === route || links.some((existing) => existing.route === target)) continue;
|
|
411
462
|
if (UNSAFE_LINK.test(link.label) || UNSAFE_LINK.test(target.replace(/[/_-]+/g, " "))) {
|
|
412
|
-
await
|
|
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);
|
|
413
468
|
continue;
|
|
414
469
|
}
|
|
415
470
|
links.push({ label: link.label, route: target, ...(link.menu ? { menu: link.menu } : {}) });
|
|
@@ -430,7 +485,9 @@ export async function discoverSite(baseUrl: string, options: DiscoveryOptions =
|
|
|
430
485
|
await emit("form", `Found a form: ${form.name} (${form.fields.map((field) => field.label).join(", ")})`, route);
|
|
431
486
|
}
|
|
432
487
|
if (dwellMs > 0) await page.waitForTimeout(dwellMs);
|
|
433
|
-
if (next.path.length
|
|
488
|
+
if (next.path.length >= maxDepth) {
|
|
489
|
+
for (const link of links) tooDeep.add(link.route);
|
|
490
|
+
} else {
|
|
434
491
|
for (const link of links) {
|
|
435
492
|
if (!seen.has(link.route)) {
|
|
436
493
|
queue.push({
|
|
@@ -447,6 +504,19 @@ export async function discoverSite(baseUrl: string, options: DiscoveryOptions =
|
|
|
447
504
|
await emit("error", `Could not open ${next.route}: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`, next.route);
|
|
448
505
|
}
|
|
449
506
|
}
|
|
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
|
+
}
|
|
450
520
|
result.flows = flowsFor(`${root}${start.search}`, result.pages, { openEachPage: options.openEachPage });
|
|
451
521
|
await emit("flows", `Discovered ${result.flows.length} flow${result.flows.length === 1 ? "" : "s"} across ${result.pages.length} page${result.pages.length === 1 ? "" : "s"}`);
|
|
452
522
|
await emit("done", "Exploration complete");
|
|
@@ -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
|
+
}
|
package/src/runtime/web-suite.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { PlaywrightDriver } from "./playwright-driver.js";
|
|
4
|
-
import type { DriverResult } from "./driver.js";
|
|
4
|
+
import type { AppDriver, DriverResult } from "./driver.js";
|
|
5
5
|
import { targetLabel, type RunTarget } from "./browser-matrix.js";
|
|
6
6
|
import { resolveTargetConcurrency, runPool } from "./target-runner.js";
|
|
7
7
|
|
|
@@ -13,6 +13,8 @@ export interface WebSuiteOptions {
|
|
|
13
13
|
runId?: string;
|
|
14
14
|
headless?: boolean;
|
|
15
15
|
timeoutMs?: number;
|
|
16
|
+
/** Wraps the suite's driver, e.g. to give flows their secrets (secret-driver.ts). */
|
|
17
|
+
wrapDriver?: (driver: AppDriver) => AppDriver;
|
|
16
18
|
/**
|
|
17
19
|
* Comma-separated substrings matched against flow file names, for re-running
|
|
18
20
|
* part of a suite. Include the sign-in flow when the suite shares a session.
|
|
@@ -96,7 +98,8 @@ export async function runWebSuite(options: WebSuiteOptions): Promise<WebSuiteRep
|
|
|
96
98
|
throw new Error(`No flow files found in ${options.flowsDir}${options.grep ? ` matching "${options.grep}"` : ""}.`);
|
|
97
99
|
}
|
|
98
100
|
|
|
99
|
-
const
|
|
101
|
+
const base = new PlaywrightDriver({ headless: options.headless ?? true });
|
|
102
|
+
const driver = options.wrapDriver ? options.wrapDriver(base) : base;
|
|
100
103
|
const startedAt = new Date().toISOString();
|
|
101
104
|
const targets = options.targets?.length ? options.targets : undefined;
|
|
102
105
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Storage } from "@google-cloud/storage";
|
|
2
|
-
import { LocalObjectStore, type ObjectStore, type StorageProvider, type StoredObject } from "./object-store.js";
|
|
2
|
+
import { LocalObjectStore, withSecretRedaction, type ObjectStore, type StorageProvider, type StoredObject } from "./object-store.js";
|
|
3
3
|
import { S3ObjectStore } from "./remote-object-store.js";
|
|
4
4
|
import { RegistryObjectStore } from "./registry-object-store.js";
|
|
5
5
|
import { resolveLocalStorageDirectory, type StorageSettings } from "./storage-directory.js";
|
|
@@ -106,6 +106,10 @@ export function createProjectObjectStore(cfg: StorageSettings, fallback = "./run
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
export function createObjectStore(config: ObjectStoreConfig): ObjectStore {
|
|
109
|
+
return withSecretRedaction(openObjectStore(config));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function openObjectStore(config: ObjectStoreConfig): ObjectStore {
|
|
109
113
|
const nested = (config as Record<string, unknown>)[config.provider] as Record<string, string | undefined> | undefined;
|
|
110
114
|
const value = (name: string, fallback?: string) => nested?.[name] ?? fallback;
|
|
111
115
|
if (config.provider === "local") {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { redactBody, redactSecrets } from "../runtime/secret-redaction.js";
|
|
3
4
|
|
|
4
5
|
/** "bugmole" is Bugmole-managed storage, reached through the registry. */
|
|
5
6
|
export type StorageProvider = "local" | "gcs" | "s3" | "r2" | "bugmole";
|
|
@@ -22,6 +23,16 @@ export type ObjectStore = {
|
|
|
22
23
|
publicUrl?(key: string, expiresInSeconds?: number): Promise<string>;
|
|
23
24
|
};
|
|
24
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Wraps a store so text uploads never carry a flow secret the worker typed:
|
|
28
|
+
* logs, results, manifests and failure context are redacted on the way out.
|
|
29
|
+
*/
|
|
30
|
+
export function withSecretRedaction(store: ObjectStore): ObjectStore {
|
|
31
|
+
const put = store.put.bind(store);
|
|
32
|
+
store.put = (key, body, contentType) => put(key, redactBody(body, contentType), contentType);
|
|
33
|
+
return store;
|
|
34
|
+
}
|
|
35
|
+
|
|
25
36
|
export function normalizeKey(key: string): string {
|
|
26
37
|
const normalized = key.replaceAll("\\", "/").replace(/^\/+/, "");
|
|
27
38
|
if (!normalized || normalized.split("/").some((part) => part === ".." || part === ".")) {
|
|
@@ -97,5 +108,5 @@ export class LocalObjectStore implements ObjectStore {
|
|
|
97
108
|
}
|
|
98
109
|
|
|
99
110
|
export function json(value: unknown): string {
|
|
100
|
-
return JSON.stringify(value, null, 2);
|
|
111
|
+
return redactSecrets(JSON.stringify(value, null, 2));
|
|
101
112
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import type { ObjectStore, StoredObject } from "./object-store.js";
|
|
4
|
+
import { putTestCaseResults, testCaseHistoryKey, testCaseHistoryPrefix, testCaseResultsKey } from "./test-case-results.js";
|
|
5
|
+
|
|
6
|
+
function memoryStore(): ObjectStore & { objects: Map<string, string> } {
|
|
7
|
+
const objects = new Map<string, string>();
|
|
8
|
+
const stored = (key: string): StoredObject => ({ key, size: objects.get(key)!.length, modifiedAt: new Date(0).toISOString() });
|
|
9
|
+
return {
|
|
10
|
+
provider: "local",
|
|
11
|
+
objects,
|
|
12
|
+
async put(key, body) {
|
|
13
|
+
objects.set(key, typeof body === "string" ? body : new TextDecoder().decode(body));
|
|
14
|
+
return stored(key);
|
|
15
|
+
},
|
|
16
|
+
async get(key) {
|
|
17
|
+
return new TextEncoder().encode(objects.get(key)!);
|
|
18
|
+
},
|
|
19
|
+
async getText(key) {
|
|
20
|
+
if (!objects.has(key)) throw new Error("missing");
|
|
21
|
+
return objects.get(key)!;
|
|
22
|
+
},
|
|
23
|
+
async has(key) {
|
|
24
|
+
return objects.has(key);
|
|
25
|
+
},
|
|
26
|
+
async list(prefix) {
|
|
27
|
+
return [...objects.keys()].filter((key) => key.startsWith(prefix)).map(stored);
|
|
28
|
+
},
|
|
29
|
+
async deletePrefix(prefix) {
|
|
30
|
+
for (const key of [...objects.keys()]) if (key.startsWith(prefix)) objects.delete(key);
|
|
31
|
+
},
|
|
32
|
+
async publicUrl(key) {
|
|
33
|
+
return key;
|
|
34
|
+
},
|
|
35
|
+
} as ObjectStore & { objects: Map<string, string> };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test("latest and history keys stay inside the project and apart from each other", () => {
|
|
39
|
+
assert.equal(testCaseResultsKey("qa framework", "login"), "projects/qa%20framework/test-case-results/login.json");
|
|
40
|
+
assert.equal(testCaseHistoryPrefix("qa framework", "login"), "projects/qa%20framework/test-case-history/login/");
|
|
41
|
+
assert.ok(!testCaseHistoryPrefix("demo").startsWith("projects/demo/test-case-results/"));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("history keys sort by recording time and name the run", () => {
|
|
45
|
+
const early = testCaseHistoryKey("demo", "login", "2026-09-01T09:05:03.007Z", "run-b");
|
|
46
|
+
const late = testCaseHistoryKey("demo", "login", "2026-09-10T00:00:00.000Z", "run-a");
|
|
47
|
+
assert.equal(early, "projects/demo/test-case-history/login/20260901T090503007Z-run-b.json");
|
|
48
|
+
assert.ok(early < late);
|
|
49
|
+
// A run id that isn't safe in a key is replaced, not copied into the path.
|
|
50
|
+
const unsafe = testCaseHistoryKey("demo", "login", "2026-09-01T00:00:00.000Z", "../other");
|
|
51
|
+
assert.match(unsafe, /^projects\/demo\/test-case-history\/login\/20260901T000000000Z-[0-9a-f]{8}\.json$/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("each write replaces the latest file and adds one history entry", async () => {
|
|
55
|
+
const store = memoryStore();
|
|
56
|
+
await putTestCaseResults(store, "demo", { caseSetId: "login", recordedAt: "2026-09-01T00:00:00.000Z", runId: "r1", results: [{ caseId: "a", status: "passed" }] });
|
|
57
|
+
const second = await putTestCaseResults(store, "demo", { caseSetId: "login", recordedAt: "2026-09-02T00:00:00.000Z", results: [{ caseId: "a", status: "failed" }] });
|
|
58
|
+
assert.equal(second.objectKey, "projects/demo/test-case-results/login.json");
|
|
59
|
+
assert.equal(JSON.parse(store.objects.get(second.objectKey)!).results[0].status, "failed");
|
|
60
|
+
const history = await store.list(testCaseHistoryPrefix("demo", "login"));
|
|
61
|
+
assert.equal(history.length, 2);
|
|
62
|
+
assert.ok(history.some((object) => object.key === second.historyKey));
|
|
63
|
+
assert.equal((await store.list("projects/demo/test-case-results/")).length, 1);
|
|
64
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import type { ObjectStore } from "./object-store.js";
|
|
3
|
+
import { projectPrefix } from "./keys.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Where test case verdicts live.
|
|
7
|
+
*
|
|
8
|
+
* `latest` is the one file per case set every reader already knows; each
|
|
9
|
+
* write replaces it. `history` keeps one file per write beside it so the
|
|
10
|
+
* dashboard can show a case's pass/fail trail. History sits under its own
|
|
11
|
+
* prefix rather than inside `test-case-results/`: readers list that prefix
|
|
12
|
+
* and treat every JSON file in it as the latest verdicts for a case set.
|
|
13
|
+
*/
|
|
14
|
+
export function testCaseResultsKey(projectId: string, caseSetId: string): string {
|
|
15
|
+
return `${projectPrefix(projectId)}/test-case-results/${caseSetId}.json`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function testCaseHistoryPrefix(projectId: string, caseSetId?: string): string {
|
|
19
|
+
const base = `${projectPrefix(projectId)}/test-case-history/`;
|
|
20
|
+
return caseSetId ? `${base}${caseSetId}/` : base;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One history file name per write. It starts with the recording time in a
|
|
25
|
+
* fixed-width form so names sort oldest to newest, then the run id (or a
|
|
26
|
+
* random suffix) so two writes in the same millisecond never collide.
|
|
27
|
+
*/
|
|
28
|
+
export function testCaseHistoryKey(projectId: string, caseSetId: string, recordedAt: string, runId?: string): string {
|
|
29
|
+
const parsed = typeof recordedAt === "string" ? Date.parse(recordedAt) : Number.NaN;
|
|
30
|
+
const stamp = new Date(Number.isFinite(parsed) ? parsed : Date.now()).toISOString().replace(/[-:.]/g, "");
|
|
31
|
+
const suffix = typeof runId === "string" && /^[a-zA-Z0-9._-]+$/.test(runId) ? runId : randomBytes(4).toString("hex");
|
|
32
|
+
return `${testCaseHistoryPrefix(projectId, caseSetId)}${stamp}-${suffix}.json`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Write a result set as the latest verdicts and as one more history entry. */
|
|
36
|
+
export async function putTestCaseResults(
|
|
37
|
+
store: ObjectStore,
|
|
38
|
+
projectId: string,
|
|
39
|
+
record: { caseSetId: string; recordedAt: string; runId?: string } & Record<string, unknown>,
|
|
40
|
+
): Promise<{ objectKey: string; historyKey: string }> {
|
|
41
|
+
const body = JSON.stringify(record, null, 2) + "\n";
|
|
42
|
+
const objectKey = testCaseResultsKey(projectId, record.caseSetId);
|
|
43
|
+
const historyKey = testCaseHistoryKey(projectId, record.caseSetId, record.recordedAt, record.runId);
|
|
44
|
+
// History first: if the second write fails the latest file is unchanged,
|
|
45
|
+
// rather than showing a verdict that never reached the history.
|
|
46
|
+
await store.put(historyKey, body, "application/json");
|
|
47
|
+
await store.put(objectKey, body, "application/json");
|
|
48
|
+
return { objectKey, historyKey };
|
|
49
|
+
}
|