@bugmole/cli 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -4
- package/package.json +7 -4
- 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 +290 -43
- package/scripts/ensure-playwright.cjs +87 -0
- package/scripts/ensure-playwright.test.ts +36 -0
- package/scripts/sync-byok.d.mts +2 -0
- package/scripts/sync-byok.mjs +18 -0
- package/scripts/sync-launcher-version.cjs +24 -0
- package/scripts/sync-launcher-version.test.ts +28 -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/0034_signup_attribution.sql +19 -0
- package/src/registry/migrations/0035_domain_verify_file.sql +6 -0
- 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 +24 -0
- package/src/registry-worker/devices/policy.ts +4 -5
- package/src/registry-worker/devices/routes.ts +8 -8
- package/src/registry-worker/domains/domains.test.ts +64 -1
- package/src/registry-worker/domains/routes.ts +32 -15
- package/src/registry-worker/domains.ts +40 -2
- 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 +11 -0
- package/src/registry-worker/feedback/feedback.test.ts +47 -0
- package/src/registry-worker/feedback/routes.ts +2 -2
- package/src/registry-worker/feedback/website.ts +77 -0
- 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 +494 -173
- 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.test.ts +24 -1
- package/src/registry-worker/org/routes.ts +9 -8
- 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 +59 -0
- package/src/registry-worker/runner/tokens.ts +12 -1
- 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/attribution.test.ts +147 -0
- package/src/registry-worker/signup/attribution.ts +194 -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/ai-exploration.test.ts +34 -1
- package/src/runtime/ai-exploration.ts +97 -3
- package/src/runtime/appium-driver.ts +7 -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 +87 -7
- 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 +43 -8
- package/src/runtime/init-wizard.ts +112 -6
- package/src/runtime/journey-editor.ts +78 -2
- package/src/runtime/journey-graph.test.ts +60 -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 +164 -0
- package/src/runtime/playwright-driver.ts +286 -15
- 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-job.test.ts +21 -0
- package/src/runtime/run-job.ts +8 -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 +181 -4
- package/src/runtime/site-discovery.ts +248 -29
- package/src/runtime/vault-federation.test.ts +37 -0
- package/src/runtime/vault-federation.ts +128 -0
- package/src/runtime/web-suite.test.ts +65 -0
- package/src/runtime/web-suite.ts +8 -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,141 @@
|
|
|
1
|
+
// Roles are who a test journey is run as: a guest, a signed-in member, an
|
|
2
|
+
// admin. Bugmole suggests them (an agent through bugmole_role_propose or
|
|
3
|
+
// `bugmole roles suggest`, from sign-in walls, role-gated routes and menus,
|
|
4
|
+
// spec/roles.yaml and discovered journeys); a person confirms, renames or
|
|
5
|
+
// rejects each one. Only confirmed roles are used for test cases and runs.
|
|
6
|
+
//
|
|
7
|
+
// - Anyone who can write to the project may suggest a role. A person who can
|
|
8
|
+
// manage the project may add one directly, and it is confirmed.
|
|
9
|
+
// - Only a person who can manage the project decides — never an API key or
|
|
10
|
+
// a runner, or an agent could confirm its own suggestion.
|
|
11
|
+
// - Suggesting a role a person already decided on changes nothing: a
|
|
12
|
+
// rejected role stays rejected however often it is proposed again.
|
|
13
|
+
import type { AuthedRouteContext } from "../routes/index.js";
|
|
14
|
+
import { matchPath, registerRoutes } from "../routes/index.js";
|
|
15
|
+
import { memberOfProject, newId, requestJson, response, type Principal } from "../core.js";
|
|
16
|
+
import { auditProject } from "../org/audit.js";
|
|
17
|
+
import {
|
|
18
|
+
ROLE_DESCRIPTION_MAX, ROLE_EVIDENCE_MAX, normalizeRoleName, roleSourceOf, roleStatusOf,
|
|
19
|
+
type RoleSource, type RoleStatus,
|
|
20
|
+
} from "../../registry/roles.js";
|
|
21
|
+
|
|
22
|
+
type RoleRow = {
|
|
23
|
+
id: string; name: string; description: string; status: RoleStatus; source: RoleSource; evidence: string | null;
|
|
24
|
+
suggestedBy: string; decidedBy: string | null; decidedAt: string | null; createdAt: string; updatedAt: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const roleSelect = `
|
|
28
|
+
SELECT id, name, description, status, source, evidence, suggested_by AS suggestedBy,
|
|
29
|
+
decided_by AS decidedBy, decided_at AS decidedAt, created_at AS createdAt, updated_at AS updatedAt
|
|
30
|
+
FROM project_roles`;
|
|
31
|
+
|
|
32
|
+
const isPerson = (principal: Principal) => !principal.apiKeyId && !principal.runner;
|
|
33
|
+
const who = (principal: Principal) => principal.email || principal.aegisId;
|
|
34
|
+
|
|
35
|
+
function text(value: unknown, max: number): string {
|
|
36
|
+
return typeof value === "string" ? value.trim().slice(0, max) : "";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function listRoles({ env, principal, url }: AuthedRouteContext, projectId: string): Promise<Response> {
|
|
40
|
+
if (!(await memberOfProject(env, principal, projectId))) return response({ error: "Project scope is not allowed" }, 403);
|
|
41
|
+
const status = roleStatusOf(url.searchParams.get("status"));
|
|
42
|
+
const rows = await env.DB.prepare(`
|
|
43
|
+
${roleSelect} WHERE project_id = ? AND (? IS NULL OR status = ?)
|
|
44
|
+
ORDER BY CASE status WHEN 'suggested' THEN 0 WHEN 'confirmed' THEN 1 ELSE 2 END, name
|
|
45
|
+
`).bind(projectId, status, status).all<RoleRow>();
|
|
46
|
+
return response({ roles: rows.results });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function proposeRole({ env, principal, request }: AuthedRouteContext, projectId: string): Promise<Response> {
|
|
50
|
+
if (!(await memberOfProject(env, principal, projectId, "project.write"))) return response({ error: "Project scope is not allowed" }, 403);
|
|
51
|
+
const body = await requestJson(request);
|
|
52
|
+
const name = normalizeRoleName(body?.name);
|
|
53
|
+
if (!name) return response({ error: "name is required: letters, numbers and underscores, up to 60 characters" }, 422);
|
|
54
|
+
const description = text(body?.description, ROLE_DESCRIPTION_MAX);
|
|
55
|
+
const evidence = text(body?.evidence, ROLE_EVIDENCE_MAX) || null;
|
|
56
|
+
// A person with management rights adding a role is choosing it; anything
|
|
57
|
+
// else (an agent, the CLI, a member) is a suggestion for someone to decide.
|
|
58
|
+
const manager = isPerson(principal) && (await memberOfProject(env, principal, projectId, "project.manage"));
|
|
59
|
+
const requestedSource = roleSourceOf(body?.source);
|
|
60
|
+
const source: RoleSource = manager && (!requestedSource || requestedSource === "person") ? "person" : requestedSource && requestedSource !== "person" ? requestedSource : "agent";
|
|
61
|
+
const status: RoleStatus = source === "person" ? "confirmed" : "suggested";
|
|
62
|
+
if (source !== "person" && !evidence) return response({ error: "Say what points at this role in evidence (a sign-in wall, a gated route or menu, a spec entry)" }, 422);
|
|
63
|
+
|
|
64
|
+
const existing = await env.DB.prepare(`${roleSelect} WHERE project_id = ? AND name = ?`).bind(projectId, name).first<RoleRow>();
|
|
65
|
+
if (existing) {
|
|
66
|
+
// Suggesting again only refreshes a suggestion's evidence; a person's
|
|
67
|
+
// decision (confirmed or rejected) stands.
|
|
68
|
+
if (existing.status === "suggested" && status === "suggested") {
|
|
69
|
+
await env.DB.prepare("UPDATE project_roles SET evidence = ?, description = CASE WHEN description = '' THEN ? ELSE description END, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
|
70
|
+
.bind(evidence, description, existing.id).run();
|
|
71
|
+
} else if (existing.status !== "confirmed" && status === "confirmed") {
|
|
72
|
+
await env.DB.prepare("UPDATE project_roles SET status = 'confirmed', decided_by = ?, decided_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
|
73
|
+
.bind(who(principal), existing.id).run();
|
|
74
|
+
await auditProject(env, principal, projectId, { action: "role.confirmed", targetType: "role", targetId: existing.id, data: { name } }, request);
|
|
75
|
+
}
|
|
76
|
+
const role = await env.DB.prepare(`${roleSelect} WHERE id = ?`).bind(existing.id).first<RoleRow>();
|
|
77
|
+
return response({ role, alreadyDecided: existing.status !== "suggested" && status === "suggested" }, 200);
|
|
78
|
+
}
|
|
79
|
+
if (!description) return response({ error: `description is required, up to ${ROLE_DESCRIPTION_MAX} characters` }, 422);
|
|
80
|
+
const id = newId("role");
|
|
81
|
+
await env.DB.prepare(`
|
|
82
|
+
INSERT INTO project_roles (id, project_id, name, description, status, source, evidence, suggested_by, decided_by, decided_at)
|
|
83
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = 'confirmed' THEN CURRENT_TIMESTAMP ELSE NULL END)
|
|
84
|
+
`).bind(id, projectId, name, description, status, source, evidence, who(principal), status === "confirmed" ? who(principal) : null, status).run();
|
|
85
|
+
await auditProject(env, principal, projectId, { action: status === "confirmed" ? "role.created" : "role.suggested", targetType: "role", targetId: id, data: { name, source } }, request);
|
|
86
|
+
return response({ role: await env.DB.prepare(`${roleSelect} WHERE id = ?`).bind(id).first<RoleRow>() }, 201);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function decideRole({ env, principal, request }: AuthedRouteContext, projectId: string, roleId: string): Promise<Response> {
|
|
90
|
+
if (!(await memberOfProject(env, principal, projectId))) return response({ error: "Project scope is not allowed" }, 403);
|
|
91
|
+
if (!isPerson(principal) || !(await memberOfProject(env, principal, projectId, "project.manage"))) {
|
|
92
|
+
return response({ error: "Only a person who manages the project can confirm, rename or reject roles" }, 403);
|
|
93
|
+
}
|
|
94
|
+
const role = await env.DB.prepare(`${roleSelect} WHERE id = ? AND project_id = ?`).bind(roleId, projectId).first<RoleRow>();
|
|
95
|
+
if (!role) return response({ error: "Not found" }, 404);
|
|
96
|
+
if (request.method === "DELETE") {
|
|
97
|
+
await env.DB.prepare("DELETE FROM project_roles WHERE id = ? AND project_id = ?").bind(roleId, projectId).run();
|
|
98
|
+
await auditProject(env, principal, projectId, { action: "role.deleted", targetType: "role", targetId: roleId, data: { name: role.name } }, request);
|
|
99
|
+
return response({ deleted: true });
|
|
100
|
+
}
|
|
101
|
+
const body = await requestJson(request);
|
|
102
|
+
const action = body?.action === "confirm" || body?.action === "reject" ? body.action : body?.action === undefined || body?.action === "edit" ? "edit" : null;
|
|
103
|
+
if (!action) return response({ error: "action must be confirm, reject or edit" }, 422);
|
|
104
|
+
let name = role.name;
|
|
105
|
+
if (body?.name !== undefined) {
|
|
106
|
+
const renamed = normalizeRoleName(body.name);
|
|
107
|
+
if (!renamed) return response({ error: "name must be letters, numbers and underscores, up to 60 characters" }, 422);
|
|
108
|
+
name = renamed;
|
|
109
|
+
}
|
|
110
|
+
const description = body?.description !== undefined ? text(body.description, ROLE_DESCRIPTION_MAX) : role.description;
|
|
111
|
+
if (!description) return response({ error: `description is required, up to ${ROLE_DESCRIPTION_MAX} characters` }, 422);
|
|
112
|
+
const status: RoleStatus = action === "confirm" ? "confirmed" : action === "reject" ? "rejected" : role.status;
|
|
113
|
+
const decided = action !== "edit";
|
|
114
|
+
try {
|
|
115
|
+
await env.DB.prepare(`
|
|
116
|
+
UPDATE project_roles SET name = ?, description = ?, status = ?,
|
|
117
|
+
decided_by = CASE WHEN ? THEN ? ELSE decided_by END,
|
|
118
|
+
decided_at = CASE WHEN ? THEN CURRENT_TIMESTAMP ELSE decided_at END,
|
|
119
|
+
updated_at = CURRENT_TIMESTAMP
|
|
120
|
+
WHERE id = ? AND project_id = ?
|
|
121
|
+
`).bind(name, description, status, decided ? 1 : 0, who(principal), decided ? 1 : 0, roleId, projectId).run();
|
|
122
|
+
} catch {
|
|
123
|
+
return response({ error: "A role with that name already exists" }, 409);
|
|
124
|
+
}
|
|
125
|
+
const auditAction = action === "confirm" ? "role.confirmed" : action === "reject" ? "role.rejected" : "role.updated";
|
|
126
|
+
await auditProject(env, principal, projectId, {
|
|
127
|
+
action: auditAction, targetType: "role", targetId: roleId,
|
|
128
|
+
data: { name, ...(name !== role.name ? { renamedFrom: role.name } : {}) },
|
|
129
|
+
}, request);
|
|
130
|
+
return response({ role: await env.DB.prepare(`${roleSelect} WHERE id = ?`).bind(roleId).first<RoleRow>() });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
registerRoutes(async (context) => {
|
|
134
|
+
const { url, request } = context;
|
|
135
|
+
let params = matchPath(url.pathname, "/api/projects/:projectId/roles");
|
|
136
|
+
if (params && request.method === "GET") return listRoles(context, params.projectId);
|
|
137
|
+
if (params && request.method === "POST") return proposeRole(context, params.projectId);
|
|
138
|
+
params = matchPath(url.pathname, "/api/projects/:projectId/roles/:roleId");
|
|
139
|
+
if (params && (request.method === "PATCH" || request.method === "DELETE")) return decideRole(context, params.projectId, params.roleId);
|
|
140
|
+
return null;
|
|
141
|
+
});
|
|
@@ -115,6 +115,7 @@ async function queuedJobs(env: Env, nowIso: string): Promise<QueuedJob[]> {
|
|
|
115
115
|
FROM ${JOB_TABLE[kind]}
|
|
116
116
|
WHERE execution_mode = 'cloud' AND status = 'queued' AND workspace_id IS NOT NULL
|
|
117
117
|
AND (next_dispatch_at IS NULL OR next_dispatch_at <= ?)
|
|
118
|
+
${kind === "task" ? "AND approved_at IS NOT NULL" : ""}
|
|
118
119
|
ORDER BY requested_at ASC LIMIT 500
|
|
119
120
|
`).bind(nowIso).all<QueuedJob>();
|
|
120
121
|
const [runs, tasks] = await Promise.all([select("run"), select("task")]);
|
|
@@ -10,6 +10,10 @@ import { cloudRunMaxMinutes } from "./policy.js";
|
|
|
10
10
|
import { cloudWorkerId } from "./jobs.js";
|
|
11
11
|
import "./dispatch.js";
|
|
12
12
|
import "./metering.js";
|
|
13
|
+
import { approvalModeOf } from "../../registry/task-scheduling.js";
|
|
14
|
+
import { signInFlowOf } from "../secrets/routes.js";
|
|
15
|
+
import { getEntitlements } from "../billing/entitlements.js";
|
|
16
|
+
import { entitlementsFor } from "../../billing/plan-catalog.js";
|
|
13
17
|
|
|
14
18
|
/** A Worker request body is capped at 100 MB; stay under it. */
|
|
15
19
|
export const MAX_UPLOAD_BYTES = 95 * 1024 * 1024;
|
|
@@ -38,7 +42,7 @@ async function bundle(env: Env, principal: Principal, runId: string): Promise<Re
|
|
|
38
42
|
SELECT r.id, r.project_id AS projectId, r.workspace_id AS workspaceId, r.plan_id AS planId,
|
|
39
43
|
r.journey_id AS journeyId, r.status, r.worker_id AS workerId, r.execution_mode AS executionMode,
|
|
40
44
|
r.targets_json AS targetsJson, r.environment_base_url AS environmentBaseUrl, r.fix_from_json AS fixFromJson,
|
|
41
|
-
p.name AS projectName
|
|
45
|
+
r.case_scope_json AS caseScopeJson, p.name AS projectName
|
|
42
46
|
FROM runs r JOIN projects p ON p.id = r.project_id
|
|
43
47
|
WHERE r.id = ?
|
|
44
48
|
`).bind(runId).first<Record<string, string | null>>();
|
|
@@ -57,6 +61,7 @@ async function bundle(env: Env, principal: Principal, runId: string): Promise<Re
|
|
|
57
61
|
environmentBaseUrl: run.environmentBaseUrl,
|
|
58
62
|
targets: parseJson(run.targetsJson),
|
|
59
63
|
fixFrom: parseJson(run.fixFromJson),
|
|
64
|
+
caseScope: parseJson(run.caseScopeJson),
|
|
60
65
|
},
|
|
61
66
|
project: { id: run.projectId, name: run.projectName, workspaceId: run.workspaceId },
|
|
62
67
|
workerId: run.workerId,
|
|
@@ -76,7 +81,13 @@ async function taskBundle(env: Env, principal: Principal, taskId: string): Promi
|
|
|
76
81
|
if (task.status !== "running" || task.workerId !== cloudWorkerId({ kind: "task", id: taskId })) {
|
|
77
82
|
return response({ error: "This task isn't assigned to a cloud runner", status: task.status }, 409);
|
|
78
83
|
}
|
|
84
|
+
// Cloud exploration signs in with this first; its secrets come from /secrets.
|
|
85
|
+
const signInFlow = await signInFlowOf(env, String(task.projectId));
|
|
86
|
+
// How far exploration goes is part of the workspace's plan.
|
|
87
|
+
const { discovery } = task.workspaceId ? await getEntitlements(env, String(task.workspaceId)) : entitlementsFor("free");
|
|
79
88
|
return response({
|
|
89
|
+
signInFlow: signInFlow?.content ?? null,
|
|
90
|
+
discovery,
|
|
80
91
|
task: {
|
|
81
92
|
id: task.id,
|
|
82
93
|
projectId: task.projectId,
|
|
@@ -148,11 +159,17 @@ async function deletePrefix({ env, principal, url, request }: AuthedRouteContext
|
|
|
148
159
|
async function updateSettings({ env, principal, request }: AuthedRouteContext, projectId: string): Promise<Response> {
|
|
149
160
|
if (!(await memberOfProject(env, principal, projectId, "project.manage"))) return response({ error: "Project scope is not allowed" }, 403);
|
|
150
161
|
const body = await requestJson(request);
|
|
162
|
+
// Either setting may be sent alone; each is checked only when present.
|
|
151
163
|
const mode = body?.defaultExecutionMode;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
return response({
|
|
164
|
+
const approval = body?.taskApprovalMode;
|
|
165
|
+
if (mode === undefined && approval === undefined) return response({ error: "Send defaultExecutionMode or taskApprovalMode" }, 422);
|
|
166
|
+
if (mode !== undefined && mode !== "self" && mode !== "cloud") return response({ error: "defaultExecutionMode must be self or cloud" }, 422);
|
|
167
|
+
if (approval !== undefined && !approvalModeOf(approval)) return response({ error: "taskApprovalMode must be manual, auto or extreme" }, 422);
|
|
168
|
+
if (mode !== undefined) await env.DB.prepare("UPDATE projects SET default_execution_mode = ? WHERE id = ?").bind(mode, projectId).run();
|
|
169
|
+
if (approval !== undefined) await env.DB.prepare("UPDATE projects SET task_approval_mode = ? WHERE id = ?").bind(approval, projectId).run();
|
|
170
|
+
const changed = { ...(mode !== undefined ? { defaultExecutionMode: mode } : {}), ...(approval !== undefined ? { taskApprovalMode: approval } : {}) };
|
|
171
|
+
await auditProject(env, principal, projectId, { action: "project.settings_updated", targetType: "project", targetId: projectId, data: changed }, request);
|
|
172
|
+
return response({ project: { id: projectId, ...changed } });
|
|
156
173
|
}
|
|
157
174
|
|
|
158
175
|
registerRoutes(async (context) => {
|
|
@@ -58,6 +58,9 @@ test("runner tokens name one run, can't be altered and expire", async () => {
|
|
|
58
58
|
assert.equal(allowed("PUT", "/api/projects/shop/artifacts/object?key=projects/shop/plans/a.yaml"), false);
|
|
59
59
|
assert.equal(allowed("PUT", "/api/projects/shop/artifacts/object?key=projects/shop/runs/run_1/video.webm"), true);
|
|
60
60
|
assert.equal(allowed("PUT", "/api/projects/shop/artifacts/object?key=projects/shop/runs/run_10/video.webm"), false);
|
|
61
|
+
assert.equal(allowed("PUT", "/api/projects/shop/artifacts/object?key=projects/shop/journeys/graph.json"), true, "a passing run promotes the journey it proved");
|
|
62
|
+
assert.equal(allowed("PUT", "/api/projects/shop/artifacts/object?key=projects/shop/journeys/other.json"), false);
|
|
63
|
+
assert.equal(allowed("PUT", "/api/projects/other/artifacts/object?key=projects/other/journeys/graph.json"), false);
|
|
61
64
|
assert.equal(allowed("GET", "/api/projects/other/artifacts"), false);
|
|
62
65
|
assert.equal(allowed("GET", "/api/projects"), false);
|
|
63
66
|
assert.equal(allowed("POST", "/api/tasks/run_1"), false);
|
|
@@ -148,6 +151,7 @@ test("discovery runs in the cloud too, publishes what it finds, and is billed",
|
|
|
148
151
|
assert.equal(bundle.status, 200);
|
|
149
152
|
assert.equal(bundle.json.task.description, "Explore https://shop.test");
|
|
150
153
|
assert.equal(bundle.json.workerId, `cloud:task:${taskId}`);
|
|
154
|
+
assert.deepEqual(bundle.json.discovery, { maxPages: 12, maxDepth: 2 }, "exploration is bounded by the workspace's plan");
|
|
151
155
|
assert.equal((await call(env, "GET", `/api/runner/runs/${taskId}/bundle`, { token })).status, 403);
|
|
152
156
|
|
|
153
157
|
const flowKey = `projects/${projectId}/spec/flows/discovered/home.yaml`;
|
|
@@ -214,6 +218,61 @@ test("a discovered URL fills a project's empty default environment but never ove
|
|
|
214
218
|
}
|
|
215
219
|
});
|
|
216
220
|
|
|
221
|
+
test("a discovered URL makes an existing environment the default instead of adding a second Production", async () => {
|
|
222
|
+
const launcher = fakeLauncher();
|
|
223
|
+
try {
|
|
224
|
+
const { env, db, projectId, owner } = await cloudProject();
|
|
225
|
+
db.db.prepare(`
|
|
226
|
+
INSERT INTO project_environments (id, project_id, app_id, name, base_url, status, is_default)
|
|
227
|
+
VALUES ('production', ?, 'default', 'Production', '', 'active', 0)
|
|
228
|
+
`).run(projectId);
|
|
229
|
+
const created = await call(env, "POST", `/api/projects/${projectId}/tasks`, {
|
|
230
|
+
as: owner,
|
|
231
|
+
body: { title: "Explore https://shop.test", description: "Explore https://shop.test", taskType: "discovery", idempotencyKey: "d1", executionMode: "cloud" },
|
|
232
|
+
});
|
|
233
|
+
const taskId = created.json.task.id;
|
|
234
|
+
const { token } = launcher.launches[0];
|
|
235
|
+
await call(env, "POST", `/api/tasks/${taskId}`, { token, body: { workerId: `cloud:task:${taskId}`, status: "completed", completionSummary: "Explored 1 page" } });
|
|
236
|
+
const environments = db.db.prepare("SELECT id, base_url AS baseUrl, is_default AS isDefault FROM project_environments WHERE project_id = ?").all(projectId).map((row: any) => ({ ...row }));
|
|
237
|
+
assert.deepEqual(environments, [{ id: "production", baseUrl: "https://shop.test", isDefault: 1 }]);
|
|
238
|
+
} finally {
|
|
239
|
+
launcher.restore();
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("an app can't have two environments with the same name", async () => {
|
|
244
|
+
const { env, projectId, owner } = await cloudProject();
|
|
245
|
+
const create = (id: string, name: string) => call(env, "POST", `/api/projects/${projectId}/environments`, {
|
|
246
|
+
as: owner,
|
|
247
|
+
body: { id, name, baseUrl: "https://shop.test" },
|
|
248
|
+
});
|
|
249
|
+
assert.equal((await create("production", "Production")).status, 201);
|
|
250
|
+
const twin = await create("prod-2", " production ");
|
|
251
|
+
assert.equal(twin.status, 409);
|
|
252
|
+
assert.match(twin.json.error, /already has an environment named "production" \(production\)/);
|
|
253
|
+
assert.equal((await create("staging", "Staging")).status, 201);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("a cloud exploration waiting for approval starts a runner only once it's approved", async () => {
|
|
257
|
+
const launcher = fakeLauncher();
|
|
258
|
+
try {
|
|
259
|
+
const { env, projectId, owner } = await cloudProject();
|
|
260
|
+
assert.equal((await call(env, "PATCH", `/api/projects/${projectId}/settings`, { as: owner, body: { taskApprovalMode: "manual" } })).status, 200);
|
|
261
|
+
const created = await call(env, "POST", `/api/projects/${projectId}/tasks`, {
|
|
262
|
+
as: owner,
|
|
263
|
+
body: { title: "Explore https://shop.test", description: "Explore https://shop.test", taskType: "discovery", idempotencyKey: "d1", executionMode: "cloud" },
|
|
264
|
+
});
|
|
265
|
+
assert.equal(created.status, 201, JSON.stringify(created.json));
|
|
266
|
+
assert.equal(created.json.task.approvedAt, null);
|
|
267
|
+
await runScheduled(env, "* * * * *");
|
|
268
|
+
assert.equal(launcher.launches.length, 0, "neither queuing nor the dispatcher starts it");
|
|
269
|
+
assert.equal((await call(env, "POST", `/api/tasks/${created.json.task.id}/approve`, { as: owner })).status, 200);
|
|
270
|
+
assert.equal(launcher.launches.length, 1, "approving hands it to a runner");
|
|
271
|
+
} finally {
|
|
272
|
+
launcher.restore();
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
217
276
|
test("runs and discovery share the workspace's parallel slots", async () => {
|
|
218
277
|
const launcher = fakeLauncher();
|
|
219
278
|
try {
|
|
@@ -72,19 +72,30 @@ export function runnerMayCall(identity: RunnerIdentity, method: string, url: URL
|
|
|
72
72
|
const key = url.searchParams.get(path.endsWith("/object") ? "key" : "prefix") ?? "";
|
|
73
73
|
if (identity.kind === "run") {
|
|
74
74
|
if (path === `/api/runner/runs/${id}/bundle`) return method === "GET";
|
|
75
|
+
// The project's sign-in secrets, for this run only (secrets/routes.ts).
|
|
76
|
+
if (path === `/api/runner/runs/${id}/secrets`) return method === "GET";
|
|
77
|
+
// A token its project's own cloud trusts, to read the project's vault (identity/oidc.ts).
|
|
78
|
+
if (path === `/api/runner/runs/${id}/identity-token`) return method === "GET";
|
|
75
79
|
if (path === `/api/runs/${id}`) return method === "GET" || method === "POST";
|
|
76
80
|
if (path === `/api/runs/${id}/device-access`) return method === "GET";
|
|
77
81
|
if (path === `/api/runs/${id}/device-sessions`) return method === "POST";
|
|
78
82
|
if (path.startsWith(`/api/runs/${id}/device-sessions/`)) return method === "GET" || method === "POST";
|
|
79
83
|
if (artifacts) {
|
|
80
84
|
if (method === "GET") return true;
|
|
81
|
-
|
|
85
|
+
if ((method === "PUT" || method === "DELETE") && key.startsWith(`projects/${project}/runs/${id}/`)) return true;
|
|
86
|
+
// A passing run promotes the draft journey it proved (run-job.ts), so
|
|
87
|
+
// it may rewrite the graph, and only the graph, of its own project.
|
|
88
|
+
return method === "PUT" && key === `projects/${project}/journeys/graph.json`;
|
|
82
89
|
}
|
|
83
90
|
return false;
|
|
84
91
|
}
|
|
85
92
|
if (path === `/api/runner/tasks/${id}/bundle`) return method === "GET";
|
|
93
|
+
if (path === `/api/runner/tasks/${id}/secrets`) return method === "GET";
|
|
94
|
+
if (path === `/api/runner/tasks/${id}/identity-token`) return method === "GET";
|
|
86
95
|
if (path === `/api/tasks/${id}`) return method === "POST";
|
|
87
96
|
if (path === `/api/projects/${project}/plans`) return method === "POST";
|
|
97
|
+
// An exploration that meets a sign-in wall suggests roles; a person confirms them.
|
|
98
|
+
if (path === `/api/projects/${project}/roles`) return method === "POST";
|
|
88
99
|
if (artifacts) {
|
|
89
100
|
if (method === "GET") return true;
|
|
90
101
|
return method === "PUT" && ["spec", "plans", "journeys"].some((folder) => key.startsWith(`projects/${project}/${folder}/`));
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Encryption for project secrets (sign-in passwords a cloud run types).
|
|
2
|
+
//
|
|
3
|
+
// One registry secret, PROJECT_SECRETS_KEY (32 random bytes, base64), is the
|
|
4
|
+
// root. Each project gets its own AES-256-GCM key from it with HKDF-SHA256
|
|
5
|
+
// (info "bugmole:project-secret:<projectId>"), and every value is sealed with
|
|
6
|
+
// additional data "<projectId>|<environmentId>|<name>", so a ciphertext copied
|
|
7
|
+
// to another project, environment or name fails to decrypt instead of being
|
|
8
|
+
// typed somewhere it was never meant to go.
|
|
9
|
+
//
|
|
10
|
+
// Rotation: set the new key as PROJECT_SECRETS_KEY with a higher
|
|
11
|
+
// PROJECT_SECRETS_KEY_VERSION, and keep the old one as
|
|
12
|
+
// PROJECT_SECRETS_KEY_PREVIOUS (+ _PREVIOUS_VERSION) until no row still has
|
|
13
|
+
// the old key_version. Rows are re-sealed with the current key when they are
|
|
14
|
+
// next read or replaced.
|
|
15
|
+
import { base64, fromBase64 } from "../core.js";
|
|
16
|
+
|
|
17
|
+
export type SecretKeyEnv = {
|
|
18
|
+
PROJECT_SECRETS_KEY?: string;
|
|
19
|
+
PROJECT_SECRETS_KEY_VERSION?: string;
|
|
20
|
+
PROJECT_SECRETS_KEY_PREVIOUS?: string;
|
|
21
|
+
PROJECT_SECRETS_KEY_PREVIOUS_VERSION?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type SealedSecret = { ciphertext: string; iv: string; keyVersion: number };
|
|
25
|
+
export type SecretScope = { projectId: string; environmentId: string | null; name: string };
|
|
26
|
+
|
|
27
|
+
export const PROJECT_SECRETS_UNCONFIGURED =
|
|
28
|
+
"Project secrets are turned off: this registry has no PROJECT_SECRETS_KEY. Nothing was stored.";
|
|
29
|
+
|
|
30
|
+
const encoder = new TextEncoder();
|
|
31
|
+
|
|
32
|
+
function decodeKey(value: string): Uint8Array | null {
|
|
33
|
+
try {
|
|
34
|
+
const bytes = fromBase64(value.trim());
|
|
35
|
+
return bytes.length === 32 ? bytes : null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function versionOf(value: string | undefined, fallback: number): number {
|
|
42
|
+
const parsed = Number(value);
|
|
43
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type RootKey = { version: number; bytes: Uint8Array };
|
|
47
|
+
|
|
48
|
+
/** The keys this registry can use: the current one first. Empty when the feature is off. */
|
|
49
|
+
export function rootKeys(env: SecretKeyEnv): RootKey[] {
|
|
50
|
+
const current = env.PROJECT_SECRETS_KEY ? decodeKey(env.PROJECT_SECRETS_KEY) : null;
|
|
51
|
+
if (!current) return [];
|
|
52
|
+
const version = versionOf(env.PROJECT_SECRETS_KEY_VERSION, 1);
|
|
53
|
+
const keys: RootKey[] = [{ version, bytes: current }];
|
|
54
|
+
const previous = env.PROJECT_SECRETS_KEY_PREVIOUS ? decodeKey(env.PROJECT_SECRETS_KEY_PREVIOUS) : null;
|
|
55
|
+
const previousVersion = versionOf(env.PROJECT_SECRETS_KEY_PREVIOUS_VERSION, version - 1);
|
|
56
|
+
if (previous && previousVersion !== version) keys.push({ version: previousVersion, bytes: previous });
|
|
57
|
+
return keys;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Whether project secrets can be stored and read on this registry. */
|
|
61
|
+
export function secretsConfigured(env: SecretKeyEnv): boolean {
|
|
62
|
+
return rootKeys(env).length > 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function derivedKey(root: Uint8Array, info: string, usage: "encrypt" | "decrypt"): Promise<CryptoKey> {
|
|
66
|
+
const material = await crypto.subtle.importKey("raw", root, "HKDF", false, ["deriveKey"]);
|
|
67
|
+
return crypto.subtle.deriveKey(
|
|
68
|
+
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: encoder.encode(info) },
|
|
69
|
+
material,
|
|
70
|
+
{ name: "AES-GCM", length: 256 },
|
|
71
|
+
false,
|
|
72
|
+
[usage],
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Where a sealed value belongs: `info` picks the derived key (one per project
|
|
78
|
+
* and purpose), `aad` binds the ciphertext to the exact row and field it was
|
|
79
|
+
* written for. Other features (the Jira integration) seal their credentials
|
|
80
|
+
* under the same root key with their own `info`, so a project secret and a
|
|
81
|
+
* Jira token never share a key.
|
|
82
|
+
*/
|
|
83
|
+
export type SealContext = { info: string; aad: string };
|
|
84
|
+
|
|
85
|
+
/** The additional data that binds a value to where it belongs. */
|
|
86
|
+
export function secretAad(scope: SecretScope): Uint8Array {
|
|
87
|
+
return encoder.encode(`${scope.projectId}|${scope.environmentId ?? ""}|${scope.name}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function projectSecretContext(scope: SecretScope): SealContext {
|
|
91
|
+
return { info: `bugmole:project-secret:${scope.projectId}`, aad: new TextDecoder().decode(secretAad(scope)) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Seals a value with the current key under `context`. Throws when the feature is off. */
|
|
95
|
+
export async function sealInContext(env: SecretKeyEnv, context: SealContext, value: string): Promise<SealedSecret> {
|
|
96
|
+
const [current] = rootKeys(env);
|
|
97
|
+
if (!current) throw new Error(PROJECT_SECRETS_UNCONFIGURED);
|
|
98
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
99
|
+
const sealed = await crypto.subtle.encrypt(
|
|
100
|
+
{ name: "AES-GCM", iv, additionalData: encoder.encode(context.aad) },
|
|
101
|
+
await derivedKey(current.bytes, context.info, "encrypt"),
|
|
102
|
+
encoder.encode(value),
|
|
103
|
+
);
|
|
104
|
+
return { ciphertext: base64(new Uint8Array(sealed)), iv: base64(iv), keyVersion: current.version };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Opens a value sealed under `context`. Throws on an unknown key version or any mismatch. */
|
|
108
|
+
export async function openInContext(env: SecretKeyEnv, context: SealContext, sealed: SealedSecret): Promise<string> {
|
|
109
|
+
const root = rootKeys(env).find((key) => key.version === sealed.keyVersion);
|
|
110
|
+
if (!root) throw new Error(`No PROJECT_SECRETS_KEY for key version ${sealed.keyVersion}`);
|
|
111
|
+
const opened = await crypto.subtle.decrypt(
|
|
112
|
+
{ name: "AES-GCM", iv: fromBase64(sealed.iv), additionalData: encoder.encode(context.aad) },
|
|
113
|
+
await derivedKey(root.bytes, context.info, "decrypt"),
|
|
114
|
+
fromBase64(sealed.ciphertext),
|
|
115
|
+
);
|
|
116
|
+
return new TextDecoder().decode(opened);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Seals a value with the current key. Throws when the feature is off. */
|
|
120
|
+
export async function sealSecret(env: SecretKeyEnv, scope: SecretScope, value: string): Promise<SealedSecret> {
|
|
121
|
+
return sealInContext(env, projectSecretContext(scope), value);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Opens a sealed value. Throws when the key version is unknown, or when the
|
|
126
|
+
* ciphertext, project, environment or name don't match what was sealed.
|
|
127
|
+
*/
|
|
128
|
+
export async function openSecret(env: SecretKeyEnv, scope: SecretScope, sealed: SealedSecret): Promise<string> {
|
|
129
|
+
return openInContext(env, projectSecretContext(scope), sealed);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The key version new values are sealed with, or null when the feature is off. */
|
|
133
|
+
export function currentKeyVersion(env: SecretKeyEnv): number | null {
|
|
134
|
+
return rootKeys(env)[0]?.version ?? null;
|
|
135
|
+
}
|