agent-device-lab 0.0.0-stage → 0.3.1

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 (54) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +150 -2
  4. package/assets/dashboard/app.css +238 -0
  5. package/assets/dashboard/app.js +976 -0
  6. package/assets/dashboard/index.html +120 -0
  7. package/bin/agentlab.js +8 -0
  8. package/dist/cli/ci.js +443 -0
  9. package/dist/cli/flow.js +127 -0
  10. package/dist/cli/main.js +855 -0
  11. package/dist/cli/signals.js +38 -0
  12. package/dist/core/action-log.js +125 -0
  13. package/dist/core/auth.js +175 -0
  14. package/dist/core/bundle.js +432 -0
  15. package/dist/core/checks.js +137 -0
  16. package/dist/core/ci.js +389 -0
  17. package/dist/core/commands.js +366 -0
  18. package/dist/core/control.js +217 -0
  19. package/dist/core/detectors.js +392 -0
  20. package/dist/core/devices.js +83 -0
  21. package/dist/core/doctor.js +203 -0
  22. package/dist/core/explore-safety.js +110 -0
  23. package/dist/core/extract.js +1445 -0
  24. package/dist/core/feed.js +493 -0
  25. package/dist/core/findings.js +179 -0
  26. package/dist/core/format.js +311 -0
  27. package/dist/core/init.js +607 -0
  28. package/dist/core/lab.js +1979 -0
  29. package/dist/core/migrate.js +35 -0
  30. package/dist/core/observation.js +161 -0
  31. package/dist/core/ownership.js +142 -0
  32. package/dist/core/process-group.js +206 -0
  33. package/dist/core/process-identity.js +56 -0
  34. package/dist/core/profile.js +816 -0
  35. package/dist/core/project-runner.js +359 -0
  36. package/dist/core/replay.js +303 -0
  37. package/dist/core/reproduction.js +36 -0
  38. package/dist/core/scan-policy.js +156 -0
  39. package/dist/core/scan-report.js +145 -0
  40. package/dist/core/scan.js +455 -0
  41. package/dist/core/schema.js +39 -0
  42. package/dist/core/services.js +134 -0
  43. package/dist/core/steps.js +80 -0
  44. package/dist/core/sweep.js +138 -0
  45. package/dist/core/trace-sanitize.js +641 -0
  46. package/dist/core/url-redact.js +25 -0
  47. package/dist/core/versions.js +29 -0
  48. package/dist/core/zip.js +264 -0
  49. package/dist/daemon/main.js +160 -0
  50. package/dist/daemon/state.js +93 -0
  51. package/dist/dashboard/open.js +25 -0
  52. package/dist/dashboard/server.js +366 -0
  53. package/dist/mcp/server.js +89 -0
  54. package/package.json +63 -4
@@ -0,0 +1,455 @@
1
+ import { copyFileSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { redactUrlSecrets } from './url-redact.js';
3
+ import { join } from 'node:path';
4
+ import { checkFilter, measureState, shot } from './checks.js';
5
+ import { getDevice } from './devices.js';
6
+ import { wrapHits } from './detectors.js';
7
+ import { blockedRequest, classifyControl, orderCandidates, stateSignature } from './explore-safety.js';
8
+ import { controlTraits, installShiftObserver, takeLayoutShifts } from './extract.js';
9
+ import { Lab } from './lab.js';
10
+ import { evaluatePolicy, groupFindings, matchSuppressions } from './scan-policy.js';
11
+ import { scanReportHtml } from './scan-report.js';
12
+ import { check, stepRequest } from './steps.js';
13
+ import { LabError, SCHEMA_VERSION, } from './schema.js';
14
+ const DECISIONS_MAX = 60;
15
+ const controlKey = (c) => `${c.role}\u0000${c.name}\u0000${c.context ?? ''}`;
16
+ const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'scenario';
17
+ export async function runScan(env, opts) {
18
+ const cfg = env.profile.scan;
19
+ const t0 = Date.now();
20
+ const startedAt = new Date().toISOString();
21
+ const scenarios = selectScenarios(env.profile, opts);
22
+ const plan = scenarios.map((sc) => {
23
+ const ids = opts.devices ? (sc.devices ? sc.devices.filter((d) => opts.devices.includes(d)) : [...opts.devices]) : (sc.devices ?? cfg.devices);
24
+ return { sc, devices: ids.map((d) => getDevice(d, env.profile.devices)) };
25
+ });
26
+ const dir = join(env.runDir, 'scans', opts.id);
27
+ mkdirSync(dir, { recursive: true });
28
+ const exploring = (sc) => opts.explore ?? sc.explore ?? cfg.explore.enabled;
29
+ env.emit({
30
+ kind: 'scan', phase: 'start', id: opts.id, explore: scenarios.some(exploring),
31
+ runs: plan.flatMap(({ sc, devices }) => devices.map((d) => ({ scenario: sc.name, route: redactUrlSecrets(sc.route), device: d.id, width: d.viewport.width, height: d.viewport.height }))),
32
+ });
33
+ const runs = [];
34
+ const budget = { exploreMs: 0 };
35
+ let interrupted;
36
+ for (const [i, { sc, devices }] of plan.entries()) {
37
+ if (interrupted)
38
+ break;
39
+ // Per state label, what each device measured: compared across nearby widths when all have run.
40
+ const samples = new Map();
41
+ const scRuns = [];
42
+ for (const [j, device] of devices.entries()) {
43
+ const stop = env.checkpoint?.();
44
+ if (stop) {
45
+ // Everything not yet run is listed, never silently dropped; runs already done keep their results.
46
+ const skipped = [...devices.slice(j).map((d) => ({ scenario: sc.name, device: d.id })),
47
+ ...plan.slice(i + 1).flatMap((p) => p.devices.map((d) => ({ scenario: p.sc.name, device: d.id })))];
48
+ interrupted = { ...stop, skipped };
49
+ break;
50
+ }
51
+ env.emit({ kind: 'scan', phase: 'run-start', id: opts.id, scenario: sc.name, device: device.id });
52
+ const run = await runScenario(env, opts, sc, device, join(dir, 'frames', `${i + 1}-${slug(sc.name)}`, device.id), exploring(sc), budget, samples);
53
+ runs.push(run);
54
+ scRuns.push(run);
55
+ env.emit({ kind: 'scan', phase: 'run-done', id: opts.id, run });
56
+ }
57
+ // Label wrapping only shows by comparing widths, so it is checked once every device has run.
58
+ if (checkFilter(cfg.checks, sc.checks)('text-wrap-change')) {
59
+ for (const [label, list] of samples) {
60
+ for (const { device, hit } of wrapHits(list, { ratio: cfg.wrapNearbyRatio, noWrap: cfg.noWrap, route: redactUrlSecrets(sc.route) })) {
61
+ const s = list.find((x) => x.device === device);
62
+ const r = env.store.recordHit(hit, { route: s.route, viewport: { width: s.width }, gen: env.gen() }, s.repro, { device, keepRefs: false, scenario: sc.name, state: label, ...(s.frame ? { frame: s.frame } : {}) });
63
+ const state = s.run.states.find((st) => st.id === s.stateId);
64
+ if (state && !state.findings.includes(r.finding.id))
65
+ state.findings.push(r.finding.id);
66
+ if (!s.run.findings.includes(r.finding.id))
67
+ s.run.findings.push(r.finding.id);
68
+ if (r.fresh)
69
+ env.emit({ kind: 'findings', findings: [r.finding], ...(s.frame ? { frame: s.frame } : {}) });
70
+ }
71
+ }
72
+ }
73
+ }
74
+ const ids = new Set(runs.flatMap((r) => r.findings));
75
+ const findings = env.store.list().filter((f) => ids.has(f.id));
76
+ const today = opts.today ?? new Date().toISOString().slice(0, 10);
77
+ const { statuses, applied } = matchSuppressions(findings, cfg.suppressions, { today, scenarios: scenarios.map((s) => s.name), devices: [...new Set(runs.map((r) => r.device))] });
78
+ for (const f of findings)
79
+ env.store.suppress(f.id, applied.get(f.id));
80
+ const result = {
81
+ schemaVersion: SCHEMA_VERSION, id: opts.id, startedAt, ms: Date.now() - t0,
82
+ devices: [...new Set(plan.flatMap((p) => p.devices.map((d) => d.id)))], explore: scenarios.some(exploring),
83
+ runs, findings: structuredClone(findings), groups: groupFindings(findings), suppressions: statuses,
84
+ verdict: interruptedVerdict(evaluatePolicy(findings, runs, cfg.policy), interrupted),
85
+ reports: { html: join(dir, 'report.html'), json: join(dir, 'result.json') }, exploreMs: budget.exploreMs,
86
+ ...(interrupted ? { interrupted } : {}),
87
+ };
88
+ writeFileSync(result.reports.json, JSON.stringify(result, null, 2));
89
+ writeFileSync(result.reports.html, scanReportHtml(result, dir));
90
+ env.emit({ kind: 'scan', phase: 'done', id: opts.id, result });
91
+ return result;
92
+ }
93
+ /** An interrupted scan never claims pass or fail: only part of its plan ran. */
94
+ function interruptedVerdict(v, i) {
95
+ if (!i)
96
+ return v;
97
+ const why = i.reason === 'takeover' ? 'a person took over the browser' : i.reason === 'paused' ? 'a person paused the session' : 'a person stopped the session';
98
+ return { ...v, result: 'incomplete', reasons: [`interrupted: ${why}; ${i.skipped.length} scenario run(s) did not run`, ...v.reasons] };
99
+ }
100
+ function selectScenarios(profile, opts) {
101
+ if (opts.route) {
102
+ if (!opts.route.startsWith('/'))
103
+ throw new LabError('invalid_request', `scan route must be a path starting with "/", got "${opts.route}"`);
104
+ return [{ name: `${redactUrlSecrets(opts.route)} as loaded`, route: opts.route, auth: 'session', steps: [], cleanup: [] }];
105
+ }
106
+ const declared = profile.scan.scenarios;
107
+ if (!declared.length)
108
+ return [{ name: `${profile.startPath} as loaded`, route: profile.startPath, auth: 'session', steps: [], cleanup: [] }];
109
+ if (!opts.scenarios?.length)
110
+ return declared;
111
+ const unknown = opts.scenarios.filter((n) => !declared.some((s) => s.name === n));
112
+ if (unknown.length) {
113
+ throw new LabError('not_found', `No scenario ${unknown.map((n) => `"${n}"`).join(', ')} in ${profile.profilePath}`, { hint: `Scenarios: ${declared.map((s) => s.name).join(', ')}` });
114
+ }
115
+ return declared.filter((s) => opts.scenarios.includes(s.name));
116
+ }
117
+ async function runScenario(env, opts, sc, device, dir, explore, budget, samples) {
118
+ const cfg = env.profile.scan;
119
+ const t0 = Date.now();
120
+ mkdirSync(dir, { recursive: true });
121
+ const run = {
122
+ scenario: sc.name, device: device.id, width: device.viewport.width, height: device.viewport.height, status: 'ok', ms: 0,
123
+ states: [], decisions: [], decisionsOmitted: 0, limits: [], findings: [],
124
+ };
125
+ const enabled = checkFilter(cfg.checks, sc.checks);
126
+ const detectors = { tapTargets: cfg.tapTargets, layoutShiftMin: cfg.layoutShiftMin, enabled };
127
+ const url = env.profile.app.url.replace(/\/+$/, '') + sc.route;
128
+ let phase = 'context';
129
+ const r = {};
130
+ // Exploration guard: while a candidate is activated, requests that could change data and
131
+ // navigations off the origin are blocked (and reported), whatever the control claimed to be.
132
+ const guard = { on: false, blocked: [] };
133
+ const origin = new URL(env.profile.app.url).origin;
134
+ let storage;
135
+ let prefix;
136
+ let beforeFrame;
137
+ const open = async () => {
138
+ const lab = await Lab.isolated({
139
+ browser: env.browser, profile: env.profile, device, storageState: storage, headed: env.headed, watch: env.watch,
140
+ sessionId: `${env.sessionId}/${opts.id}/${slug(sc.name)}/${device.id}`, runDir: dir,
141
+ init: {
142
+ scripts: enabled('layout-shift') ? [installShiftObserver] : [],
143
+ ...(explore ? { guard: (method, u, nav) => {
144
+ if (!guard.on)
145
+ return undefined;
146
+ const label = blockedRequest(method, u, nav, origin);
147
+ if (label)
148
+ guard.blocked.push(label);
149
+ return label;
150
+ } } : {}),
151
+ },
152
+ });
153
+ if (enabled('layout-shift')) {
154
+ lab.onActed = async () => { beforeFrame = await shot(lab.activePage, join(dir, 'before.jpg')); };
155
+ }
156
+ return lab;
157
+ };
158
+ const describeOpen = `scan ${opts.id}: open ${redactUrlSecrets(url)} in a new ${device.id} context (${device.viewport.width}x${device.viewport.height}) ` +
159
+ (sc.auth === 'session' ? 'with the session\'s cookies and storage' : sc.auth === 'saved' ? 'with the saved sign-in state' : 'signed out (fresh)');
160
+ const setup = async (lab, adopt) => {
161
+ const loaded = await lab.load(sc.route, describeOpen);
162
+ if (!adopt)
163
+ return loaded.observation;
164
+ phase = 'setup';
165
+ for (const [i, step] of sc.steps.entries())
166
+ await runStep(lab, step, `setup step ${i + 1}`, adopt ? (f) => adoptFindings(f, 'setup') : undefined);
167
+ return lab.lastObservation;
168
+ };
169
+ const adoptFindings = (fs, state) => {
170
+ for (const f of fs) {
171
+ const a = env.store.adopt(f, [...prefix, ...f.reproduction], { device: device.id, keepRefs: false, scenario: sc.name, state });
172
+ if (!run.findings.includes(a.finding.id))
173
+ run.findings.push(a.finding.id);
174
+ if (a.fresh)
175
+ env.emit({ kind: 'findings', findings: [a.finding], ...(a.finding.frame ? { frame: a.finding.frame } : {}) });
176
+ }
177
+ };
178
+ const measure = async (st, when) => {
179
+ const lab = r.lab;
180
+ // What the state looked like right after the last input, before it settled: kept only as the
181
+ // "before" frame of a layout-shift finding.
182
+ const before = beforeFrame ? join(dir, `${st.id}-before.jpg`) : undefined;
183
+ if (before)
184
+ copyFileSync(beforeFrame, before);
185
+ beforeFrame = undefined;
186
+ const m = await measureState(lab.activePage, {
187
+ store: env.store, sessionId: env.sessionId, gen: env.gen(), ctx: { device: device.id, scenario: sc.name, state: st.label },
188
+ reproduction: [...prefix, ...lab.steps], detectors, reachLimit: 40, frameFile: (suffix) => join(dir, `${st.id}-${suffix}.jpg`),
189
+ observation: lab.lastObservation, shift: { when, ...(before ? { before } : {}) },
190
+ onFindings: (findings, frame) => env.emit({ kind: 'findings', findings, ...(frame ? { frame } : {}) }),
191
+ });
192
+ if (before && !m.findings.some((id) => env.store.get(id)?.frames?.some((f) => f.path === before)))
193
+ rmSync(before, { force: true });
194
+ const o = m.observation;
195
+ Object.assign(st, {
196
+ status: 'measured', route: o.route, ...(o.dialog ? { dialog: o.dialog } : {}), controls: o.controls.length, ...(m.frame ? { frame: m.frame } : {}), findings: m.findings,
197
+ budget: { controlsOmitted: o.omitted, elementsOmitted: m.omitted, reachChecked: m.reachChecked },
198
+ });
199
+ for (const id of m.findings)
200
+ if (!run.findings.includes(id))
201
+ run.findings.push(id);
202
+ const list = samples.get(st.label) ?? [];
203
+ list.push({ device: device.id, width: device.viewport.width, labels: m.labels, hiddenLabels: m.hiddenLabels, ...(m.frame ? { frame: m.frame } : {}),
204
+ route: o.route, repro: [...prefix, ...lab.steps], stateId: st.id, run });
205
+ samples.set(st.label, list);
206
+ env.emit({ kind: 'scan', phase: 'state', id: opts.id, scenario: sc.name, device: device.id, state: st });
207
+ return o;
208
+ };
209
+ try {
210
+ storage = sc.auth === 'session' ? await env.sessionState() : sc.auth === 'saved' ? env.savedState() : undefined;
211
+ prefix = sc.auth === 'session' ? [...env.sessionHistory] : [];
212
+ r.lab = await open();
213
+ phase = 'load';
214
+ await setup(r.lab, true);
215
+ phase = 'checks';
216
+ const s0 = { id: 's0', label: sc.steps.length ? 'after setup' : 'as loaded', path: [...sc.steps.map((s, i) => s.label ?? `setup step ${i + 1}`)], depth: 0, status: 'measured', findings: [] };
217
+ run.states.push(s0);
218
+ const o0 = await measure(s0, sc.steps.length ? 'while the page loaded and the scenario was set up' : 'while the page loaded');
219
+ if (explore) {
220
+ const t = Date.now();
221
+ try {
222
+ await exploreFrom(o0);
223
+ }
224
+ finally {
225
+ budget.exploreMs += Date.now() - t;
226
+ }
227
+ }
228
+ if (sc.cleanup.length) {
229
+ // Cleanup starts from the scenario's state. When exploration left the context elsewhere and it
230
+ // cannot be closed back, cleanup runs in a fresh context on the route as loaded (setup is not
231
+ // replayed: it may create data).
232
+ phase = 'cleanup';
233
+ if (r.at && r.at.depth === 0 && !(await isAt(r.at))) {
234
+ await r.lab?.close('fresh context for cleanup').catch(() => undefined);
235
+ r.lab = await open();
236
+ await setup(r.lab, false);
237
+ }
238
+ for (const [i, step] of sc.cleanup.entries())
239
+ await runStep(r.lab, step, `cleanup step ${i + 1}`);
240
+ }
241
+ }
242
+ catch (err) {
243
+ run.status = 'failed';
244
+ run.failedAt = phase;
245
+ run.error = LabError.from(err).toJSON();
246
+ }
247
+ finally {
248
+ await r.lab?.close('scenario finished').catch(() => undefined);
249
+ rmSync(join(dir, 'before.jpg'), { force: true });
250
+ }
251
+ run.ms = Date.now() - t0;
252
+ return run;
253
+ async function runStep(lab, step, label, onFindings) {
254
+ let result;
255
+ try {
256
+ result = await lab.act(stepRequest(lab, step));
257
+ }
258
+ catch (err) {
259
+ const e = LabError.from(err);
260
+ throw new LabError(e.code, `${label} (${step.label ?? `${step.do}${step.name ? ` "${step.name}"` : ''}`}): ${e.message}`, { ...(e.hint ? { hint: e.hint } : {}) });
261
+ }
262
+ if (result.outcome === 'error') {
263
+ throw new LabError(result.error.code, `${label} (${step.label ?? `${step.do}${step.name ? ` "${step.name}"` : ''}`}): ${result.error.message}`, { ...(result.error.hint ? { hint: result.error.hint } : {}) });
264
+ }
265
+ onFindings?.(result.newFindings);
266
+ if (step.expect) {
267
+ const failed = check(step.expect, lab.lastObservation, result).filter((c) => !c.ok);
268
+ if (failed.length)
269
+ throw new LabError('action_failed', `${label}: expectation failed: ${failed.map((c) => c.text).join('; ')}`, { hint: 'The scenario did not reach its state, so its checks did not run.' });
270
+ }
271
+ }
272
+ /** Breadth-first from the scenario's state, one safe activation at a time, within the limits. */
273
+ async function exploreFrom(o0) {
274
+ const ex = cfg.explore;
275
+ const decided = new Map();
276
+ const activated = new Set();
277
+ const exploreStart = Date.now();
278
+ const root = nodeFor('', [], 0, o0);
279
+ root.candidates = await candidatesAt(o0, undefined);
280
+ r.at = root;
281
+ const queue = [root];
282
+ let explored = 0;
283
+ const stop = (why) => { if (!run.limits.includes(why))
284
+ run.limits.push(why); };
285
+ for (let node = queue.shift(); node; node = queue.shift()) {
286
+ const pending = node.candidates.filter((c) => !activated.has(controlKey(c)));
287
+ if (pending.length > ex.maxActionsPerState)
288
+ stop(`maxActionsPerState (${ex.maxActionsPerState}): ${pending.length - ex.maxActionsPerState} candidate(s) in ${node.label || 'the scenario state'} not tried`);
289
+ for (const [ci, cand] of pending.slice(0, ex.maxActionsPerState).entries()) {
290
+ const remaining = () => pending.length - ci + queue.reduce((n, q) => n + q.candidates.length, 0);
291
+ if (explored >= ex.maxStates)
292
+ return stop(`maxStates (${ex.maxStates}): ${remaining()} candidate(s) not explored`);
293
+ if (budget.exploreMs + (Date.now() - exploreStart) >= ex.maxMs)
294
+ return stop(`maxMs (${ex.maxMs} ms, whole scan): ${remaining()} candidate(s) not explored`);
295
+ activated.add(controlKey(cand));
296
+ explored++;
297
+ const label = node.label ? `${node.label} → ${cand.label}` : cand.label;
298
+ const st = { id: `s${explored}`, label, path: [...run.states[0].path, ...node.path.map((p) => p.label), cand.label], depth: node.depth + 1, status: 'measured', findings: [] };
299
+ run.states.push(st);
300
+ try {
301
+ await reach(node);
302
+ await r.lab.activePage.evaluate(takeLayoutShifts).catch(() => undefined);
303
+ guard.blocked = [];
304
+ guard.on = true;
305
+ let result;
306
+ try {
307
+ result = await r.lab.act({ action: 'click', ref: refFor(r.lab, cand) });
308
+ }
309
+ finally {
310
+ guard.on = false;
311
+ }
312
+ r.at = undefined;
313
+ if (guard.blocked.length) {
314
+ // It tried to change data or leave: not a state to measure, and never activated again.
315
+ Object.assign(st, { status: 'blocked', blocked: [...guard.blocked] });
316
+ decided.set(controlKey(cand), { role: cand.role, name: cand.name, ...(cand.context ? { context: cand.context } : {}), verdict: 'skip',
317
+ reason: `sent ${guard.blocked[0]} when activated; the request was blocked and the state was not measured` });
318
+ env.emit({ kind: 'scan', phase: 'state', id: opts.id, scenario: sc.name, device: device.id, state: st });
319
+ continue;
320
+ }
321
+ if (result.outcome === 'error')
322
+ throw new LabError(result.error.code, result.error.message);
323
+ adoptFindings(result.newFindings, label);
324
+ const o = await measure(st, `after activating ${cand.label}`);
325
+ // A state on another route was reached by navigating: measured, but not explored further.
326
+ if (node.depth + 1 < ex.maxDepth && o.route === o0.route) {
327
+ const child = nodeFor(label, [...node.path, cand], node.depth + 1, o);
328
+ // Deeper states explore only what the activation revealed, not the page around it again.
329
+ child.candidates = await candidatesAt(o, node.keys);
330
+ if (child.candidates.length)
331
+ queue.push(child);
332
+ }
333
+ st.restore = await restore(node, cand);
334
+ }
335
+ catch (err) {
336
+ Object.assign(st, { status: 'failed', error: LabError.from(err).toJSON() });
337
+ r.at = undefined;
338
+ env.emit({ kind: 'scan', phase: 'state', id: opts.id, scenario: sc.name, device: device.id, state: st });
339
+ }
340
+ }
341
+ }
342
+ const all = [...decided.values()];
343
+ run.decisions = all.slice(0, DECISIONS_MAX);
344
+ run.decisionsOmitted = Math.max(0, all.length - DECISIONS_MAX);
345
+ async function candidatesAt(o, parentKeys) {
346
+ const fresh = o.controls.filter((c) => !parentKeys || !parentKeys.has(controlKey(c)));
347
+ const traits = await r.lab.activePage.evaluate(controlTraits, fresh.map((c) => c.ref)).catch(() => []);
348
+ const byRef = new Map(traits.map((t) => [t.ref, t]));
349
+ const out = [];
350
+ const counts = new Map();
351
+ for (const c of fresh)
352
+ counts.set(controlKey(c), (counts.get(controlKey(c)) ?? 0) + 1);
353
+ for (const [i, c] of fresh.entries()) {
354
+ const d = classifyControl(c, byRef.get(c.ref), ex, o.route);
355
+ if (!d)
356
+ continue;
357
+ if (d.verdict === 'explore' && (counts.get(controlKey(c)) ?? 0) > 1) {
358
+ d.verdict = 'skip';
359
+ d.reason = 'more than one control has this role, name and context, so it cannot be targeted without guessing';
360
+ }
361
+ if (!decided.has(controlKey(c)) || d.verdict === 'skip')
362
+ decided.set(controlKey(c), d);
363
+ if (d.verdict === 'explore')
364
+ out.push({ role: c.role, name: c.name, ...(c.context ? { context: c.context } : {}), kind: d.kind, label: `${c.role} "${c.name}"`, order: i });
365
+ }
366
+ return orderCandidates(out).map(({ order: _, ...c }) => c);
367
+ }
368
+ }
369
+ function nodeFor(label, path, depth, o) {
370
+ return {
371
+ label, path, depth, sig: stateSignature(o), candidates: [], keys: new Set(o.controls.map(controlKey)),
372
+ selectedTabs: new Set(o.controls.filter((c) => c.role === 'tab' && c.selected).map(controlKey)),
373
+ };
374
+ }
375
+ /** Whether the context is in `node`'s state now (observing it if unsure). */
376
+ async function isAt(node) {
377
+ if (r.at === node)
378
+ return true;
379
+ const o = await r.lab.observe().catch(() => undefined);
380
+ if (o && stateSignature(o) === node.sig) {
381
+ r.at = node;
382
+ return true;
383
+ }
384
+ return false;
385
+ }
386
+ /** Be in `node`'s state: already there, or a fresh context with the setup and the path replayed. */
387
+ async function reach(node) {
388
+ if (await isAt(node))
389
+ return;
390
+ await r.lab?.close('fresh context for the next state').catch(() => undefined);
391
+ r.lab = await open();
392
+ await setup(r.lab, false);
393
+ for (const [i, s] of sc.steps.entries())
394
+ await runStep(r.lab, s, `setup step ${i + 1} (replayed)`);
395
+ for (const p of node.path) {
396
+ const res = await r.lab.act({ action: 'click', ref: refFor(r.lab, p) });
397
+ if (res.outcome === 'error')
398
+ throw new LabError(res.error.code, `replaying ${p.label}: ${res.error.message}`);
399
+ }
400
+ r.at = node;
401
+ }
402
+ /** Return to `node` after measuring a state: close what was opened, or press Escape, else start over. */
403
+ async function restore(node, cand) {
404
+ const lab = r.lab;
405
+ const back = async (req) => {
406
+ const res = await lab.act(req).catch(() => undefined);
407
+ return !!res && res.outcome === 'success' && stateSignature(lab.lastObservation) === node.sig;
408
+ };
409
+ const now = lab.lastObservation;
410
+ const opener = now.controls.filter((c) => controlKey(c) === controlKey(cand));
411
+ let how = 'fresh-context';
412
+ if (opener.length === 1 && (opener[0].expanded || opener[0].pressed) && await back({ action: 'click', ref: opener[0].ref }))
413
+ how = 'toggle';
414
+ else if (cand.kind === 'tab') {
415
+ // Switch back to the tab that was selected before (exactly one match, or not at all).
416
+ const was = now.controls.filter((c) => c.role === 'tab' && !c.selected && node.selectedTabs.has(controlKey(c)));
417
+ if (was.length === 1 && await back({ action: 'click', ref: was[0].ref }))
418
+ how = 'toggle';
419
+ }
420
+ if (how === 'fresh-context' && await back({ action: 'press', key: 'Escape' }))
421
+ how = 'escape';
422
+ r.at = how === 'fresh-context' ? undefined : node;
423
+ return how;
424
+ }
425
+ }
426
+ /** A ref for a candidate in the latest observation: exactly one control with its role, name and context, never a guess. */
427
+ function refFor(lab, c) {
428
+ const matches = (lab.lastObservation?.controls ?? []).filter((x) => controlKey(x) === controlKey(c));
429
+ if (matches.length === 1)
430
+ return matches[0].ref;
431
+ throw new LabError(matches.length ? 'ambiguous_target' : 'not_found', `${c.label}${c.context ? ` in "${c.context}"` : ''}: ${matches.length ? `${matches.length} matching controls` : 'not in the latest observation'}`);
432
+ }
433
+ /**
434
+ * What a command returns for a scan: the verdict, each run's outcome and the problem groups, bounded.
435
+ * Findings are ids (details through inspect); frames and full evidence stay in the report files.
436
+ */
437
+ export function scanSummary(r, maxGroups = 30) {
438
+ return {
439
+ schemaVersion: r.schemaVersion, id: r.id, ms: r.ms, explore: r.explore, exploreMs: r.exploreMs, verdict: r.verdict, reports: r.reports,
440
+ ...(r.interrupted ? { interrupted: r.interrupted } : {}),
441
+ runs: r.runs.map((x) => ({
442
+ scenario: x.scenario, device: x.device, status: x.status, ...(x.failedAt ? { failedAt: x.failedAt, error: x.error?.message } : {}),
443
+ states: x.states.length, findings: x.findings,
444
+ ...(x.limits.length ? { limits: x.limits } : {}),
445
+ ...(x.decisions.some((d) => d.verdict === 'skip') ? { skipped: x.decisions.filter((d) => d.verdict === 'skip').length } : {}),
446
+ })),
447
+ groups: r.groups.slice(0, maxGroups).map((g) => ({
448
+ id: g.id, title: g.title, kind: g.kind, severity: g.severity, confidence: g.confidence, route: g.route, scenarios: g.scenarios, devices: g.devices,
449
+ findings: g.findings, ...(g.suppressed ? { suppressed: true } : {}),
450
+ })),
451
+ groupsOmitted: Math.max(0, r.groups.length - maxGroups),
452
+ suppressions: r.suppressions.filter((x) => x.status !== 'applied'),
453
+ };
454
+ }
455
+ //# sourceMappingURL=scan.js.map
@@ -0,0 +1,39 @@
1
+ // Versioned, JSON-serialisable contract shared by the CLI, the daemon and (later) the MCP adapter.
2
+ // Anything returned to an agent must be expressible with these types.
3
+ import { redactUrlSecrets } from './url-redact.js';
4
+ export const SCHEMA_VERSION = 1;
5
+ const RECOVERABLE = new Set([
6
+ 'unknown_ref', 'stale_ref', 'not_found', 'ambiguous_target', 'not_visible', 'disabled',
7
+ 'obstructed', 'not_fillable', 'action_failed', 'invalid_request',
8
+ 'not_selectable', 'not_checkable', 'not_uploadable', 'upload_not_allowed', 'no_history', 'unknown_tab',
9
+ 'session_paused', 'human_control', 'observation_required', 'invalid_control',
10
+ ]);
11
+ export class LabError extends Error {
12
+ code;
13
+ hint;
14
+ details;
15
+ constructor(code, message, opts = {}) {
16
+ // A message can quote a URL (a failed navigation, a refused route): its secrets never go out.
17
+ super(redactUrlSecrets(message));
18
+ this.name = 'LabError';
19
+ this.code = code;
20
+ this.hint = opts.hint === undefined ? undefined : redactUrlSecrets(opts.hint);
21
+ this.details = opts.details;
22
+ }
23
+ toJSON() {
24
+ return {
25
+ code: this.code,
26
+ message: this.message,
27
+ ...(this.hint ? { hint: this.hint } : {}),
28
+ recoverable: RECOVERABLE.has(this.code),
29
+ ...(this.details ? { details: this.details } : {}),
30
+ };
31
+ }
32
+ static from(err) {
33
+ if (err instanceof LabError)
34
+ return err;
35
+ const message = err instanceof Error ? err.message : String(err);
36
+ return new LabError('action_failed', message.split('\n')[0] ?? message);
37
+ }
38
+ }
39
+ //# sourceMappingURL=schema.js.map
@@ -0,0 +1,134 @@
1
+ import { join } from 'node:path';
2
+ import { startOrder } from './profile.js';
3
+ import { Service } from './project-runner.js';
4
+ import { LabError } from './schema.js';
5
+ /** Names of required environment variables that are unset or empty, per service. Values are never read out. */
6
+ export function missingEnv(specs, env = process.env) {
7
+ return specs
8
+ .map((s) => ({ service: s.name, names: s.requiredEnv.filter((n) => !env[n] && !s.env[n]) }))
9
+ .filter((m) => m.names.length);
10
+ }
11
+ /**
12
+ * The project's services for one session. Independent services start concurrently; a service starts
13
+ * only when everything it depends on is ready. If a required service fails, services still starting
14
+ * are abandoned, and every service this group started is stopped in reverse order. Reused services
15
+ * are never stopped.
16
+ */
17
+ export class ServiceGroup {
18
+ services;
19
+ /** In the order they became ready. */
20
+ constructor(services) {
21
+ this.services = services;
22
+ }
23
+ static async start(specs, opts) {
24
+ const missing = missingEnv(specs, opts.env);
25
+ if (missing.length) {
26
+ const names = [...new Set(missing.flatMap((m) => m.names))];
27
+ throw new LabError('missing_env', `Required environment variable${names.length > 1 ? 's' : ''} not set: ${names.join(', ')}`, {
28
+ hint: 'Export them in the shell (or MCP client environment) that runs agentlab. Only the names are checked and reported.',
29
+ details: { missing },
30
+ });
31
+ }
32
+ const byName = new Map(specs.map((s) => [s.name, s]));
33
+ const report = new Map(startOrder(specs).map((n) => [n, { name: n, status: 'pending' }]));
34
+ const ready = [];
35
+ const abort = new AbortController();
36
+ let failure;
37
+ const running = new Map();
38
+ const external = () => {
39
+ failure ??= new LabError('startup_failed', 'service start abandoned: the session is closing');
40
+ abort.abort();
41
+ };
42
+ if (opts.signal?.aborted)
43
+ external();
44
+ else
45
+ opts.signal?.addEventListener('abort', external, { once: true });
46
+ const run = (name) => {
47
+ let p = running.get(name);
48
+ if (p)
49
+ return p;
50
+ p = (async () => {
51
+ const spec = byName.get(name);
52
+ const deps = await Promise.all(spec.dependsOn.map(run));
53
+ const entry = report.get(name);
54
+ if (failure) {
55
+ entry.status = 'skipped';
56
+ entry.detail = 'not started: another service failed';
57
+ return undefined;
58
+ }
59
+ const missingDep = spec.dependsOn.find((_, i) => !deps[i]);
60
+ if (missingDep) {
61
+ entry.status = 'skipped';
62
+ entry.detail = `not started: depends on "${missingDep}", which failed`;
63
+ if (spec.required) {
64
+ failure = new LabError('startup_failed', `service "${name}" needs optional service "${missingDep}", which failed`, { details: { service: name } });
65
+ abort.abort();
66
+ }
67
+ return undefined;
68
+ }
69
+ try {
70
+ const svc = await Service.ensure(spec, {
71
+ logFile: join(opts.runDir, `${name}.log`),
72
+ onLog: (line) => opts.onLog?.(name, line),
73
+ signal: abort.signal,
74
+ onUnexpectedExit: (exit) => opts.onUnexpectedExit?.({ ...exit, required: spec.required }),
75
+ });
76
+ ready.push(svc);
77
+ entry.status = svc.info.owned ? 'ready' : 'reused';
78
+ opts.onReady?.(svc.info);
79
+ return svc;
80
+ }
81
+ catch (err) {
82
+ const error = LabError.from(err);
83
+ const abandoned = error.details?.aborted === true;
84
+ entry.status = abandoned ? 'aborted' : 'failed';
85
+ entry.detail = error.message;
86
+ if (!abandoned && spec.required && !failure) {
87
+ failure = error;
88
+ abort.abort();
89
+ }
90
+ return undefined;
91
+ }
92
+ })();
93
+ running.set(name, p);
94
+ return p;
95
+ };
96
+ await Promise.all(specs.map((s) => run(s.name)));
97
+ opts.signal?.removeEventListener('abort', external);
98
+ const group = new ServiceGroup(ready);
99
+ if (failure) {
100
+ const stopped = await group.stop();
101
+ for (const s of stopped) {
102
+ const entry = report.get(s.name);
103
+ if (entry && s.owned)
104
+ entry.stopped = s.detail;
105
+ }
106
+ throw new LabError(failure.code, failure.message, {
107
+ hint: failure.hint,
108
+ details: { ...failure.details, services: [...report.values()], stopped },
109
+ });
110
+ }
111
+ return group;
112
+ }
113
+ get(name) {
114
+ return name === undefined ? undefined : this.services.find((s) => s.spec.name === name);
115
+ }
116
+ infos() {
117
+ return this.services.map((s) => s.info);
118
+ }
119
+ /** Snapshot every owned process service's group members; only those whose leader is still alive can be. */
120
+ refreshMembers() {
121
+ for (const svc of this.services)
122
+ svc.refreshMembers();
123
+ }
124
+ /** Stop owned services, most recently ready first (dependents before what they depend on). */
125
+ async stop() {
126
+ const results = [];
127
+ for (const svc of [...this.services].reverse()) {
128
+ const r = await svc.stop();
129
+ results.push({ name: svc.spec.name, owned: svc.info.owned, stopped: r.stopped, detail: r.reason });
130
+ }
131
+ return results;
132
+ }
133
+ }
134
+ //# sourceMappingURL=services.js.map