@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.
Files changed (153) hide show
  1. package/package.json +5 -2
  2. package/scripts/bugmole-admin.mjs +194 -0
  3. package/scripts/bugmole-admin.test.mjs +62 -0
  4. package/scripts/bugmole.test.ts +94 -2
  5. package/scripts/bugmole.ts +233 -46
  6. package/scripts/sync-byok.d.mts +2 -0
  7. package/scripts/sync-byok.mjs +18 -0
  8. package/scripts/sync-plan-catalog.d.mts +3 -0
  9. package/scripts/sync-plan-catalog.mjs +16 -6
  10. package/spec/domain_rules.yaml +139 -0
  11. package/spec/roles.yaml +20 -0
  12. package/spec/test-case-results.schema.json +20 -4
  13. package/spec/test-cases.schema.json +131 -16
  14. package/src/billing/plan-catalog.test.ts +82 -2
  15. package/src/billing/plan-catalog.ts +212 -9
  16. package/src/integrations/jira.ts +264 -0
  17. package/src/mcp/roles-and-review.test.ts +111 -0
  18. package/src/mcp/server.ts +358 -22
  19. package/src/mcp/write-test-cases.test.ts +68 -0
  20. package/src/registry/control-plane-client.ts +121 -6
  21. package/src/registry/migrations/0036_task_approval.sql +11 -0
  22. package/src/registry/migrations/0037_spec_proposals.sql +27 -0
  23. package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
  24. package/src/registry/migrations/0039_project_secrets.sql +37 -0
  25. package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
  26. package/src/registry/migrations/0041_jira_integration.sql +75 -0
  27. package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
  28. package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
  29. package/src/registry/migrations/0044_project_identity.sql +31 -0
  30. package/src/registry/project-identity.test.ts +99 -0
  31. package/src/registry/project-identity.ts +206 -0
  32. package/src/registry/roles.test.ts +66 -0
  33. package/src/registry/roles.ts +201 -0
  34. package/src/registry/task-scheduling.test.ts +94 -0
  35. package/src/registry/task-scheduling.ts +199 -2
  36. package/src/registry/test-case-revisions.test.ts +57 -0
  37. package/src/registry/test-case-revisions.ts +132 -0
  38. package/src/registry-worker/ai/routes.ts +3 -3
  39. package/src/registry-worker/artifacts.ts +23 -4
  40. package/src/registry-worker/billing/billing-core.test.ts +1 -1
  41. package/src/registry-worker/billing/checkout-routes.ts +53 -4
  42. package/src/registry-worker/billing/enforcement.ts +67 -12
  43. package/src/registry-worker/billing/paypal/api.ts +14 -0
  44. package/src/registry-worker/billing/paypal/client.ts +9 -0
  45. package/src/registry-worker/billing/paypal/provider.ts +10 -1
  46. package/src/registry-worker/billing/paypal.test.ts +108 -1
  47. package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
  48. package/src/registry-worker/billing/provider.ts +8 -0
  49. package/src/registry-worker/billing/routes.ts +2 -1
  50. package/src/registry-worker/billing/subscriptions.ts +114 -4
  51. package/src/registry-worker/core.ts +22 -0
  52. package/src/registry-worker/devices/policy.ts +4 -5
  53. package/src/registry-worker/devices/routes.ts +8 -8
  54. package/src/registry-worker/feature-access.ts +165 -0
  55. package/src/registry-worker/feature-flags/admin.ts +300 -0
  56. package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
  57. package/src/registry-worker/feature-flags/routes.ts +102 -0
  58. package/src/registry-worker/features.ts +9 -0
  59. package/src/registry-worker/feedback/routes.ts +2 -2
  60. package/src/registry-worker/flags.ts +80 -18
  61. package/src/registry-worker/github/checks.ts +4 -4
  62. package/src/registry-worker/hooks.ts +9 -0
  63. package/src/registry-worker/identity/oidc.test.ts +103 -0
  64. package/src/registry-worker/identity/oidc.ts +176 -0
  65. package/src/registry-worker/index.ts +477 -174
  66. package/src/registry-worker/jira/connection.ts +111 -0
  67. package/src/registry-worker/jira/jira.test.ts +404 -0
  68. package/src/registry-worker/jira/routes.ts +432 -0
  69. package/src/registry-worker/jira/workflow.ts +577 -0
  70. package/src/registry-worker/jobs/retention.ts +21 -5
  71. package/src/registry-worker/mcp/tools.ts +2 -1
  72. package/src/registry-worker/notifications/alerts.ts +4 -4
  73. package/src/registry-worker/notifications/notifications.test.ts +11 -0
  74. package/src/registry-worker/notifications/routes.ts +9 -10
  75. package/src/registry-worker/notifications/teams.ts +6 -2
  76. package/src/registry-worker/org/routes.ts +4 -4
  77. package/src/registry-worker/projects/identity.ts +194 -0
  78. package/src/registry-worker/projects/inactivity.ts +162 -0
  79. package/src/registry-worker/projects/projects.test.ts +283 -0
  80. package/src/registry-worker/proposals/proposals.test.ts +80 -0
  81. package/src/registry-worker/proposals/routes.ts +183 -0
  82. package/src/registry-worker/roles/roles.test.ts +84 -0
  83. package/src/registry-worker/roles/routes.ts +141 -0
  84. package/src/registry-worker/runner/dispatch.ts +1 -0
  85. package/src/registry-worker/runner/routes.ts +22 -5
  86. package/src/registry-worker/runner/runner.test.ts +21 -0
  87. package/src/registry-worker/runner/tokens.ts +8 -0
  88. package/src/registry-worker/secrets/crypto.ts +135 -0
  89. package/src/registry-worker/secrets/routes.ts +296 -0
  90. package/src/registry-worker/secrets/secrets.test.ts +237 -0
  91. package/src/registry-worker/signup/policy.ts +2 -2
  92. package/src/registry-worker/signup/routes.ts +12 -3
  93. package/src/registry-worker/sso/membership.ts +4 -2
  94. package/src/registry-worker/sso/routes.ts +24 -8
  95. package/src/registry-worker/sso/sso.test.ts +3 -2
  96. package/src/registry-worker/task-approval.test.ts +67 -0
  97. package/src/registry-worker/task-resume.test.ts +196 -0
  98. package/src/registry-worker/task-review.test.ts +121 -0
  99. package/src/runtime/appium-driver.ts +1 -1
  100. package/src/runtime/apply-proposals.test.ts +74 -0
  101. package/src/runtime/apply-proposals.ts +41 -0
  102. package/src/runtime/cloud-secrets.test.ts +201 -0
  103. package/src/runtime/cloud-secrets.ts +210 -0
  104. package/src/runtime/config-validate.ts +12 -9
  105. package/src/runtime/cursor-driver-run.ts +14 -3
  106. package/src/runtime/discovery-task.test.ts +26 -1
  107. package/src/runtime/discovery-task.ts +67 -6
  108. package/src/runtime/executor.ts +18 -10
  109. package/src/runtime/explorer.test.ts +32 -0
  110. package/src/runtime/explorer.ts +48 -0
  111. package/src/runtime/flow-language.ts +22 -8
  112. package/src/runtime/init-wizard.ts +112 -6
  113. package/src/runtime/journey-editor.ts +39 -2
  114. package/src/runtime/journey-graph.test.ts +18 -0
  115. package/src/runtime/local-registry-stub.test.ts +64 -0
  116. package/src/runtime/local-registry-stub.ts +255 -7
  117. package/src/runtime/local-vault.ts +65 -0
  118. package/src/runtime/pipeline.test.ts +23 -0
  119. package/src/runtime/pipeline.ts +61 -20
  120. package/src/runtime/planner.test.ts +24 -1
  121. package/src/runtime/planner.ts +88 -23
  122. package/src/runtime/playwright-driver.test.ts +21 -2
  123. package/src/runtime/playwright-driver.ts +49 -7
  124. package/src/runtime/project-identity.test.ts +161 -0
  125. package/src/runtime/project-identity.ts +232 -0
  126. package/src/runtime/project-roles.test.ts +107 -0
  127. package/src/runtime/project-roles.ts +158 -0
  128. package/src/runtime/propose-cli.ts +66 -0
  129. package/src/runtime/record-run-verdicts.test.ts +58 -0
  130. package/src/runtime/record-run-verdicts.ts +38 -4
  131. package/src/runtime/reporter.ts +1 -1
  132. package/src/runtime/reset.ts +2 -0
  133. package/src/runtime/roles-cli.test.ts +47 -0
  134. package/src/runtime/roles-cli.ts +60 -0
  135. package/src/runtime/run-once.ts +68 -5
  136. package/src/runtime/scenario-matrix.test.ts +41 -0
  137. package/src/runtime/scenario-matrix.ts +98 -0
  138. package/src/runtime/secret-driver.ts +77 -0
  139. package/src/runtime/secret-redaction.ts +69 -0
  140. package/src/runtime/secret-sources.test.ts +442 -0
  141. package/src/runtime/secret-sources.ts +176 -0
  142. package/src/runtime/secrets-cli.ts +146 -0
  143. package/src/runtime/serve-worker.ts +64 -6
  144. package/src/runtime/site-discovery.test.ts +47 -4
  145. package/src/runtime/site-discovery.ts +79 -9
  146. package/src/runtime/vault-federation.test.ts +37 -0
  147. package/src/runtime/vault-federation.ts +128 -0
  148. package/src/runtime/web-suite.ts +5 -2
  149. package/src/storage/create-object-store.ts +5 -1
  150. package/src/storage/object-store.ts +12 -1
  151. package/src/storage/test-case-results.test.ts +64 -0
  152. package/src/storage/test-case-results.ts +49 -0
  153. package/src/vendor/byok.ts +371 -0
@@ -0,0 +1,77 @@
1
+ // Gives a flow the secrets it types, from wherever the project keeps them
2
+ // (see secret-sources.ts), for exactly as long as the flow runs: each value is
3
+ // read, attached to that flow's driver context as BUGMOLE_FLOW_SECRET_<NAME>,
4
+ // and registered for redaction. Keeping resolved values off process.env makes
5
+ // concurrent browser/device targets safe.
6
+
7
+ import fs from "node:fs/promises";
8
+ import type { AppDriver, DriverContext, DriverResult } from "./driver.js";
9
+ import { FLOW_SECRET_ENV_PREFIX, referencedSecretNames } from "./flow-language.js";
10
+ import { redactSecrets, registerSecretValue } from "./secret-redaction.js";
11
+ import { defaultLocalReference, describeReference, resolveSecret, SOURCE_LABEL, type SecretDeps, type SecretReference } from "./secret-sources.js";
12
+
13
+ export type FlowSecretOptions = {
14
+ map: Record<string, SecretReference>;
15
+ projectId: string;
16
+ deps?: SecretDeps;
17
+ /**
18
+ * Look in this machine's secure store for names the map doesn't list (default
19
+ * true). Off on a cloud runner, which has none: the driver reports
20
+ * the missing secret itself.
21
+ */
22
+ localFallback?: boolean;
23
+ };
24
+
25
+ function blocked(message: string): DriverResult {
26
+ return { status: "blocked", message, artifacts: [], failureCode: "secret_unavailable", recoverable: false };
27
+ }
28
+
29
+ /**
30
+ * Wraps a driver so each flow gets the secrets it references. A name already
31
+ * set in the process environment (BUGMOLE_FLOW_SECRET_<NAME>, or a Bugmole
32
+ * Cloud runner's released secrets) is used as is; resolved names are carried
33
+ * in the individual DriverContext.
34
+ */
35
+ export function withFlowSecrets(driver: AppDriver, options: FlowSecretOptions): AppDriver {
36
+ return {
37
+ id: driver.id,
38
+ supports: (platform) => driver.supports(platform),
39
+ async run(context: DriverContext): Promise<DriverResult> {
40
+ let content: string;
41
+ try {
42
+ content = await fs.readFile(context.flowPath, "utf8");
43
+ } catch {
44
+ return driver.run(context);
45
+ }
46
+ const env = options.deps?.env ?? process.env;
47
+ const needed = referencedSecretNames(content).filter((name) => !env[`${FLOW_SECRET_ENV_PREFIX}${name}`]);
48
+ if (!needed.length) return driver.run(context);
49
+
50
+ const values: Record<string, string> = {};
51
+ for (const name of needed) {
52
+ const mapped = options.map[name];
53
+ if (!mapped && options.localFallback === false) continue;
54
+ const reference = mapped ?? defaultLocalReference(options.projectId, name);
55
+ try {
56
+ values[name] = await resolveSecret(reference, options.deps);
57
+ } catch (error) {
58
+ const reason = error instanceof Error ? error.message : String(error);
59
+ return blocked(
60
+ `The flow types {{secret.${name}}}, but ${SOURCE_LABEL[reference.kind]} (${describeReference(reference)}) couldn't supply it: ${redactSecrets(reason)}.`
61
+ + (reference.kind === "local" ? ` Store it with: bugmole secrets set ${name}` : ""),
62
+ );
63
+ }
64
+ }
65
+
66
+ // Keep resolved values on this flow's context instead of mutating the
67
+ // process environment. Browser/device matrices run flows concurrently;
68
+ // global variables would let one target remove another target's secret.
69
+ const environment = { ...(context.environment ?? {}) };
70
+ for (const [name, value] of Object.entries(values)) {
71
+ environment[`${FLOW_SECRET_ENV_PREFIX}${name}`] = value;
72
+ registerSecretValue(value);
73
+ }
74
+ return driver.run({ ...context, environment });
75
+ },
76
+ };
77
+ }
@@ -0,0 +1,69 @@
1
+ // Keeps flow secrets out of everything a run leaves behind. Every secret value
2
+ // a worker types (or is handed by the registry for a cloud run) is remembered
3
+ // here for the life of the process, and logs, step results, manifests,
4
+ // uploads and registry updates pass through redactSecrets before they leave.
5
+
6
+ /** What a secret value is replaced with. */
7
+ export const REDACTED = "[secret]";
8
+
9
+ /**
10
+ * Values shorter than this are not redacted: replacing every "1" or "ok" in a
11
+ * log would destroy it. A secret that short is not a real password anyway.
12
+ */
13
+ export const MIN_REDACTED_LENGTH = 4;
14
+
15
+ const values = new Set<string>();
16
+
17
+ /** Remembers a secret value so it is redacted from then on. */
18
+ export function registerSecretValue(value: string | undefined | null): void {
19
+ if (typeof value !== "string" || value.length < MIN_REDACTED_LENGTH) return;
20
+ values.add(value);
21
+ }
22
+
23
+ /** Forgets every remembered value; for tests and for a runner finishing its job. */
24
+ export function forgetSecretValues(): void {
25
+ values.clear();
26
+ }
27
+
28
+ /** Whether any secret value is currently remembered. */
29
+ export function hasSecretValues(): boolean {
30
+ return values.size > 0;
31
+ }
32
+
33
+ /** The forms a value can take in text: as typed, JSON-escaped, and URL-encoded. */
34
+ function variants(value: string): string[] {
35
+ const forms = new Set([value, JSON.stringify(value).slice(1, -1), encodeURIComponent(value)]);
36
+ return [...forms].filter((form) => form.length >= MIN_REDACTED_LENGTH);
37
+ }
38
+
39
+ /** Replaces every remembered secret value in `text` with "[secret]". */
40
+ export function redactSecrets(text: string): string {
41
+ if (values.size === 0 || !text) return text;
42
+ // Longest first, so a secret that contains another is replaced whole.
43
+ const forms = [...values].flatMap(variants).sort((a, b) => b.length - a.length);
44
+ let result = text;
45
+ for (const form of forms) {
46
+ if (result.includes(form)) result = result.split(form).join(REDACTED);
47
+ }
48
+ return result;
49
+ }
50
+
51
+ const TEXT_TYPES = /^(text\/|application\/(json|x-ndjson|xml|yaml|x-yaml|javascript)|[^;]*\+json)/i;
52
+
53
+ /** Whether an upload of this content type is text that could carry a secret. */
54
+ export function isTextContentType(contentType: string | undefined): boolean {
55
+ return Boolean(contentType && TEXT_TYPES.test(contentType.trim()));
56
+ }
57
+
58
+ /**
59
+ * Redacts an upload body when it is text. Binary evidence (screenshots,
60
+ * video, trace archives) is passed through unchanged.
61
+ */
62
+ export function redactBody(body: string | Uint8Array, contentType?: string): string | Uint8Array {
63
+ if (values.size === 0) return body;
64
+ if (typeof body === "string") return redactSecrets(body);
65
+ if (!isTextContentType(contentType)) return body;
66
+ const text = new TextDecoder().decode(body);
67
+ const redacted = redactSecrets(text);
68
+ return redacted === text ? body : new TextEncoder().encode(redacted);
69
+ }
@@ -0,0 +1,442 @@
1
+ import assert from "node:assert/strict";
2
+ import { generateKeyPairSync } from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import http from "node:http";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import test from "node:test";
8
+ import { chromium } from "playwright";
9
+ import { deleteLocal, parseSecretReference, resolveSecret, secretMap, secretStatuses, writeLocal, type SecretDeps } from "./secret-sources.js";
10
+ import { openLocalVault, resetLocalVault, type LocalVaultLike } from "./local-vault.js";
11
+ import { BYOK_HEADER, byokCopy } from "../../scripts/sync-byok.mjs";
12
+ import { withFlowSecrets } from "./secret-driver.js";
13
+ import { resolveVaultReferences, missingSecretsMessage } from "./cloud-secrets.js";
14
+ import { runSecretsCommand } from "./secrets-cli.js";
15
+ import { registerSecretValue, redactSecrets, forgetSecretValues } from "./secret-redaction.js";
16
+ import type { AppDriver, DriverContext } from "./driver.js";
17
+
18
+ test("the vendored resolver is the synced copy, and references parse through it", async () => {
19
+ const copy = await fs.readFile(byokCopy, "utf8");
20
+ assert.ok(copy.startsWith(BYOK_HEADER), "run node scripts/sync-byok.mjs");
21
+ assert.deepEqual(parseSecretReference("awssm://us-east-1/qa/app#password"), { kind: "aws", region: "us-east-1", secretId: "qa/app", field: "password" });
22
+ assert.equal(parseSecretReference("arcana://acme/pw"), null, "only sources the CLI reads by itself");
23
+ });
24
+
25
+ test("the config's map keeps references and never echoes a pasted value", () => {
26
+ const { map, problems } = secretMap({ secrets: { APP_PASSWORD: "keychain://bugmole:acme/APP_PASSWORD", LEAKED: "correct horse battery staple", "bad-name": "env://X" } });
27
+ assert.deepEqual(Object.keys(map), ["APP_PASSWORD"]);
28
+ assert.equal(problems.length, 2);
29
+ assert.ok(problems.every((problem) => !problem.includes("correct horse")));
30
+ assert.deepEqual(secretMap({}).map, {});
31
+ assert.deepEqual(secretMap({ secrets: ["x"] }).problems, ["secrets must map each name to a reference"]);
32
+ });
33
+
34
+ test("reads AWS Secrets Manager with a signed GetSecretValue, and one field of a JSON secret", async () => {
35
+ const calls: Array<{ url: string; init: RequestInit }> = [];
36
+ const deps: SecretDeps = {
37
+ env: { AWS_ACCESS_KEY_ID: "AKIDEXAMPLE", AWS_SECRET_ACCESS_KEY: "secret-key" },
38
+ fetch: (async (url: string, init: RequestInit) => {
39
+ calls.push({ url, init });
40
+ return new Response(JSON.stringify({ SecretString: JSON.stringify({ password: "s3cr3t-pw", user: "qa" }) }), { status: 200 });
41
+ }) as typeof fetch,
42
+ };
43
+ assert.equal(await resolveSecret(parseSecretReference("awssm://us-east-1/qa/app#password")!, deps), "s3cr3t-pw");
44
+ assert.equal(calls[0].url, "https://secretsmanager.us-east-1.amazonaws.com/");
45
+ const headers = calls[0].init.headers as Record<string, string>;
46
+ assert.equal(headers["x-amz-target"], "secretsmanager.GetSecretValue");
47
+ assert.match(headers.authorization, /^AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE\/\d{8}\/us-east-1\/secretsmanager\/aws4_request/);
48
+ assert.equal(calls[0].init.body, JSON.stringify({ SecretId: "qa/app" }));
49
+ await assert.rejects(resolveSecret(parseSecretReference("awssm://us-east-1/qa/app#nope")!, deps), /no text field "nope"/);
50
+ await assert.rejects(
51
+ resolveSecret(parseSecretReference("awssm://us-east-1/qa/app")!, { env: {}, allowCli: false }),
52
+ /No AWS credentials/,
53
+ );
54
+ });
55
+
56
+ test("reads Google Secret Manager with gcloud's token, or a service account key", async () => {
57
+ const urls: string[] = [];
58
+ const fetchStub = (async (url: string, init?: RequestInit) => {
59
+ urls.push(url);
60
+ if (url.includes("oauth2")) {
61
+ assert.match(String(init?.body), /grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=/);
62
+ return new Response(JSON.stringify({ access_token: "sa-token" }), { status: 200 });
63
+ }
64
+ const token = new Headers(init?.headers).get("authorization");
65
+ return new Response(JSON.stringify({ payload: { data: Buffer.from(`value-for:${token}`).toString("base64") } }), { status: 200 });
66
+ }) as typeof fetch;
67
+ const reference = parseSecretReference("gcpsm://projects/acme/secrets/qa")!;
68
+ assert.equal(await resolveSecret(reference, { env: {}, fetch: fetchStub, exec: async () => "gcloud-token\n" }), "value-for:Bearer gcloud-token");
69
+ assert.equal(urls[0], "https://secretmanager.googleapis.com/v1/projects/acme/secrets/qa/versions/latest:access");
70
+
71
+ const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
72
+ const key = JSON.stringify({ client_email: "qa@acme.iam.gserviceaccount.com", private_key: privateKey.export({ type: "pkcs8", format: "pem" }) });
73
+ assert.equal(await resolveSecret(reference, { env: { VAULT_GCP_SERVICE_ACCOUNT_KEY: key }, googleKeyVariable: "VAULT_GCP_SERVICE_ACCOUNT_KEY", fetch: fetchStub }), "value-for:Bearer sa-token");
74
+ });
75
+
76
+ /** An in-memory stand-in for this machine's vault. */
77
+ function memoryVault(initial: Record<string, string> = {}): LocalVaultLike & { entries: Map<string, string> } {
78
+ const entries = new Map(Object.entries(initial));
79
+ return {
80
+ entries,
81
+ get: (name) => entries.get(name) ?? null,
82
+ set: (name, value) => { entries.set(name, value); },
83
+ delete: (name) => entries.delete(name),
84
+ has: (name) => entries.has(name),
85
+ list: () => [...entries.keys()].sort(),
86
+ };
87
+ }
88
+
89
+ test("the local secure store: the vault per project, or the OS keychain where there's no vault", async () => {
90
+ const vault = memoryVault();
91
+ const reference = parseSecretReference("local://PIN", "acme")!;
92
+ assert.deepEqual(reference, { kind: "local", project: "acme", name: "PIN" });
93
+ assert.equal(await writeLocal(reference as Extract<typeof reference, { kind: "local" }>, "4321", { localVault: vault }), "vault");
94
+ assert.deepEqual([...vault.entries.keys()], ["acme/PIN"]);
95
+ assert.equal(await resolveSecret(reference, { localVault: vault }), "4321");
96
+ await assert.rejects(resolveSecret(parseSecretReference("local://OTHER", "acme")!, { localVault: vault }), /OTHER isn't in this machine's secure store/);
97
+
98
+ // No vault installed: the keychain, with the value on stdin, never in arguments.
99
+ const calls: Array<{ args: string[]; input?: string }> = [];
100
+ const keychain: SecretDeps = { localVault: null, platform: "darwin", exec: async (_file, args, options) => { calls.push({ args, input: options?.input }); return "pw\n"; } };
101
+ assert.equal(await writeLocal({ kind: "local", project: "acme", name: "PIN" }, 'p"w\\1', keychain), "keychain");
102
+ assert.deepEqual(calls[0].args, ["-i"]);
103
+ assert.equal(calls[0].input, 'add-generic-password -U -s "bugmole:acme" -a "PIN" -l "Bugmole PIN" -w "p\\"w\\\\1"\n');
104
+ assert.equal(await resolveSecret({ kind: "local", project: "acme", name: "PIN" }, keychain), "pw");
105
+ assert.equal(await deleteLocal({ kind: "local", project: "acme", name: "PIN" }, { localVault: vault }), true);
106
+ });
107
+
108
+ test("the real secure store, where this machine has one (the vault package and a credential store)", async (t) => {
109
+ const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-vault-"));
110
+ resetLocalVault();
111
+ try {
112
+ const env = { ...process.env, BUGMOLE_DATA_DIR: dataDir };
113
+ const vault = await openLocalVault(env);
114
+ if (!vault) {
115
+ t.skip("no vault package or credential store on this machine");
116
+ return;
117
+ }
118
+ const reference = { kind: "local" as const, project: "bugmole-test", name: `PW_${process.pid}` };
119
+ assert.equal(await writeLocal(reference, "real-vault-value", { env }), "vault");
120
+ assert.equal(await resolveSecret(reference, { env }), "real-vault-value");
121
+ const onDisk = await fs.readFile(path.join(dataDir, "vault", "vault.json"), "utf8");
122
+ assert.ok(!onDisk.includes("real-vault-value"));
123
+ assert.equal(await deleteLocal(reference, { env }), true);
124
+ } finally {
125
+ resetLocalVault();
126
+ await fs.rm(dataDir, { recursive: true, force: true });
127
+ }
128
+ });
129
+
130
+ test("status says where each secret comes from without reading any value", async () => {
131
+ const read: string[][] = [];
132
+ const statuses = await secretStatuses(
133
+ ["APP_PASSWORD", "PIN", "LOCAL", "TOKEN", "GONE"],
134
+ {
135
+ APP_PASSWORD: parseSecretReference("awssm://us-east-1/qa/app")!,
136
+ TOKEN: parseSecretReference("env://CI_TOKEN")!,
137
+ GONE: parseSecretReference("gcpsm://projects/a/secrets/b")!,
138
+ },
139
+ "acme",
140
+ {
141
+ env: { BUGMOLE_FLOW_SECRET_PIN: "1234" },
142
+ localVault: memoryVault({ "acme/LOCAL": "x" }),
143
+ platform: "darwin",
144
+ exec: async (file, args) => {
145
+ read.push([file, ...args]);
146
+ if (args.includes("-w") || args.includes("print-access-token") || file === "aws") throw new Error("must not read a value");
147
+ if (file === "gcloud") return "";
148
+ if (args.includes("LOCAL")) return "attributes";
149
+ throw new Error("missing");
150
+ },
151
+ },
152
+ );
153
+ const byName = Object.fromEntries(statuses.map((status) => [status.name, status]));
154
+ assert.equal(byName.APP_PASSWORD.source, "aws");
155
+ assert.equal(byName.PIN.ready, true);
156
+ assert.equal(byName.LOCAL.source, "local");
157
+ assert.equal(byName.LOCAL.ready, true);
158
+ assert.equal(byName.TOKEN.ready, false);
159
+ assert.equal(byName.GONE.ready, false);
160
+ assert.ok(!read.some((call) => call.includes("-w")));
161
+ });
162
+
163
+ const flowContext = async (flow: string) => {
164
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-secret-flow-"));
165
+ const flowPath = path.join(directory, "flow.yaml");
166
+ await fs.writeFile(flowPath, flow);
167
+ const context: DriverContext = { runId: "r", projectId: "acme", journeyId: "j", flowPath, platform: "web", target: {}, timeoutMs: 5000, artifactsDir: directory };
168
+ return { directory, context };
169
+ };
170
+
171
+ const recordingDriver = (seen: Array<Record<string, string | undefined>>, env: Record<string, string | undefined>): AppDriver => ({
172
+ id: "playwright",
173
+ supports: () => true,
174
+ async run(context) {
175
+ const runEnv = { ...env, ...(context.environment ?? {}) };
176
+ seen.push({ PW: runEnv.BUGMOLE_FLOW_SECRET_PW, PIN: runEnv.BUGMOLE_FLOW_SECRET_PIN });
177
+ return { status: "passed", message: "ok", artifacts: [] };
178
+ },
179
+ });
180
+
181
+ test("a flow gets its secrets for the run only, and a missing one blocks it with the reason", async () => {
182
+ const { directory, context } = await flowContext('url: https://a.test\n---\n- inputText: "{{secret.PW}}"\n- inputText: "{{secret.PIN}}"\n');
183
+ try {
184
+ const env: Record<string, string | undefined> = { CI_PIN: "4321", BUGMOLE_FLOW_SECRET_PW: "already-set" };
185
+ const seen: Array<Record<string, string | undefined>> = [];
186
+ const wrapped = withFlowSecrets(recordingDriver(seen, env), { map: { PIN: parseSecretReference("env://CI_PIN")! }, projectId: "acme", deps: { env } });
187
+ assert.equal((await wrapped.run(context)).status, "passed");
188
+ assert.deepEqual(seen, [{ PW: "already-set", PIN: "4321" }]);
189
+ assert.equal(env.BUGMOLE_FLOW_SECRET_PIN, undefined, "taken out after the run");
190
+ assert.equal(redactSecrets("typed 4321"), "typed [secret]");
191
+ forgetSecretValues();
192
+
193
+ delete env.CI_PIN;
194
+ const missing = await wrapped.run(context);
195
+ assert.equal(missing.status, "blocked");
196
+ assert.equal(missing.failureCode, "secret_unavailable");
197
+ assert.match(missing.message, /\{\{secret\.PIN\}\}.*an environment variable \(env:\/\/CI_PIN\)/);
198
+
199
+ // On a cloud runner an unlisted name isn't looked for in a keychain; the driver reports it.
200
+ delete env.BUGMOLE_FLOW_SECRET_PW;
201
+ const cloud = withFlowSecrets(recordingDriver(seen, env), { map: {}, projectId: "acme", deps: { env }, localFallback: false });
202
+ assert.equal((await cloud.run(context)).status, "passed");
203
+ } finally {
204
+ await fs.rm(directory, { recursive: true, force: true });
205
+ }
206
+ });
207
+
208
+ test("parallel flow runs keep resolved secrets on their own driver context", async () => {
209
+ const { directory, context } = await flowContext('url: https://a.test\n---\n- inputText: "{{secret.PIN}}"\n');
210
+ try {
211
+ const seen: Array<string | undefined> = [];
212
+ const driver: AppDriver = {
213
+ id: "playwright",
214
+ supports: () => true,
215
+ async run(runContext) {
216
+ await new Promise((resolve) => setTimeout(resolve, 10));
217
+ seen.push(runContext.environment?.BUGMOLE_FLOW_SECRET_PIN);
218
+ return { status: "passed", message: "ok", artifacts: [] };
219
+ },
220
+ };
221
+ const env: Record<string, string | undefined> = { CI_PIN: "4321" };
222
+ const wrapped = withFlowSecrets(driver, {
223
+ map: { PIN: parseSecretReference("env://CI_PIN")! },
224
+ projectId: "acme",
225
+ deps: { env },
226
+ });
227
+ await Promise.all([wrapped.run(context), wrapped.run({ ...context, runId: "second" })]);
228
+ assert.deepEqual(seen, ["4321", "4321"]);
229
+ assert.equal(env.BUGMOLE_FLOW_SECRET_PIN, undefined);
230
+ } finally {
231
+ await fs.rm(directory, { recursive: true, force: true });
232
+ }
233
+ });
234
+
235
+ test("end to end: a flow typing one secret from the environment and one resolved from a vault gets both", async (t) => {
236
+ try {
237
+ await fs.access(chromium.executablePath());
238
+ } catch {
239
+ t.skip("Chromium is not installed");
240
+ return;
241
+ }
242
+ const typed: string[] = [];
243
+ const server = http.createServer((request, response) => {
244
+ if (request.method === "POST") {
245
+ let body = "";
246
+ request.on("data", (chunk) => { body += chunk; });
247
+ request.on("end", () => {
248
+ const form = new URLSearchParams(body);
249
+ typed.push(`${form.get("email")}|${form.get("password")}`);
250
+ response.writeHead(200, { "content-type": "text/html" });
251
+ response.end("<h1>Welcome back</h1>");
252
+ });
253
+ return;
254
+ }
255
+ response.writeHead(200, { "content-type": "text/html" });
256
+ response.end('<form method="post"><label>Email <input name="email"></label><label>Password <input name="password" type="password"></label><button>Sign in</button></form>');
257
+ });
258
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
259
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-mixed-secrets-"));
260
+ // As in CI or on a cloud runner: one secret is already in the environment.
261
+ process.env.BUGMOLE_FLOW_SECRET_MIXED_EMAIL = "qa@example.com";
262
+ try {
263
+ const baseUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}/`;
264
+ const flowPath = path.join(directory, "sign-in.yaml");
265
+ await fs.writeFile(flowPath, `url: ${baseUrl}\n---\n- launchApp\n- tapOn: "Email"\n- inputText: "{{secret.MIXED_EMAIL}}"\n- tapOn: "Password"\n- inputText: "{{secret.MIXED_PASSWORD}}"\n- tapOn: "Sign in"\n- assertVisible: "Welcome back"\n`);
266
+ const { PlaywrightDriver } = await import("./playwright-driver.js");
267
+ const wrapped = withFlowSecrets(new PlaywrightDriver({ headless: true }), {
268
+ map: {},
269
+ projectId: "acme",
270
+ deps: { localVault: memoryVault({ "acme/MIXED_PASSWORD": "from-the-store" }) },
271
+ });
272
+ const result = await wrapped.run({
273
+ runId: "mixed", projectId: "acme", journeyId: "sign-in", flowPath, platform: "web", target: {}, timeoutMs: 30_000,
274
+ artifactsDir: path.join(directory, "artifacts"),
275
+ });
276
+ assert.equal(result.status, "passed", result.message);
277
+ assert.deepEqual(typed, ["qa@example.com|from-the-store"]);
278
+ } finally {
279
+ delete process.env.BUGMOLE_FLOW_SECRET_MIXED_EMAIL;
280
+ server.close();
281
+ await fs.rm(directory, { recursive: true, force: true });
282
+ forgetSecretValues();
283
+ }
284
+ });
285
+
286
+ test("end to end: a sign-in flow types a password from this machine's secure store, and the value is gone afterwards", async (t) => {
287
+ try {
288
+ await fs.access(chromium.executablePath());
289
+ } catch {
290
+ t.skip("Chromium is not installed");
291
+ return;
292
+ }
293
+ const typed: string[] = [];
294
+ const server = http.createServer((request, response) => {
295
+ if (request.method === "POST") {
296
+ let body = "";
297
+ request.on("data", (chunk) => { body += chunk; });
298
+ request.on("end", () => {
299
+ typed.push(new URLSearchParams(body).get("password") ?? "");
300
+ response.writeHead(200, { "content-type": "text/html" });
301
+ response.end("<h1>Welcome back</h1>");
302
+ });
303
+ return;
304
+ }
305
+ response.writeHead(200, { "content-type": "text/html" });
306
+ response.end('<form method="post"><label>Password <input name="password" type="password"></label><button>Sign in</button></form>');
307
+ });
308
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
309
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-secret-e2e-"));
310
+ try {
311
+ const baseUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}/`;
312
+ const flowPath = path.join(directory, "sign-in.yaml");
313
+ await fs.writeFile(flowPath, `url: ${baseUrl}\n---\n- launchApp\n- tapOn: "Password"\n- inputText: "{{secret.APP_PASSWORD}}"\n- tapOn: "Sign in"\n- assertVisible: "Welcome back"\n`);
314
+ const { PlaywrightDriver } = await import("./playwright-driver.js");
315
+ // `bugmole secrets set APP_PASSWORD` put the value in this machine's store.
316
+ const store: SecretDeps = { localVault: memoryVault({ "acme/APP_PASSWORD": "kept-in-keychain" }) };
317
+ const wrapped = withFlowSecrets(new PlaywrightDriver({ headless: true }), { map: {}, projectId: "acme", deps: store });
318
+ const result = await wrapped.run({
319
+ runId: "e2e", projectId: "acme", journeyId: "sign-in", flowPath, platform: "web", target: {}, timeoutMs: 30_000,
320
+ artifactsDir: path.join(directory, "artifacts"), sessionStatePath: path.join(directory, "session.json"),
321
+ });
322
+ assert.equal(result.status, "passed", result.message);
323
+ assert.deepEqual(typed, ["kept-in-keychain"]);
324
+ assert.equal(process.env.BUGMOLE_FLOW_SECRET_APP_PASSWORD, undefined, "taken out after the flow");
325
+ assert.ok(!JSON.stringify(result).includes("kept-in-keychain"), "never in the result");
326
+ await fs.access(path.join(directory, "session.json"));
327
+ } finally {
328
+ server.close();
329
+ await fs.rm(directory, { recursive: true, force: true });
330
+ forgetSecretValues();
331
+ }
332
+ });
333
+
334
+ test("Bugmole Cloud reads gcpsm:// and awssm:// values by trusting a Bugmole identity token, never a stored key", async () => {
335
+ const provider = "projects/123/locations/global/workloadIdentityPools/bugmole/providers/bugmole";
336
+ const audiences: string[] = [];
337
+ const calls: string[] = [];
338
+ const identityToken = async (audience: string) => { audiences.push(audience); return `bugmole-jwt-for-${audience}`; };
339
+ const fetchStub = (async (url: string, init?: RequestInit) => {
340
+ calls.push(url);
341
+ if (url === "https://sts.googleapis.com/v1/token") {
342
+ const body = JSON.parse(String(init?.body));
343
+ assert.equal(body.subjectToken, `bugmole-jwt-for-//iam.googleapis.com/${provider}`);
344
+ assert.equal(body.audience, `//iam.googleapis.com/${provider}`);
345
+ return new Response(JSON.stringify({ access_token: "federated-google-token" }), { status: 200 });
346
+ }
347
+ if (url.startsWith("https://iamcredentials.googleapis.com/")) {
348
+ assert.equal(new Headers(init?.headers).get("authorization"), "Bearer federated-google-token");
349
+ return new Response(JSON.stringify({ accessToken: "service-account-token" }), { status: 200 });
350
+ }
351
+ if (url.startsWith("https://secretmanager.googleapis.com/")) {
352
+ const auth = new Headers(init?.headers).get("authorization");
353
+ return new Response(JSON.stringify({ payload: { data: Buffer.from(`google-value:${auth}`).toString("base64") } }), { status: 200 });
354
+ }
355
+ if (url === "https://sts.amazonaws.com/") {
356
+ const form = new URLSearchParams(String(init?.body));
357
+ assert.equal(form.get("Action"), "AssumeRoleWithWebIdentity");
358
+ assert.equal(form.get("WebIdentityToken"), "bugmole-jwt-for-sts.amazonaws.com");
359
+ assert.equal(form.get("RoleArn"), "arn:aws:iam::123456789012:role/bugmole-reader");
360
+ return new Response("<AssumeRoleWithWebIdentityResponse><Credentials><AccessKeyId>ASIATEMP</AccessKeyId><SecretAccessKey>temp-secret</SecretAccessKey><SessionToken>temp-session</SessionToken></Credentials></AssumeRoleWithWebIdentityResponse>", { status: 200 });
361
+ }
362
+ if (url.startsWith("https://secretsmanager.")) {
363
+ assert.match((init?.headers as Record<string, string>).authorization, /Credential=ASIATEMP\//);
364
+ assert.equal((init?.headers as Record<string, string>)["x-amz-security-token"], "temp-session");
365
+ return new Response(JSON.stringify({ SecretString: "aws-value" }), { status: 200 });
366
+ }
367
+ throw new Error(`unexpected ${url}`);
368
+ }) as typeof fetch;
369
+
370
+ const resolved = await resolveVaultReferences({
371
+ available: true,
372
+ unreadable: [],
373
+ secrets: {
374
+ APP_PASSWORD: "gcpsm://projects/acme/secrets/qa",
375
+ ADMIN_TOKEN: "gcpsm://projects/acme/secrets/admin",
376
+ API_KEY: "awssm://us-east-1/qa/app",
377
+ PLAIN: "typed-as-is",
378
+ PIN: "local://PIN",
379
+ VAULT_GCP_WORKLOAD_PROVIDER: provider,
380
+ VAULT_AWS_ROLE_ARN: "arn:aws:iam::123456789012:role/bugmole-reader",
381
+ },
382
+ }, { fetch: fetchStub, identityToken, sessionName: "bugmole-run-1" });
383
+ assert.deepEqual(resolved.secrets, {
384
+ PLAIN: "typed-as-is",
385
+ APP_PASSWORD: "google-value:Bearer federated-google-token",
386
+ ADMIN_TOKEN: "google-value:Bearer federated-google-token",
387
+ API_KEY: "aws-value",
388
+ });
389
+ assert.deepEqual(audiences, [`//iam.googleapis.com/${provider}`, "sts.amazonaws.com"], "one sign-in per cloud, shared by its secrets");
390
+ assert.match(resolved.vaultErrors!.PIN, /only be read on your own machine/);
391
+ assert.equal(redactSecrets("token federated-google-token"), "token [secret]");
392
+ forgetSecretValues();
393
+
394
+ // A named service account is impersonated with the federated token.
395
+ const impersonated = await resolveVaultReferences({
396
+ available: true, unreadable: [],
397
+ secrets: { APP_PASSWORD: "gcpsm://projects/acme/secrets/qa", VAULT_GCP_WORKLOAD_PROVIDER: provider, VAULT_GCP_SERVICE_ACCOUNT: "reader@acme.iam.gserviceaccount.com" },
398
+ }, { fetch: fetchStub, identityToken });
399
+ assert.equal(impersonated.secrets.APP_PASSWORD, "google-value:Bearer service-account-token");
400
+
401
+ // Stored keys aren't a way in; the message says what to set instead.
402
+ const keyed = await resolveVaultReferences({
403
+ available: true, unreadable: [],
404
+ secrets: { APP_PASSWORD: "gcpsm://projects/acme/secrets/qa", API_KEY: "awssm://us-east-1/qa/app", VAULT_GCP_SERVICE_ACCOUNT_KEY: "{}", VAULT_AWS_ACCESS_KEY_ID: "AKID", VAULT_AWS_SECRET_ACCESS_KEY: "x" },
405
+ }, { fetch: fetchStub, identityToken });
406
+ assert.match(keyed.vaultErrors!.APP_PASSWORD, /Set VAULT_GCP_WORKLOAD_PROVIDER .*a stored service account key isn't used/);
407
+ assert.match(keyed.vaultErrors!.API_KEY, /Set VAULT_AWS_ROLE_ARN .*stored access keys aren't used/);
408
+ assert.match(missingSecretsMessage('- inputText: "{{secret.APP_PASSWORD}}"', keyed)!, /APP_PASSWORD couldn't be read from your vault/);
409
+ forgetSecretValues();
410
+ });
411
+
412
+ test("`bugmole secrets set` stores a piped value for this project and never prints it", async () => {
413
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-secrets-cli-"));
414
+ try {
415
+ await fs.mkdir(path.join(directory, "spec", "flows"), { recursive: true });
416
+ await fs.writeFile(path.join(directory, "spec", "flows", "sign-in.yaml"), '- inputText: "{{secret.APP_PASSWORD}}"\n');
417
+ const vault = memoryVault();
418
+ const deps: SecretDeps = { env: {}, localVault: vault };
419
+ const output: string[] = [];
420
+ const sink = { write: (text: string) => { output.push(text); return true; } } as unknown as NodeJS.WriteStream;
421
+ const stdin = Object.assign((async function* () { yield Buffer.from("piped-pw\n"); })(), { isTTY: false }) as unknown as NodeJS.ReadStream;
422
+ const cfg = { project: { id: "acme" }, runtime: { spec_dir: "./spec" }, secrets: { TOKEN: "env://CI_TOKEN" } };
423
+ const code = await runSecretsCommand(cfg, ["node", "bugmole", "secrets", "set", "APP_PASSWORD"], { workspaceRoot: directory, stdin, stdout: sink, stderr: sink, deps });
424
+ assert.equal(code, 0);
425
+ assert.deepEqual([...vault.entries], [["acme/APP_PASSWORD", "piped-pw"]]);
426
+ assert.ok(!output.join("").includes("piped-pw"));
427
+ assert.equal(await runSecretsCommand(cfg, ["secrets", "set", "TOKEN"], { workspaceRoot: directory, stdout: sink, stderr: sink, deps }), 1, "a name mapped to another source is changed there");
428
+
429
+ output.length = 0;
430
+ await runSecretsCommand(cfg, ["secrets"], { workspaceRoot: directory, stdout: sink, stderr: sink, deps });
431
+ assert.match(output.join(""), /✓ APP_PASSWORD {2}this machine's secure store {2}local:\/\/APP_PASSWORD/);
432
+ assert.match(output.join(""), /✗ TOKEN {2}an environment variable/);
433
+ } finally {
434
+ await fs.rm(directory, { recursive: true, force: true });
435
+ }
436
+ });
437
+
438
+ test("values the redactor knows never leave in a result", () => {
439
+ registerSecretValue("hunter22");
440
+ assert.equal(redactSecrets("Typed hunter22 into Password"), "Typed [secret] into Password");
441
+ forgetSecretValues();
442
+ });