@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
@@ -95,3 +95,68 @@ test("typed secrets never reach the uploaded log, and the shared session is dele
95
95
  await fs.rm(dir, { recursive: true, force: true });
96
96
  }
97
97
  });
98
+
99
+ test("a flow written for production runs against --base-url, not production", async (t) => {
100
+ const { chromium } = await import("playwright");
101
+ try {
102
+ await fs.access(chromium.executablePath());
103
+ } catch {
104
+ t.skip("Chromium is not installed");
105
+ return;
106
+ }
107
+ const http = await import("node:http");
108
+ const seen: string[] = [];
109
+ const server = http.createServer((request, response) => {
110
+ seen.push(request.url ?? "");
111
+ response.writeHead(200, { "content-type": "text/html" });
112
+ response.end("<!doctype html><h1>Pricing on staging</h1>");
113
+ });
114
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
115
+ const { port } = server.address() as { port: number };
116
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-suite-rebase-"));
117
+ await fs.writeFile(path.join(dir, "pricing.yaml"), "url: https://production.invalid/pricing/\n---\n- launchApp\n- assertVisible: \"Pricing on staging\"\n");
118
+ try {
119
+ const report = await runWebSuite({ flowsDir: dir, baseUrl: `http://127.0.0.1:${port}`, artifactsDir: path.join(dir, "artifacts"), projectId: "demo" });
120
+ assert.equal(report.totals.passed, 1, report.flows[0]?.message);
121
+ assert.ok(seen.includes("/pricing/"), seen.join(","));
122
+ } finally {
123
+ server.close();
124
+ await fs.rm(dir, { recursive: true, force: true });
125
+ }
126
+ });
127
+
128
+ test("waiting for text survives the page navigating away mid-check, as a sign-in redirect does", async (t) => {
129
+ const { chromium } = await import("playwright");
130
+ try {
131
+ await fs.access(chromium.executablePath());
132
+ } catch {
133
+ t.skip("Chromium is not installed");
134
+ return;
135
+ }
136
+ const { isNavigationError } = await import("./playwright-driver.js");
137
+ assert.equal(isNavigationError(new Error("page.evaluate: Execution context was destroyed, most likely because of a navigation")), true);
138
+ assert.equal(isNavigationError(new Error("Target closed")), false);
139
+ const http = await import("node:http");
140
+ const server = http.createServer((request, response) => {
141
+ response.writeHead(200, { "content-type": "text/html" });
142
+ if (request.url === "/app") {
143
+ // The app is slow to answer, so the check runs while the page is between documents.
144
+ setTimeout(() => response.end("<!doctype html><h1>Quality, as a flow.</h1>"), 700);
145
+ return;
146
+ }
147
+ response.end("<!doctype html><p>Signing you in…</p><script>setTimeout(() => { location.href = '/app'; }, 150)</script>");
148
+ });
149
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
150
+ const { port } = server.address() as { port: number };
151
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-suite-nav-"));
152
+ await fs.writeFile(path.join(dir, "sign-in.yaml"), "url: /\n---\n- launchApp\n- assertVisible:\n text: \"Quality, as a flow.\"\n timeout: 10000\n");
153
+ try {
154
+ for (let attempt = 0; attempt < 3; attempt++) {
155
+ const report = await runWebSuite({ flowsDir: dir, baseUrl: `http://127.0.0.1:${port}/`, artifactsDir: path.join(dir, `artifacts-${attempt}`), projectId: "demo" });
156
+ assert.equal(report.totals.passed, 1, report.flows[0]?.message);
157
+ }
158
+ } finally {
159
+ server.close();
160
+ await fs.rm(dir, { recursive: true, force: true });
161
+ }
162
+ });
@@ -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 driver = new PlaywrightDriver({ headless: options.headless ?? true });
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
 
@@ -125,6 +128,9 @@ export async function runWebSuite(options: WebSuiteOptions): Promise<WebSuiteRep
125
128
  flowPath,
126
129
  platform: "web",
127
130
  target: { baseUrl: options.baseUrl },
131
+ // The suite runs against --base-url: a flow written for another
132
+ // address (production, say) is moved onto it, as plan runs do.
133
+ rebaseOrigin: true,
128
134
  timeoutMs: options.timeoutMs ?? 30_000,
129
135
  artifactsDir: options.artifactsDir,
130
136
  sessionStatePath,
@@ -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
+ }
@@ -0,0 +1,371 @@
1
+ // VENDORED from theaiinc/arcana packages/byok/src/byok.ts by `node scripts/sync-byok.mjs` — change it there, then sync.
2
+ // Bring-your-own-vault secret resolution: read a secret by reference from
3
+ // the vault its owner already uses, without the Arcana app, a phone, or any
4
+ // third-party SDK.
5
+ //
6
+ // gcpsm://projects/P/secrets/S[/versions/V] Google Secret Manager
7
+ // awssm://REGION/SECRET_ID[#field] AWS Secrets Manager (#field reads one field of a JSON secret)
8
+ // keychain://SERVICE/ACCOUNT the machine's keychain (macOS Keychain, Linux Secret Service)
9
+ // env://VARIABLE an environment variable
10
+ //
11
+ // A reference is a pointer, not a credential, so it is safe to store and
12
+ // commit. A value is returned to the caller only, never logged or put in an
13
+ // error message.
14
+ //
15
+ // One file, no dependencies, erasable TypeScript only: Node runs it as is,
16
+ // and other projects (the Bugmole CLI) vendor it verbatim.
17
+
18
+ import { execFile } from "node:child_process";
19
+ import { createHash, createHmac, createSign } from "node:crypto";
20
+
21
+ export type SecretReference =
22
+ | { kind: "gcp"; project: string; secret: string; version: string }
23
+ | { kind: "aws"; region: string; secretId: string; field?: string }
24
+ | { kind: "keychain"; service: string; account: string }
25
+ | { kind: "env"; variable: string };
26
+
27
+ export type ByokDeps = {
28
+ env?: Record<string, string | undefined>;
29
+ fetch?: typeof fetch;
30
+ /** Runs a program and returns its stdout; replaceable in tests. */
31
+ exec?: (file: string, args: string[], options?: { input?: string }) => Promise<string>;
32
+ platform?: string;
33
+ /**
34
+ * Environment prefixes AWS keys are read from, in order. A host can put a
35
+ * tenant's own read-only keys first (Bugmole Cloud uses VAULT_AWS_).
36
+ */
37
+ awsPrefixes?: string[];
38
+ /** Name of an environment variable holding a Google service account's JSON key. */
39
+ googleKeyVariable?: string;
40
+ /** Allow the aws, gcloud, security and secret-tool CLIs (default true). */
41
+ allowCli?: boolean;
42
+ };
43
+
44
+ export function parseReference(value: unknown): SecretReference | null {
45
+ if (typeof value !== "string" || /[\r\n]/.test(value)) return null;
46
+ const text = value.trim();
47
+ let match = /^gcpsm:\/\/projects\/([^/]+)\/secrets\/([^/]+)(?:\/versions\/([^/]+))?$/.exec(text);
48
+ if (match) return { kind: "gcp", project: match[1], secret: match[2], version: match[3] ?? "latest" };
49
+ match = /^awssm:\/\/([a-z]{2}(?:-[a-z]+)+-\d)\/([^#]+?)(?:#([^#]+))?$/.exec(text);
50
+ if (match) return { kind: "aws", region: match[1], secretId: decodeURIComponent(match[2]), ...(match[3] ? { field: match[3] } : {}) };
51
+ match = /^keychain:\/\/([^/]+)\/([^/]+)$/.exec(text);
52
+ if (match) return { kind: "keychain", service: match[1], account: match[2] };
53
+ match = /^env:\/\/([A-Za-z_][A-Za-z0-9_]*)$/.exec(text);
54
+ if (match) return { kind: "env", variable: match[1] };
55
+ return null;
56
+ }
57
+
58
+ export function formatReference(reference: SecretReference): string {
59
+ switch (reference.kind) {
60
+ case "gcp": return `gcpsm://projects/${reference.project}/secrets/${reference.secret}/versions/${reference.version}`;
61
+ case "aws": return `awssm://${reference.region}/${reference.secretId}${reference.field ? `#${reference.field}` : ""}`;
62
+ case "keychain": return `keychain://${reference.service}/${reference.account}`;
63
+ case "env": return `env://${reference.variable}`;
64
+ }
65
+ }
66
+
67
+ export const SOURCE_NAME: Record<SecretReference["kind"], string> = {
68
+ gcp: "Google Secret Manager",
69
+ aws: "AWS Secrets Manager",
70
+ keychain: "this machine's keychain",
71
+ env: "an environment variable",
72
+ };
73
+
74
+ function defaultExec(file: string, args: string[], options?: { input?: string }): Promise<string> {
75
+ return new Promise((resolve, reject) => {
76
+ const child = execFile(file, args, { maxBuffer: 1024 * 1024 }, (error, stdout) => (error ? reject(error) : resolve(String(stdout))));
77
+ child.stdin?.end(options?.input);
78
+ });
79
+ }
80
+
81
+ function execOf(deps: ByokDeps): NonNullable<ByokDeps["exec"]> {
82
+ if (deps.allowCli === false) return async (file) => { throw new Error(`${file} isn't available here`); };
83
+ return deps.exec ?? defaultExec;
84
+ }
85
+
86
+ // ---------- the machine's keychain ----------
87
+
88
+ type Keychain = Extract<SecretReference, { kind: "keychain" }>;
89
+
90
+ const quoted = (text: string) => `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
91
+
92
+ async function readKeychain(reference: Keychain, deps: ByokDeps): Promise<string> {
93
+ const exec = execOf(deps);
94
+ const platform = deps.platform ?? process.platform;
95
+ if (platform !== "darwin" && platform !== "linux") {
96
+ throw new Error("This machine has no supported keychain (macOS Keychain, or secret-tool on Linux)");
97
+ }
98
+ try {
99
+ if (platform === "darwin") return (await exec("security", ["find-generic-password", "-s", reference.service, "-a", reference.account, "-w"])).replace(/\n$/, "");
100
+ return await exec("secret-tool", ["lookup", "service", reference.service, "account", reference.account]);
101
+ } catch {
102
+ throw new Error(`No keychain entry ${formatReference(reference)} on this machine`);
103
+ }
104
+ }
105
+
106
+ /** Stores a value in the machine's keychain, replacing any earlier one. The value goes in on stdin, never in a command's arguments. */
107
+ export async function writeKeychain(reference: Keychain, value: string, label: string, deps: ByokDeps = {}): Promise<void> {
108
+ const exec = execOf(deps);
109
+ const platform = deps.platform ?? process.platform;
110
+ if (platform === "darwin") {
111
+ // `security add-generic-password -w VALUE` would show the value in the
112
+ // process list; its interactive mode reads the same command from stdin.
113
+ await exec("security", ["-i"], {
114
+ input: `add-generic-password -U -s ${quoted(reference.service)} -a ${quoted(reference.account)} -l ${quoted(label)} -w ${quoted(value)}\n`,
115
+ });
116
+ return;
117
+ }
118
+ if (platform === "linux") {
119
+ await exec("secret-tool", ["store", "--label", label, "service", reference.service, "account", reference.account], { input: value });
120
+ return;
121
+ }
122
+ throw new Error("This machine has no supported keychain (macOS Keychain, or secret-tool on Linux)");
123
+ }
124
+
125
+ export async function deleteKeychain(reference: Keychain, deps: ByokDeps = {}): Promise<boolean> {
126
+ const exec = execOf(deps);
127
+ const platform = deps.platform ?? process.platform;
128
+ try {
129
+ if (platform === "darwin") await exec("security", ["delete-generic-password", "-s", reference.service, "-a", reference.account]);
130
+ else if (platform === "linux") await exec("secret-tool", ["clear", "service", reference.service, "account", reference.account]);
131
+ else return false;
132
+ return true;
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ /** Whether a keychain entry exists, without reading its value. */
139
+ export async function keychainHas(reference: Keychain, deps: ByokDeps = {}): Promise<boolean> {
140
+ const exec = execOf(deps);
141
+ const platform = deps.platform ?? process.platform;
142
+ try {
143
+ // Without -w macOS prints the entry's attributes, not its value.
144
+ if (platform === "darwin") await exec("security", ["find-generic-password", "-s", reference.service, "-a", reference.account]);
145
+ else if (platform === "linux") {
146
+ const found = await exec("secret-tool", ["search", "service", reference.service, "account", reference.account]);
147
+ if (!found.trim()) return false;
148
+ } else return false;
149
+ return true;
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ // ---------- AWS Secrets Manager ----------
156
+
157
+ export type AwsCredentials = { accessKeyId: string; secretAccessKey: string; sessionToken?: string };
158
+
159
+ const sha256Hex = (value: string) => createHash("sha256").update(value).digest("hex");
160
+ const hmac = (key: string | Buffer, value: string) => createHmac("sha256", key).update(value).digest();
161
+
162
+ /** AWS Signature Version 4 for a JSON POST. */
163
+ export function signAws(params: { credentials: AwsCredentials; region: string; service: string; url: string; headers: Record<string, string>; body: string; now?: Date }): Record<string, string> {
164
+ const now = params.now ?? new Date();
165
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
166
+ const date = amzDate.slice(0, 8);
167
+ const url = new URL(params.url);
168
+ const headers: Record<string, string> = {
169
+ ...Object.fromEntries(Object.entries(params.headers).map(([key, value]) => [key.toLowerCase(), value.trim()])),
170
+ host: url.host,
171
+ "x-amz-date": amzDate,
172
+ ...(params.credentials.sessionToken ? { "x-amz-security-token": params.credentials.sessionToken } : {}),
173
+ };
174
+ const names = Object.keys(headers).sort();
175
+ const canonical = [
176
+ "POST",
177
+ url.pathname || "/",
178
+ url.searchParams.toString(),
179
+ names.map((name) => `${name}:${headers[name]}\n`).join(""),
180
+ names.join(";"),
181
+ sha256Hex(params.body),
182
+ ].join("\n");
183
+ const scope = `${date}/${params.region}/${params.service}/aws4_request`;
184
+ const toSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256Hex(canonical)].join("\n");
185
+ const key = hmac(hmac(hmac(hmac(`AWS4${params.credentials.secretAccessKey}`, date), params.region), params.service), "aws4_request");
186
+ const signature = createHmac("sha256", key).update(toSign).digest("hex");
187
+ const { host: _host, ...rest } = headers;
188
+ return {
189
+ ...rest,
190
+ authorization: `AWS4-HMAC-SHA256 Credential=${params.credentials.accessKeyId}/${scope}, SignedHeaders=${names.join(";")}, Signature=${signature}`,
191
+ };
192
+ }
193
+
194
+ /** AWS keys from the configured environment prefixes, then the AWS CLI (profiles, SSO). */
195
+ export async function awsCredentials(deps: ByokDeps = {}): Promise<AwsCredentials> {
196
+ const env = deps.env ?? process.env;
197
+ for (const prefix of deps.awsPrefixes ?? ["AWS_"]) {
198
+ const accessKeyId = env[`${prefix}ACCESS_KEY_ID`];
199
+ const secretAccessKey = env[`${prefix}SECRET_ACCESS_KEY`];
200
+ if (accessKeyId && secretAccessKey) {
201
+ const sessionToken = env[`${prefix}SESSION_TOKEN`];
202
+ return { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) };
203
+ }
204
+ }
205
+ try {
206
+ const parsed = JSON.parse(await execOf(deps)("aws", ["configure", "export-credentials", "--format", "process"])) as { AccessKeyId?: string; SecretAccessKey?: string; SessionToken?: string };
207
+ if (parsed.AccessKeyId && parsed.SecretAccessKey) {
208
+ return { accessKeyId: parsed.AccessKeyId, secretAccessKey: parsed.SecretAccessKey, ...(parsed.SessionToken ? { sessionToken: parsed.SessionToken } : {}) };
209
+ }
210
+ } catch {
211
+ // Reported below.
212
+ }
213
+ throw new Error("No AWS credentials: set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or sign in with the AWS CLI");
214
+ }
215
+
216
+ async function readAws(reference: Extract<SecretReference, { kind: "aws" }>, deps: ByokDeps): Promise<string> {
217
+ const credentials = await awsCredentials(deps);
218
+ const url = `https://secretsmanager.${reference.region}.amazonaws.com/`;
219
+ const body = JSON.stringify({ SecretId: reference.secretId });
220
+ const headers = signAws({
221
+ credentials,
222
+ region: reference.region,
223
+ service: "secretsmanager",
224
+ url,
225
+ headers: { "content-type": "application/x-amz-json-1.1", "x-amz-target": "secretsmanager.GetSecretValue" },
226
+ body,
227
+ });
228
+ const reply = await (deps.fetch ?? fetch)(url, { method: "POST", headers, body });
229
+ if (!reply.ok) throw new Error(`AWS Secrets Manager refused ${formatReference(reference)} (${reply.status}): ${await errorText(reply)}`);
230
+ const payload = await reply.json() as { SecretString?: string; SecretBinary?: string };
231
+ const text = payload.SecretString ?? (payload.SecretBinary ? Buffer.from(payload.SecretBinary, "base64").toString("utf8") : undefined);
232
+ if (text === undefined) throw new Error(`${formatReference(reference)} has no value`);
233
+ return reference.field ? jsonField(text, reference.field, formatReference(reference)) : text;
234
+ }
235
+
236
+ // ---------- Google Secret Manager ----------
237
+
238
+ type ServiceAccountKey = { client_email?: string; private_key?: string; token_uri?: string };
239
+
240
+ async function serviceAccountToken(key: ServiceAccountKey, doFetch: typeof fetch): Promise<string> {
241
+ if (!key.client_email || !key.private_key) throw new Error("The service account key needs client_email and private_key");
242
+ const now = Math.floor(Date.now() / 1000);
243
+ const tokenUri = key.token_uri ?? "https://oauth2.googleapis.com/token";
244
+ const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString("base64url");
245
+ const unsigned = `${encode({ alg: "RS256", typ: "JWT" })}.${encode({
246
+ iss: key.client_email,
247
+ scope: "https://www.googleapis.com/auth/cloud-platform",
248
+ aud: tokenUri,
249
+ iat: now,
250
+ exp: now + 600,
251
+ })}`;
252
+ const signature = createSign("RSA-SHA256").update(unsigned).sign(key.private_key).toString("base64url");
253
+ const reply = await doFetch(tokenUri, {
254
+ method: "POST",
255
+ headers: { "content-type": "application/x-www-form-urlencoded" },
256
+ body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion: `${unsigned}.${signature}` }).toString(),
257
+ });
258
+ if (!reply.ok) throw new Error(`Google refused the service account (${reply.status}): ${await errorText(reply)}`);
259
+ const body = await reply.json() as { access_token?: string };
260
+ if (!body.access_token) throw new Error("Google returned no access token for the service account");
261
+ return body.access_token;
262
+ }
263
+
264
+ /** A Google access token: a service account key, GOOGLE_OAUTH_ACCESS_TOKEN, or the signed-in gcloud CLI. */
265
+ export async function googleAccessToken(deps: ByokDeps = {}): Promise<string> {
266
+ const env = deps.env ?? process.env;
267
+ const keyJson = deps.googleKeyVariable ? env[deps.googleKeyVariable] : undefined;
268
+ if (keyJson) {
269
+ let key: ServiceAccountKey;
270
+ try {
271
+ key = JSON.parse(keyJson) as ServiceAccountKey;
272
+ } catch {
273
+ throw new Error(`${deps.googleKeyVariable} isn't a service account key (JSON)`);
274
+ }
275
+ return serviceAccountToken(key, deps.fetch ?? fetch);
276
+ }
277
+ if (env.GOOGLE_OAUTH_ACCESS_TOKEN) return env.GOOGLE_OAUTH_ACCESS_TOKEN;
278
+ try {
279
+ const token = (await execOf(deps)("gcloud", ["auth", "print-access-token"])).trim();
280
+ if (token) return token;
281
+ } catch {
282
+ // Reported below.
283
+ }
284
+ throw new Error("No Google credentials: sign in with `gcloud auth login`, or set GOOGLE_OAUTH_ACCESS_TOKEN");
285
+ }
286
+
287
+ async function readGoogle(reference: Extract<SecretReference, { kind: "gcp" }>, deps: ByokDeps): Promise<string> {
288
+ const token = await googleAccessToken(deps);
289
+ const url = `https://secretmanager.googleapis.com/v1/projects/${encodeURIComponent(reference.project)}/secrets/${encodeURIComponent(reference.secret)}/versions/${encodeURIComponent(reference.version)}:access`;
290
+ const reply = await (deps.fetch ?? fetch)(url, { headers: { authorization: `Bearer ${token}` } });
291
+ if (!reply.ok) throw new Error(`Google Secret Manager refused ${formatReference(reference)} (${reply.status}): ${await errorText(reply)}`);
292
+ const body = await reply.json() as { payload?: { data?: string } };
293
+ if (!body.payload?.data) throw new Error(`${formatReference(reference)} has no value`);
294
+ return Buffer.from(body.payload.data, "base64").toString("utf8");
295
+ }
296
+
297
+ // ---------- shared ----------
298
+
299
+ async function errorText(reply: Response): Promise<string> {
300
+ const text = (await reply.text().catch(() => "")).slice(0, 300);
301
+ try {
302
+ const parsed = JSON.parse(text) as { message?: string; Message?: string; error?: { message?: string } };
303
+ return parsed.error?.message ?? parsed.message ?? parsed.Message ?? text;
304
+ } catch {
305
+ return text;
306
+ }
307
+ }
308
+
309
+ function jsonField(text: string, field: string, label: string): string {
310
+ let parsed: unknown;
311
+ try {
312
+ parsed = JSON.parse(text);
313
+ } catch {
314
+ throw new Error(`${label} isn't JSON, so it has no field "${field}"`);
315
+ }
316
+ const value = (parsed as Record<string, unknown> | null)?.[field];
317
+ if (typeof value !== "string" && typeof value !== "number") throw new Error(`${label} has no text field "${field}"`);
318
+ return String(value);
319
+ }
320
+
321
+ /** Reads the value a reference points at. Errors name the reference, never a value. */
322
+ export async function resolveReference(reference: SecretReference, deps: ByokDeps = {}): Promise<string> {
323
+ switch (reference.kind) {
324
+ case "env": {
325
+ const value = (deps.env ?? process.env)[reference.variable];
326
+ if (value === undefined || value === "") throw new Error(`${reference.variable} is not set`);
327
+ return value;
328
+ }
329
+ case "keychain": return readKeychain(reference, deps);
330
+ case "aws": return readAws(reference, deps);
331
+ case "gcp": return readGoogle(reference, deps);
332
+ }
333
+ }
334
+
335
+ export type Readiness = { ready: boolean; detail: string };
336
+
337
+ /**
338
+ * Whether a reference can probably be read here, without reading it: an env
339
+ * variable or keychain entry exists, or credentials for the cloud vault do.
340
+ */
341
+ export async function readiness(reference: SecretReference, deps: ByokDeps = {}): Promise<Readiness> {
342
+ const env = deps.env ?? process.env;
343
+ switch (reference.kind) {
344
+ case "env":
345
+ return env[reference.variable] ? { ready: true, detail: `Read from ${reference.variable}` } : { ready: false, detail: `${reference.variable} is not set` };
346
+ case "keychain":
347
+ return (await keychainHas(reference, deps))
348
+ ? { ready: true, detail: "In this machine's keychain" }
349
+ : { ready: false, detail: `No keychain entry ${formatReference(reference)}` };
350
+ case "aws":
351
+ try {
352
+ await awsCredentials({ ...deps, allowCli: false });
353
+ return { ready: true, detail: "AWS keys found; read when it's used" };
354
+ } catch {
355
+ // Checking the AWS CLI's profile could start a sign-in, so it isn't.
356
+ return deps.allowCli === false
357
+ ? { ready: false, detail: "No AWS keys" }
358
+ : { ready: true, detail: "No AWS keys in the environment; the AWS CLI's profile is tried when it's used" };
359
+ }
360
+ case "gcp": {
361
+ if ((deps.googleKeyVariable && env[deps.googleKeyVariable]) || env.GOOGLE_OAUTH_ACCESS_TOKEN) return { ready: true, detail: "Google credentials found; read when it's used" };
362
+ try {
363
+ const account = (await execOf(deps)("gcloud", ["auth", "list", "--filter=status:ACTIVE", "--format=value(account)"])).trim();
364
+ if (account) return { ready: true, detail: `Signed in to gcloud as ${account}; read when it's used` };
365
+ } catch {
366
+ // Reported below.
367
+ }
368
+ return { ready: false, detail: "No Google credentials: run gcloud auth login, or set GOOGLE_OAUTH_ACCESS_TOKEN" };
369
+ }
370
+ }
371
+ }