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.
- package/LICENSE +202 -0
- package/NOTICE +10 -0
- package/README.md +142 -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 +58 -4
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="referrer" content="no-referrer">
|
|
7
|
+
<title>Agent Device Lab</title>
|
|
8
|
+
<link rel="stylesheet" href="/app.css">
|
|
9
|
+
<script src="/app.js" defer></script>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<header class="top">
|
|
13
|
+
<div class="brand">Agent Device Lab</div>
|
|
14
|
+
<span id="state" class="pill" data-state="idle">connecting</span>
|
|
15
|
+
<span id="session" class="muted mono"></span>
|
|
16
|
+
<span class="grow"></span>
|
|
17
|
+
<span id="conn" class="conn" data-conn="connecting">connecting…</span>
|
|
18
|
+
</header>
|
|
19
|
+
|
|
20
|
+
<section class="status" id="status" aria-label="Session status">
|
|
21
|
+
<div><span class="k">Project</span><span id="s-project">–</span></div>
|
|
22
|
+
<div><span class="k">Device</span><span id="s-device">–</span></div>
|
|
23
|
+
<div class="wide"><span class="k">URL</span><span id="s-url" class="mono">–</span></div>
|
|
24
|
+
<div><span class="k">Console errors</span><span id="s-console" class="num">0</span></div>
|
|
25
|
+
<div><span class="k">Failed requests</span><span id="s-failed" class="num">0</span></div>
|
|
26
|
+
<div><span class="k">Findings</span><span id="s-findings" class="num">0</span></div>
|
|
27
|
+
<div class="wide"><span class="k">Server</span><span id="s-server">–</span></div>
|
|
28
|
+
</section>
|
|
29
|
+
|
|
30
|
+
<section id="supervision" class="supervision" aria-label="Session supervision" hidden>
|
|
31
|
+
<div class="sup-main">
|
|
32
|
+
<span id="ctl-badge" class="mode-badge" role="status" data-mode="agent">Agent in control</span>
|
|
33
|
+
<span id="ctl-detail" class="muted small"></span>
|
|
34
|
+
<div id="ctl-buttons" class="ctl-buttons" role="group" aria-label="Supervision controls">
|
|
35
|
+
<button type="button" id="ctl-pause" data-op="pause" aria-pressed="false" aria-disabled="true">Pause after this action</button>
|
|
36
|
+
<button type="button" id="ctl-pause-next" data-op="pause-next" aria-disabled="true">Pause before next action</button>
|
|
37
|
+
<button type="button" id="ctl-resume" data-op="resume" aria-disabled="true">Resume</button>
|
|
38
|
+
<button type="button" id="ctl-takeover" data-op="takeover" aria-pressed="false" aria-disabled="true">Take over</button>
|
|
39
|
+
<button type="button" id="ctl-return" data-op="return" aria-disabled="true">Return control to agent</button>
|
|
40
|
+
<button type="button" id="ctl-stop" data-op="stop" class="danger" aria-disabled="true">Stop run</button>
|
|
41
|
+
<button type="button" id="ctl-emergency-stop" data-op="emergency-stop" class="danger strong" aria-disabled="true">Emergency stop</button>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
<div id="ctl-error" class="ctl-error" role="alert" hidden></div>
|
|
45
|
+
</section>
|
|
46
|
+
<p id="ctl-viewonly" class="viewonly small muted" hidden>view-only link: controls need the URL from `agentlab ui`</p>
|
|
47
|
+
|
|
48
|
+
<div id="notice" class="notice" hidden></div>
|
|
49
|
+
<section id="start-error" class="start-error" hidden aria-live="polite"></section>
|
|
50
|
+
|
|
51
|
+
<section id="scan" class="panel scan" hidden aria-label="Stateful scan">
|
|
52
|
+
<h2>Scan <span id="scan-caption" class="muted small"></span></h2>
|
|
53
|
+
<div id="scan-verdict" class="scan-verdict"></div>
|
|
54
|
+
<div class="scan-matrix-wrap"><table id="scan-matrix" class="scan-matrix"></table></div>
|
|
55
|
+
<div id="scan-run" class="scan-run" hidden></div>
|
|
56
|
+
</section>
|
|
57
|
+
|
|
58
|
+
<main class="grid">
|
|
59
|
+
<section class="panel viewport-panel" aria-label="Live viewport">
|
|
60
|
+
<h2>Viewport <span id="vp-caption" class="muted small"></span></h2>
|
|
61
|
+
<p id="vp-now" class="vp-now" role="status"></p>
|
|
62
|
+
<div class="device" id="device">
|
|
63
|
+
<img id="viewport" alt="Live view of the device viewport">
|
|
64
|
+
<div id="vp-empty" class="vp-empty">No frames yet</div>
|
|
65
|
+
</div>
|
|
66
|
+
<section id="human-panel" class="human-panel" hidden aria-label="Take over the browser">
|
|
67
|
+
<p class="human-banner"><strong>You have control of the browser.</strong> The agent is waiting. Your actions are recorded as descriptions only (no typed text).<span id="human-headed" hidden> You can also use the browser window directly.</span></p>
|
|
68
|
+
<p class="small muted">Click the viewport to tap; scroll over it to scroll.</p>
|
|
69
|
+
<form id="human-form" class="human-form" autocomplete="off">
|
|
70
|
+
<label class="small" for="human-text">Type into the focused field</label>
|
|
71
|
+
<div class="human-row">
|
|
72
|
+
<input id="human-text" type="text" autocomplete="off" autocapitalize="off" spellcheck="false" maxlength="500">
|
|
73
|
+
<button type="submit" id="human-type">Type</button>
|
|
74
|
+
</div>
|
|
75
|
+
<label class="small"><input type="checkbox" id="human-hide"> Hide what I type on this screen</label>
|
|
76
|
+
</form>
|
|
77
|
+
<div class="human-keys" role="group" aria-label="Keys">
|
|
78
|
+
<button type="button" data-key="Enter">Enter</button>
|
|
79
|
+
<button type="button" data-key="Tab">Tab</button>
|
|
80
|
+
<button type="button" data-key="Shift+Tab">Shift+Tab</button>
|
|
81
|
+
<button type="button" data-key="Escape">Escape</button>
|
|
82
|
+
<button type="button" data-key="Backspace">Backspace</button>
|
|
83
|
+
<button type="button" data-key="ArrowUp" aria-label="Arrow up">↑</button>
|
|
84
|
+
<button type="button" data-key="ArrowDown" aria-label="Arrow down">↓</button>
|
|
85
|
+
<button type="button" data-key="ArrowLeft" aria-label="Arrow left">←</button>
|
|
86
|
+
<button type="button" data-key="ArrowRight" aria-label="Arrow right">→</button>
|
|
87
|
+
</div>
|
|
88
|
+
</section>
|
|
89
|
+
<section id="sweep" class="sweep" hidden aria-label="Responsive sweep">
|
|
90
|
+
<h2>Sweep <span id="sweep-caption" class="muted small"></span></h2>
|
|
91
|
+
<ul id="sweep-devices" class="sweep-devices"></ul>
|
|
92
|
+
</section>
|
|
93
|
+
</section>
|
|
94
|
+
|
|
95
|
+
<section class="panel timeline-panel" aria-label="Action timeline">
|
|
96
|
+
<h2>Timeline <span id="tl-count" class="muted small"></span></h2>
|
|
97
|
+
<ol id="timeline" class="timeline"></ol>
|
|
98
|
+
<details class="serverlog">
|
|
99
|
+
<summary>Server output <span id="log-count" class="muted small"></span></summary>
|
|
100
|
+
<pre id="serverlog"></pre>
|
|
101
|
+
</details>
|
|
102
|
+
</section>
|
|
103
|
+
|
|
104
|
+
<section class="panel findings-panel" aria-label="Findings">
|
|
105
|
+
<h2>Findings</h2>
|
|
106
|
+
<div id="device-filter" class="chips" role="group" aria-label="Filter findings by device"></div>
|
|
107
|
+
<div id="confidence-filter" class="chips" role="group" aria-label="Filter findings by confidence" hidden></div>
|
|
108
|
+
<div id="scenario-filter" class="chips" role="group" aria-label="Filter findings by scenario" hidden></div>
|
|
109
|
+
<div id="group-row" class="chips" hidden><button id="group-toggle" type="button" aria-pressed="false">Group by problem</button></div>
|
|
110
|
+
<div id="run-filter" class="run-filter small" hidden></div>
|
|
111
|
+
<ul id="findings" class="findings"><li class="muted empty">None recorded</li></ul>
|
|
112
|
+
<details id="suppressed" class="suppressed" hidden>
|
|
113
|
+
<summary id="suppressed-summary"></summary>
|
|
114
|
+
<ul id="suppressed-list" class="findings"></ul>
|
|
115
|
+
</details>
|
|
116
|
+
<article id="finding-detail" class="finding-detail" hidden></article>
|
|
117
|
+
</section>
|
|
118
|
+
</main>
|
|
119
|
+
</body>
|
|
120
|
+
</html>
|
package/bin/agentlab.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Checked before loading anything else, so an old Node.js gets a clear message rather than a syntax error.
|
|
3
|
+
const [major] = process.versions.node.split('.').map(Number);
|
|
4
|
+
if (major < 22) {
|
|
5
|
+
process.stderr.write(`agentlab needs Node.js 22 or newer; this is ${process.versions.node}.\n`);
|
|
6
|
+
process.exit(1);
|
|
7
|
+
}
|
|
8
|
+
await import('../dist/cli/main.js');
|
package/dist/cli/ci.js
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { chromium } from 'playwright';
|
|
4
|
+
import { AUTH_STATE_ENV, authStateFromEnv, authStateSecrets, savedStateProblem } from '../core/auth.js';
|
|
5
|
+
import { envSecrets, leakedSecrets, redactTree } from '../core/bundle.js';
|
|
6
|
+
import { CI_FILES, CI_SCHEMA, EXIT, applySuppressions, collectFindings, evaluateCi, formatCiText, frameRefs, groupForCi, parseCiResult, renderCi, resolveCi, withFrames, } from '../core/ci.js';
|
|
7
|
+
import { redactSecrets } from '../core/feed.js';
|
|
8
|
+
import { formatError } from '../core/format.js';
|
|
9
|
+
import { Lab } from '../core/lab.js';
|
|
10
|
+
import { loadProfile } from '../core/profile.js';
|
|
11
|
+
import { evaluatePolicy, groupFindings } from '../core/scan-policy.js';
|
|
12
|
+
import { scanReportHtml } from '../core/scan-report.js';
|
|
13
|
+
import { scanSummary } from '../core/scan.js';
|
|
14
|
+
import { isSecretStep } from '../core/steps.js';
|
|
15
|
+
import { LabError } from '../core/schema.js';
|
|
16
|
+
import { CONTRACT_VERSIONS, productVersion } from '../core/versions.js';
|
|
17
|
+
import { runFlowDetailed } from './flow.js';
|
|
18
|
+
import { holdIfTerminating, onTermination } from './signals.js';
|
|
19
|
+
const MAX_FRAMES = 200;
|
|
20
|
+
const never = () => new Promise(() => undefined);
|
|
21
|
+
/** Every problem with what was asked for, before anything starts. Read-only: no project command runs. */
|
|
22
|
+
async function validate(inv) {
|
|
23
|
+
const checks = [];
|
|
24
|
+
const problems = [];
|
|
25
|
+
const check = (name, ok, detail) => { checks.push({ name, ok, detail }); if (!ok)
|
|
26
|
+
problems.push(`${name}: ${detail}`); };
|
|
27
|
+
let profile;
|
|
28
|
+
try {
|
|
29
|
+
profile = await loadProfile(inv.project);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
const e = LabError.from(err);
|
|
33
|
+
// A JSON parser's own message quotes the file's text; say only what is wrong.
|
|
34
|
+
const message = e.code === 'invalid_profile' ? e.message.replace(/^(Cannot read [^:]+): [\s\S]*$/, '$1: the file is missing or not valid JSON') : e.message;
|
|
35
|
+
check('profile', false, redactSecrets(message));
|
|
36
|
+
return { checks, problems };
|
|
37
|
+
}
|
|
38
|
+
check('profile', true, `${profile.name}: ${profile.profilePath} (schemaVersion ${profile.schemaVersion}, ${profile.services.length} service${profile.services.length === 1 ? '' : 's'})`);
|
|
39
|
+
const { selections: sel, problems: selection } = resolveCi(profile, inv);
|
|
40
|
+
for (const p of selection)
|
|
41
|
+
check('selection', false, p);
|
|
42
|
+
if (!selection.length)
|
|
43
|
+
check('selection', true, `${sel.flows.length} flow(s), ${sel.routes.length} route(s), ${sel.scenarios.length} scenario(s)`);
|
|
44
|
+
const exe = chromium.executablePath();
|
|
45
|
+
check('browser', existsSync(exe), existsSync(exe) ? 'Chromium is installed' : 'Chromium is not installed (run `agentlab install-browser`, with --with-deps on a bare CI image)');
|
|
46
|
+
for (const s of profile.services) {
|
|
47
|
+
const missing = s.requiredEnv.filter((n) => !process.env[n]);
|
|
48
|
+
check(`environment for service "${s.name}"`, !missing.length, missing.length ? `required variable${missing.length === 1 ? '' : 's'} not set: ${missing.join(', ')}` : s.requiredEnv.length ? `${s.requiredEnv.join(', ')} set` : 'nothing required');
|
|
49
|
+
}
|
|
50
|
+
for (const f of sel.flows) {
|
|
51
|
+
const file = resolve(profile.root, f);
|
|
52
|
+
let detail;
|
|
53
|
+
if (!existsSync(file))
|
|
54
|
+
detail = 'the file does not exist';
|
|
55
|
+
else {
|
|
56
|
+
try {
|
|
57
|
+
const flow = JSON.parse(readFileSync(file, 'utf8'));
|
|
58
|
+
if (typeof flow.name !== 'string' || !Array.isArray(flow.steps))
|
|
59
|
+
detail = 'needs a "name" and a "steps" list';
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
detail = 'is not valid JSON';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
check(`flow ${f}`, !detail, detail ?? 'found and parsed');
|
|
66
|
+
}
|
|
67
|
+
let authState;
|
|
68
|
+
if (sel.auth === 'saved') {
|
|
69
|
+
const problem = savedStateProblem(profile.auth, profile.root);
|
|
70
|
+
check('auth (saved)', !problem, problem ?? 'a usable saved sign-in state exists');
|
|
71
|
+
}
|
|
72
|
+
else if (sel.auth === 'env') {
|
|
73
|
+
const value = process.env[AUTH_STATE_ENV];
|
|
74
|
+
if (!value)
|
|
75
|
+
check('auth (env)', false, `${AUTH_STATE_ENV} is not set`);
|
|
76
|
+
else {
|
|
77
|
+
try {
|
|
78
|
+
authState = authStateFromEnv(value);
|
|
79
|
+
check('auth (env)', true, `${AUTH_STATE_ENV} holds ${authState.cookies.length} cookie(s) and ${authState.origins.length} origin(s) of storage`);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
check('auth (env)', false, LabError.from(err).message);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { profile, sel, ...(authState ? { authState } : {}), checks, problems };
|
|
87
|
+
}
|
|
88
|
+
function printChecks(checks, problems, json) {
|
|
89
|
+
if (json)
|
|
90
|
+
process.stdout.write(JSON.stringify({ ok: !problems.length, checks, problems }, null, 2) + '\n');
|
|
91
|
+
else {
|
|
92
|
+
for (const c of checks)
|
|
93
|
+
process.stdout.write(`${c.ok ? 'ok ' : 'FAIL'} ${c.name}: ${c.detail}\n`);
|
|
94
|
+
process.stdout.write(problems.length ? `${problems.length} problem${problems.length === 1 ? '' : 's'}: the run would not start (exit 2)\n` : 'valid: `agentlab test` can run\n');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// ---------- files ----------
|
|
98
|
+
function ensureDir(dir) {
|
|
99
|
+
const fresh = !existsSync(dir);
|
|
100
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
101
|
+
if (fresh)
|
|
102
|
+
chmodSync(dir, 0o700);
|
|
103
|
+
}
|
|
104
|
+
function put(file, data) {
|
|
105
|
+
ensureDir(dirname(file));
|
|
106
|
+
writeFileSync(file, data, { mode: 0o600 });
|
|
107
|
+
chmodSync(file, 0o600);
|
|
108
|
+
}
|
|
109
|
+
/** Copy the evidence frames (JPEGs inside the lab's run directories only) into `<out>/<sub>/`; returns source → relative path. */
|
|
110
|
+
function copyFrames(sources, out, sub, roots) {
|
|
111
|
+
const map = new Map();
|
|
112
|
+
const real = roots.flatMap((r) => { try {
|
|
113
|
+
return [realpathSync(r)];
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return [];
|
|
117
|
+
} });
|
|
118
|
+
for (const src of sources) {
|
|
119
|
+
if (map.size >= MAX_FRAMES)
|
|
120
|
+
break;
|
|
121
|
+
try {
|
|
122
|
+
const file = realpathSync(src);
|
|
123
|
+
if (!/\.jpe?g$/i.test(file) || !real.some((r) => file.startsWith(r + sep)) || !statSync(file).isFile())
|
|
124
|
+
continue;
|
|
125
|
+
const name = `${String(map.size + 1).padStart(3, '0')}-${basename(file)}`;
|
|
126
|
+
ensureDir(join(out, sub));
|
|
127
|
+
copyFileSync(file, join(out, sub, name));
|
|
128
|
+
chmodSync(join(out, sub, name), 0o600);
|
|
129
|
+
map.set(src, `${sub}/${name}`);
|
|
130
|
+
}
|
|
131
|
+
catch { /* a frame that cannot be copied is left out */ }
|
|
132
|
+
}
|
|
133
|
+
return map;
|
|
134
|
+
}
|
|
135
|
+
export async function runCi(inv) {
|
|
136
|
+
// Registered first: a signal during validation must exit with its own code, not die with the default action.
|
|
137
|
+
let onSignal = async () => undefined;
|
|
138
|
+
const removeSignals = onTermination((signal) => onSignal(signal), { stateDir: inv.stateDir });
|
|
139
|
+
const t0 = Date.now();
|
|
140
|
+
const startedAt = new Date(t0).toISOString();
|
|
141
|
+
const v = await validate(inv);
|
|
142
|
+
if (inv.validateOnly) {
|
|
143
|
+
printChecks(v.checks, v.problems, inv.json);
|
|
144
|
+
process.exit(v.problems.length ? EXIT.couldNotRun : 0);
|
|
145
|
+
}
|
|
146
|
+
const { profile, sel } = v;
|
|
147
|
+
const out = resolve(sel?.out ?? './agentlab-results');
|
|
148
|
+
const state = { flows: [], sweeps: [], labs: [], bundles: [], traces: [], auth: 'fresh' };
|
|
149
|
+
// Redaction set: the project's secret environment, the sign-in state's values and every lab's own secrets.
|
|
150
|
+
const base = new Set(profile ? envSecrets(profile) : []);
|
|
151
|
+
if (v.authState)
|
|
152
|
+
for (const s of authStateSecrets(v.authState))
|
|
153
|
+
base.add(s);
|
|
154
|
+
// Values a flow or scenario types into secret-looking fields are known before the browser is: they never print.
|
|
155
|
+
const seeded = [
|
|
156
|
+
...(profile?.scan.scenarios.flatMap((sc) => [...sc.steps, ...sc.cleanup]) ?? []),
|
|
157
|
+
...(sel?.flows ?? []).flatMap((f) => { try {
|
|
158
|
+
return JSON.parse(readFileSync(resolve(profile.root, f), 'utf8')).steps ?? [];
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return [];
|
|
162
|
+
} }),
|
|
163
|
+
];
|
|
164
|
+
for (const st of seeded)
|
|
165
|
+
if (typeof st.value === 'string' && st.value.length >= 4 && isSecretStep(st))
|
|
166
|
+
base.add(st.value);
|
|
167
|
+
const envState = process.env[AUTH_STATE_ENV]?.trim();
|
|
168
|
+
if (envState && envState.length >= 6)
|
|
169
|
+
base.add(envState);
|
|
170
|
+
const secretsNow = () => [...new Set([...base, ...state.labs.flatMap((l) => l.secretValues())])].filter((s) => s.length >= 4).sort((a, b) => b.length - a.length);
|
|
171
|
+
const say = (text) => { (inv.json ? process.stderr : process.stdout).write(redactTree(text, secretsNow())); };
|
|
172
|
+
const note = (text) => { process.stderr.write(redactTree(`agentlab: ${text}\n`, secretsNow())); };
|
|
173
|
+
if (!profile || !sel || v.problems.length) {
|
|
174
|
+
const reasons = v.problems.map((p) => redactTree(p, secretsNow()));
|
|
175
|
+
process.stderr.write(`agentlab: cannot run:\n${reasons.map((r) => ` - ${r}`).join('\n')}\n`);
|
|
176
|
+
if (profile && sel) {
|
|
177
|
+
try {
|
|
178
|
+
await writeOutputs({ inv, profile, sel, state, out, t0, startedAt, secretsNow, override: { exitCode: EXIT.couldNotRun, reason: `could not run: ${reasons.join('; ')}` }, quiet: true });
|
|
179
|
+
}
|
|
180
|
+
catch { /* the message above is the result */ }
|
|
181
|
+
}
|
|
182
|
+
process.exit(EXIT.couldNotRun);
|
|
183
|
+
}
|
|
184
|
+
// Start from a clean artifacts directory: only the names this command writes are removed.
|
|
185
|
+
for (const name of ['ci-result.json', 'summary.txt', 'report.html', 'junit.xml', 'frames', 'scan', 'bundles', 'traces'])
|
|
186
|
+
rmSync(join(out, name), { recursive: true, force: true });
|
|
187
|
+
ensureDir(out);
|
|
188
|
+
const finish = (override) => writeOutputs({ inv, profile, sel, state, out, t0, startedAt, secretsNow, ...(override ? { override } : {}) });
|
|
189
|
+
const abort = async (kind, text, exitCode) => {
|
|
190
|
+
if (state.aborted)
|
|
191
|
+
return;
|
|
192
|
+
state.aborted = { kind, text };
|
|
193
|
+
await Promise.allSettled(state.labs.map((l) => l.close(text)));
|
|
194
|
+
try {
|
|
195
|
+
await finish({ exitCode, reason: text });
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
process.stderr.write(`agentlab: ${LabError.from(err).message}\n`);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
onSignal = (signal) => abort('cancelled', `cancelled by ${signal}`, { SIGHUP: 129, SIGINT: 130, SIGTERM: 143 }[signal] ?? 1);
|
|
202
|
+
const seconds = Math.round(sel.timeoutMs / 100) / 10;
|
|
203
|
+
const timer = setTimeout(() => { void abort('timeout', `timed out after ${seconds}s`, EXIT.timedOut).finally(() => process.exit(EXIT.timedOut)); }, sel.timeoutMs);
|
|
204
|
+
const bundlesDir = join(out, 'bundles');
|
|
205
|
+
const traceOn = sel.trace !== 'off';
|
|
206
|
+
const startAuth = { auth: sel.auth === 'saved' ? 'saved' : 'fresh', ...(v.authState ? { authState: v.authState } : {}) };
|
|
207
|
+
const failWith = (err) => {
|
|
208
|
+
const e = LabError.from(err).toJSON();
|
|
209
|
+
const tail = Array.isArray(e.details?.logTail) ? `\nserver log tail:\n${e.details.logTail.slice(-10).map((l) => ` ${redactSecrets(String(l))}`).join('\n')}` : '';
|
|
210
|
+
state.problem = `${formatError({ ...e, details: undefined }).split('\n').slice(0, 2).join(' ')}${tail}`;
|
|
211
|
+
};
|
|
212
|
+
const bundleTrace = (t) => typeof t === 'string' ? t : `dropped: ${t.dropped}`;
|
|
213
|
+
note('starting');
|
|
214
|
+
try {
|
|
215
|
+
// 1. Flows, each in its own session.
|
|
216
|
+
for (const file of sel.flows) {
|
|
217
|
+
if (state.aborted)
|
|
218
|
+
await never();
|
|
219
|
+
note(`flow ${file}`);
|
|
220
|
+
let bundleDir;
|
|
221
|
+
const outcome = await runFlowDetailed(resolve(profile.root, file), {
|
|
222
|
+
headed: inv.headed, json: false, stateDir: inv.stateDir, write: say, project: profile.root, ...startAuth, trace: traceOn,
|
|
223
|
+
onLab: (l) => { state.labs.push(l); },
|
|
224
|
+
beforeClose: async (l, o) => {
|
|
225
|
+
state.auth = l.status().session?.auth ?? state.auth;
|
|
226
|
+
if (o.failure) {
|
|
227
|
+
const b = await l.bundle({ reason: o.reason ?? 'flow failed', failure: o.failure, dir: bundlesDir });
|
|
228
|
+
bundleDir = `bundles/${b.id}`;
|
|
229
|
+
state.bundles.push({ id: b.id, dir: bundleDir, for: `flow ${basename(file)}`, trace: bundleTrace(b.trace) });
|
|
230
|
+
}
|
|
231
|
+
else if (sel.trace === 'always') {
|
|
232
|
+
const dest = join(out, 'traces', `flow-${state.flows.length + 1}.zip`);
|
|
233
|
+
const saved = await l.saveTrace(dest);
|
|
234
|
+
state.traces.push('path' in saved ? { name: `flow ${basename(file)}`, file: relative(out, dest) } : { name: `flow ${basename(file)}`, dropped: saved.dropped });
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
if (state.aborted)
|
|
239
|
+
await never();
|
|
240
|
+
if (outcome.startError) {
|
|
241
|
+
const e = outcome.startError;
|
|
242
|
+
failWith(new LabError(e.code, e.message, { ...(e.hint ? { hint: e.hint } : {}), ...(e.details ? { details: e.details } : {}) }));
|
|
243
|
+
state.problem = `flow ${file} could not start: ${state.problem}`;
|
|
244
|
+
state.flows.push({ name: outcome.name, file, passed: false, steps: outcome.steps, completed: 0, ms: outcome.elapsedMs, failedStep: { index: 0, label: 'start', kind: 'start', text: 'the session could not start' } });
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
state.flows.push({
|
|
248
|
+
name: outcome.name, file, passed: outcome.passed, steps: outcome.steps, completed: outcome.completed, ms: outcome.elapsedMs,
|
|
249
|
+
...(outcome.failedStep ? { failedStep: outcome.failedStep } : {}), ...(bundleDir ? { bundle: bundleDir } : {}),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
// 2. One session for the sweeps and the scan.
|
|
253
|
+
if (!state.problem && (sel.routes.length || sel.scenarios.length)) {
|
|
254
|
+
const lab = new Lab({ stateDir: inv.stateDir });
|
|
255
|
+
state.labs.push(lab);
|
|
256
|
+
try {
|
|
257
|
+
const started = await lab.start({ project: profile.root, headed: inv.headed, ...startAuth, ...(traceOn ? { trace: true } : {}) });
|
|
258
|
+
state.chromium = started.session.browser.version;
|
|
259
|
+
state.auth = started.session.auth ?? 'fresh';
|
|
260
|
+
for (const route of sel.routes) {
|
|
261
|
+
if (state.aborted)
|
|
262
|
+
await never();
|
|
263
|
+
note(`sweep ${route}`);
|
|
264
|
+
state.sweeps.push(await lab.sweep({ route, devices: sel.sweepDevices }));
|
|
265
|
+
}
|
|
266
|
+
if (sel.scenarios.length) {
|
|
267
|
+
if (state.aborted)
|
|
268
|
+
await never();
|
|
269
|
+
note(`scan ${sel.scenarios.length} scenario(s)`);
|
|
270
|
+
state.scan = await lab.scan({ scenarios: sel.scenarios, ...(sel.devices ? { devices: sel.devices } : {}) });
|
|
271
|
+
}
|
|
272
|
+
if (state.aborted)
|
|
273
|
+
await never();
|
|
274
|
+
const now = assemble(profile, sel, state);
|
|
275
|
+
if (now.verdict.failingFindings.length) {
|
|
276
|
+
const failing = now.findings.filter((f) => now.verdict.failingFindings.includes(f.id));
|
|
277
|
+
const b = await lab.bundle({
|
|
278
|
+
reason: `${failing.length} finding(s) failed the CI policy: ${failing.map((f) => f.id).join(', ')}`,
|
|
279
|
+
failure: { kind: 'findings', fingerprints: [...new Set(failing.map((f) => f.fingerprint))] }, dir: bundlesDir,
|
|
280
|
+
});
|
|
281
|
+
state.bundles.push({ id: b.id, dir: `bundles/${b.id}`, for: 'findings of the sweeps and scan', trace: bundleTrace(b.trace) });
|
|
282
|
+
}
|
|
283
|
+
else if (sel.trace === 'always') {
|
|
284
|
+
const dest = join(out, 'traces', 'session.zip');
|
|
285
|
+
const saved = await lab.saveTrace(dest);
|
|
286
|
+
state.traces.push('path' in saved ? { name: 'sweeps and scan', file: relative(out, dest) } : { name: 'sweeps and scan', dropped: saved.dropped });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
if (state.aborted)
|
|
291
|
+
await never();
|
|
292
|
+
failWith(err);
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
if (!state.aborted)
|
|
296
|
+
await lab.close('ci finished');
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
if (state.aborted)
|
|
302
|
+
await never();
|
|
303
|
+
failWith(err);
|
|
304
|
+
}
|
|
305
|
+
finally {
|
|
306
|
+
// Whatever happened, no lab is left holding a browser or a service (an abort closes them itself).
|
|
307
|
+
if (!state.aborted)
|
|
308
|
+
await Promise.allSettled(state.labs.map((l) => l.close('ci finished')));
|
|
309
|
+
}
|
|
310
|
+
if (state.aborted)
|
|
311
|
+
await never();
|
|
312
|
+
clearTimeout(timer);
|
|
313
|
+
removeSignals();
|
|
314
|
+
let code;
|
|
315
|
+
try {
|
|
316
|
+
code = await finish(state.problem ? { exitCode: EXIT.couldNotRun, reason: `could not run: ${state.problem}` } : undefined);
|
|
317
|
+
}
|
|
318
|
+
catch (err) {
|
|
319
|
+
process.stderr.write(`agentlab: ${redactTree(LabError.from(err).message, secretsNow())}\n`);
|
|
320
|
+
code = EXIT.couldNotRun;
|
|
321
|
+
}
|
|
322
|
+
await holdIfTerminating();
|
|
323
|
+
process.exit(code);
|
|
324
|
+
}
|
|
325
|
+
/** The findings of the sweeps and the scan with the project's suppressions applied, their groups and the verdict. */
|
|
326
|
+
function assemble(profile, sel, state) {
|
|
327
|
+
const sweeps = state.sweeps.map((s) => ({
|
|
328
|
+
id: s.id, route: s.route, ms: s.ms,
|
|
329
|
+
devices: s.devices.map((d) => ({ device: d.device, width: d.width, status: d.status, ...(d.error ? { error: d.error.message } : {}), findings: d.findings, ms: d.ms })),
|
|
330
|
+
}));
|
|
331
|
+
const raw = collectFindings(state.sweeps, state.scan);
|
|
332
|
+
const devices = [...new Set([...state.sweeps.flatMap((s) => s.devices.map((d) => d.device)), ...(state.scan?.runs.map((r) => r.device) ?? [])])];
|
|
333
|
+
const { findings, statuses } = applySuppressions(raw, profile, { scenarios: sel.scenarios, devices });
|
|
334
|
+
const groups = groupForCi(findings);
|
|
335
|
+
const verdict = evaluateCi({ flows: state.flows, sweeps, scanRuns: state.scan?.runs ?? [], findings, groups, sel });
|
|
336
|
+
return { sweeps, findings, statuses, groups, verdict };
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Build the result, copy evidence per policy, redact every output, verify that no secret survived, and only then
|
|
340
|
+
* write. Returns the exit code. Throws (writing no result files) when a secret would remain.
|
|
341
|
+
*/
|
|
342
|
+
async function writeOutputs(o) {
|
|
343
|
+
const { inv, profile, sel, state, out } = o;
|
|
344
|
+
const a = assemble(profile, sel, state);
|
|
345
|
+
const aborted = state.aborted;
|
|
346
|
+
let verdict = a.verdict;
|
|
347
|
+
if (o.override) {
|
|
348
|
+
verdict = { ...a.verdict, result: 'error', exitCode: o.override.exitCode, reasons: [o.override.reason, ...(a.verdict.result === 'fail' ? a.verdict.reasons : [])] };
|
|
349
|
+
}
|
|
350
|
+
const roots = state.labs.flatMap((l) => { const d = l.status().session?.runDir; return d ? [d] : []; });
|
|
351
|
+
const wantFrames = sel.evidence === 'always' || (sel.evidence === 'on-failure' && verdict.result !== 'pass');
|
|
352
|
+
// Evidence frames, then the scan's own report with its frames beside it.
|
|
353
|
+
const frames = wantFrames ? copyFrames(frameRefs(a.findings), out, 'frames', roots) : new Map();
|
|
354
|
+
let scanForFiles;
|
|
355
|
+
if (state.scan) {
|
|
356
|
+
const ids = new Set(state.scan.findings.map((f) => f.id));
|
|
357
|
+
const scanFindings = a.findings.filter((f) => ids.has(f.id));
|
|
358
|
+
const ciPolicy = evaluatePolicy(scanFindings, state.scan.runs, { failOn: sel.failOn, failOnErrors: sel.scenarioErrors === 'fail', failOnHeuristic: sel.failOnHeuristic });
|
|
359
|
+
scanForFiles = {
|
|
360
|
+
...state.scan, findings: scanFindings, groups: groupFindings(scanFindings), suppressions: a.statuses,
|
|
361
|
+
verdict: { ...ciPolicy, result: state.scan.interrupted ? 'incomplete' : ciPolicy.result },
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
const scanFrames = wantFrames && scanForFiles ? copyFrames(frameRefs(scanForFiles.findings), out, 'scan/frames', roots) : new Map();
|
|
365
|
+
const artifacts = [{ path: CI_FILES.json, kind: 'result (JSON)' }];
|
|
366
|
+
if (sel.formats.includes('text'))
|
|
367
|
+
artifacts.push({ path: CI_FILES.text, kind: 'summary' });
|
|
368
|
+
if (sel.formats.includes('html'))
|
|
369
|
+
artifacts.push({ path: CI_FILES.html, kind: 'report' });
|
|
370
|
+
if (sel.formats.includes('junit'))
|
|
371
|
+
artifacts.push({ path: CI_FILES.junit, kind: 'JUnit XML' });
|
|
372
|
+
if (scanForFiles)
|
|
373
|
+
artifacts.push({ path: 'scan/report.html', kind: 'the scan\'s own report' }, { path: 'scan/result.json', kind: 'the scan\'s full result' });
|
|
374
|
+
if (frames.size)
|
|
375
|
+
artifacts.push({ path: 'frames/', kind: `${frames.size} evidence frame(s)` });
|
|
376
|
+
const scanSummaryJson = scanForFiles ? { ...scanSummary(scanForFiles, 200), reports: { html: 'scan/report.html', json: 'scan/result.json' } } : undefined;
|
|
377
|
+
const result = {
|
|
378
|
+
schema: CI_SCHEMA, version: CONTRACT_VERSIONS.report, product: { name: 'agentlab', version: productVersion() }, startedAt: o.startedAt, ms: Date.now() - o.t0,
|
|
379
|
+
command: inv.command, project: { name: profile.name }, selections: sel,
|
|
380
|
+
environment: { node: process.version, platform: process.platform, arch: process.arch, ...(state.chromium ? { chromium: state.chromium } : {}), ci: Boolean(process.env.CI), auth: state.auth },
|
|
381
|
+
flows: state.flows, sweeps: a.sweeps, ...(scanSummaryJson ? { scan: scanSummaryJson } : {}),
|
|
382
|
+
findings: withFrames(a.findings, frames), groups: a.groups, suppressions: a.statuses, verdict,
|
|
383
|
+
...(aborted?.kind === 'timeout' ? { timedOut: true } : {}), ...(aborted?.kind === 'cancelled' ? { cancelled: aborted.text } : {}),
|
|
384
|
+
bundles: state.bundles, traces: state.traces, artifacts,
|
|
385
|
+
};
|
|
386
|
+
const secrets = o.secretsNow();
|
|
387
|
+
const safe = redactTree(result, secrets);
|
|
388
|
+
const outputs = new Map();
|
|
389
|
+
outputs.set(CI_FILES.json, renderCi(safe, 'json', out));
|
|
390
|
+
for (const f of sel.formats)
|
|
391
|
+
if (f !== 'json')
|
|
392
|
+
outputs.set(CI_FILES[f], renderCi(safe, f, out));
|
|
393
|
+
if (scanForFiles) {
|
|
394
|
+
const rel = new Map([...scanFrames].map(([k, v]) => [k, v.replace(/^scan\//, '')]));
|
|
395
|
+
const abs = new Map([...scanFrames].map(([k, v]) => [k, join(out, v)]));
|
|
396
|
+
const stripStateFrames = (r) => ({ ...r, runs: r.runs.map((run) => ({ ...run, states: run.states.map(({ frame: _frame, ...s }) => s) })) });
|
|
397
|
+
const forHtml = redactTree({ ...stripStateFrames(scanForFiles), findings: withFrames(scanForFiles.findings, abs), reports: { html: join(out, 'scan', 'report.html'), json: join(out, 'scan', 'result.json') } }, secrets);
|
|
398
|
+
const forJson = redactTree({ ...stripStateFrames(scanForFiles), findings: withFrames(scanForFiles.findings, rel), reports: { html: 'report.html', json: 'result.json' } }, secrets);
|
|
399
|
+
outputs.set('scan/report.html', scanReportHtml(forHtml, join(out, 'scan')));
|
|
400
|
+
outputs.set('scan/result.json', JSON.stringify(forJson, null, 2) + '\n');
|
|
401
|
+
}
|
|
402
|
+
// Fail closed: nothing is written when a known secret value is still in any output.
|
|
403
|
+
const text = formatCiText(safe);
|
|
404
|
+
const leaked = Math.max(leakedSecrets(text, secrets), ...[...outputs.values()].map((t) => leakedSecrets(t, secrets)));
|
|
405
|
+
if (leaked) {
|
|
406
|
+
throw new LabError('bundle_unsafe', `An output still contained ${leaked} secret value(s) after redaction; no result files were written`, { hint: 'This is a bug in agentlab: report it without attaching the run.' });
|
|
407
|
+
}
|
|
408
|
+
for (const [name, content] of outputs)
|
|
409
|
+
put(join(out, name), content);
|
|
410
|
+
if (!o.quiet) {
|
|
411
|
+
process.stdout.write(inv.json ? JSON.stringify(safe, null, 2) + '\n' : `${text}results: ${shownPath(out)}${sep}\n`);
|
|
412
|
+
}
|
|
413
|
+
return verdict.exitCode;
|
|
414
|
+
}
|
|
415
|
+
const shownPath = (p) => { const r = relative(process.cwd(), p); return r.startsWith('..') ? p : r || '.'; };
|
|
416
|
+
// ---------- report ----------
|
|
417
|
+
/** `agentlab report <out-dir|ci-result.json> [--format text|json|html|junit] [--out <file>]`: re-render a saved result. No browser. */
|
|
418
|
+
export function runReport(target, format, outFile) {
|
|
419
|
+
const fail = (message) => { process.stderr.write(`agentlab: ${message}\n`); process.exit(EXIT.couldNotRun); };
|
|
420
|
+
if (!target)
|
|
421
|
+
return fail('report needs a results directory or a ci-result.json');
|
|
422
|
+
const f = format ?? 'text';
|
|
423
|
+
if (!['text', 'json', 'html', 'junit'].includes(f))
|
|
424
|
+
return fail('--format must be text, json, html or junit');
|
|
425
|
+
const path = resolve(target);
|
|
426
|
+
const file = existsSync(path) && statSync(path).isDirectory() ? join(path, CI_FILES.json) : path;
|
|
427
|
+
if (!existsSync(file))
|
|
428
|
+
return fail(`no CI result at ${file}`);
|
|
429
|
+
let result;
|
|
430
|
+
try {
|
|
431
|
+
result = parseCiResult(JSON.parse(readFileSync(file, 'utf8')));
|
|
432
|
+
}
|
|
433
|
+
catch (err) {
|
|
434
|
+
return fail(err instanceof SyntaxError ? `${file} is not valid JSON` : LabError.from(err).message);
|
|
435
|
+
}
|
|
436
|
+
const text = renderCi(result, f, dirname(file));
|
|
437
|
+
if (outFile)
|
|
438
|
+
put(resolve(outFile), text);
|
|
439
|
+
else
|
|
440
|
+
process.stdout.write(text);
|
|
441
|
+
process.exit(0);
|
|
442
|
+
}
|
|
443
|
+
//# sourceMappingURL=ci.js.map
|