@bugmole/cli 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -2
- package/scripts/bugmole-admin.mjs +194 -0
- package/scripts/bugmole-admin.test.mjs +62 -0
- package/scripts/bugmole.test.ts +94 -2
- package/scripts/bugmole.ts +233 -46
- package/scripts/sync-byok.d.mts +2 -0
- package/scripts/sync-byok.mjs +18 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -6
- package/spec/domain_rules.yaml +139 -0
- package/spec/roles.yaml +20 -0
- package/spec/test-case-results.schema.json +20 -4
- package/spec/test-cases.schema.json +131 -16
- package/src/billing/plan-catalog.test.ts +82 -2
- package/src/billing/plan-catalog.ts +212 -9
- package/src/integrations/jira.ts +264 -0
- package/src/mcp/roles-and-review.test.ts +111 -0
- package/src/mcp/server.ts +358 -22
- package/src/mcp/write-test-cases.test.ts +68 -0
- package/src/registry/control-plane-client.ts +121 -6
- package/src/registry/migrations/0036_task_approval.sql +11 -0
- package/src/registry/migrations/0037_spec_proposals.sql +27 -0
- package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
- package/src/registry/migrations/0039_project_secrets.sql +37 -0
- package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
- package/src/registry/migrations/0041_jira_integration.sql +75 -0
- package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
- package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
- package/src/registry/migrations/0044_project_identity.sql +31 -0
- package/src/registry/project-identity.test.ts +99 -0
- package/src/registry/project-identity.ts +206 -0
- package/src/registry/roles.test.ts +66 -0
- package/src/registry/roles.ts +201 -0
- package/src/registry/task-scheduling.test.ts +94 -0
- package/src/registry/task-scheduling.ts +199 -2
- package/src/registry/test-case-revisions.test.ts +57 -0
- package/src/registry/test-case-revisions.ts +132 -0
- package/src/registry-worker/ai/routes.ts +3 -3
- package/src/registry-worker/artifacts.ts +23 -4
- package/src/registry-worker/billing/billing-core.test.ts +1 -1
- package/src/registry-worker/billing/checkout-routes.ts +53 -4
- package/src/registry-worker/billing/enforcement.ts +67 -12
- package/src/registry-worker/billing/paypal/api.ts +14 -0
- package/src/registry-worker/billing/paypal/client.ts +9 -0
- package/src/registry-worker/billing/paypal/provider.ts +10 -1
- package/src/registry-worker/billing/paypal.test.ts +108 -1
- package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
- package/src/registry-worker/billing/provider.ts +8 -0
- package/src/registry-worker/billing/routes.ts +2 -1
- package/src/registry-worker/billing/subscriptions.ts +114 -4
- package/src/registry-worker/core.ts +22 -0
- package/src/registry-worker/devices/policy.ts +4 -5
- package/src/registry-worker/devices/routes.ts +8 -8
- package/src/registry-worker/feature-access.ts +165 -0
- package/src/registry-worker/feature-flags/admin.ts +300 -0
- package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
- package/src/registry-worker/feature-flags/routes.ts +102 -0
- package/src/registry-worker/features.ts +9 -0
- package/src/registry-worker/feedback/routes.ts +2 -2
- package/src/registry-worker/flags.ts +80 -18
- package/src/registry-worker/github/checks.ts +4 -4
- package/src/registry-worker/hooks.ts +9 -0
- package/src/registry-worker/identity/oidc.test.ts +103 -0
- package/src/registry-worker/identity/oidc.ts +176 -0
- package/src/registry-worker/index.ts +477 -174
- package/src/registry-worker/jira/connection.ts +111 -0
- package/src/registry-worker/jira/jira.test.ts +404 -0
- package/src/registry-worker/jira/routes.ts +432 -0
- package/src/registry-worker/jira/workflow.ts +577 -0
- package/src/registry-worker/jobs/retention.ts +21 -5
- package/src/registry-worker/mcp/tools.ts +2 -1
- package/src/registry-worker/notifications/alerts.ts +4 -4
- package/src/registry-worker/notifications/notifications.test.ts +11 -0
- package/src/registry-worker/notifications/routes.ts +9 -10
- package/src/registry-worker/notifications/teams.ts +6 -2
- package/src/registry-worker/org/routes.ts +4 -4
- package/src/registry-worker/projects/identity.ts +194 -0
- package/src/registry-worker/projects/inactivity.ts +162 -0
- package/src/registry-worker/projects/projects.test.ts +283 -0
- package/src/registry-worker/proposals/proposals.test.ts +80 -0
- package/src/registry-worker/proposals/routes.ts +183 -0
- package/src/registry-worker/roles/roles.test.ts +84 -0
- package/src/registry-worker/roles/routes.ts +141 -0
- package/src/registry-worker/runner/dispatch.ts +1 -0
- package/src/registry-worker/runner/routes.ts +22 -5
- package/src/registry-worker/runner/runner.test.ts +21 -0
- package/src/registry-worker/runner/tokens.ts +8 -0
- package/src/registry-worker/secrets/crypto.ts +135 -0
- package/src/registry-worker/secrets/routes.ts +296 -0
- package/src/registry-worker/secrets/secrets.test.ts +237 -0
- package/src/registry-worker/signup/policy.ts +2 -2
- package/src/registry-worker/signup/routes.ts +12 -3
- package/src/registry-worker/sso/membership.ts +4 -2
- package/src/registry-worker/sso/routes.ts +24 -8
- package/src/registry-worker/sso/sso.test.ts +3 -2
- package/src/registry-worker/task-approval.test.ts +67 -0
- package/src/registry-worker/task-resume.test.ts +196 -0
- package/src/registry-worker/task-review.test.ts +121 -0
- package/src/runtime/appium-driver.ts +1 -1
- package/src/runtime/apply-proposals.test.ts +74 -0
- package/src/runtime/apply-proposals.ts +41 -0
- package/src/runtime/cloud-secrets.test.ts +201 -0
- package/src/runtime/cloud-secrets.ts +210 -0
- package/src/runtime/config-validate.ts +12 -9
- package/src/runtime/cursor-driver-run.ts +14 -3
- package/src/runtime/discovery-task.test.ts +26 -1
- package/src/runtime/discovery-task.ts +67 -6
- package/src/runtime/executor.ts +18 -10
- package/src/runtime/explorer.test.ts +32 -0
- package/src/runtime/explorer.ts +48 -0
- package/src/runtime/flow-language.ts +22 -8
- package/src/runtime/init-wizard.ts +112 -6
- package/src/runtime/journey-editor.ts +39 -2
- package/src/runtime/journey-graph.test.ts +18 -0
- package/src/runtime/local-registry-stub.test.ts +64 -0
- package/src/runtime/local-registry-stub.ts +255 -7
- package/src/runtime/local-vault.ts +65 -0
- package/src/runtime/pipeline.test.ts +23 -0
- package/src/runtime/pipeline.ts +61 -20
- package/src/runtime/planner.test.ts +24 -1
- package/src/runtime/planner.ts +88 -23
- package/src/runtime/playwright-driver.test.ts +21 -2
- package/src/runtime/playwright-driver.ts +49 -7
- package/src/runtime/project-identity.test.ts +161 -0
- package/src/runtime/project-identity.ts +232 -0
- package/src/runtime/project-roles.test.ts +107 -0
- package/src/runtime/project-roles.ts +158 -0
- package/src/runtime/propose-cli.ts +66 -0
- package/src/runtime/record-run-verdicts.test.ts +58 -0
- package/src/runtime/record-run-verdicts.ts +38 -4
- package/src/runtime/reporter.ts +1 -1
- package/src/runtime/reset.ts +2 -0
- package/src/runtime/roles-cli.test.ts +47 -0
- package/src/runtime/roles-cli.ts +60 -0
- package/src/runtime/run-once.ts +68 -5
- package/src/runtime/scenario-matrix.test.ts +41 -0
- package/src/runtime/scenario-matrix.ts +98 -0
- package/src/runtime/secret-driver.ts +77 -0
- package/src/runtime/secret-redaction.ts +69 -0
- package/src/runtime/secret-sources.test.ts +442 -0
- package/src/runtime/secret-sources.ts +176 -0
- package/src/runtime/secrets-cli.ts +146 -0
- package/src/runtime/serve-worker.ts +64 -6
- package/src/runtime/site-discovery.test.ts +47 -4
- package/src/runtime/site-discovery.ts +79 -9
- package/src/runtime/vault-federation.test.ts +37 -0
- package/src/runtime/vault-federation.ts +128 -0
- package/src/runtime/web-suite.ts +5 -2
- package/src/storage/create-object-store.ts +5 -1
- package/src/storage/object-store.ts +12 -1
- package/src/storage/test-case-results.test.ts +64 -0
- package/src/storage/test-case-results.ts +49 -0
- package/src/vendor/byok.ts +371 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import worker from "../registry-worker/index.js";
|
|
7
|
+
import { call, createContext, createTestEnv, seedProject, seedVerifiedDomain } from "../registry-worker/testing/harness.js";
|
|
8
|
+
import { runnerLauncher, type LaunchRequest } from "../registry-worker/runner/dispatch.js";
|
|
9
|
+
import { ControlPlaneClient } from "../registry/control-plane-client.js";
|
|
10
|
+
import { createObjectStore } from "../storage/create-object-store.js";
|
|
11
|
+
import { json } from "../storage/object-store.js";
|
|
12
|
+
import { interpolateTemplate } from "./flow-language.js";
|
|
13
|
+
import { forgetSecretValues, redactBody, redactSecrets, registerSecretValue, REDACTED } from "./secret-redaction.js";
|
|
14
|
+
import { injectFlowSecrets, missingSecretsMessage, signInWithFlow } from "./cloud-secrets.js";
|
|
15
|
+
import { runOnce } from "./run-once.js";
|
|
16
|
+
|
|
17
|
+
const KEY = Buffer.alloc(32, 3).toString("base64");
|
|
18
|
+
|
|
19
|
+
test("remembered secret values are redacted from text, JSON, URLs and text uploads", () => {
|
|
20
|
+
forgetSecretValues();
|
|
21
|
+
try {
|
|
22
|
+
assert.equal(redactSecrets("nothing to hide"), "nothing to hide");
|
|
23
|
+
registerSecretValue("p@ss\"word/1");
|
|
24
|
+
registerSecretValue("abc");
|
|
25
|
+
assert.equal(redactSecrets("typed p@ss\"word/1 into the field"), `typed ${REDACTED} into the field`);
|
|
26
|
+
assert.equal(redactSecrets(JSON.stringify({ error: "value p@ss\"word/1" })), JSON.stringify({ error: `value ${REDACTED}` }));
|
|
27
|
+
assert.equal(redactSecrets(`https://x.test/?p=${encodeURIComponent("p@ss\"word/1")}`), `https://x.test/?p=${REDACTED}`);
|
|
28
|
+
assert.equal(redactSecrets("abc stays: too short to be a real secret"), "abc stays: too short to be a real secret");
|
|
29
|
+
assert.ok(!json({ message: "p@ss\"word/1" }).includes("word/1"), "manifests and results");
|
|
30
|
+
const log = redactBody(new TextEncoder().encode("line p@ss\"word/1"), "text/plain; charset=utf-8");
|
|
31
|
+
assert.equal(new TextDecoder().decode(log as Uint8Array), `line ${REDACTED}`);
|
|
32
|
+
const png = new TextEncoder().encode("p@ss\"word/1");
|
|
33
|
+
assert.equal(redactBody(png, "image/png"), png, "binary evidence is left alone");
|
|
34
|
+
} finally {
|
|
35
|
+
forgetSecretValues();
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a secret a flow types is redacted from uploads and registry updates from then on", async () => {
|
|
40
|
+
forgetSecretValues();
|
|
41
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-redact-"));
|
|
42
|
+
const originalFetch = globalThis.fetch;
|
|
43
|
+
const sent: string[] = [];
|
|
44
|
+
try {
|
|
45
|
+
assert.equal(interpolateTemplate("{{secret.APP_PASSWORD}}", {}, { allowSecrets: true, env: { BUGMOLE_FLOW_SECRET_APP_PASSWORD: "typed-secret-1" } }), "typed-secret-1");
|
|
46
|
+
const store = createObjectStore({ provider: "local", localDirectory: directory });
|
|
47
|
+
await store.put("projects/p/runs/r/playwright.log", "ERROR: typed-secret-1 rejected", "text/plain");
|
|
48
|
+
assert.equal(await store.getText("projects/p/runs/r/playwright.log"), `ERROR: ${REDACTED} rejected`);
|
|
49
|
+
|
|
50
|
+
globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => {
|
|
51
|
+
sent.push(String(init?.body));
|
|
52
|
+
return Response.json({ updated: true });
|
|
53
|
+
}) as typeof fetch;
|
|
54
|
+
await new ControlPlaneClient({ registryUrl: "https://r.test", apiKey: "k", projectId: "p" })
|
|
55
|
+
.updateRun("run_1", { status: "error", errorMessage: "Login failed for typed-secret-1" });
|
|
56
|
+
assert.ok(!sent[0].includes("typed-secret-1") && sent[0].includes(REDACTED));
|
|
57
|
+
} finally {
|
|
58
|
+
globalThis.fetch = originalFetch;
|
|
59
|
+
forgetSecretValues();
|
|
60
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("secrets live in the environment only while the job runs", () => {
|
|
65
|
+
const env: Record<string, string | undefined> = { BUGMOLE_FLOW_SECRET_KEEP: "mine" };
|
|
66
|
+
const remove = injectFlowSecrets({ APP_PASSWORD: "cloud-value", KEEP: "cloud-keep" }, env);
|
|
67
|
+
assert.equal(env.BUGMOLE_FLOW_SECRET_APP_PASSWORD, "cloud-value");
|
|
68
|
+
assert.equal(redactSecrets("cloud-value"), REDACTED);
|
|
69
|
+
remove();
|
|
70
|
+
assert.deepEqual(env, { BUGMOLE_FLOW_SECRET_KEEP: "mine" });
|
|
71
|
+
assert.equal(redactSecrets("cloud-value"), "cloud-value");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a sign-in flow that uses a secret the project doesn't have fails naming it", async () => {
|
|
75
|
+
const flow = "url: https://shop.test\n---\n- launchApp\n- inputText: \"{{secret.APP_EMAIL}}\"\n- inputText: \"{{secret.APP_PASSWORD}}\"\n";
|
|
76
|
+
const released = { secrets: { APP_EMAIL: "qa@shop.test" }, unreadable: [], available: true };
|
|
77
|
+
assert.match(missingSecretsMessage(flow, released)!, /no secret named APP_PASSWORD is set/);
|
|
78
|
+
assert.equal(missingSecretsMessage(flow, { ...released, secrets: { APP_EMAIL: "a", APP_PASSWORD: "b" } }), null);
|
|
79
|
+
assert.match(missingSecretsMessage(flow, { secrets: {}, unreadable: [], available: false })!, /turned off/);
|
|
80
|
+
assert.match(missingSecretsMessage(flow, { ...released, unreadable: ["APP_PASSWORD"] })!, /APP_PASSWORD couldn't be decrypted/);
|
|
81
|
+
let ran = false;
|
|
82
|
+
await assert.rejects(signInWithFlow({
|
|
83
|
+
flow, baseUrl: "https://shop.test", workdir: os.tmpdir(), projectId: "shop", jobId: "t", released,
|
|
84
|
+
driver: { run: async () => { ran = true; return { status: "passed", message: "", artifacts: [] }; } },
|
|
85
|
+
}), /APP_PASSWORD/);
|
|
86
|
+
assert.equal(ran, false, "no browser opens for a flow that can't finish");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/** Sends every fetch to the in-process registry. */
|
|
90
|
+
function registryFetch(env: ReturnType<typeof createTestEnv>["env"]): typeof fetch {
|
|
91
|
+
return (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
92
|
+
const { ctx, flush } = createContext();
|
|
93
|
+
const reply = await worker.fetch(new Request(input, init), env, ctx);
|
|
94
|
+
await flush();
|
|
95
|
+
return reply;
|
|
96
|
+
}) as typeof fetch;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function withEnv<T>(values: Record<string, string>, body: () => Promise<T>): Promise<T> {
|
|
100
|
+
const saved = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]]));
|
|
101
|
+
Object.assign(process.env, values);
|
|
102
|
+
return body().finally(() => {
|
|
103
|
+
for (const [key, value] of Object.entries(saved)) {
|
|
104
|
+
if (value === undefined) delete process.env[key];
|
|
105
|
+
else process.env[key] = value;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function queuedExploration(signInFlow: string, secrets: Record<string, string>) {
|
|
111
|
+
const setup = createTestEnv({ RUNNER_TOKEN_SECRET: "s", FEATURE_CLOUD_RUNNERS: "on", PROJECT_SECRETS_KEY: KEY });
|
|
112
|
+
const { env, db } = setup;
|
|
113
|
+
const project = await seedProject(env, { storageProvider: "bugmole" });
|
|
114
|
+
seedVerifiedDomain(db, project.workspaceId, "shop.test");
|
|
115
|
+
for (const [name, value] of Object.entries(secrets)) {
|
|
116
|
+
assert.equal((await call(env, "PUT", `/api/projects/${project.projectId}/secrets/${name}`, { as: project.owner, body: { value } })).status, 201);
|
|
117
|
+
}
|
|
118
|
+
assert.equal((await call(env, "PUT", `/api/projects/${project.projectId}/sign-in-flow`, { as: project.owner, body: { content: signInFlow } })).status, 200);
|
|
119
|
+
const launches: LaunchRequest[] = [];
|
|
120
|
+
const original = runnerLauncher.start;
|
|
121
|
+
runnerLauncher.start = async (_env, launch) => {
|
|
122
|
+
launches.push(launch);
|
|
123
|
+
};
|
|
124
|
+
try {
|
|
125
|
+
const created = await call(env, "POST", `/api/projects/${project.projectId}/tasks`, {
|
|
126
|
+
as: project.owner, body: { title: "Explore https://shop.test", description: "Explore https://shop.test/", taskType: "discovery", idempotencyKey: "d", executionMode: "cloud" },
|
|
127
|
+
});
|
|
128
|
+
assert.equal(created.status, 201);
|
|
129
|
+
} finally {
|
|
130
|
+
runnerLauncher.start = original;
|
|
131
|
+
}
|
|
132
|
+
const launch = launches.find((item) => item.kind === "task")!;
|
|
133
|
+
const runnerEnv = { BUGMOLE_REGISTRY_URL: launch.registryUrl, BUGMOLE_TASK_ID: launch.id, BUGMOLE_RUN_TOKEN: launch.token };
|
|
134
|
+
const taskOf = async () => {
|
|
135
|
+
const tasks = await call(env, "GET", `/api/projects/${project.projectId}/tasks`, { as: project.owner });
|
|
136
|
+
return tasks.json.tasks.find((item: { id: string }) => item.id === launch.id);
|
|
137
|
+
};
|
|
138
|
+
return { ...setup, ...project, launch, runnerEnv, taskOf };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const FLOW = "url: https://shop.test/login\n---\n- launchApp\n- inputText: \"{{secret.APP_EMAIL}}\"\n- inputText: \"{{secret.APP_PASSWORD}}\"\n";
|
|
142
|
+
|
|
143
|
+
test("cloud exploration signs in with the project's flow and secrets, then explores signed in", async () => {
|
|
144
|
+
const { env, runnerEnv, taskOf } = await queuedExploration(FLOW, { APP_EMAIL: "qa@shop.test", APP_PASSWORD: "hunter2-cloud" });
|
|
145
|
+
const seen: { flow?: string; typed?: string; storageState?: string } = {};
|
|
146
|
+
const signIn = (async (options: Parameters<typeof signInWithFlow>[0]) => {
|
|
147
|
+
seen.flow = options.flow;
|
|
148
|
+
// What the flow would type, read the way the driver reads it.
|
|
149
|
+
seen.typed = interpolateTemplate("{{secret.APP_PASSWORD}}", {}, { allowSecrets: true });
|
|
150
|
+
return path.join(options.workdir, "session.json");
|
|
151
|
+
}) as typeof signInWithFlow;
|
|
152
|
+
const discover = (async (url: string, options: { storageState?: string; onEvent?: (event: unknown, snapshot: unknown) => Promise<void> }) => {
|
|
153
|
+
seen.storageState = options.storageState;
|
|
154
|
+
const result = {
|
|
155
|
+
baseUrl: url,
|
|
156
|
+
// A page that echoes what was typed must not carry it anywhere.
|
|
157
|
+
events: [{ at: new Date().toISOString(), kind: "page", text: "Found Account for hunter2-cloud" }],
|
|
158
|
+
pages: [{ route: "/account", title: "Account", path: [], links: [], forms: [] }],
|
|
159
|
+
flows: [],
|
|
160
|
+
};
|
|
161
|
+
await options.onEvent?.(result.events[0], result);
|
|
162
|
+
return result;
|
|
163
|
+
}) as never;
|
|
164
|
+
const originalFetch = globalThis.fetch;
|
|
165
|
+
globalThis.fetch = registryFetch(env);
|
|
166
|
+
try {
|
|
167
|
+
const code = await withEnv(runnerEnv, () => runOnce(process.env, { discover, signIn, log: () => undefined }));
|
|
168
|
+
assert.equal(code, 0);
|
|
169
|
+
} finally {
|
|
170
|
+
globalThis.fetch = originalFetch;
|
|
171
|
+
}
|
|
172
|
+
assert.equal(seen.flow, FLOW);
|
|
173
|
+
assert.equal(seen.typed, "hunter2-cloud", "the secret is in the flow's environment during the job");
|
|
174
|
+
assert.match(String(seen.storageState), /session\.json$/, "exploration starts from the signed-in session");
|
|
175
|
+
assert.equal(process.env.BUGMOLE_FLOW_SECRET_APP_PASSWORD, undefined, "and gone afterwards");
|
|
176
|
+
const task = await taskOf();
|
|
177
|
+
assert.equal(task.status, "completed", JSON.stringify(task));
|
|
178
|
+
const text = JSON.stringify(task);
|
|
179
|
+
assert.ok(!text.includes("hunter2-cloud"), "no secret in the task's progress");
|
|
180
|
+
assert.ok(text.includes("Signed in"), text);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("cloud exploration stops with a clear message when the sign-in flow needs a secret that isn't set", async () => {
|
|
184
|
+
const { env, runnerEnv, taskOf } = await queuedExploration(FLOW, { APP_EMAIL: "qa@shop.test" });
|
|
185
|
+
let explored = false;
|
|
186
|
+
const discover = (async () => {
|
|
187
|
+
explored = true;
|
|
188
|
+
throw new Error("should not explore");
|
|
189
|
+
}) as never;
|
|
190
|
+
const originalFetch = globalThis.fetch;
|
|
191
|
+
globalThis.fetch = registryFetch(env);
|
|
192
|
+
try {
|
|
193
|
+
await withEnv(runnerEnv, () => runOnce(process.env, { discover, log: () => undefined }));
|
|
194
|
+
} finally {
|
|
195
|
+
globalThis.fetch = originalFetch;
|
|
196
|
+
}
|
|
197
|
+
assert.equal(explored, false);
|
|
198
|
+
const task = await taskOf();
|
|
199
|
+
assert.equal(task.status, "error");
|
|
200
|
+
assert.match(JSON.stringify(task), /no secret named APP_PASSWORD is set for this project/);
|
|
201
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Sign-in for cloud runs, on the runner's side. A Bugmole Cloud container
|
|
2
|
+
// fetches its project's secrets for the one run or exploration it holds,
|
|
3
|
+
// puts them in its own environment as BUGMOLE_FLOW_SECRET_<NAME> for as long
|
|
4
|
+
// as the job lasts, and redacts them from everything it reports. Cloud
|
|
5
|
+
// exploration also runs the project's sign-in flow first, so it explores
|
|
6
|
+
// what is behind the sign-in page.
|
|
7
|
+
import fs from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { FLOW_SECRET_ENV_PREFIX, referencedSecretNames } from "./flow-language.js";
|
|
10
|
+
import { forgetSecretValues, redactSecrets, registerSecretValue } from "./secret-redaction.js";
|
|
11
|
+
import type { DriverContext, DriverResult } from "./driver.js";
|
|
12
|
+
import { describeReference, parseSecretReference, resolveSecret, type SecretDeps, type SecretReference } from "./secret-sources.js";
|
|
13
|
+
import { awsWebIdentityCredentials, googleFederatedToken, type IdentityTokenSource } from "./vault-federation.js";
|
|
14
|
+
|
|
15
|
+
export type ReleasedSecrets = {
|
|
16
|
+
secrets: Record<string, string>;
|
|
17
|
+
unreadable: string[];
|
|
18
|
+
available: boolean;
|
|
19
|
+
/** Secrets whose value is a reference into the project's own vault that couldn't be read, and why. */
|
|
20
|
+
vaultErrors?: Record<string, string>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Project settings for reading the project's own vault: never typed by a flow. */
|
|
24
|
+
export const VAULT_CREDENTIAL = /^VAULT_/;
|
|
25
|
+
|
|
26
|
+
/** Stored cloud keys Bugmole no longer accepts: a cloud trusts Bugmole's identity token instead. */
|
|
27
|
+
const STORED_KEYS = ["VAULT_AWS_ACCESS_KEY_ID", "VAULT_AWS_SECRET_ACCESS_KEY", "VAULT_AWS_SESSION_TOKEN", "VAULT_GCP_SERVICE_ACCOUNT_KEY"];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Bring your own vault on Bugmole Cloud: a project secret's value may be a
|
|
31
|
+
* reference into Google Secret Manager (gcpsm://…) or AWS Secrets Manager
|
|
32
|
+
* (awssm://…) instead of the value itself. Nobody stores a key for it: the
|
|
33
|
+
* runner signs in to the project's cloud with a short-lived Bugmole identity
|
|
34
|
+
* token the cloud was set up to trust (vault-federation.ts), using the
|
|
35
|
+
* project's settings VAULT_GCP_WORKLOAD_PROVIDER (+ VAULT_GCP_SERVICE_ACCOUNT)
|
|
36
|
+
* and VAULT_AWS_ROLE_ARN. Local, keychain and env references only exist on a
|
|
37
|
+
* person's own machine.
|
|
38
|
+
*/
|
|
39
|
+
export async function resolveVaultReferences(
|
|
40
|
+
released: ReleasedSecrets,
|
|
41
|
+
deps: SecretDeps & { identityToken?: IdentityTokenSource; sessionName?: string } = {},
|
|
42
|
+
): Promise<ReleasedSecrets> {
|
|
43
|
+
const settings: Record<string, string> = {};
|
|
44
|
+
const secrets: Record<string, string> = {};
|
|
45
|
+
for (const [name, value] of Object.entries(released.secrets)) {
|
|
46
|
+
if (VAULT_CREDENTIAL.test(name)) settings[name] = value;
|
|
47
|
+
else secrets[name] = value;
|
|
48
|
+
}
|
|
49
|
+
const vaultErrors: Record<string, string> = { ...(released.vaultErrors ?? {}) };
|
|
50
|
+
const references: Array<[string, Extract<SecretReference, { kind: "aws" | "gcp" }>]> = [];
|
|
51
|
+
for (const [name, value] of Object.entries(secrets)) {
|
|
52
|
+
const reference = parseSecretReference(value);
|
|
53
|
+
if (!reference) continue;
|
|
54
|
+
delete secrets[name];
|
|
55
|
+
if (reference.kind !== "aws" && reference.kind !== "gcp") {
|
|
56
|
+
vaultErrors[name] = `${describeReference(reference)} can only be read on your own machine; on Bugmole Cloud use the value, or a gcpsm:// or awssm:// reference`;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
references.push([name, reference]);
|
|
60
|
+
}
|
|
61
|
+
const storedKeys = STORED_KEYS.filter((name) => settings[name]);
|
|
62
|
+
// One sign-in per cloud, shared by every secret it holds, and only if one is used.
|
|
63
|
+
const sessions = new Map<"gcp" | "aws", Promise<SecretDeps>>();
|
|
64
|
+
const session = (kind: "gcp" | "aws"): Promise<SecretDeps> => {
|
|
65
|
+
if (!sessions.has(kind)) {
|
|
66
|
+
sessions.set(kind, (async (): Promise<SecretDeps> => {
|
|
67
|
+
if (!deps.identityToken) throw new Error("This runner can't get an identity token for your cloud");
|
|
68
|
+
if (kind === "gcp") {
|
|
69
|
+
const provider = settings.VAULT_GCP_WORKLOAD_PROVIDER;
|
|
70
|
+
if (!provider) {
|
|
71
|
+
throw new Error(`Set VAULT_GCP_WORKLOAD_PROVIDER to your Workload Identity provider so Bugmole Cloud can read Google Secret Manager${storedKeys.length ? " (a stored service account key isn't used)" : ""}`);
|
|
72
|
+
}
|
|
73
|
+
const token = await googleFederatedToken({ provider, serviceAccount: settings.VAULT_GCP_SERVICE_ACCOUNT || undefined, identityToken: deps.identityToken, fetch: deps.fetch });
|
|
74
|
+
registerSecretValue(token);
|
|
75
|
+
return { ...deps, env: { GOOGLE_OAUTH_ACCESS_TOKEN: token }, allowCli: false };
|
|
76
|
+
}
|
|
77
|
+
const roleArn = settings.VAULT_AWS_ROLE_ARN;
|
|
78
|
+
if (!roleArn) {
|
|
79
|
+
throw new Error(`Set VAULT_AWS_ROLE_ARN to the IAM role Bugmole Cloud may assume to read AWS Secrets Manager${storedKeys.length ? " (stored access keys aren't used)" : ""}`);
|
|
80
|
+
}
|
|
81
|
+
const credentials = await awsWebIdentityCredentials({ roleArn, sessionName: deps.sessionName ?? "bugmole", identityToken: deps.identityToken, fetch: deps.fetch });
|
|
82
|
+
for (const value of Object.values(credentials)) registerSecretValue(value);
|
|
83
|
+
return {
|
|
84
|
+
...deps,
|
|
85
|
+
env: { CLOUD_AWS_ACCESS_KEY_ID: credentials.accessKeyId, CLOUD_AWS_SECRET_ACCESS_KEY: credentials.secretAccessKey, CLOUD_AWS_SESSION_TOKEN: credentials.sessionToken },
|
|
86
|
+
awsPrefixes: ["CLOUD_AWS_"],
|
|
87
|
+
allowCli: false,
|
|
88
|
+
};
|
|
89
|
+
})());
|
|
90
|
+
}
|
|
91
|
+
return sessions.get(kind)!;
|
|
92
|
+
};
|
|
93
|
+
for (const [name, reference] of references) {
|
|
94
|
+
try {
|
|
95
|
+
secrets[name] = await resolveSecret(reference, await session(reference.kind));
|
|
96
|
+
} catch (error) {
|
|
97
|
+
vaultErrors[name] = redactSecrets(error instanceof Error ? error.message : String(error));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
for (const value of Object.values(settings)) registerSecretValue(value);
|
|
101
|
+
return { ...released, secrets, ...(Object.keys(vaultErrors).length ? { vaultErrors } : {}) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Asks the registry for the job's secrets. A registry with project secrets
|
|
106
|
+
* turned off (503) releases none; any other refusal is an error.
|
|
107
|
+
*/
|
|
108
|
+
export async function fetchRunnerSecrets(
|
|
109
|
+
doFetch: typeof fetch,
|
|
110
|
+
registryUrl: string,
|
|
111
|
+
token: string,
|
|
112
|
+
job: { kind: "run" | "task"; id: string },
|
|
113
|
+
): Promise<ReleasedSecrets> {
|
|
114
|
+
const reply = await doFetch(`${registryUrl}/api/runner/${job.kind}s/${encodeURIComponent(job.id)}/secrets`, {
|
|
115
|
+
headers: { authorization: `Bearer ${token}` },
|
|
116
|
+
});
|
|
117
|
+
if (reply.status === 503) return { secrets: {}, unreadable: [], available: false };
|
|
118
|
+
if (!reply.ok) throw new Error(`The registry refused this ${job.kind}'s secrets (${reply.status})`);
|
|
119
|
+
const body = await reply.json() as { secrets?: Record<string, unknown>; unreadable?: unknown };
|
|
120
|
+
const secrets: Record<string, string> = {};
|
|
121
|
+
for (const [name, value] of Object.entries(body.secrets ?? {})) {
|
|
122
|
+
if (typeof value === "string") secrets[name] = value;
|
|
123
|
+
}
|
|
124
|
+
const unreadable = Array.isArray(body.unreadable) ? body.unreadable.filter((name): name is string => typeof name === "string") : [];
|
|
125
|
+
return { secrets, unreadable, available: true };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Puts the secrets in the process environment and remembers them for
|
|
130
|
+
* redaction. The returned function takes them out again.
|
|
131
|
+
*/
|
|
132
|
+
export function injectFlowSecrets(secrets: Record<string, string>, env: Record<string, string | undefined> = process.env): () => void {
|
|
133
|
+
const saved = new Map<string, string | undefined>();
|
|
134
|
+
for (const [name, value] of Object.entries(secrets)) {
|
|
135
|
+
const variable = `${FLOW_SECRET_ENV_PREFIX}${name}`;
|
|
136
|
+
saved.set(variable, env[variable]);
|
|
137
|
+
env[variable] = value;
|
|
138
|
+
registerSecretValue(value);
|
|
139
|
+
}
|
|
140
|
+
return () => {
|
|
141
|
+
for (const [variable, previous] of saved) {
|
|
142
|
+
if (previous === undefined) delete env[variable];
|
|
143
|
+
else env[variable] = previous;
|
|
144
|
+
}
|
|
145
|
+
forgetSecretValues();
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A clear message for a sign-in flow that uses a secret the project doesn't have. */
|
|
150
|
+
export function missingSecretsMessage(flow: string, released: ReleasedSecrets): string | null {
|
|
151
|
+
const names = referencedSecretNames(flow);
|
|
152
|
+
if (names.length && !released.available) {
|
|
153
|
+
return `The sign-in flow uses ${names.map((name) => `{{secret.${name}}}`).join(", ")}, but project secrets are turned off on this Bugmole registry.`;
|
|
154
|
+
}
|
|
155
|
+
const fromVault = names.filter((name) => released.vaultErrors?.[name] && !(name in released.secrets));
|
|
156
|
+
if (fromVault.length) {
|
|
157
|
+
return fromVault.map((name) => `${name} couldn't be read from your vault: ${released.vaultErrors![name]}`).join(" ");
|
|
158
|
+
}
|
|
159
|
+
const unreadable = names.filter((name) => released.unreadable.includes(name) && !(name in released.secrets));
|
|
160
|
+
if (unreadable.length) {
|
|
161
|
+
return `The secret${unreadable.length === 1 ? "" : "s"} ${unreadable.join(", ")} couldn't be decrypted. Set ${unreadable.length === 1 ? "it" : "them"} again under Settings → Sign-in for cloud runs.`;
|
|
162
|
+
}
|
|
163
|
+
const missing = names.filter((name) => !(name in released.secrets));
|
|
164
|
+
if (!missing.length) return null;
|
|
165
|
+
const list = missing.join(", ");
|
|
166
|
+
return `The sign-in flow uses ${missing.map((name) => `{{secret.${name}}}`).join(", ")}, but no secret named ${list} is set for this project`
|
|
167
|
+
+ " (or its run's environment). Add it under Settings → Sign-in for cloud runs.";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
type FlowRunner = { run(context: DriverContext): Promise<DriverResult> };
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Runs the project's sign-in flow in a fresh browser and returns the saved
|
|
174
|
+
* session (cookies and local storage) for exploration to start from. Its
|
|
175
|
+
* evidence stays in the container's working directory, which is deleted with
|
|
176
|
+
* the job: nothing of the sign-in is uploaded.
|
|
177
|
+
*/
|
|
178
|
+
export async function signInWithFlow(options: {
|
|
179
|
+
flow: string;
|
|
180
|
+
baseUrl: string;
|
|
181
|
+
workdir: string;
|
|
182
|
+
projectId: string;
|
|
183
|
+
jobId: string;
|
|
184
|
+
released: ReleasedSecrets;
|
|
185
|
+
driver?: FlowRunner;
|
|
186
|
+
timeoutMs?: number;
|
|
187
|
+
}): Promise<string> {
|
|
188
|
+
const problem = missingSecretsMessage(options.flow, options.released);
|
|
189
|
+
if (problem) throw new Error(problem);
|
|
190
|
+
const directory = path.join(options.workdir, "sign-in");
|
|
191
|
+
await fs.mkdir(directory, { recursive: true });
|
|
192
|
+
const flowPath = path.join(directory, "sign-in.yaml");
|
|
193
|
+
await fs.writeFile(flowPath, options.flow, "utf8");
|
|
194
|
+
const sessionStatePath = path.join(directory, "session-state.json");
|
|
195
|
+
const driver = options.driver ?? new (await import("./playwright-driver.js")).PlaywrightDriver({ headless: true });
|
|
196
|
+
const result = await driver.run({
|
|
197
|
+
runId: `sign-in-${options.jobId}`,
|
|
198
|
+
projectId: options.projectId,
|
|
199
|
+
journeyId: "sign-in",
|
|
200
|
+
flowPath,
|
|
201
|
+
platform: "web",
|
|
202
|
+
target: { baseUrl: options.baseUrl },
|
|
203
|
+
rebaseOrigin: true,
|
|
204
|
+
timeoutMs: options.timeoutMs ?? 60_000,
|
|
205
|
+
artifactsDir: path.join(directory, "artifacts"),
|
|
206
|
+
sessionStatePath,
|
|
207
|
+
});
|
|
208
|
+
if (result.status !== "passed") throw new Error(`The sign-in flow did not pass: ${redactSecrets(result.message)}`);
|
|
209
|
+
return sessionStatePath;
|
|
210
|
+
}
|
|
@@ -80,8 +80,10 @@ export function validateConfig(cfg: Config): ValidationResult {
|
|
|
80
80
|
const env = classifyEnvironment(cfg.runtime.env);
|
|
81
81
|
|
|
82
82
|
// Required fields
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
// A missing env is treated as STAGING, the safest default, rather than
|
|
84
|
+
// refusing to start: `bugmole init` doesn't ask for one.
|
|
85
|
+
if (!cfg.runtime?.env && !(cfg.runtime as { environment?: string } | undefined)?.environment) {
|
|
86
|
+
warnings.push("runtime.env is not set; treating this environment as staging");
|
|
85
87
|
}
|
|
86
88
|
if (!cfg.runtime?.artifacts_dir) {
|
|
87
89
|
errors.push("runtime.artifacts_dir is required");
|
|
@@ -89,11 +91,11 @@ export function validateConfig(cfg: Config): ValidationResult {
|
|
|
89
91
|
if (!cfg.runtime?.spec_dir) {
|
|
90
92
|
errors.push("runtime.spec_dir is required");
|
|
91
93
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (!cfg.llm?.api_key) {
|
|
96
|
-
|
|
94
|
+
// Your own model key is optional: without one, steps that need AI use
|
|
95
|
+
// Bugmole AI (with a Bugmole API key) or say what's missing when they run.
|
|
96
|
+
const provider = cfg.llm?.provider;
|
|
97
|
+
if (provider && provider !== "manual" && provider !== "cursor" && !cfg.llm?.api_key) {
|
|
98
|
+
warnings.push(`llm.provider is "${provider}" but llm.api_key is empty; set LLM_API_KEY in .bugmole.env to use your own model`);
|
|
97
99
|
}
|
|
98
100
|
const driver = cfg.execution?.driver || "maestro";
|
|
99
101
|
if (!["maestro", "playwright", "simulation"].includes(driver)) {
|
|
@@ -118,10 +120,11 @@ export function validateConfig(cfg: Config): ValidationResult {
|
|
|
118
120
|
storageProvider && typeof cfg.storage?.[storageProvider] === "object"
|
|
119
121
|
? (cfg.storage[storageProvider] as { bucket?: string; account_id?: string })
|
|
120
122
|
: undefined;
|
|
121
|
-
if (storageProvider && !["local", "gcs", "s3", "r2"].includes(storageProvider)) {
|
|
123
|
+
if (storageProvider && !["local", "gcs", "s3", "r2", "bugmole"].includes(storageProvider)) {
|
|
122
124
|
errors.push(`storage.provider "${storageProvider}" is unsupported`);
|
|
123
125
|
}
|
|
124
|
-
|
|
126
|
+
// Bugmole storage needs no bucket of yours: it goes through your Bugmole account.
|
|
127
|
+
if (storageProvider && storageProvider !== "local" && storageProvider !== "bugmole" && !storageDetails?.bucket) {
|
|
125
128
|
errors.push(`storage.${storageProvider}.bucket is required`);
|
|
126
129
|
}
|
|
127
130
|
if (storageProvider === "r2" && !storageDetails?.account_id && !process.env.R2_ACCOUNT_ID) {
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
import { injectPrompt, isCursorRunning, openCursorInDirectory } from "./cursor-applescript.js";
|
|
19
19
|
import { waitForCommitResponse } from "./cursor-mcp-monitor.js";
|
|
20
20
|
import { runCursorCliDriver, type CursorCliDriverOptions } from "./cursor-cli-driver.js";
|
|
21
|
-
import { ControlPlaneClient, isClaimableTask, type RegistryTask } from "../registry/control-plane-client.js";
|
|
21
|
+
import { ControlPlaneClient, RegistryRequestError, isClaimableTask, workerApprovalMode, type RegistryTask } from "../registry/control-plane-client.js";
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Main CURSOR_DRIVER function - acts as an LLM API adapter
|
|
@@ -219,7 +219,7 @@ export async function runNextAgentTask(
|
|
|
219
219
|
});
|
|
220
220
|
const available = await client.listTasks();
|
|
221
221
|
// Discovery tasks are run by the worker's own browser, not a coding agent.
|
|
222
|
-
const next = available.tasks.find((task) => task.taskType !== "discovery" && isClaimableTask(task));
|
|
222
|
+
const next = available.tasks.find((task) => task.taskType !== "discovery" && isClaimableTask(task, Date.now(), workerApprovalMode()));
|
|
223
223
|
if (!next) return { task: null, message: "No queued agent task is available." };
|
|
224
224
|
// Losing a claim is a normal race, not a failure: another worker got
|
|
225
225
|
// there first, or the task changed state between listing and claiming.
|
|
@@ -240,6 +240,10 @@ export async function runNextAgentTask(
|
|
|
240
240
|
"",
|
|
241
241
|
next.description,
|
|
242
242
|
"",
|
|
243
|
+
// A person sent this back from review: their note is what to fix.
|
|
244
|
+
next.reviewNote && next.status === "queued"
|
|
245
|
+
? `A reviewer sent this task back after your last attempt. Address their note before completing again:\n${next.reviewNote}\n`
|
|
246
|
+
: "",
|
|
243
247
|
"Read bugmole://guidance/agent-task-queue before acting.",
|
|
244
248
|
"Report progress as you work and use bugmole_task_update for the final status.",
|
|
245
249
|
next.taskType === "ui"
|
|
@@ -273,6 +277,12 @@ export async function runNextAgentTask(
|
|
|
273
277
|
status: "blocked",
|
|
274
278
|
errorMessage: response.error ?? "Cursor task execution failed",
|
|
275
279
|
progressMessage: "Cursor execution stopped; task can be continued.",
|
|
280
|
+
}).catch((error) => {
|
|
281
|
+
// The agent finished and the task went into review before the CLI
|
|
282
|
+
// exited (a timeout after the final update): the registry keeps the
|
|
283
|
+
// reviewed work rather than marking it blocked.
|
|
284
|
+
if (error instanceof RegistryRequestError && error.status === 409) return undefined;
|
|
285
|
+
throw error;
|
|
276
286
|
});
|
|
277
287
|
return { task: claimed.task, response };
|
|
278
288
|
}
|
|
@@ -284,7 +294,8 @@ export async function runNextAgentTask(
|
|
|
284
294
|
// status (with its parity evidence and summary) always wins.
|
|
285
295
|
const after = await client.listTasks().catch(() => null);
|
|
286
296
|
const current = after?.tasks.find((task) => task.id === next.id);
|
|
287
|
-
|
|
297
|
+
// A task in review was closed by the agent and now waits for a person.
|
|
298
|
+
if (current && current.status !== "completed" && current.status !== "error" && current.status !== "in_review") {
|
|
288
299
|
await client.updateTask(next.id, {
|
|
289
300
|
workerId: options.workerId,
|
|
290
301
|
status: "completed",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import { discoveryPersona, discoveryUrl, discoveryUsesAi, withPersonaMarker } from "./discovery-task.js";
|
|
3
|
+
import { discoveryPersona, discoveryUrl, discoveryUsesAi, runDiscoveryTask, withPersonaMarker } from "./discovery-task.js";
|
|
4
4
|
|
|
5
5
|
test("discoveryUsesAi and discoveryUrl read the title and description a task was queued with", () => {
|
|
6
6
|
assert.equal(discoveryUsesAi({ title: "Explore with AI shop.test" }), true);
|
|
@@ -27,3 +27,28 @@ test("no persona marker, or one that doesn't decode to a persona, means no perso
|
|
|
27
27
|
const garbage = Buffer.from("not json", "utf8").toString("base64");
|
|
28
28
|
assert.equal(discoveryPersona({ description: `<!-- persona:${garbage} -->` }), null);
|
|
29
29
|
});
|
|
30
|
+
|
|
31
|
+
test("exploration that stops at a sign-in wall suggests guest and member roles", async () => {
|
|
32
|
+
const proposed: Array<{ name: string; source: string; evidence?: string }> = [];
|
|
33
|
+
const updates: Array<Record<string, unknown>> = [];
|
|
34
|
+
const client = {
|
|
35
|
+
claimTask: async () => ({}),
|
|
36
|
+
updateTask: async (_id: string, body: Record<string, unknown>) => { updates.push(body); return {}; },
|
|
37
|
+
publishPlan: async () => ({}),
|
|
38
|
+
proposeRole: async (input: { name: string; source: string; evidence?: string }) => {
|
|
39
|
+
proposed.push(input);
|
|
40
|
+
return { role: { name: input.name, status: "suggested" }, alreadyDecided: false };
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
const task = { id: "t1", projectId: "p", title: "Explore", description: "Explore https://app.example.com/", taskType: "discovery", priority: 1, status: "queued" };
|
|
44
|
+
const wall = {
|
|
45
|
+
baseUrl: "https://app.example.com/",
|
|
46
|
+
pages: [{ url: "https://app.example.com/login", route: "/login", title: "Sign in", heading: "Sign in", forms: [{ fields: [{ type: "password" }] }], links: [], controls: [] }],
|
|
47
|
+
flows: [],
|
|
48
|
+
events: [],
|
|
49
|
+
};
|
|
50
|
+
await runDiscoveryTask({ project: { id: "p" }, runtime: { spec_dir: "/nonexistent" } }, client as never, task as never, "w", (async () => wall) as never, { claimed: true });
|
|
51
|
+
assert.deepEqual(proposed.map((role) => [role.name, role.source]), [["guest", "sign_in_wall"], ["member", "sign_in_wall"]]);
|
|
52
|
+
assert.match(proposed[0].evidence ?? "", /reached only a sign-in page/);
|
|
53
|
+
assert.ok(updates.some((body) => /Suggested the roles guest and member/.test(String(body.progressMessage))));
|
|
54
|
+
});
|