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.
- package/LICENSE +202 -0
- package/NOTICE +10 -0
- package/README.md +150 -2
- package/assets/dashboard/app.css +238 -0
- package/assets/dashboard/app.js +976 -0
- package/assets/dashboard/index.html +120 -0
- package/bin/agentlab.js +8 -0
- package/dist/cli/ci.js +443 -0
- package/dist/cli/flow.js +127 -0
- package/dist/cli/main.js +855 -0
- package/dist/cli/signals.js +38 -0
- package/dist/core/action-log.js +125 -0
- package/dist/core/auth.js +175 -0
- package/dist/core/bundle.js +432 -0
- package/dist/core/checks.js +137 -0
- package/dist/core/ci.js +389 -0
- package/dist/core/commands.js +366 -0
- package/dist/core/control.js +217 -0
- package/dist/core/detectors.js +392 -0
- package/dist/core/devices.js +83 -0
- package/dist/core/doctor.js +203 -0
- package/dist/core/explore-safety.js +110 -0
- package/dist/core/extract.js +1445 -0
- package/dist/core/feed.js +493 -0
- package/dist/core/findings.js +179 -0
- package/dist/core/format.js +311 -0
- package/dist/core/init.js +607 -0
- package/dist/core/lab.js +1979 -0
- package/dist/core/migrate.js +35 -0
- package/dist/core/observation.js +161 -0
- package/dist/core/ownership.js +142 -0
- package/dist/core/process-group.js +206 -0
- package/dist/core/process-identity.js +56 -0
- package/dist/core/profile.js +816 -0
- package/dist/core/project-runner.js +359 -0
- package/dist/core/replay.js +303 -0
- package/dist/core/reproduction.js +36 -0
- package/dist/core/scan-policy.js +156 -0
- package/dist/core/scan-report.js +145 -0
- package/dist/core/scan.js +455 -0
- package/dist/core/schema.js +39 -0
- package/dist/core/services.js +134 -0
- package/dist/core/steps.js +80 -0
- package/dist/core/sweep.js +138 -0
- package/dist/core/trace-sanitize.js +641 -0
- package/dist/core/url-redact.js +25 -0
- package/dist/core/versions.js +29 -0
- package/dist/core/zip.js +264 -0
- package/dist/daemon/main.js +160 -0
- package/dist/daemon/state.js +93 -0
- package/dist/dashboard/open.js +25 -0
- package/dist/dashboard/server.js +366 -0
- package/dist/mcp/server.js +89 -0
- package/package.json +63 -4
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { arch, platform, release } from 'node:os';
|
|
5
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { redactSecrets, displayUrl } from './feed.js';
|
|
7
|
+
import { routePath } from './action-log.js';
|
|
8
|
+
import { SECRET_FIELD } from './steps.js';
|
|
9
|
+
import { readZip } from './zip.js';
|
|
10
|
+
import { LabError, } from './schema.js';
|
|
11
|
+
import { CONTRACT_VERSIONS, productVersion } from './versions.js';
|
|
12
|
+
// Failure bundles: a folder holding bundle.json (what happened, how to replay it), the evidence frames and,
|
|
13
|
+
// when recorded and provably clean, a sanitized Playwright trace. The bundle is meant to leave the machine as a
|
|
14
|
+
// CI artifact or a bug report, so it never holds commands' environment, request bodies, headers, typed
|
|
15
|
+
// passwords or saved sign-in state, and a final pass over everything written refuses to proceed if a secret
|
|
16
|
+
// value known to the process is still in it.
|
|
17
|
+
export const BUNDLE_KIND = 'agentlab-failure-bundle';
|
|
18
|
+
export const BUNDLE_FILE = 'bundle.json';
|
|
19
|
+
const REDACTED = '‹redacted›';
|
|
20
|
+
const SET = '‹set›';
|
|
21
|
+
const DEFAULT_KEEP = 20;
|
|
22
|
+
const DEFAULT_DAYS = 14;
|
|
23
|
+
const MAX_FRAMES = 60;
|
|
24
|
+
const MIN_ENV_SECRET = 6;
|
|
25
|
+
/** A declared service env value this long is treated as a secret whatever its name (a token, a URL with credentials). */
|
|
26
|
+
const LONG_ENV_VALUE = 16;
|
|
27
|
+
const SECRET_ENV_NAME = /secret|token|passw|api[_-]?key|auth|cookie|private|credential|signature|csrf/i;
|
|
28
|
+
/** Scenario steps aimed at a field with one of these words in its name or role keep no `value`. */
|
|
29
|
+
const SECRETISH = SECRET_FIELD;
|
|
30
|
+
const ID = /^b-\d{8}T\d{6}-[0-9a-f]{4}$/;
|
|
31
|
+
export const sha256 = (data) => createHash('sha256').update(data).digest('hex');
|
|
32
|
+
export function bundleId(now = new Date()) {
|
|
33
|
+
return `b-${now.toISOString().slice(0, 19).replace(/[-:]/g, '')}-${randomBytes(2).toString('hex')}`;
|
|
34
|
+
}
|
|
35
|
+
// ---------- what must never be written ----------
|
|
36
|
+
/** Values in the process environment that must not appear in a bundle: required and secret-named variables, and long declared service values. */
|
|
37
|
+
export function envSecrets(profile, env = process.env) {
|
|
38
|
+
const out = new Set();
|
|
39
|
+
const add = (v, min) => { if (v && v.length >= min)
|
|
40
|
+
out.add(v); };
|
|
41
|
+
for (const svc of profile.services) {
|
|
42
|
+
for (const name of svc.requiredEnv)
|
|
43
|
+
add(env[name], MIN_ENV_SECRET);
|
|
44
|
+
for (const value of Object.values(svc.env))
|
|
45
|
+
add(value, LONG_ENV_VALUE);
|
|
46
|
+
}
|
|
47
|
+
for (const [name, value] of Object.entries(env))
|
|
48
|
+
if (SECRET_ENV_NAME.test(name))
|
|
49
|
+
add(value, MIN_ENV_SECRET);
|
|
50
|
+
return [...out];
|
|
51
|
+
}
|
|
52
|
+
const variants = (secret) => [...new Set([secret, encodeURIComponent(secret)])].filter((v) => v.length > 0);
|
|
53
|
+
const escapedVariants = (secret) => [...new Set([...variants(secret), JSON.stringify(secret).slice(1, -1)])];
|
|
54
|
+
function replaceAll(text, secrets) {
|
|
55
|
+
let out = text;
|
|
56
|
+
for (const s of secrets)
|
|
57
|
+
for (const v of variants(s))
|
|
58
|
+
if (out.includes(v))
|
|
59
|
+
out = out.split(v).join(REDACTED);
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** A copy of a JSON value with every secret replaced inside its strings. */
|
|
63
|
+
export function redactTree(value, secrets) {
|
|
64
|
+
if (typeof value === 'string')
|
|
65
|
+
return replaceAll(value, secrets);
|
|
66
|
+
if (Array.isArray(value))
|
|
67
|
+
return value.map((v) => redactTree(v, secrets));
|
|
68
|
+
if (value && typeof value === 'object')
|
|
69
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactTree(v, secrets)]));
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
/** Names of the secrets still in `text`, as a count only: the values are never reported. */
|
|
73
|
+
export function leakedSecrets(text, secrets) {
|
|
74
|
+
return secrets.filter((s) => escapedVariants(s).some((v) => text.includes(v))).length;
|
|
75
|
+
}
|
|
76
|
+
// ---------- the profile, sanitized ----------
|
|
77
|
+
const isRec = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
78
|
+
/**
|
|
79
|
+
* agentlab.json as it can leave the machine: every service env value becomes ‹set› (names kept), requiredEnv
|
|
80
|
+
* names stay, strings pass through `redactSecrets` (commands, URLs, everything), and the auth section keeps
|
|
81
|
+
* only `use`, `loginPath` and the state file relative to the project root. Scenario steps aimed at a
|
|
82
|
+
* secret-looking field keep no `value`.
|
|
83
|
+
*/
|
|
84
|
+
export function sanitizeProfile(raw, root) {
|
|
85
|
+
if (!isRec(raw))
|
|
86
|
+
return {};
|
|
87
|
+
const copy = structuredClone(raw);
|
|
88
|
+
const mask = (svc) => {
|
|
89
|
+
if (isRec(svc) && isRec(svc.env))
|
|
90
|
+
svc.env = Object.fromEntries(Object.keys(svc.env).map((k) => [k, SET]));
|
|
91
|
+
};
|
|
92
|
+
mask(copy.web);
|
|
93
|
+
if (isRec(copy.services))
|
|
94
|
+
for (const svc of Object.values(copy.services))
|
|
95
|
+
mask(svc);
|
|
96
|
+
if (isRec(copy.auth)) {
|
|
97
|
+
const { use, loginPath, file } = copy.auth;
|
|
98
|
+
const rel = typeof file === 'string' ? relative(root, resolve(root, file)) : undefined;
|
|
99
|
+
copy.auth = { ...(use !== undefined ? { use } : {}), ...(loginPath !== undefined ? { loginPath } : {}), ...(rel !== undefined ? { file: rel } : {}) };
|
|
100
|
+
}
|
|
101
|
+
// Sign-in steps in declared scenarios carry test credentials: same rule as the bundle's scenario section.
|
|
102
|
+
const scenarios = isRec(copy.scan) && Array.isArray(copy.scan.scenarios) ? copy.scan.scenarios : [];
|
|
103
|
+
for (const sc of scenarios) {
|
|
104
|
+
if (!isRec(sc))
|
|
105
|
+
continue;
|
|
106
|
+
for (const key of ['steps', 'cleanup'])
|
|
107
|
+
if (Array.isArray(sc[key]))
|
|
108
|
+
sc[key] = sanitizeScenarioSteps(sc[key].filter(isRec));
|
|
109
|
+
}
|
|
110
|
+
const walk = (v) => {
|
|
111
|
+
if (typeof v === 'string')
|
|
112
|
+
return redactSecrets(v);
|
|
113
|
+
if (Array.isArray(v))
|
|
114
|
+
return v.map(walk);
|
|
115
|
+
if (isRec(v))
|
|
116
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
|
|
117
|
+
return v;
|
|
118
|
+
};
|
|
119
|
+
return walk(copy);
|
|
120
|
+
}
|
|
121
|
+
/** A declared scenario for the bundle: the `value` of a step aimed at a secret-looking field is masked. */
|
|
122
|
+
export function sanitizeScenarioSteps(steps) {
|
|
123
|
+
return steps.map((step) => {
|
|
124
|
+
const s = { ...step };
|
|
125
|
+
if (s.value !== undefined && SECRETISH.test(`${s.name ?? ''} ${s.nameContains ?? ''} ${s.role ?? ''}`))
|
|
126
|
+
s.value = '‹secret›';
|
|
127
|
+
return s;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
export function summariseConsole(entries, limit = 50) {
|
|
131
|
+
const seen = new Map();
|
|
132
|
+
for (const e of entries) {
|
|
133
|
+
const text = redactSecrets(e.text).slice(0, 300);
|
|
134
|
+
const key = `${e.type}\u0001${text}`;
|
|
135
|
+
const prev = seen.get(key);
|
|
136
|
+
seen.delete(key); // re-inserted last: order is by most recent occurrence
|
|
137
|
+
seen.set(key, { type: e.type, text, count: (prev?.count ?? 0) + 1 });
|
|
138
|
+
}
|
|
139
|
+
return [...seen.values()].slice(-limit);
|
|
140
|
+
}
|
|
141
|
+
export function summariseRequests(entries, limit = 50) {
|
|
142
|
+
return entries.slice(-limit).map((r) => ({
|
|
143
|
+
method: r.method, url: displayUrl(r.url), ...(r.status !== undefined ? { status: r.status } : {}),
|
|
144
|
+
...(r.failure !== undefined ? { failure: redactSecrets(r.failure).slice(0, 200) } : {}),
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
// ---------- frames ----------
|
|
148
|
+
/** Frames inside the run directory that belong in the bundle, by the name they get there. Nothing outside the run directory is copied. */
|
|
149
|
+
function planFrames(runDir, findings) {
|
|
150
|
+
const copies = new Map(); // absolute source → name in frames/
|
|
151
|
+
const taken = new Set();
|
|
152
|
+
let omitted = 0;
|
|
153
|
+
let root;
|
|
154
|
+
try {
|
|
155
|
+
root = realpathSync(runDir);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return { copies, omitted };
|
|
159
|
+
}
|
|
160
|
+
const add = (src) => {
|
|
161
|
+
let real;
|
|
162
|
+
try {
|
|
163
|
+
real = realpathSync(src);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
if (!real.startsWith(root + sep) || !statSync(real).isFile())
|
|
169
|
+
return undefined;
|
|
170
|
+
const known = copies.get(real);
|
|
171
|
+
if (known)
|
|
172
|
+
return known;
|
|
173
|
+
if (copies.size >= MAX_FRAMES) {
|
|
174
|
+
omitted++;
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
let name = basename(real);
|
|
178
|
+
for (let n = 2; taken.has(name); n++)
|
|
179
|
+
name = `${n}-${basename(real)}`;
|
|
180
|
+
taken.add(name);
|
|
181
|
+
copies.set(real, name);
|
|
182
|
+
return name;
|
|
183
|
+
};
|
|
184
|
+
const direct = join(runDir, 'frames');
|
|
185
|
+
if (existsSync(direct))
|
|
186
|
+
for (const f of readdirSync(direct).sort())
|
|
187
|
+
if (/\.jpe?g$/i.test(f))
|
|
188
|
+
add(join(direct, f));
|
|
189
|
+
for (const f of findings) {
|
|
190
|
+
if (f.frame)
|
|
191
|
+
add(f.frame);
|
|
192
|
+
for (const x of f.frames ?? [])
|
|
193
|
+
add(x.path);
|
|
194
|
+
}
|
|
195
|
+
return { copies, omitted };
|
|
196
|
+
}
|
|
197
|
+
function withFramePaths(findings, copies) {
|
|
198
|
+
const nameOf = (p) => {
|
|
199
|
+
try {
|
|
200
|
+
const n = copies.get(realpathSync(p));
|
|
201
|
+
return n ? `frames/${n}` : undefined;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
return findings.map((f) => {
|
|
208
|
+
const { frame, frames, ...rest } = structuredClone(f);
|
|
209
|
+
const inBundle = frame ? nameOf(frame) : undefined;
|
|
210
|
+
const extra = (frames ?? []).flatMap((x) => { const p = nameOf(x.path); return p ? [{ ...x, path: p }] : []; });
|
|
211
|
+
return { ...rest, ...(inBundle ? { frame: inBundle } : {}), ...(extra.length ? { frames: extra } : {}) };
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
// ---------- assembling and writing ----------
|
|
215
|
+
function playwrightVersion() {
|
|
216
|
+
try {
|
|
217
|
+
return createRequire(import.meta.url)('playwright/package.json').version;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return 'unknown';
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const envInt = (name, env) => {
|
|
224
|
+
const n = Number(env[name]);
|
|
225
|
+
return env[name] !== undefined && env[name] !== '' && Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
226
|
+
};
|
|
227
|
+
/** The retention limits: AGENTLAB_KEEP_BUNDLES and AGENTLAB_BUNDLE_DAYS override the defaults. */
|
|
228
|
+
export function retentionLimits(env = process.env) {
|
|
229
|
+
return { keep: envInt('AGENTLAB_KEEP_BUNDLES', env) ?? DEFAULT_KEEP, maxAgeDays: envInt('AGENTLAB_BUNDLE_DAYS', env) ?? DEFAULT_DAYS };
|
|
230
|
+
}
|
|
231
|
+
function ensureDir(dir) {
|
|
232
|
+
const fresh = !existsSync(dir);
|
|
233
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
234
|
+
if (fresh)
|
|
235
|
+
chmodSync(dir, 0o700);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Write `<dir>/<id>/bundle.json`, `frames/*.jpg` and `trace.zip` (owner-only: files 0600, directories 0700),
|
|
239
|
+
* after a final pass that replaces, then verifies the absence of, every secret value known to the process.
|
|
240
|
+
* Throws `bundle_unsafe` (writing nothing) when one is still present. Prunes old bundles afterwards.
|
|
241
|
+
*/
|
|
242
|
+
export function writeBundle(input, dir) {
|
|
243
|
+
const env = input.env ?? process.env;
|
|
244
|
+
const now = input.now ?? new Date();
|
|
245
|
+
const { profile, session } = input;
|
|
246
|
+
const limits = retentionLimits(env);
|
|
247
|
+
const id = bundleId(now);
|
|
248
|
+
// Longest first, so a secret that contains another is replaced whole.
|
|
249
|
+
const secrets = [...new Set([...input.secrets, ...envSecrets(profile, env)])].filter((s) => s.length >= 4).sort((a, b) => b.length - a.length);
|
|
250
|
+
const profileBytes = readFileSync(profile.profilePath);
|
|
251
|
+
let rawProfile = {};
|
|
252
|
+
try {
|
|
253
|
+
rawProfile = JSON.parse(profileBytes.toString('utf8'));
|
|
254
|
+
}
|
|
255
|
+
catch { /* the sanitized copy is then empty */ }
|
|
256
|
+
const declared = profile.scan.scenarios.find((s) => s.name === input.scenario?.name);
|
|
257
|
+
const frames = planFrames(input.runDir, input.findings);
|
|
258
|
+
const trace = input.trace && 'file' in input.trace ? checkTrace(input.trace.file, secrets) : undefined;
|
|
259
|
+
const traceDropped = input.trace && 'dropped' in input.trace ? input.trace.dropped : trace && 'dropped' in trace ? trace.dropped : undefined;
|
|
260
|
+
const bundle = {
|
|
261
|
+
kind: BUNDLE_KIND,
|
|
262
|
+
bundleVersion: CONTRACT_VERSIONS.bundle,
|
|
263
|
+
id,
|
|
264
|
+
product: { version: productVersion(), contracts: { ...CONTRACT_VERSIONS } },
|
|
265
|
+
environment: {
|
|
266
|
+
node: process.version, platform: platform(), arch: arch(), osRelease: release(), playwright: playwrightVersion(),
|
|
267
|
+
chromium: session.browser.version, headed: session.browser.headed, ci: Boolean(process.env.CI),
|
|
268
|
+
},
|
|
269
|
+
reason: redactSecrets(input.reason).slice(0, 500),
|
|
270
|
+
failure: input.failure,
|
|
271
|
+
project: { name: profile.name, profileSha256: sha256(profileBytes), profile: sanitizeProfile(rawProfile, profile.root) },
|
|
272
|
+
session: { id: session.id, device: session.device.id, auth: session.auth ?? 'fresh', startRoute: routePath(input.startRoute) ?? '/' },
|
|
273
|
+
...(declared && input.scenario ? { scenario: { name: declared.name, device: input.scenario.device, route: declared.route, steps: sanitizeScenarioSteps(declared.steps) } } : {}),
|
|
274
|
+
route: routePath(input.route) ?? '/',
|
|
275
|
+
actions: input.actions,
|
|
276
|
+
actionsOmitted: input.actionsOmitted,
|
|
277
|
+
observations: input.observations,
|
|
278
|
+
findings: withFramePaths(input.findings, frames.copies),
|
|
279
|
+
consoleErrors: summariseConsole(input.consoleErrors),
|
|
280
|
+
failedRequests: summariseRequests(input.failedRequests),
|
|
281
|
+
frames: [...frames.copies.values()].map((n) => `frames/${n}`),
|
|
282
|
+
framesOmitted: frames.omitted,
|
|
283
|
+
...(trace && 'file' in trace ? { trace: 'trace.zip' } : {}),
|
|
284
|
+
...(traceDropped ? { traceDropped } : {}),
|
|
285
|
+
retention: {
|
|
286
|
+
createdAt: now.toISOString(),
|
|
287
|
+
expiresAt: new Date(now.getTime() + limits.maxAgeDays * 86_400_000).toISOString(),
|
|
288
|
+
policy: `Kept locally for ${limits.maxAgeDays} days, and only the newest ${limits.keep} bundles are kept; agentlab never uploads a bundle.`,
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
const safe = redactTree(bundle, secrets);
|
|
292
|
+
const text = JSON.stringify(safe, null, 2) + '\n';
|
|
293
|
+
const leaked = leakedSecrets(text, secrets);
|
|
294
|
+
if (leaked)
|
|
295
|
+
throw new LabError('bundle_unsafe', `The bundle still contained ${leaked} secret value(s) after redaction; nothing was written`, { hint: 'This is a bug in agentlab: report it without attaching the run.' });
|
|
296
|
+
const bundleDir = join(resolve(dir), id);
|
|
297
|
+
ensureDir(resolve(dir));
|
|
298
|
+
ensureDir(bundleDir);
|
|
299
|
+
const files = [];
|
|
300
|
+
const put = (rel, write) => {
|
|
301
|
+
const to = join(bundleDir, rel);
|
|
302
|
+
ensureDir(dirname(to));
|
|
303
|
+
write(to);
|
|
304
|
+
chmodSync(to, 0o600);
|
|
305
|
+
files.push(rel);
|
|
306
|
+
};
|
|
307
|
+
try {
|
|
308
|
+
put(BUNDLE_FILE, (to) => writeFileSync(to, text, { mode: 0o600 }));
|
|
309
|
+
for (const [src, name] of frames.copies)
|
|
310
|
+
put(`frames/${name}`, (to) => copyFileSync(src, to));
|
|
311
|
+
if (trace && 'file' in trace)
|
|
312
|
+
put('trace.zip', (to) => copyFileSync(trace.file, to));
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
rmSync(bundleDir, { recursive: true, force: true });
|
|
316
|
+
throw err;
|
|
317
|
+
}
|
|
318
|
+
const pruned = pruneBundles(dir, { ...limits, now });
|
|
319
|
+
return {
|
|
320
|
+
id, dir: bundleDir, files,
|
|
321
|
+
counts: {
|
|
322
|
+
actions: bundle.actions.length, observations: bundle.observations.length, findings: bundle.findings.length,
|
|
323
|
+
consoleErrors: bundle.consoleErrors.length, failedRequests: bundle.failedRequests.length, frames: bundle.frames.length,
|
|
324
|
+
},
|
|
325
|
+
trace: trace && 'file' in trace ? 'included' : traceDropped ? { dropped: traceDropped } : 'not-recorded',
|
|
326
|
+
pruned,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
/** A last look at a sanitized trace: a secret still in any entry means it is left out (the sanitizer should have made this impossible). */
|
|
330
|
+
function checkTrace(file, secrets) {
|
|
331
|
+
try {
|
|
332
|
+
for (const entry of readZip(readFileSync(file))) {
|
|
333
|
+
const text = entry.data.toString('latin1');
|
|
334
|
+
if (leakedSecrets(text, secrets))
|
|
335
|
+
return { dropped: 'a secret value was still present in the trace after sanitizing' };
|
|
336
|
+
}
|
|
337
|
+
return { file };
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return { dropped: 'the trace could not be verified' };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// ---------- reading, listing, pruning ----------
|
|
344
|
+
/** `<bundle dir>` or `bundle.json`: the path of the file. */
|
|
345
|
+
export function resolveBundleFile(path) {
|
|
346
|
+
const p = resolve(path);
|
|
347
|
+
const file = existsSync(p) && statSync(p).isDirectory() ? join(p, BUNDLE_FILE) : p;
|
|
348
|
+
if (!existsSync(file))
|
|
349
|
+
throw new LabError('bundle_invalid', `No bundle at ${path}`, { hint: `Pass a bundle directory or its ${BUNDLE_FILE}.` });
|
|
350
|
+
return file;
|
|
351
|
+
}
|
|
352
|
+
/** Parse and validate a bundle. Its content is untrusted: the shape is checked before anything uses it. */
|
|
353
|
+
export function parseBundle(raw) {
|
|
354
|
+
const bad = (why) => { throw new LabError('bundle_invalid', `Not a usable failure bundle: ${why}`); };
|
|
355
|
+
if (!isRec(raw) || raw.kind !== BUNDLE_KIND)
|
|
356
|
+
return bad('missing kind "agentlab-failure-bundle"');
|
|
357
|
+
if (typeof raw.bundleVersion !== 'number' || !Number.isInteger(raw.bundleVersion) || raw.bundleVersion < 1)
|
|
358
|
+
return bad('bundleVersion must be a positive integer');
|
|
359
|
+
if (raw.bundleVersion > CONTRACT_VERSIONS.bundle) {
|
|
360
|
+
throw new LabError('bundle_too_new', `This bundle is version ${raw.bundleVersion}; this agentlab reads up to ${CONTRACT_VERSIONS.bundle}`, { hint: 'Upgrade agentlab to replay it.' });
|
|
361
|
+
}
|
|
362
|
+
for (const k of ['actions', 'observations', 'findings', 'frames'])
|
|
363
|
+
if (!Array.isArray(raw[k]))
|
|
364
|
+
bad(`"${k}" must be a list`);
|
|
365
|
+
if (!isRec(raw.failure) || typeof raw.failure.kind !== 'string')
|
|
366
|
+
bad('"failure" is missing');
|
|
367
|
+
if (!isRec(raw.session) || typeof raw.session.device !== 'string')
|
|
368
|
+
bad('"session.device" is missing');
|
|
369
|
+
if (!isRec(raw.project) || typeof raw.project.profileSha256 !== 'string')
|
|
370
|
+
bad('"project.profileSha256" is missing');
|
|
371
|
+
if (typeof raw.id !== 'string' || !ID.test(raw.id))
|
|
372
|
+
bad('"id" is malformed');
|
|
373
|
+
return raw;
|
|
374
|
+
}
|
|
375
|
+
export function loadBundle(path) {
|
|
376
|
+
const file = resolveBundleFile(path);
|
|
377
|
+
let raw;
|
|
378
|
+
try {
|
|
379
|
+
raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
throw new LabError('bundle_invalid', `${file} is not valid JSON`);
|
|
383
|
+
}
|
|
384
|
+
return parseBundle(raw);
|
|
385
|
+
}
|
|
386
|
+
/** Bundles under `dir`, newest first. Only directories holding a valid bundle.json count; nothing else is ever touched. */
|
|
387
|
+
export function listBundles(dir) {
|
|
388
|
+
const root = resolve(dir);
|
|
389
|
+
if (!existsSync(root))
|
|
390
|
+
return [];
|
|
391
|
+
const out = [];
|
|
392
|
+
for (const name of readdirSync(root)) {
|
|
393
|
+
if (!ID.test(name))
|
|
394
|
+
continue;
|
|
395
|
+
try {
|
|
396
|
+
const b = parseBundle(JSON.parse(readFileSync(join(root, name, BUNDLE_FILE), 'utf8')));
|
|
397
|
+
out.push({
|
|
398
|
+
id: b.id, dir: join(root, name), createdAt: b.retention?.createdAt ?? statSync(join(root, name)).mtime.toISOString(), reason: b.reason,
|
|
399
|
+
failure: b.failure.kind, actions: b.actions.length, findings: b.findings.length, trace: existsSync(join(root, name, 'trace.zip')),
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
catch { /* not ours, or unreadable: left alone */ }
|
|
403
|
+
}
|
|
404
|
+
return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
|
|
405
|
+
}
|
|
406
|
+
export function removeBundle(dir, id) {
|
|
407
|
+
if (!ID.test(id))
|
|
408
|
+
throw new LabError('invalid_request', `"${id.slice(0, 40)}" is not a bundle id`, { hint: 'List them with `agentlab bundles`.' });
|
|
409
|
+
const target = join(resolve(dir), id);
|
|
410
|
+
if (!existsSync(join(target, BUNDLE_FILE)))
|
|
411
|
+
throw new LabError('not_found', `No bundle ${id} in ${resolve(dir)}`);
|
|
412
|
+
rmSync(target, { recursive: true, force: true });
|
|
413
|
+
}
|
|
414
|
+
/** Remove bundles beyond the newest `keep` and older than `maxAgeDays`. Returns the ids removed. */
|
|
415
|
+
export function pruneBundles(dir, opts = {}) {
|
|
416
|
+
const limits = retentionLimits();
|
|
417
|
+
const keep = opts.keep ?? limits.keep;
|
|
418
|
+
const maxAgeDays = opts.maxAgeDays ?? limits.maxAgeDays;
|
|
419
|
+
const cutoff = (opts.now ?? new Date()).getTime() - maxAgeDays * 86_400_000;
|
|
420
|
+
const removed = [];
|
|
421
|
+
listBundles(dir).forEach((b, i) => {
|
|
422
|
+
if (i >= keep || Date.parse(b.createdAt) < cutoff) {
|
|
423
|
+
try {
|
|
424
|
+
removeBundle(dir, b.id);
|
|
425
|
+
removed.push(b.id);
|
|
426
|
+
}
|
|
427
|
+
catch { /* already gone */ }
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
return removed;
|
|
431
|
+
}
|
|
432
|
+
//# sourceMappingURL=bundle.js.map
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { coveredHits, detectLayout, labelKey, modalHits, shiftHits } from './detectors.js';
|
|
3
|
+
import { centreInView, coveredAtExtremes, extractPage, lookupRef, measureLayout, panIntoView, scrollToOrigin, takeLayoutShifts, targetPoint, } from './extract.js';
|
|
4
|
+
import { buildObservation } from './observation.js';
|
|
5
|
+
const OBSERVE_LIMIT = 60;
|
|
6
|
+
export async function measureState(page, m) {
|
|
7
|
+
const on = m.detectors.enabled;
|
|
8
|
+
const seen = new Set();
|
|
9
|
+
await page.evaluate(scrollToOrigin);
|
|
10
|
+
let o = m.observation;
|
|
11
|
+
if (!o) {
|
|
12
|
+
const raw = await page.evaluate(extractPage, { nextRef: 1, limit: OBSERVE_LIMIT });
|
|
13
|
+
o = buildObservation(raw, { sessionId: m.sessionId, gen: m.gen, consoleErrors: 0, failedRequests: 0 });
|
|
14
|
+
}
|
|
15
|
+
const frame = await shot(page, m.frameFile('state'));
|
|
16
|
+
const ctx = { ...m.ctx, keepRefs: false, ...(frame ? { frame } : {}) };
|
|
17
|
+
const where = { route: o.route, viewport: o.viewport, gen: o.gen };
|
|
18
|
+
const fresh = [];
|
|
19
|
+
const note = (f, isNew, evidence = frame) => {
|
|
20
|
+
seen.add(f.id);
|
|
21
|
+
if (isNew)
|
|
22
|
+
fresh.push({ findings: [f], ...(evidence ? { frame: evidence } : {}) });
|
|
23
|
+
};
|
|
24
|
+
const record = (hit, extra = {}) => {
|
|
25
|
+
const r = m.store.recordHit(hit, where, m.reproduction, { ...ctx, ...extra });
|
|
26
|
+
note(r.finding, r.fresh, extra.frame ?? frame);
|
|
27
|
+
return r.finding;
|
|
28
|
+
};
|
|
29
|
+
// 1. The observation's layout flags (document overflow, controls past the viewport edge).
|
|
30
|
+
const flags = { ...o, layout: o.layout.filter((f) => on(f.kind)) };
|
|
31
|
+
for (const r of m.store.observationResults(flags, m.reproduction, ctx))
|
|
32
|
+
note(r.finding, r.fresh);
|
|
33
|
+
// 2. Layout detectors over one measurement of the state.
|
|
34
|
+
const raw = await page.evaluate(measureLayout, { maxElements: 2500, maxItems: 40 });
|
|
35
|
+
const hits = detectLayout(raw, m.detectors);
|
|
36
|
+
const hiddenLabels = new Set();
|
|
37
|
+
// A control a fixed layer covers is explained by fixed-collision; the reach check would repeat it.
|
|
38
|
+
const collided = new Set(hits.filter((h) => h.kind === 'fixed-collision' && h.confidence === 'confirmed' && h.target).map((h) => labelKey(h.target)));
|
|
39
|
+
for (const h of hits) {
|
|
40
|
+
record(h);
|
|
41
|
+
if (h.target && h.confidence === 'confirmed' && (h.kind === 'text-clipped' || h.kind === 'text-truncated' || h.kind === 'container-clipped'))
|
|
42
|
+
hiddenLabels.add(labelKey(h.target));
|
|
43
|
+
}
|
|
44
|
+
// 3. Layout shifts while the state settled (before any scrolling here, which the API ignores anyway).
|
|
45
|
+
if (m.shift && on('layout-shift')) {
|
|
46
|
+
const entries = await page.evaluate(takeLayoutShifts).catch(() => []);
|
|
47
|
+
for (const h of shiftHits(entries, m.detectors.layoutShiftMin, m.shift.when)) {
|
|
48
|
+
const frames = [
|
|
49
|
+
...(m.shift.before ? [{ label: 'before it settled', path: m.shift.before }] : []),
|
|
50
|
+
...(frame ? [{ label: 'after it settled', path: frame }] : []),
|
|
51
|
+
];
|
|
52
|
+
record({ ...h, frames });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// 4. The reach check, as the click path does it: scroll the control into view, then on past a fixed
|
|
56
|
+
// bar, and hit-test its centre. In an open dialog, a control whose centre never comes into view
|
|
57
|
+
// cannot be reached at all.
|
|
58
|
+
let reachChecked = 0;
|
|
59
|
+
const unreachable = [];
|
|
60
|
+
const obstructed = new Set();
|
|
61
|
+
const frameTick = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => r(null))));
|
|
62
|
+
for (const c of o.controls.slice(0, m.reachLimit)) {
|
|
63
|
+
if (c.disabled || c.rect.w < 4 || c.rect.h < 4)
|
|
64
|
+
continue;
|
|
65
|
+
const handle = (await page.evaluateHandle(lookupRef, c.ref)).asElement();
|
|
66
|
+
if (!handle)
|
|
67
|
+
continue;
|
|
68
|
+
try {
|
|
69
|
+
await page.evaluate(scrollToOrigin);
|
|
70
|
+
let { dx } = await handle.evaluate(panIntoView);
|
|
71
|
+
await frameTick();
|
|
72
|
+
let point = await handle.evaluate(targetPoint);
|
|
73
|
+
if ((!point.hitOk && point.hitFixed) || (!point.inView && o.dialog)) {
|
|
74
|
+
dx += (await handle.evaluate(centreInView)).dx;
|
|
75
|
+
await frameTick();
|
|
76
|
+
point = await handle.evaluate(targetPoint);
|
|
77
|
+
}
|
|
78
|
+
reachChecked++;
|
|
79
|
+
const steps = [...m.reproduction, `scroll ${c.role} "${c.name}" into view`];
|
|
80
|
+
if (dx && on('horizontal-pan-required')) {
|
|
81
|
+
const r = m.store.panResult(o, c, { role: c.role, name: c.name, ref: c.ref }, Math.abs(dx), steps, { ...ctx, source: 'sweep-reachability' });
|
|
82
|
+
note(r.finding, r.fresh);
|
|
83
|
+
}
|
|
84
|
+
if (point.inView && !point.hitOk && point.hit && on('control-obstructed') && !collided.has(labelKey(c))) {
|
|
85
|
+
obstructed.add(labelKey(c));
|
|
86
|
+
const r = m.store.obstructionResult(o, c, point.hit, steps, { ...ctx, source: 'sweep-reachability' });
|
|
87
|
+
// The moment of obstruction is the evidence: the control scrolled into view, still covered.
|
|
88
|
+
const evidence = r.fresh ? await shot(page, m.frameFile(r.finding.id)) : undefined;
|
|
89
|
+
if (evidence)
|
|
90
|
+
r.finding.frame = evidence;
|
|
91
|
+
note(r.finding, r.fresh, evidence ?? frame);
|
|
92
|
+
}
|
|
93
|
+
if (!point.inView && o.dialog)
|
|
94
|
+
unreachable.push({ el: { role: c.role, name: c.name, selector: '', control: true }, box: { x: c.rect.x, y: c.rect.y, w: c.rect.w, h: c.rect.h } });
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
await handle.dispose().catch(() => undefined);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
await page.evaluate(scrollToOrigin);
|
|
101
|
+
if (on('modal-overflow'))
|
|
102
|
+
for (const h of modalHits(raw.modal, unreachable, raw.viewport))
|
|
103
|
+
record(h);
|
|
104
|
+
// 5. Content that stays under a fixed bar at the top or bottom of the page (skipped while a modal
|
|
105
|
+
// locks scrolling). A control the reach check already found covered is the same problem.
|
|
106
|
+
if (on('content-under-fixed') && !raw.modal) {
|
|
107
|
+
const covered = await page.evaluate(coveredAtExtremes, { maxItems: 20 });
|
|
108
|
+
const hitsUnder = coveredHits(covered).filter((h) => !(h.target && obstructed.has(labelKey(h.target))));
|
|
109
|
+
if (hitsUnder.length) {
|
|
110
|
+
await page.evaluate(() => window.scrollTo({ left: 0, top: (document.scrollingElement ?? document.documentElement).scrollHeight, behavior: 'instant' }));
|
|
111
|
+
const bottom = hitsUnder.some((h) => h.evidence.edge === 'bottom') ? await shot(page, m.frameFile('bottom')) : undefined;
|
|
112
|
+
await page.evaluate(scrollToOrigin);
|
|
113
|
+
for (const h of hitsUnder)
|
|
114
|
+
record(h, h.evidence.edge === 'bottom' && bottom ? { frame: bottom } : {});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for (const f of fresh)
|
|
118
|
+
m.onFindings?.(f.findings, f.frame);
|
|
119
|
+
return {
|
|
120
|
+
observation: o, ...(frame ? { frame } : {}), findings: [...seen], reachChecked,
|
|
121
|
+
labels: raw.labels, hiddenLabels, examined: raw.examined, omitted: raw.omitted,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** Which checks run: `enable` limits them to those kinds, `disable` removes kinds. */
|
|
125
|
+
export function checkFilter(...configs) {
|
|
126
|
+
return (kind) => configs.every((c) => !c || ((!c.enable?.length || c.enable.includes(kind)) && !(c.disable ?? []).includes(kind)));
|
|
127
|
+
}
|
|
128
|
+
export async function shot(page, file) {
|
|
129
|
+
try {
|
|
130
|
+
writeFileSync(file, await page.screenshot({ type: 'jpeg', quality: 70, scale: 'css', caret: 'initial', animations: 'allow', timeout: 5000 }));
|
|
131
|
+
return file;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
//# sourceMappingURL=checks.js.map
|