@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,176 @@
1
+ // Where a flow's secrets come from. A flow types `{{secret.NAME}}`, which
2
+ // reads BUGMOLE_FLOW_SECRET_NAME. Setting that variable by hand is the oldest
3
+ // way; this file lets a project keep each value somewhere safe and name it by
4
+ // reference instead, so no password lives in the repository, the config, a
5
+ // chat with an agent, or Bugmole:
6
+ //
7
+ // secrets:
8
+ // ADMIN_TOKEN: gcpsm://projects/acme/secrets/qa-admin # Google Secret Manager
9
+ // API_KEY: awssm://us-east-1/qa/api#key # AWS Secrets Manager (#key reads a JSON field)
10
+ // PIN: keychain://some-service/PIN # an existing OS keychain entry
11
+ // OTHER: env://MY_CI_VARIABLE # an environment variable
12
+ //
13
+ // A name with no reference is read from this machine's secure store, which
14
+ // `bugmole secrets set NAME` writes: an encrypted vault whose one key is in
15
+ // the OS credential store (local-vault.ts), or the OS keychain itself where
16
+ // that vault isn't installed. Cloud vaults are read by the vendored resolver
17
+ // in ../vendor/byok.ts.
18
+
19
+ import {
20
+ deleteKeychain as deleteEntry,
21
+ formatReference,
22
+ keychainHas,
23
+ parseReference,
24
+ readiness,
25
+ resolveReference,
26
+ SOURCE_NAME,
27
+ writeKeychain as writeEntry,
28
+ type ByokDeps,
29
+ type SecretReference as VaultReference,
30
+ } from "../vendor/byok.js";
31
+ import { openLocalVault, type LocalVaultLike } from "./local-vault.js";
32
+
33
+ /** A value in this machine's secure store, for one project. */
34
+ export type LocalReference = { kind: "local"; project: string; name: string };
35
+ export type SecretReference = VaultReference | LocalReference;
36
+
37
+ export type SecretDeps = ByokDeps & {
38
+ /** This machine's vault: undefined opens the real one, null means none is installed. */
39
+ localVault?: LocalVaultLike | null;
40
+ };
41
+
42
+ export const SOURCE_LABEL: Record<SecretReference["kind"], string> = {
43
+ ...SOURCE_NAME,
44
+ local: "this machine's secure store",
45
+ };
46
+ export const SECRET_NAME = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
47
+
48
+ /** Parses a reference; `local://NAME` needs the project it belongs to. */
49
+ export function parseSecretReference(value: unknown, projectId = "default"): SecretReference | null {
50
+ if (typeof value === "string") {
51
+ const local = /^local:\/\/([A-Za-z][A-Za-z0-9_]{0,63})$/.exec(value.trim());
52
+ if (local) return { kind: "local", project: projectId, name: local[1] };
53
+ }
54
+ return parseReference(value);
55
+ }
56
+
57
+ export function describeReference(reference: SecretReference): string {
58
+ return reference.kind === "local" ? `local://${reference.name}` : formatReference(reference);
59
+ }
60
+
61
+ /** Where `bugmole secrets set NAME` keeps a project's value. */
62
+ export function defaultLocalReference(projectId: string, name: string): LocalReference {
63
+ return { kind: "local", project: projectId, name };
64
+ }
65
+
66
+ /** The vault entry name, and the keychain entry used where there is no vault. */
67
+ const entryName = (reference: LocalReference) => `${reference.project}/${reference.name}`;
68
+ const keychainFallback = (reference: LocalReference) => ({ kind: "keychain" as const, service: `bugmole:${reference.project}`, account: reference.name });
69
+
70
+ async function localVault(deps: SecretDeps): Promise<LocalVaultLike | null> {
71
+ return deps.localVault !== undefined ? deps.localVault : openLocalVault(deps.env);
72
+ }
73
+
74
+ export async function resolveSecret(reference: SecretReference, deps: SecretDeps = {}): Promise<string> {
75
+ if (reference.kind !== "local") return resolveReference(reference, deps);
76
+ const vault = await localVault(deps);
77
+ if (vault) {
78
+ const value = vault.get(entryName(reference));
79
+ if (value === null || value === "") throw new Error(`${reference.name} isn't in this machine's secure store`);
80
+ return value;
81
+ }
82
+ try {
83
+ return await resolveReference(keychainFallback(reference), deps);
84
+ } catch {
85
+ throw new Error(`${reference.name} isn't in this machine's secure store`);
86
+ }
87
+ }
88
+
89
+ /** Keeps a value in this machine's secure store; says where it went. */
90
+ export async function writeLocal(reference: LocalReference, value: string, deps: SecretDeps = {}): Promise<"vault" | "keychain"> {
91
+ const vault = await localVault(deps);
92
+ if (vault) {
93
+ vault.set(entryName(reference), value);
94
+ return "vault";
95
+ }
96
+ await writeEntry(keychainFallback(reference), value, `Bugmole ${reference.name}`, deps);
97
+ return "keychain";
98
+ }
99
+
100
+ export async function deleteLocal(reference: LocalReference, deps: SecretDeps = {}): Promise<boolean> {
101
+ const vault = await localVault(deps);
102
+ if (vault) return vault.delete(entryName(reference));
103
+ return deleteEntry(keychainFallback(reference), deps);
104
+ }
105
+
106
+ async function localHas(reference: LocalReference, deps: SecretDeps): Promise<boolean> {
107
+ const vault = await localVault(deps);
108
+ return vault ? vault.has(entryName(reference)) : keychainHas(keychainFallback(reference), deps);
109
+ }
110
+
111
+ /**
112
+ * The project's `secrets:` map from its config, keeping well-formed entries.
113
+ * Anything else is reported, never echoed: it may be a pasted password.
114
+ */
115
+ export function secretMap(cfg: { secrets?: unknown; project?: { id?: string } } | null | undefined): { map: Record<string, SecretReference>; problems: string[] } {
116
+ const map: Record<string, SecretReference> = {};
117
+ const problems: string[] = [];
118
+ const raw = cfg?.secrets;
119
+ if (raw === undefined || raw === null) return { map, problems };
120
+ if (typeof raw !== "object" || Array.isArray(raw)) return { map, problems: ["secrets must map each name to a reference"] };
121
+ for (const [name, value] of Object.entries(raw as Record<string, unknown>)) {
122
+ if (!SECRET_NAME.test(name)) {
123
+ problems.push(`secrets.${name}: a name is letters, digits and _`);
124
+ continue;
125
+ }
126
+ const reference = parseSecretReference(value, cfg?.project?.id ?? "default");
127
+ if (!reference) {
128
+ problems.push(`secrets.${name}: not a reference (gcpsm://, awssm://, keychain://, env:// or local://). Don't put the value itself in the config.`);
129
+ continue;
130
+ }
131
+ map[name] = reference;
132
+ }
133
+ return { map, problems };
134
+ }
135
+
136
+ export type SecretStatus = {
137
+ name: string;
138
+ source: SecretReference["kind"] | "unset";
139
+ reference?: string;
140
+ /** Whether this machine can probably supply it, checked without reading the value. */
141
+ ready: boolean;
142
+ detail: string;
143
+ };
144
+
145
+ /** What this machine can supply for each name, without reading any value. */
146
+ export async function secretStatuses(
147
+ names: readonly string[],
148
+ map: Record<string, SecretReference>,
149
+ projectId: string,
150
+ deps: SecretDeps = {},
151
+ ): Promise<SecretStatus[]> {
152
+ const env = deps.env ?? process.env;
153
+ const statuses: SecretStatus[] = [];
154
+ for (const name of [...new Set(names)].sort()) {
155
+ if (env[`BUGMOLE_FLOW_SECRET_${name}`]) {
156
+ statuses.push({ name, source: "env", reference: `env://BUGMOLE_FLOW_SECRET_${name}`, ready: true, detail: "Set in this machine's environment" });
157
+ continue;
158
+ }
159
+ const mapped = map[name];
160
+ const reference = mapped ?? defaultLocalReference(projectId, name);
161
+ if (reference.kind === "local") {
162
+ const ready = await localHas(reference, deps);
163
+ statuses.push({
164
+ name,
165
+ source: ready || mapped ? "local" : "unset",
166
+ reference: describeReference(reference),
167
+ ready,
168
+ detail: ready ? "In this machine's secure store" : `Not set: run bugmole secrets set ${name}${mapped ? "" : ", or map it to your vault under secrets: in the config"}`,
169
+ });
170
+ continue;
171
+ }
172
+ const { ready, detail } = await readiness(reference, deps);
173
+ statuses.push({ name, source: reference.kind, reference: describeReference(reference), ready, detail });
174
+ }
175
+ return statuses;
176
+ }
@@ -0,0 +1,146 @@
1
+ // `bugmole secrets`: what the project's flows need, where each value comes
2
+ // from, and a place on this machine to keep the ones that have no vault.
3
+ //
4
+ // bugmole secrets list every secret the flows use, and whether this machine can supply it
5
+ // bugmole secrets set NAME store a value in this machine's secure store (typed, never echoed; or piped on stdin)
6
+ // bugmole secrets delete NAME remove it
7
+ //
8
+ // Values never print. An agent asked for a password should point the person
9
+ // at `bugmole secrets set`, which they run in their own terminal.
10
+
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { referencedSecretNames } from "./flow-language.js";
14
+ import {
15
+ defaultLocalReference,
16
+ deleteLocal,
17
+ describeReference,
18
+ SECRET_NAME,
19
+ secretMap,
20
+ secretStatuses,
21
+ SOURCE_LABEL,
22
+ writeLocal,
23
+ type SecretDeps,
24
+ type SecretStatus,
25
+ } from "./secret-sources.js";
26
+
27
+ type Config = { project?: { id?: string }; runtime?: { spec_dir?: string }; secrets?: unknown };
28
+
29
+ /** Every `{{secret.NAME}}` in the project's flow files. */
30
+ export function flowSecretNames(directories: readonly string[]): string[] {
31
+ const names = new Set<string>();
32
+ const visit = (directory: string, depth: number) => {
33
+ if (depth > 6 || !fs.existsSync(directory)) return;
34
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
35
+ if (entry.name.startsWith(".") && entry.name !== ".maestro") continue;
36
+ if (entry.name === "node_modules") continue;
37
+ const full = path.join(directory, entry.name);
38
+ if (entry.isDirectory()) visit(full, depth + 1);
39
+ else if (/\.ya?ml$/i.test(entry.name)) {
40
+ for (const name of referencedSecretNames(fs.readFileSync(full, "utf8"))) names.add(name);
41
+ }
42
+ }
43
+ };
44
+ for (const directory of directories) visit(directory, 0);
45
+ return [...names].sort();
46
+ }
47
+
48
+ /** The secrets a project uses (its flows plus its config's map) and what this machine can supply. */
49
+ export async function projectSecretStatuses(cfg: Config, workspaceRoot: string, deps: SecretDeps = {}): Promise<{ statuses: SecretStatus[]; problems: string[] }> {
50
+ const { map, problems } = secretMap(cfg);
51
+ const specDir = path.resolve(workspaceRoot, cfg.runtime?.spec_dir || "./spec");
52
+ // Flows live in spec/ by default, but the docs and many projects keep them
53
+ // in flows/ or .maestro/ at the root.
54
+ const flowDirs = [specDir, path.resolve(workspaceRoot, "flows"), path.resolve(workspaceRoot, ".maestro")];
55
+ const names = [...flowSecretNames([...new Set(flowDirs)]), ...Object.keys(map)];
56
+ const statuses = await secretStatuses(names, map, cfg.project?.id ?? "default", deps);
57
+ return { statuses, problems };
58
+ }
59
+
60
+ async function readValue(name: string, stdin: NodeJS.ReadStream, stderr: NodeJS.WriteStream): Promise<string> {
61
+ if (!stdin.isTTY) {
62
+ // Piped: from a password manager's CLI, say. One trailing newline is the pipe's, not the value's.
63
+ const chunks: Buffer[] = [];
64
+ for await (const chunk of stdin) chunks.push(Buffer.from(chunk));
65
+ return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
66
+ }
67
+ stderr.write(`Value for ${name} (not shown): `);
68
+ stdin.setRawMode?.(true);
69
+ stdin.resume();
70
+ return new Promise((resolve, reject) => {
71
+ let value = "";
72
+ const done = (error?: Error) => {
73
+ stdin.setRawMode?.(false);
74
+ stdin.pause();
75
+ stdin.off("data", onData);
76
+ stderr.write("\n");
77
+ if (error) reject(error);
78
+ else resolve(value);
79
+ };
80
+ const onData = (chunk: Buffer) => {
81
+ for (const char of String(chunk)) {
82
+ if (char === "\r" || char === "\n") return done();
83
+ if (char === "\u0003") return done(new Error("Cancelled"));
84
+ if (char === "\u007f" || char === "\b") value = value.slice(0, -1);
85
+ else value += char;
86
+ }
87
+ };
88
+ stdin.on("data", onData);
89
+ });
90
+ }
91
+
92
+ export async function runSecretsCommand(
93
+ cfg: Config,
94
+ argv: readonly string[],
95
+ io: { workspaceRoot: string; stdin?: NodeJS.ReadStream; stdout?: NodeJS.WriteStream; stderr?: NodeJS.WriteStream; deps?: SecretDeps } = { workspaceRoot: process.cwd() },
96
+ ): Promise<number> {
97
+ const stdout = io.stdout ?? process.stdout;
98
+ const stderr = io.stderr ?? process.stderr;
99
+ const projectId = cfg.project?.id ?? "default";
100
+ const rest = argv.slice(argv.indexOf("secrets") + 1).filter((arg) => !arg.startsWith("--"));
101
+ const [action = "list", name] = rest;
102
+
103
+ if (action === "list") {
104
+ const { statuses, problems } = await projectSecretStatuses(cfg, io.workspaceRoot, io.deps);
105
+ for (const problem of problems) stderr.write(`[Bugmole] ${problem}\n`);
106
+ if (!statuses.length) {
107
+ stdout.write("No flow uses {{secret.NAME}} and the config maps no secrets.\n");
108
+ return 0;
109
+ }
110
+ for (const status of statuses) {
111
+ const source = status.source === "unset" ? "not set" : SOURCE_LABEL[status.source];
112
+ stdout.write(`${status.ready ? "✓" : "✗"} ${status.name} ${source}${status.reference ? ` ${status.reference}` : ""}\n ${status.detail}\n`);
113
+ }
114
+ return statuses.every((status) => status.ready) ? 0 : 1;
115
+ }
116
+
117
+ if (action !== "set" && action !== "delete") {
118
+ stderr.write("Usage: bugmole secrets [list] | set NAME | delete NAME\n");
119
+ return 2;
120
+ }
121
+ if (!name || !SECRET_NAME.test(name)) {
122
+ stderr.write(`Usage: bugmole secrets ${action} NAME (letters, digits and _)\n`);
123
+ return 2;
124
+ }
125
+ const { map } = secretMap(cfg);
126
+ const mapped = map[name];
127
+ if (mapped && mapped.kind !== "local") {
128
+ stderr.write(`${name} comes from ${SOURCE_LABEL[mapped.kind]} (${describeReference(mapped)}); change it there.\n`);
129
+ return 1;
130
+ }
131
+ const reference = mapped ?? defaultLocalReference(projectId, name);
132
+ if (reference.kind !== "local") return 1;
133
+ if (action === "delete") {
134
+ const removed = await deleteLocal(reference, io.deps);
135
+ stdout.write(removed ? `Removed ${name} from this machine's secure store.\n` : `${name} wasn't in this machine's secure store.\n`);
136
+ return removed ? 0 : 1;
137
+ }
138
+ const value = await readValue(name, io.stdin ?? process.stdin, stderr);
139
+ if (!value) {
140
+ stderr.write("Nothing stored: the value was empty.\n");
141
+ return 1;
142
+ }
143
+ await writeLocal(reference, value, io.deps);
144
+ stdout.write(`Stored ${name} in this machine's secure store. Flows that type {{secret.${name}}} use it on this machine.\n`);
145
+ return 0;
146
+ }
@@ -1,14 +1,17 @@
1
1
  import os from "node:os";
2
2
  import type WebSocket from "ws";
3
- import { ControlPlaneClient, RegistryRequestError, type RegistryRun } from "../registry/control-plane-client.js";
3
+ import { ControlPlaneClient, RegistryRequestError, isProjectInactive, projectInactiveMessage, type RegistryRun } from "../registry/control-plane-client.js";
4
+ import { detectProjectIdentity } from "./project-identity.js";
4
5
  import { executeRegistryRun, registryDeviceAccess } from "./run-job.js";
5
6
  import { connectServeGateway } from "./serve-gateway.js";
6
7
  import { runNextAgentTask } from "./cursor-driver-run.js";
7
8
  import { reconcilePipeline } from "./pipeline.js";
8
9
  import { publishProjectSpec } from "./project-spec.js";
9
10
  import { recordRunVerdicts } from "./record-run-verdicts.js";
10
- import { isClaimableTask, MAX_TASK_ATTEMPTS } from "../registry/task-scheduling.js";
11
+ import { isClaimableTask, MAX_TASK_ATTEMPTS, workerApprovalMode } from "../registry/task-scheduling.js";
12
+ import { applyAcceptedProposals } from "./apply-proposals.js";
11
13
  import { DISCOVERY_TASK_TYPE, runDiscoveryTask } from "./discovery-task.js";
14
+ import { discoveryLimitsFromEnv } from "./site-discovery.js";
12
15
 
13
16
  export { RUN_HEARTBEAT_MS } from "./run-job.js";
14
17
 
@@ -29,9 +32,30 @@ export function workerTasksEnabled(env: Record<string, string | undefined> = pro
29
32
  return env.BUGMOLE_WORKER_TASKS?.trim().toLowerCase() !== "false";
30
33
  }
31
34
 
32
- /** Another worker claimed the run first. That is not a failure of the run. */
35
+ /** Another worker claimed the run first. That is not a failure of the run (a paused project is its own case). */
33
36
  export function isClaimConflict(error: unknown): boolean {
34
- return error instanceof RegistryRequestError && error.status === 409;
37
+ return error instanceof RegistryRequestError && error.status === 409 && !isProjectInactive(error);
38
+ }
39
+
40
+ /**
41
+ * Reports which app this project is (so teammates' clones join it, and
42
+ * monorepo apps stay separate) and says so when the project is paused.
43
+ * Best-effort: serving never waits on it or fails because of it.
44
+ */
45
+ export async function announceProjectIdentity(
46
+ client: Pick<ControlPlaneClient, "reportIdentity">,
47
+ projectId: string,
48
+ options: { cwd: string; appPath?: string },
49
+ log: Pick<Console, "log" | "warn"> = console,
50
+ ): Promise<void> {
51
+ const detection = detectProjectIdentity({ cwd: options.cwd, appPath: options.appPath });
52
+ if (!detection.identity) return;
53
+ try {
54
+ const reply = await client.reportIdentity(detection.identity as Record<string, unknown>);
55
+ if (reply.inactiveAt) log.warn(`[Bugmole] ${projectInactiveMessage(projectId, reply.activateUrl)}`);
56
+ } catch (error) {
57
+ if (error instanceof RegistryRequestError && error.detail) log.warn(`[Bugmole] ${error.detail}`);
58
+ }
35
59
  }
36
60
 
37
61
  export async function startServeWorker(cfg: any): Promise<void> {
@@ -54,6 +78,11 @@ export async function startServeWorker(cfg: any): Promise<void> {
54
78
  // Consecutive failed runs per flow, so a run that keeps crashing stops
55
79
  // being retried forever. See the catch in processRun.
56
80
  const runFailures = new Map<string, number>();
81
+ let pausedNoticeShown = false;
82
+ void announceProjectIdentity(client, projectId, {
83
+ cwd: process.cwd(),
84
+ appPath: cfg.project?.app_path ?? cfg.project?.appPath,
85
+ }).catch(() => undefined);
57
86
 
58
87
  const processRun = async (run: RegistryRun) => {
59
88
  if (active.has(run.id)) return;
@@ -63,6 +92,14 @@ export async function startServeWorker(cfg: any): Promise<void> {
63
92
  await client.claimRun(run.id, workerId);
64
93
  } catch (error) {
65
94
  if (isClaimConflict(error)) return;
95
+ if (isProjectInactive(error)) {
96
+ // Not the run's fault: it waits, queued, until someone activates the project.
97
+ if (!pausedNoticeShown) {
98
+ pausedNoticeShown = true;
99
+ console.warn(`[Bugmole] ${projectInactiveMessage(projectId, typeof error.body?.activateUrl === "string" ? error.body.activateUrl : null)}`);
100
+ }
101
+ return;
102
+ }
66
103
  throw error;
67
104
  }
68
105
  const result = await executeRegistryRun(cfg, client, run, {
@@ -89,6 +126,8 @@ export async function startServeWorker(cfg: any): Promise<void> {
89
126
  reason: (item as { reason?: { code?: string; message?: string } }).reason,
90
127
  })),
91
128
  run.id,
129
+ undefined,
130
+ run.caseScope,
92
131
  );
93
132
  if (judged) {
94
133
  console.log("[Bugmole] Recorded " + judged.judged + " case verdicts for " + judged.caseSetId);
@@ -129,6 +168,7 @@ export async function startServeWorker(cfg: any): Promise<void> {
129
168
  [],
130
169
  run.id,
131
170
  `The run failed ${failures} times and never reached this step. Last error: ${message}`,
171
+ run.caseScope,
132
172
  ).catch(() => undefined);
133
173
  console.warn(
134
174
  "[Bugmole] " + run.journeyId + " failed " + failures
@@ -193,10 +233,10 @@ export async function startServeWorker(cfg: any): Promise<void> {
193
233
  discoveryInFlight = true;
194
234
  try {
195
235
  const { tasks } = await client.listTasks();
196
- const next = tasks.find((task) => task.taskType === DISCOVERY_TASK_TYPE && task.executionMode !== "cloud" && isClaimableTask(task));
236
+ const next = tasks.find((task) => task.taskType === DISCOVERY_TASK_TYPE && task.executionMode !== "cloud" && isClaimableTask(task, Date.now(), workerApprovalMode()));
197
237
  if (next) {
198
238
  console.log(`[Bugmole] Exploring for discovery task ${next.id}`);
199
- await runDiscoveryTask(cfg, client, next, workerId);
239
+ await runDiscoveryTask(cfg, client, next, workerId, undefined, { limits: discoveryLimitsFromEnv() });
200
240
  }
201
241
  } catch (error) {
202
242
  console.warn(`[Bugmole] Discovery poll failed: ${error instanceof Error ? error.message : error}`);
@@ -205,6 +245,23 @@ export async function startServeWorker(cfg: any): Promise<void> {
205
245
  }
206
246
  };
207
247
 
248
+ // Accepted spec proposals for a project on its own storage wait for a
249
+ // worker to write them; Bugmole storage applies them in the registry.
250
+ let proposalsInFlight = false;
251
+ const pollProposals = async () => {
252
+ if (proposalsInFlight || cfg.storage?.provider === "bugmole") return;
253
+ proposalsInFlight = true;
254
+ try {
255
+ for (const result of await applyAcceptedProposals(cfg, client)) {
256
+ console.log(`[Bugmole] Proposal ${result.id} ${result.outcome}: ${result.detail}`);
257
+ }
258
+ } catch (error) {
259
+ console.warn(`[Bugmole] Applying proposals failed: ${error instanceof Error ? error.message : error}`);
260
+ } finally {
261
+ proposalsInFlight = false;
262
+ }
263
+ };
264
+
208
265
  const poll = async () => {
209
266
  try {
210
267
  const { runs } = await client.listRuns();
@@ -216,6 +273,7 @@ export async function startServeWorker(cfg: any): Promise<void> {
216
273
  console.warn(`[Bugmole] Worker registry poll failed: ${error instanceof Error ? error.message : error}`);
217
274
  }
218
275
  void pollDiscovery();
276
+ void pollProposals();
219
277
  if (tasksEnabled) void pollTasks();
220
278
  };
221
279
 
@@ -5,16 +5,16 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import test from "node:test";
7
7
  import { chromium } from "playwright";
8
- import { discoverableRoute, discoverSite, flowsFor, slugify, stoppedAtSignIn } from "./site-discovery.js";
9
- import { discoveryUrl, runDiscoveryTask } from "./discovery-task.js";
8
+ import { discoverableRoute, discoverSite, discoveryLimitsFromEnv, flowsFor, slugify, stoppedAtSignIn } from "./site-discovery.js";
9
+ import { discoveryUrl, progressOf, runDiscoveryTask } from "./discovery-task.js";
10
10
  import { PlaywrightDriver } from "./playwright-driver.js";
11
11
  import { startLocalRegistryStub } from "./local-registry-stub.js";
12
12
  import { ControlPlaneClient } from "../registry/control-plane-client.js";
13
13
 
14
14
  const pages: Record<string, string> = {
15
- "/": `<title>Acme</title><nav><a href="/pricing">Pricing</a> <a href="/signup">Sign up</a> <a href="/logout">Log out</a> <a href="https://elsewhere.example/">Blog</a></nav><h1>Welcome to Acme</h1>`,
15
+ "/": `<title>Acme</title><nav><a href="/pricing">Pricing</a> <a href="/signup">Sign up</a> <a href="/logout">Log out</a> <a href="https://elsewhere.example/">Blog</a> </nav><h1>Welcome to Acme</h1>`,
16
16
  "/pricing": `<title>Pricing · Acme</title><a href="/">Home</a><h1>Plans for every team</h1>`,
17
- "/signup": `<title>Sign up · Acme</title><h1>Create your account</h1>
17
+ "/signup": `<title>Sign up · Acme</title><h1>Create your account</h1><a href="/">Home</a> <a href="/logout">Log out</a> <a href="/api/export">Export</a> <a href="/report.csv" download>Download report</a> <a href="/data">Raw data</a>
18
18
  <form aria-label="Registration"><label>Email <input type="email" required></label><label>Password <input type="password" required></label><button type="submit">Create account</button></form>`,
19
19
  "/logout": `<title>Bye</title><h1>You are signed out</h1>`,
20
20
  };
@@ -24,6 +24,11 @@ async function withSite(run: (baseUrl: string, hits: string[]) => Promise<void>)
24
24
  const server = http.createServer((request, response) => {
25
25
  const route = new URL(request.url ?? "/", "http://x").pathname;
26
26
  hits.push(route);
27
+ if (route === "/data" || route === "/api/export" || route === "/report.csv") {
28
+ response.writeHead(200, { "content-type": route === "/report.csv" ? "text/csv" : "application/json" });
29
+ response.end(route === "/report.csv" ? "a,b\n" : JSON.stringify({ rows: [] }));
30
+ return;
31
+ }
27
32
  const body = pages[route];
28
33
  response.writeHead(body ? 200 : 404, { "content-type": "text/html" });
29
34
  response.end(`<!doctype html><html><body>${body ?? "Not found"}</body></html>`);
@@ -86,6 +91,10 @@ test("explores a site from its URL like a visitor, and the flows it writes pass"
86
91
  const result = await discoverSite(baseUrl, { dwellMs: 0, onEvent: (event) => { events.push(event.text); } });
87
92
  assert.deepEqual(result.pages.map((page) => page.route).sort(), ["/", "/pricing", "/signup"]);
88
93
  assert.ok(!hits.includes("/logout"), "never opens a sign-out link");
94
+ assert.ok(!hits.includes("/api/export") && !hits.includes("/report.csv"), "never opens an API or download link");
95
+ assert.ok(events.some((text) => text.startsWith("Skipping “Export” (it downloads a file or calls the API)")));
96
+ assert.ok(events.some((text) => /“Raw data” opens a file \(application\/json\), not a page/.test(text)), "a link that opens JSON isn't a page");
97
+ assert.equal(events.filter((text) => text.startsWith("Skipping “Log out”")).length, 1, "a skipped link is reported once, not on every page that has it");
89
98
  assert.ok(events.includes("Visiting home page"));
90
99
  assert.ok(events.includes("Clicking “Pricing”"));
91
100
  assert.ok(events.some((text) => text.startsWith("Skipping “Log out”")));
@@ -118,6 +127,40 @@ test("explores a site from its URL like a visitor, and the flows it writes pass"
118
127
  });
119
128
  });
120
129
 
130
+ test("a crawl cut short by its limits says which linked pages it never opened", async (t) => {
131
+ if (!(await chromiumInstalled())) {
132
+ t.skip("Chromium is not installed");
133
+ return;
134
+ }
135
+ await withSite(async (baseUrl) => {
136
+ const events: Array<{ kind: string; text: string }> = [];
137
+ const capped = await discoverSite(baseUrl, { dwellMs: 0, maxPages: 2, onEvent: (event) => { events.push(event); } });
138
+ assert.equal(capped.pages.length, 2);
139
+ assert.equal(capped.limits?.maxPages, 2);
140
+ assert.equal(capped.limits?.stoppedAtPageLimit, true);
141
+ assert.equal(capped.limits?.notFollowed.length, 1);
142
+ assert.ok(!capped.pages.some((page) => page.route === capped.limits?.notFollowed[0]));
143
+ assert.match(events.find((event) => event.kind === "limit")?.text ?? "", /Stopped at the 2-page limit; 1 linked page was not opened/);
144
+ const progress = progressOf(baseUrl, capped);
145
+ assert.equal(progress.limits?.notFollowed, 1);
146
+ assert.deepEqual(progress.limits?.notFollowedRoutes, capped.limits?.notFollowed);
147
+
148
+ const shallow = await discoverSite(baseUrl, { dwellMs: 0, maxDepth: 0 });
149
+ assert.deepEqual(shallow.pages.map((page) => page.route), ["/"]);
150
+ assert.equal(shallow.limits?.stoppedAtPageLimit, false);
151
+ assert.deepEqual([...(shallow.limits?.notFollowed ?? [])].sort(), ["/pricing", "/signup"]);
152
+
153
+ const whole = await discoverSite(baseUrl, { dwellMs: 0 });
154
+ assert.deepEqual(whole.limits, { maxPages: 12, maxDepth: 2, stoppedAtPageLimit: false, notFollowed: [] });
155
+ });
156
+ });
157
+
158
+ test("a project's own worker reads its exploration limits from the environment", () => {
159
+ assert.deepEqual(discoveryLimitsFromEnv({ BUGMOLE_DISCOVERY_MAX_PAGES: "60", BUGMOLE_DISCOVERY_MAX_DEPTH: "4" }), { maxPages: 60, maxDepth: 4 });
160
+ assert.deepEqual(discoveryLimitsFromEnv({ BUGMOLE_DISCOVERY_MAX_PAGES: "0", BUGMOLE_DISCOVERY_MAX_DEPTH: "two" }), {});
161
+ assert.deepEqual(discoveryLimitsFromEnv({}), {});
162
+ });
163
+
121
164
  test("explores a signed-in app from a saved session, and stays inside it", async (t) => {
122
165
  if (!(await chromiumInstalled())) {
123
166
  t.skip("Chromium is not installed");