agent-device-lab 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +142 -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 +58 -4
@@ -0,0 +1,816 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
3
+ import { isIP } from 'node:net';
4
+ import { SWEEP_DEVICES } from './devices.js';
5
+ import { DETECTORS } from './detectors.js';
6
+ import { DEFAULT_POLICY } from './scan-policy.js';
7
+ import { LabError, SCHEMA_VERSION, } from './schema.js';
8
+ import { CONTRACT_VERSIONS, productVersion } from './versions.js';
9
+ export const PROFILE_FILE = 'agentlab.json';
10
+ export const DEFAULT_SETTLE = { quietMs: 120, maxMs: 5000, backgroundRequests: [], timerMaxMs: 1000 };
11
+ export const DEFAULT_AUTH_FILE = '.agentlab/auth/state.json';
12
+ const SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP', 'SIGQUIT'];
13
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
14
+ const SERVICE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
15
+ export const MIGRATION_NOTE = 'schemaVersion 1 profile: run `agentlab migrate` to rewrite it as schemaVersion 2';
16
+ // The keys each object may carry. Anything else is reported, so a typo is never silently reinterpreted.
17
+ // `description`, `comment` and keys starting with `//` are always allowed, for comments.
18
+ const TOP_KEYS = ['$schema', 'schemaVersion', 'name', 'web', 'services', 'app', 'startPath', 'device', 'devices', 'allowExternalUrl', 'settle', 'auth', 'uploads', 'scan', 'ci'];
19
+ const WEB_KEYS = ['command', 'cwd', 'url', 'env', 'readiness', 'reuseExisting'];
20
+ const WEB_READINESS_KEYS = ['path', 'status', 'timeoutMs', 'intervalMs'];
21
+ const SERVICE_KEYS = ['command', 'cwd', 'url', 'env', 'requiredEnv', 'readiness', 'dependsOn', 'reuseExisting', 'required', 'mode', 'shutdown'];
22
+ const READINESS_KEYS = ['tcp', 'log', 'alive', 'url', 'path', 'status', 'timeoutMs', 'intervalMs'];
23
+ const SHUTDOWN_KEYS = ['signal', 'graceMs', 'command'];
24
+ const APP_KEYS = ['url', 'service'];
25
+ const DEVICE_KEYS = ['extends', 'label', 'viewport', 'deviceScaleFactor', 'isMobile', 'hasTouch', 'userAgent'];
26
+ const SETTLE_KEYS = ['quietMs', 'maxMs', 'backgroundRequests', 'timerMaxMs'];
27
+ const AUTH_KEYS = ['file', 'use', 'loginPath'];
28
+ const SCAN_KEYS = ['devices', 'checks', 'tapTargets', 'noWrap', 'wrapNearbyRatio', 'layoutShiftMin', 'explore', 'scenarios', 'suppressions', 'policy'];
29
+ const CHECKS_KEYS = ['enable', 'disable'];
30
+ const EXPLORE_KEYS = ['enabled', 'maxDepth', 'maxStates', 'maxActionsPerState', 'maxMs', 'allow', 'deny'];
31
+ const SCENARIO_KEYS = ['name', 'route', 'auth', 'explore', 'devices', 'steps', 'cleanup', 'checks'];
32
+ // The keys a step reads (FlowStep in steps.ts), and those of its expectation (Expectation).
33
+ const STEP_KEYS = ['do', 'role', 'name', 'nameContains', 'value', 'key', 'values', 'direction', 'amount', 'to', 'dx', 'dy', 'files', 'tab', 'path', 'label', 'expect'];
34
+ const TARGET_KEYS = ['role', 'name', 'nameContains'];
35
+ const EXPECT_KEYS = ['route', 'heading', 'dialog', 'message', 'control', 'noControl', 'layout', 'note', 'navigated', 'tab', 'findings'];
36
+ const SUPPRESSION_KEYS = ['kind', 'fingerprint', 'route', 'target', 'scenario', 'device', 'reason', 'expires'];
37
+ const POLICY_KEYS = ['failOn', 'failOnErrors', 'failOnHeuristic'];
38
+ const CI_KEYS = ['flows', 'routes', 'scenarios', 'devices', 'failOn', 'failOnHeuristic', 'scenarioErrors', 'out', 'trace', 'evidence', 'timeoutMs', 'auth'];
39
+ const isCommentKey = (k) => k === 'description' || k === 'comment' || k.startsWith('//');
40
+ /** Levenshtein distance. Pure. */
41
+ export function editDistance(a, b) {
42
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
43
+ for (let i = 1; i <= a.length; i++) {
44
+ const row = [i];
45
+ for (let j = 1; j <= b.length; j++)
46
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
47
+ prev = row;
48
+ }
49
+ return prev[b.length];
50
+ }
51
+ /** The candidate closest to `key` within distance 2 (and shorter than the key itself), if any. */
52
+ export function didYouMean(key, candidates) {
53
+ let best;
54
+ let bestDistance = Infinity;
55
+ for (const c of candidates) {
56
+ const d = c.toLowerCase() === key.toLowerCase() ? 0 : editDistance(key, c);
57
+ if (d < bestDistance) {
58
+ best = c;
59
+ bestDistance = d;
60
+ }
61
+ }
62
+ return best !== undefined && bestDistance <= 2 && bestDistance < key.length ? best : undefined;
63
+ }
64
+ /** Report keys of `obj` that are neither in `allowed` nor comment keys. `at` names the object ("" for the top level). */
65
+ function checkKeys(obj, allowed, at, problems) {
66
+ if (!isRecord(obj))
67
+ return;
68
+ for (const k of Object.keys(obj)) {
69
+ if (allowed.includes(k) || isCommentKey(k))
70
+ continue;
71
+ const near = didYouMean(k, allowed);
72
+ problems.push(`unknown key "${k}" ${at ? `in "${at}"` : 'at top level'}${near ? ` (did you mean "${near}"?)` : ''}`);
73
+ }
74
+ }
75
+ /**
76
+ * The declared schemaVersion. A newer file throws before anything else is read, so it is never
77
+ * partially interpreted; a missing or malformed one is a problem.
78
+ */
79
+ function readVersion(obj, problems) {
80
+ const v = obj.schemaVersion;
81
+ const [min, max] = [CONTRACT_VERSIONS.profileSupported[0], CONTRACT_VERSIONS.profile];
82
+ if (v === undefined)
83
+ problems.push(`"schemaVersion" is required (${max} for this version of agentlab)`);
84
+ else if (typeof v !== 'number' || !Number.isInteger(v) || v < min)
85
+ problems.push(`"schemaVersion" must be a whole number from ${min} to ${max}`);
86
+ else if (v > max) {
87
+ throw new LabError('profile_too_new', `agentlab.json has schemaVersion ${v}; this agentlab ${productVersion()} reads schemaVersion ${min}–${max}`, {
88
+ hint: 'Upgrade agentlab (see docs/install.md), or use a profile written for this version.',
89
+ });
90
+ }
91
+ else
92
+ return v;
93
+ return undefined;
94
+ }
95
+ /** Resolve `target` (a directory or a path to agentlab.json) and load the project profile. */
96
+ export async function loadProfile(target) {
97
+ let profilePath = resolve(target);
98
+ const info = await stat(profilePath).catch(() => undefined);
99
+ if (!info)
100
+ throw new LabError('invalid_profile', `No project profile at ${profilePath}`, { hint: 'Run `agentlab init` in the project to propose one.' });
101
+ if (info.isDirectory())
102
+ profilePath = join(profilePath, PROFILE_FILE);
103
+ let raw;
104
+ try {
105
+ raw = JSON.parse(await readFile(profilePath, 'utf8'));
106
+ }
107
+ catch (err) {
108
+ throw new LabError('invalid_profile', `Cannot read ${profilePath}: ${err.message}`, {
109
+ hint: `Run \`agentlab init\` to propose ${PROFILE_FILE}, or write one declaring services (see README).`,
110
+ });
111
+ }
112
+ return parseProfile(raw, profilePath);
113
+ }
114
+ /**
115
+ * Validate a parsed profile. Pure; exported for tests. Two shapes are accepted: schemaVersion 1 with a
116
+ * single `web` section (it becomes one service named "web"), and schemaVersion 2 with named `services`.
117
+ */
118
+ export function parseProfile(raw, profilePath) {
119
+ const problems = [];
120
+ const obj = isRecord(raw) ? raw : (problems.push('profile must be a JSON object'), {});
121
+ const root = dirname(profilePath);
122
+ const num = numberReader(problems);
123
+ const declared = readVersion(obj, problems);
124
+ checkKeys(obj, TOP_KEYS, '', problems);
125
+ let services;
126
+ let web;
127
+ let app;
128
+ if (obj.services === undefined) {
129
+ // schemaVersion 1: one web server.
130
+ if (declared !== undefined && declared !== 1)
131
+ problems.push(`"schemaVersion" must be 1 for a profile with a "web" section (or 2 with "services")`);
132
+ web = parseWeb(obj.web, root, problems, num);
133
+ services = [{
134
+ name: 'web', command: web.command, cwd: web.cwd, url: web.url, env: web.env, requiredEnv: [],
135
+ readiness: { kind: 'http', url: web.url, ...web.readiness }, dependsOn: [], reuseExisting: web.reuseExisting,
136
+ required: true, mode: 'process', shutdown: { signal: 'SIGTERM', graceMs: 5000 },
137
+ }];
138
+ app = { url: web.url, service: 'web' };
139
+ }
140
+ else {
141
+ if (declared !== undefined && declared !== 2)
142
+ problems.push('"schemaVersion" must be 2 for a profile with "services"');
143
+ if (obj.web !== undefined)
144
+ problems.push('use either "web" (schemaVersion 1) or "services" (schemaVersion 2), not both');
145
+ services = [];
146
+ if (!isRecord(obj.services) || !Object.keys(obj.services).length)
147
+ problems.push('"services" must map at least one name to a service');
148
+ else
149
+ for (const [name, spec] of Object.entries(obj.services))
150
+ services.push(parseService(name, spec, root, problems, num));
151
+ for (const s of services) {
152
+ for (const dep of s.dependsOn) {
153
+ if (dep === s.name)
154
+ problems.push(`service "${s.name}" depends on itself`);
155
+ else if (!services.some((o) => o.name === dep))
156
+ problems.push(`service "${s.name}" depends on unknown service "${dep}"`);
157
+ }
158
+ }
159
+ if (!problems.length) {
160
+ try {
161
+ startOrder(services);
162
+ }
163
+ catch (err) {
164
+ problems.push(err.message);
165
+ }
166
+ }
167
+ app = parseApp(obj.app, services, problems);
168
+ }
169
+ const settle = isRecord(obj.settle) ? obj.settle : {};
170
+ if (obj.settle !== undefined && !isRecord(obj.settle))
171
+ problems.push('"settle" must be an object');
172
+ checkKeys(settle, SETTLE_KEYS, 'settle', problems);
173
+ const background = settle.backgroundRequests ?? [];
174
+ if (!Array.isArray(background) || !background.every((b) => typeof b === 'string' && b.length > 0)) {
175
+ problems.push('"settle.backgroundRequests" must be a list of non-empty strings');
176
+ }
177
+ const startPath = obj.startPath ?? '/';
178
+ if (typeof startPath !== 'string' || !startPath.startsWith('/'))
179
+ problems.push('"startPath" must start with "/"');
180
+ const profile = {
181
+ schemaVersion: obj.services === undefined ? 1 : 2,
182
+ name: typeof obj.name === 'string' ? obj.name : 'project',
183
+ root,
184
+ profilePath,
185
+ services,
186
+ app,
187
+ ...(web ? { web } : {}),
188
+ startPath: String(startPath),
189
+ device: typeof obj.device === 'string' ? obj.device : 'mobile-390',
190
+ devices: parseDevices(obj.devices, problems),
191
+ allowExternalUrl: obj.allowExternalUrl === true,
192
+ settle: {
193
+ quietMs: num(settle.quietMs, 'settle.quietMs', DEFAULT_SETTLE.quietMs),
194
+ maxMs: num(settle.maxMs, 'settle.maxMs', DEFAULT_SETTLE.maxMs),
195
+ backgroundRequests: Array.isArray(background) ? background : [],
196
+ timerMaxMs: settle.timerMaxMs === 0 ? 0 : num(settle.timerMaxMs, 'settle.timerMaxMs', DEFAULT_SETTLE.timerMaxMs),
197
+ },
198
+ auth: parseAuth(obj.auth, root, problems),
199
+ uploads: { allow: parseUploads(obj.uploads, root, problems) },
200
+ scan: parseScan(obj.scan, problems),
201
+ ci: parseCi(obj.ci, problems),
202
+ ...(obj.services === undefined ? { migration: MIGRATION_NOTE } : {}),
203
+ };
204
+ if (problems.length) {
205
+ throw new LabError('invalid_profile', `Invalid profile ${profilePath}: ${problems.join('; ')}`, { details: { problems } });
206
+ }
207
+ assertUrlAllowed(profile.app.url, profile.allowExternalUrl);
208
+ for (const s of services)
209
+ if (s.url)
210
+ assertUrlAllowed(s.url, profile.allowExternalUrl);
211
+ return profile;
212
+ }
213
+ /**
214
+ * Names in a safe start order: every service after its dependencies, otherwise in declaration order.
215
+ * Throws on a dependency cycle. Pure.
216
+ */
217
+ export function startOrder(services) {
218
+ const order = [];
219
+ const state = new Map();
220
+ const byName = new Map(services.map((s) => [s.name, s]));
221
+ const visit = (name, path) => {
222
+ if (state.get(name) === 'done')
223
+ return;
224
+ if (state.get(name) === 'visiting')
225
+ throw new Error(`services have a dependency cycle: ${[...path, name].join(' → ')}`);
226
+ state.set(name, 'visiting');
227
+ for (const dep of byName.get(name)?.dependsOn ?? [])
228
+ if (byName.has(dep))
229
+ visit(dep, [...path, name]);
230
+ state.set(name, 'done');
231
+ order.push(name);
232
+ };
233
+ for (const s of services)
234
+ visit(s.name, []);
235
+ return order;
236
+ }
237
+ function numberReader(problems) {
238
+ return (v, name, fallback) => {
239
+ if (v === undefined)
240
+ return fallback;
241
+ if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0)
242
+ problems.push(`"${name}" must be a positive number`);
243
+ return v;
244
+ };
245
+ }
246
+ function parseWeb(raw, root, problems, num) {
247
+ const web = isRecord(raw) ? raw : (problems.push('"web" section is required'), {});
248
+ const readiness = isRecord(web.readiness) ? web.readiness : {};
249
+ checkKeys(web, WEB_KEYS, 'web', problems);
250
+ checkKeys(readiness, WEB_READINESS_KEYS, 'web.readiness', problems);
251
+ if (typeof web.command !== 'string' || !web.command.trim())
252
+ problems.push('"web.command" must be a non-empty string');
253
+ if (typeof web.url !== 'string' || !URL.canParse(web.url))
254
+ problems.push('"web.url" must be an absolute URL');
255
+ if (web.env !== undefined && (!isRecord(web.env) || !Object.values(web.env).every((v) => typeof v === 'string'))) {
256
+ problems.push('"web.env" must map names to strings');
257
+ }
258
+ const readyPath = readiness.path ?? '/';
259
+ if (typeof readyPath !== 'string' || !readyPath.startsWith('/'))
260
+ problems.push('"web.readiness.path" must start with "/"');
261
+ return {
262
+ command: String(web.command ?? ''),
263
+ cwd: resolve(root, typeof web.cwd === 'string' ? web.cwd : '.'),
264
+ url: String(web.url ?? '').replace(/\/+$/, ''),
265
+ env: (isRecord(web.env) ? web.env : {}),
266
+ readiness: {
267
+ path: String(readyPath),
268
+ status: num(readiness.status, 'web.readiness.status', 200),
269
+ timeoutMs: num(readiness.timeoutMs, 'web.readiness.timeoutMs', 60_000),
270
+ intervalMs: num(readiness.intervalMs, 'web.readiness.intervalMs', 250),
271
+ },
272
+ reuseExisting: web.reuseExisting !== false,
273
+ };
274
+ }
275
+ function parseService(name, raw, root, problems, num) {
276
+ const at = `services.${name}`;
277
+ if (!SERVICE_NAME.test(name))
278
+ problems.push(`service name "${name}" must be letters, digits, "-" or "_"`);
279
+ const s = isRecord(raw) ? raw : (problems.push(`"${at}" must be an object`), {});
280
+ checkKeys(s, SERVICE_KEYS, at, problems);
281
+ if (typeof s.command !== 'string' || !s.command.trim())
282
+ problems.push(`"${at}.command" must be a non-empty string`);
283
+ let url;
284
+ if (s.url !== undefined) {
285
+ if (typeof s.url !== 'string' || !URL.canParse(s.url))
286
+ problems.push(`"${at}.url" must be an absolute URL`);
287
+ else
288
+ url = s.url.replace(/\/+$/, '');
289
+ }
290
+ if (s.env !== undefined && (!isRecord(s.env) || !Object.values(s.env).every((v) => typeof v === 'string'))) {
291
+ problems.push(`"${at}.env" must map names to strings`);
292
+ }
293
+ const requiredEnv = s.requiredEnv ?? [];
294
+ if (!Array.isArray(requiredEnv) || !requiredEnv.every((n) => typeof n === 'string' && ENV_NAME.test(n))) {
295
+ problems.push(`"${at}.requiredEnv" must be a list of environment variable names`);
296
+ }
297
+ const dependsOn = s.dependsOn ?? [];
298
+ if (!Array.isArray(dependsOn) || !dependsOn.every((n) => typeof n === 'string'))
299
+ problems.push(`"${at}.dependsOn" must be a list of service names`);
300
+ const mode = s.mode ?? 'process';
301
+ if (mode !== 'process' && mode !== 'oneshot')
302
+ problems.push(`"${at}.mode" must be "process" or "oneshot"`);
303
+ const sd = isRecord(s.shutdown) ? s.shutdown : {};
304
+ if (s.shutdown !== undefined && !isRecord(s.shutdown))
305
+ problems.push(`"${at}.shutdown" must be an object`);
306
+ checkKeys(sd, SHUTDOWN_KEYS, `${at}.shutdown`, problems);
307
+ const signal = sd.signal ?? 'SIGTERM';
308
+ if (!SIGNALS.includes(signal))
309
+ problems.push(`"${at}.shutdown.signal" must be one of ${SIGNALS.join(', ')}`);
310
+ if (sd.command !== undefined && (typeof sd.command !== 'string' || !sd.command.trim()))
311
+ problems.push(`"${at}.shutdown.command" must be a non-empty string`);
312
+ if (sd.command !== undefined && mode !== 'oneshot')
313
+ problems.push(`"${at}.shutdown.command" is only for mode "oneshot"; a process is stopped by signal`);
314
+ return {
315
+ name,
316
+ command: String(s.command ?? ''),
317
+ cwd: resolve(root, typeof s.cwd === 'string' ? s.cwd : '.'),
318
+ ...(url ? { url } : {}),
319
+ env: (isRecord(s.env) ? s.env : {}),
320
+ requiredEnv: Array.isArray(requiredEnv) ? requiredEnv : [],
321
+ readiness: parseReadiness(s.readiness, url, mode === 'oneshot', at, problems, num),
322
+ dependsOn: Array.isArray(dependsOn) ? dependsOn : [],
323
+ reuseExisting: s.reuseExisting !== false,
324
+ required: s.required !== false,
325
+ mode: mode === 'oneshot' ? 'oneshot' : 'process',
326
+ shutdown: {
327
+ signal: signal,
328
+ graceMs: num(sd.graceMs, `${at}.shutdown.graceMs`, 5000),
329
+ ...(typeof sd.command === 'string' ? { command: sd.command } : {}),
330
+ },
331
+ };
332
+ }
333
+ function parseReadiness(raw, url, oneshot, at, problems, num) {
334
+ if (raw !== undefined && !isRecord(raw)) {
335
+ problems.push(`"${at}.readiness" must be an object`);
336
+ return { kind: 'alive', ms: 1 };
337
+ }
338
+ const r = (raw ?? {});
339
+ checkKeys(r, READINESS_KEYS, `${at}.readiness`, problems);
340
+ const timeoutMs = num(r.timeoutMs, `${at}.readiness.timeoutMs`, 60_000);
341
+ const intervalMs = num(r.intervalMs, `${at}.readiness.intervalMs`, 250);
342
+ const kinds = ['tcp', 'log', 'alive', 'url', 'path'].filter((k) => r[k] !== undefined);
343
+ if (kinds.length > 1 && !(kinds.length === 2 && kinds.includes('url') && kinds.includes('path'))) {
344
+ problems.push(`"${at}.readiness" declares more than one check (${kinds.join(', ')})`);
345
+ }
346
+ if (r.tcp !== undefined) {
347
+ const m = typeof r.tcp === 'string' ? /^(.+):(\d{1,5})$/.exec(r.tcp) : null;
348
+ if (!m) {
349
+ problems.push(`"${at}.readiness.tcp" must be "host:port"`);
350
+ return { kind: 'tcp', host: '127.0.0.1', port: 0, timeoutMs, intervalMs };
351
+ }
352
+ return { kind: 'tcp', host: m[1].replace(/^\[|\]$/g, ''), port: Number(m[2]), timeoutMs, intervalMs };
353
+ }
354
+ if (r.log !== undefined) {
355
+ if (typeof r.log !== 'string' || !r.log)
356
+ problems.push(`"${at}.readiness.log" must be a non-empty pattern`);
357
+ else {
358
+ try {
359
+ new RegExp(r.log);
360
+ }
361
+ catch {
362
+ problems.push(`"${at}.readiness.log" is not a valid regular expression`);
363
+ }
364
+ }
365
+ return { kind: 'log', pattern: String(r.log ?? ''), timeoutMs };
366
+ }
367
+ if (r.alive !== undefined)
368
+ return { kind: 'alive', ms: num(r.alive, `${at}.readiness.alive`, 1000) };
369
+ const status = num(r.status, `${at}.readiness.status`, 200);
370
+ if (r.url !== undefined) {
371
+ if (typeof r.url !== 'string' || !URL.canParse(r.url))
372
+ problems.push(`"${at}.readiness.url" must be an absolute URL`);
373
+ const u = URL.canParse(String(r.url)) ? new URL(String(r.url)) : undefined;
374
+ return { kind: 'http', url: u ? u.origin : '', path: u ? u.pathname + u.search : '/', status, timeoutMs, intervalMs };
375
+ }
376
+ if (r.path !== undefined || url) {
377
+ const path = r.path ?? '/';
378
+ if (typeof path !== 'string' || !path.startsWith('/'))
379
+ problems.push(`"${at}.readiness.path" must start with "/"`);
380
+ if (!url)
381
+ problems.push(`"${at}.readiness.path" needs "${at}.url"`);
382
+ return { kind: 'http', url: url ?? '', path: String(path), status, timeoutMs, intervalMs };
383
+ }
384
+ if (oneshot)
385
+ return { kind: 'exit', timeoutMs };
386
+ problems.push(`"${at}" has no url, so it needs a readiness check: {"tcp": "host:port"}, {"log": "pattern"} or {"alive": ms}`);
387
+ return { kind: 'alive', ms: 1 };
388
+ }
389
+ function parseApp(raw, services, problems) {
390
+ if (raw !== undefined && !isRecord(raw)) {
391
+ problems.push('"app" must be an object: {"service": name} or {"url": url}');
392
+ return { url: '' };
393
+ }
394
+ const a = (raw ?? {});
395
+ checkKeys(a, APP_KEYS, 'app', problems);
396
+ if (a.url !== undefined) {
397
+ if (typeof a.url !== 'string' || !URL.canParse(a.url))
398
+ problems.push('"app.url" must be an absolute URL');
399
+ const url = String(a.url).replace(/\/+$/, '');
400
+ const owner = services.find((s) => s.url === url);
401
+ return { url, ...(owner ? { service: owner.name } : {}) };
402
+ }
403
+ let name = typeof a.service === 'string' ? a.service : undefined;
404
+ if (a.service !== undefined && typeof a.service !== 'string')
405
+ problems.push('"app.service" must be a service name');
406
+ if (!name) {
407
+ const withUrl = services.filter((s) => s.url);
408
+ name = services.find((s) => s.name === 'web' && s.url)?.name ?? (withUrl.length === 1 ? withUrl[0].name : undefined);
409
+ if (!name) {
410
+ if (services.length)
411
+ problems.push('set "app": {"service": name} or {"url": url} to say which URL is the application');
412
+ return { url: '' };
413
+ }
414
+ }
415
+ const svc = services.find((s) => s.name === name);
416
+ if (!svc) {
417
+ problems.push(`"app.service" names unknown service "${name}"`);
418
+ return { url: '' };
419
+ }
420
+ if (!svc.url) {
421
+ problems.push(`"app.service" "${name}" has no url`);
422
+ return { url: '' };
423
+ }
424
+ return { url: svc.url, service: name };
425
+ }
426
+ function parseDevices(raw, problems) {
427
+ if (raw === undefined)
428
+ return {};
429
+ if (!isRecord(raw)) {
430
+ problems.push('"devices" must map ids to device profiles');
431
+ return {};
432
+ }
433
+ const out = {};
434
+ for (const [id, d] of Object.entries(raw)) {
435
+ if (!isRecord(d)) {
436
+ problems.push(`"devices.${id}" must be an object`);
437
+ continue;
438
+ }
439
+ checkKeys(d, DEVICE_KEYS, `devices.${id}`, problems);
440
+ const vp = d.viewport;
441
+ checkKeys(vp, ['width', 'height'], `devices.${id}.viewport`, problems);
442
+ if (vp !== undefined && !(isRecord(vp) && [vp.width, vp.height].every((n) => typeof n === 'number' && n >= 100 && n <= 4000))) {
443
+ problems.push(`"devices.${id}.viewport" must be {width, height} between 100 and 4000`);
444
+ }
445
+ if (d.extends === undefined && vp === undefined)
446
+ problems.push(`"devices.${id}" needs "extends" or "viewport"`);
447
+ if (d.extends !== undefined && typeof d.extends !== 'string')
448
+ problems.push(`"devices.${id}.extends" must be a device id`);
449
+ if (d.deviceScaleFactor !== undefined && !(typeof d.deviceScaleFactor === 'number' && d.deviceScaleFactor > 0 && d.deviceScaleFactor <= 4)) {
450
+ problems.push(`"devices.${id}.deviceScaleFactor" must be between 0 and 4`);
451
+ }
452
+ for (const flag of ['isMobile', 'hasTouch']) {
453
+ if (d[flag] !== undefined && typeof d[flag] !== 'boolean')
454
+ problems.push(`"devices.${id}.${flag}" must be true or false`);
455
+ }
456
+ out[id] = d;
457
+ }
458
+ return out;
459
+ }
460
+ function parseAuth(raw, root, problems) {
461
+ if (raw !== undefined && !isRecord(raw))
462
+ problems.push('"auth" must be an object');
463
+ const a = isRecord(raw) ? raw : {};
464
+ checkKeys(a, AUTH_KEYS, 'auth', problems);
465
+ const file = a.file ?? DEFAULT_AUTH_FILE;
466
+ if (typeof file !== 'string' || !file)
467
+ problems.push('"auth.file" must be a path');
468
+ const use = a.use ?? 'auto';
469
+ if (use !== 'auto' && use !== 'never')
470
+ problems.push('"auth.use" must be "auto" or "never"');
471
+ if (a.loginPath !== undefined && (typeof a.loginPath !== 'string' || !a.loginPath.startsWith('/')))
472
+ problems.push('"auth.loginPath" must start with "/"');
473
+ return {
474
+ file: resolve(root, String(file)),
475
+ use: use === 'never' ? 'never' : 'auto',
476
+ ...(typeof a.loginPath === 'string' ? { loginPath: a.loginPath } : {}),
477
+ };
478
+ }
479
+ function parseUploads(raw, root, problems) {
480
+ if (raw === undefined)
481
+ return [];
482
+ checkKeys(raw, ['allow'], 'uploads', problems);
483
+ const allow = isRecord(raw) ? raw.allow : undefined;
484
+ if (!Array.isArray(allow) || !allow.every((p) => typeof p === 'string' && p.length > 0)) {
485
+ problems.push('"uploads.allow" must be a list of project-relative paths');
486
+ return [];
487
+ }
488
+ return allow.map((p) => {
489
+ const abs = resolve(root, p);
490
+ const rel = relative(root, abs);
491
+ if (isAbsolute(p) || rel.startsWith('..'))
492
+ problems.push(`"uploads.allow" entry "${p}" must be inside the project`);
493
+ return abs;
494
+ });
495
+ }
496
+ export const DEFAULT_EXPLORE = { enabled: false, maxDepth: 1, maxStates: 12, maxActionsPerState: 8, maxMs: 120_000, allow: [], deny: [] };
497
+ export function defaultScan() {
498
+ return {
499
+ devices: [...SWEEP_DEVICES], checks: {}, tapTargets: { standard: 'wcag22-aa' }, noWrap: [], wrapNearbyRatio: 1.35, layoutShiftMin: 0.05,
500
+ explore: { ...DEFAULT_EXPLORE, allow: [], deny: [] }, scenarios: [], suppressions: [], policy: { ...DEFAULT_POLICY },
501
+ };
502
+ }
503
+ const ACTIONS = ['click', 'fill', 'press', 'select', 'check', 'uncheck', 'scroll', 'swipe', 'back', 'forward', 'hover', 'upload', 'drag', 'open_tab', 'switch_tab', 'close_tab'];
504
+ const TARGETED = ['click', 'fill', 'select', 'check', 'uncheck', 'hover', 'upload', 'drag'];
505
+ const SEVERITIES = ['high', 'medium', 'low', 'none'];
506
+ /** The `scan` section: devices, checks, stateful scenarios, exploration, suppressions and the result policy. */
507
+ function parseScan(raw, problems) {
508
+ const scan = defaultScan();
509
+ if (raw === undefined)
510
+ return scan;
511
+ if (!isRecord(raw)) {
512
+ problems.push('"scan" must be an object');
513
+ return scan;
514
+ }
515
+ checkKeys(raw, SCAN_KEYS, 'scan', problems);
516
+ const kinds = Object.keys(DETECTORS);
517
+ const checkKinds = (v, at) => {
518
+ if (v === undefined)
519
+ return [];
520
+ if (!Array.isArray(v) || !v.every((k) => typeof k === 'string' && kinds.includes(k))) {
521
+ problems.push(`"${at}" must be a list of check names (${kinds.join(', ')})`);
522
+ return [];
523
+ }
524
+ return v;
525
+ };
526
+ const checks = (v, at) => {
527
+ if (v === undefined)
528
+ return {};
529
+ if (!isRecord(v)) {
530
+ problems.push(`"${at}" must be an object with "enable" and/or "disable"`);
531
+ return {};
532
+ }
533
+ checkKeys(v, CHECKS_KEYS, at, problems);
534
+ const out = {};
535
+ if (v.enable !== undefined)
536
+ out.enable = checkKinds(v.enable, `${at}.enable`);
537
+ if (v.disable !== undefined)
538
+ out.disable = checkKinds(v.disable, `${at}.disable`);
539
+ return out;
540
+ };
541
+ const devices = (v, at) => {
542
+ if (v === undefined)
543
+ return undefined;
544
+ if (!Array.isArray(v) || !v.length || !v.every((d) => typeof d === 'string' && d)) {
545
+ problems.push(`"${at}" must be a non-empty list of device ids`);
546
+ return undefined;
547
+ }
548
+ return v;
549
+ };
550
+ const matchers = (v, at) => {
551
+ if (v === undefined)
552
+ return [];
553
+ if (!Array.isArray(v) || !v.every((m) => isRecord(m) && (m.role !== undefined || m.name !== undefined) &&
554
+ ['role', 'name', 'route'].every((k) => m[k] === undefined || typeof m[k] === 'string') && Object.keys(m).every((k) => ['role', 'name', 'route'].includes(k)))) {
555
+ problems.push(`"${at}" must be a list of {"role", "name", "route"} matchers (role or name required; name and route may use *)`);
556
+ return [];
557
+ }
558
+ return v;
559
+ };
560
+ const steps = (v, at) => {
561
+ if (v === undefined)
562
+ return [];
563
+ if (!Array.isArray(v)) {
564
+ problems.push(`"${at}" must be a list of steps`);
565
+ return [];
566
+ }
567
+ v.forEach((st, i) => {
568
+ if (!isRecord(st) || typeof st.do !== 'string' || !ACTIONS.includes(st.do)) {
569
+ problems.push(`"${at}[${i}].do" must be one of ${ACTIONS.join(', ')}`);
570
+ return;
571
+ }
572
+ checkKeys(st, STEP_KEYS, `${at}[${i}]`, problems);
573
+ checkKeys(st.to, TARGET_KEYS, `${at}[${i}].to`, problems);
574
+ checkKeys(st.expect, EXPECT_KEYS, `${at}[${i}].expect`, problems);
575
+ if (isRecord(st.expect)) {
576
+ checkKeys(st.expect.control, [...TARGET_KEYS, 'disabled', 'invalid'], `${at}[${i}].expect.control`, problems);
577
+ checkKeys(st.expect.noControl, TARGET_KEYS, `${at}[${i}].expect.noControl`, problems);
578
+ }
579
+ if (TARGETED.includes(st.do) && st.name === undefined && st.nameContains === undefined)
580
+ problems.push(`"${at}[${i}]" (${st.do}) needs "name" or "nameContains", optionally with "role"`);
581
+ if (st.do === 'fill' && typeof st.value !== 'string')
582
+ problems.push(`"${at}[${i}]" (fill) needs a "value"`);
583
+ if (st.do === 'press' && typeof st.key !== 'string')
584
+ problems.push(`"${at}[${i}]" (press) needs a "key"`);
585
+ });
586
+ return v;
587
+ };
588
+ if (raw.devices !== undefined)
589
+ scan.devices = devices(raw.devices, 'scan.devices') ?? scan.devices;
590
+ scan.checks = checks(raw.checks, 'scan.checks');
591
+ if (raw.tapTargets !== undefined) {
592
+ checkKeys(raw.tapTargets, ['standard'], 'scan.tapTargets', problems);
593
+ const t = isRecord(raw.tapTargets) ? raw.tapTargets.standard : undefined;
594
+ if (t !== 'wcag22-aa' && t !== 'wcag22-aaa')
595
+ problems.push('"scan.tapTargets.standard" must be "wcag22-aa" (24 px with the spacing exception) or "wcag22-aaa" (44 px)');
596
+ else
597
+ scan.tapTargets = { standard: t };
598
+ }
599
+ scan.noWrap = matchers(raw.noWrap, 'scan.noWrap');
600
+ if (raw.wrapNearbyRatio !== undefined) {
601
+ if (typeof raw.wrapNearbyRatio !== 'number' || raw.wrapNearbyRatio <= 1 || raw.wrapNearbyRatio > 3)
602
+ problems.push('"scan.wrapNearbyRatio" must be a number above 1 and at most 3');
603
+ else
604
+ scan.wrapNearbyRatio = raw.wrapNearbyRatio;
605
+ }
606
+ if (raw.layoutShiftMin !== undefined) {
607
+ if (typeof raw.layoutShiftMin !== 'number' || raw.layoutShiftMin <= 0 || raw.layoutShiftMin > 1)
608
+ problems.push('"scan.layoutShiftMin" must be a number above 0 and at most 1');
609
+ else
610
+ scan.layoutShiftMin = raw.layoutShiftMin;
611
+ }
612
+ if (raw.explore !== undefined) {
613
+ if (!isRecord(raw.explore))
614
+ problems.push('"scan.explore" must be an object');
615
+ else {
616
+ const e = raw.explore;
617
+ checkKeys(e, EXPLORE_KEYS, 'scan.explore', problems);
618
+ const lim = (k, max) => {
619
+ if (e[k] === undefined)
620
+ return;
621
+ if (typeof e[k] !== 'number' || !Number.isInteger(e[k]) || e[k] < 1 || e[k] > max)
622
+ problems.push(`"scan.explore.${k}" must be a whole number from 1 to ${max}`);
623
+ else
624
+ scan.explore[k] = e[k];
625
+ };
626
+ if (e.enabled !== undefined && typeof e.enabled !== 'boolean')
627
+ problems.push('"scan.explore.enabled" must be true or false');
628
+ scan.explore.enabled = e.enabled === true;
629
+ lim('maxDepth', 4);
630
+ lim('maxStates', 100);
631
+ lim('maxActionsPerState', 50);
632
+ lim('maxMs', 3_600_000);
633
+ scan.explore.allow = matchers(e.allow, 'scan.explore.allow');
634
+ scan.explore.deny = matchers(e.deny, 'scan.explore.deny');
635
+ }
636
+ }
637
+ if (raw.scenarios !== undefined) {
638
+ if (!Array.isArray(raw.scenarios))
639
+ problems.push('"scan.scenarios" must be a list');
640
+ else {
641
+ const names = new Set();
642
+ raw.scenarios.forEach((sc, i) => {
643
+ const at = `scan.scenarios[${i}]`;
644
+ if (!isRecord(sc)) {
645
+ problems.push(`"${at}" must be an object`);
646
+ return;
647
+ }
648
+ checkKeys(sc, SCENARIO_KEYS, at, problems);
649
+ if (typeof sc.name !== 'string' || !sc.name.trim())
650
+ problems.push(`"${at}.name" must be a non-empty string`);
651
+ else if (names.has(sc.name))
652
+ problems.push(`scenario name "${sc.name}" is used twice`);
653
+ else
654
+ names.add(sc.name);
655
+ if (typeof sc.route !== 'string' || !sc.route.startsWith('/'))
656
+ problems.push(`"${at}.route" must start with "/"`);
657
+ const auth = sc.auth ?? 'session';
658
+ if (!['session', 'saved', 'fresh'].includes(auth))
659
+ problems.push(`"${at}.auth" must be "session", "saved" or "fresh"`);
660
+ if (sc.explore !== undefined && typeof sc.explore !== 'boolean')
661
+ problems.push(`"${at}.explore" must be true or false`);
662
+ const scenario = {
663
+ name: String(sc.name ?? ''), route: String(sc.route ?? '/'), auth: auth,
664
+ ...(sc.devices !== undefined ? { devices: devices(sc.devices, `${at}.devices`) } : {}),
665
+ steps: steps(sc.steps, `${at}.steps`), cleanup: steps(sc.cleanup, `${at}.cleanup`),
666
+ ...(sc.checks !== undefined ? { checks: checks(sc.checks, `${at}.checks`) } : {}),
667
+ ...(typeof sc.explore === 'boolean' ? { explore: sc.explore } : {}),
668
+ };
669
+ scan.scenarios.push(scenario);
670
+ });
671
+ }
672
+ }
673
+ if (raw.suppressions !== undefined) {
674
+ if (!Array.isArray(raw.suppressions))
675
+ problems.push('"scan.suppressions" must be a list');
676
+ else
677
+ raw.suppressions.forEach((su, i) => {
678
+ const at = `scan.suppressions[${i}]`;
679
+ if (!isRecord(su)) {
680
+ problems.push(`"${at}" must be an object`);
681
+ return;
682
+ }
683
+ checkKeys(su, SUPPRESSION_KEYS, at, problems);
684
+ checkKeys(su.target, ['role', 'name'], `${at}.target`, problems);
685
+ if (typeof su.reason !== 'string' || !su.reason.trim())
686
+ problems.push(`"${at}.reason" must say why the finding is accepted`);
687
+ if (su.kind === undefined && su.fingerprint === undefined)
688
+ problems.push(`"${at}" needs "kind" or "fingerprint" (a suppression may not match every finding)`);
689
+ const ks = su.kind === undefined ? [] : Array.isArray(su.kind) ? su.kind : [su.kind];
690
+ if (!ks.every((k) => typeof k === 'string' && kinds.includes(k)))
691
+ problems.push(`"${at}.kind" must be a check name or a list of them`);
692
+ if (su.expires !== undefined && (typeof su.expires !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(su.expires)))
693
+ problems.push(`"${at}.expires" must be a date, YYYY-MM-DD`);
694
+ for (const k of ['fingerprint', 'route', 'scenario', 'device'])
695
+ if (su[k] !== undefined && typeof su[k] !== 'string')
696
+ problems.push(`"${at}.${k}" must be a string`);
697
+ if (su.target !== undefined && !(isRecord(su.target) && ['role', 'name'].every((k) => su.target[k] === undefined || typeof su.target[k] === 'string')))
698
+ problems.push(`"${at}.target" must be {"role", "name"}`);
699
+ scan.suppressions.push(su);
700
+ });
701
+ }
702
+ if (raw.policy !== undefined) {
703
+ if (!isRecord(raw.policy))
704
+ problems.push('"scan.policy" must be an object');
705
+ else {
706
+ const p = raw.policy;
707
+ checkKeys(p, POLICY_KEYS, 'scan.policy', problems);
708
+ if (p.failOn !== undefined && !SEVERITIES.includes(p.failOn))
709
+ problems.push(`"scan.policy.failOn" must be one of ${SEVERITIES.join(', ')}`);
710
+ else if (p.failOn !== undefined)
711
+ scan.policy.failOn = p.failOn;
712
+ for (const k of ['failOnErrors', 'failOnHeuristic']) {
713
+ if (p[k] !== undefined && typeof p[k] !== 'boolean')
714
+ problems.push(`"scan.policy.${k}" must be true or false`);
715
+ else if (p[k] !== undefined)
716
+ scan.policy[k] = p[k];
717
+ }
718
+ }
719
+ }
720
+ return scan;
721
+ }
722
+ /** The `ci` section: what `agentlab test` runs and how it decides. Strict like the others. */
723
+ function parseCi(raw, problems) {
724
+ const ci = {};
725
+ if (raw === undefined)
726
+ return ci;
727
+ if (!isRecord(raw)) {
728
+ problems.push('"ci" must be an object');
729
+ return ci;
730
+ }
731
+ checkKeys(raw, CI_KEYS, 'ci', problems);
732
+ const list = (k) => {
733
+ const v = raw[k];
734
+ if (v === undefined)
735
+ return;
736
+ if (!Array.isArray(v) || !v.every((x) => typeof x === 'string' && x))
737
+ problems.push(`"ci.${k}" must be a list of non-empty strings`);
738
+ else if (k === 'routes' && !v.every((x) => x.startsWith('/')))
739
+ problems.push('"ci.routes" entries must start with "/"');
740
+ else
741
+ ci[k] = v;
742
+ };
743
+ list('flows');
744
+ list('routes');
745
+ list('devices');
746
+ if (raw.scenarios !== undefined) {
747
+ if (raw.scenarios === 'all')
748
+ ci.scenarios = 'all';
749
+ else if (Array.isArray(raw.scenarios) && raw.scenarios.every((x) => typeof x === 'string' && x))
750
+ ci.scenarios = raw.scenarios;
751
+ else
752
+ problems.push('"ci.scenarios" must be a list of scenario names or "all"');
753
+ }
754
+ const oneOf = (k, allowed) => {
755
+ const v = raw[k];
756
+ if (v === undefined)
757
+ return;
758
+ if (typeof v !== 'string' || !allowed.includes(v))
759
+ problems.push(`"ci.${k}" must be one of ${allowed.join(', ')}`);
760
+ else
761
+ ci[k] = v;
762
+ };
763
+ oneOf('failOn', SEVERITIES);
764
+ oneOf('scenarioErrors', ['fail', 'report']);
765
+ oneOf('trace', ['off', 'on-failure', 'always']);
766
+ oneOf('evidence', ['off', 'on-failure', 'always']);
767
+ oneOf('auth', ['fresh', 'saved', 'env']);
768
+ if (raw.failOnHeuristic !== undefined) {
769
+ if (typeof raw.failOnHeuristic !== 'boolean')
770
+ problems.push('"ci.failOnHeuristic" must be true or false');
771
+ else
772
+ ci.failOnHeuristic = raw.failOnHeuristic;
773
+ }
774
+ if (raw.out !== undefined) {
775
+ if (typeof raw.out !== 'string' || !raw.out.trim())
776
+ problems.push('"ci.out" must be a non-empty path');
777
+ else
778
+ ci.out = raw.out;
779
+ }
780
+ if (raw.timeoutMs !== undefined) {
781
+ if (typeof raw.timeoutMs !== 'number' || !Number.isFinite(raw.timeoutMs) || raw.timeoutMs < 1000)
782
+ problems.push('"ci.timeoutMs" must be a number of milliseconds, at least 1000');
783
+ else
784
+ ci.timeoutMs = raw.timeoutMs;
785
+ }
786
+ return ci;
787
+ }
788
+ /** Default policy: only localhost and private development hosts, unless explicitly opted in. */
789
+ export function isLocalOrPrivateHost(hostname) {
790
+ const host = hostname.replace(/^\[|\]$/g, '').toLowerCase();
791
+ if (host === 'localhost' || host.endsWith('.localhost') || host === '::1')
792
+ return true;
793
+ if (isIP(host) === 4) {
794
+ const [a, b] = host.split('.').map(Number);
795
+ return a === 127 || a === 10 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31);
796
+ }
797
+ if (isIP(host) === 6)
798
+ return /^f[cd]/.test(host) || host.startsWith('fe80');
799
+ return false;
800
+ }
801
+ export function assertUrlAllowed(url, allowExternal) {
802
+ const { protocol, hostname } = new URL(url);
803
+ if (protocol !== 'http:' && protocol !== 'https:') {
804
+ throw new LabError('url_not_allowed', `Only http(s) URLs are supported, got ${protocol}`);
805
+ }
806
+ if (!allowExternal && !isLocalOrPrivateHost(hostname)) {
807
+ throw new LabError('url_not_allowed', `${hostname} is not a local or private development host`, {
808
+ hint: 'Set "allowExternalUrl": true in agentlab.json to opt in explicitly.',
809
+ });
810
+ }
811
+ }
812
+ function isRecord(v) {
813
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
814
+ }
815
+ export { SCHEMA_VERSION };
816
+ //# sourceMappingURL=profile.js.map