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
package/dist/cli/main.js
ADDED
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { createInterface } from 'node:readline/promises';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { parseArgs } from 'node:util';
|
|
8
|
+
import { DEVICES } from '../core/devices.js';
|
|
9
|
+
import { formatError, formatScan } from '../core/format.js';
|
|
10
|
+
import { scanSummary } from '../core/scan.js';
|
|
11
|
+
import { clearState, daemonCheck, paths, readDashboardRecord, readState, rpc, stateDir, userStateRoot } from '../daemon/state.js';
|
|
12
|
+
import { runCi, runReport } from './ci.js';
|
|
13
|
+
import { runFlow } from './flow.js';
|
|
14
|
+
import { holdIfTerminating, onTermination } from './signals.js';
|
|
15
|
+
import { keepRuns, liveSessionIds, pruneRuns, reapOrphans, stopRecordedServices } from '../core/ownership.js';
|
|
16
|
+
import { runMcpServer } from '../mcp/server.js';
|
|
17
|
+
import { SessionFeed } from '../core/feed.js';
|
|
18
|
+
import { Lab } from '../core/lab.js';
|
|
19
|
+
import { Dashboard } from '../dashboard/server.js';
|
|
20
|
+
import { hasDisplay, openInBrowser } from '../dashboard/open.js';
|
|
21
|
+
import { formatProposal, proposeProfile, writeProposal } from '../core/init.js';
|
|
22
|
+
import { formatDoctor, runDoctor } from '../core/doctor.js';
|
|
23
|
+
import { authStatus } from '../core/auth.js';
|
|
24
|
+
import { PROFILE_FILE, loadProfile } from '../core/profile.js';
|
|
25
|
+
import { migrateProfile } from '../core/migrate.js';
|
|
26
|
+
import { listBundles, loadBundle, pruneBundles, removeBundle, retentionLimits } from '../core/bundle.js';
|
|
27
|
+
import { formatReplay, replayBundle, replayExitCode } from '../core/replay.js';
|
|
28
|
+
import { LabError } from '../core/schema.js';
|
|
29
|
+
import { CONTRACT_VERSIONS, productVersion } from '../core/versions.js';
|
|
30
|
+
const HELP = `agentlab: launch a project, drive it in a mobile browser, and see what changed.
|
|
31
|
+
|
|
32
|
+
Setup:
|
|
33
|
+
agentlab init [dir] [--yes | --print] [--force]
|
|
34
|
+
inspect the project and propose agentlab.json (shown before saving)
|
|
35
|
+
agentlab doctor [--project <dir>] [--no-launch]
|
|
36
|
+
check Node.js, the browser, display, permissions, ports and the profile
|
|
37
|
+
agentlab migrate [--project <dir|file>] [--yes | --print] [--force]
|
|
38
|
+
rewrite an older agentlab.json as schemaVersion 2 (keeps agentlab.json.v1.bak)
|
|
39
|
+
agentlab clean stop services a crashed session left running and prune old runs (AGENTLAB_KEEP_RUNS, default 20)
|
|
40
|
+
agentlab install-browser [--with-deps] install the Chromium build this version of Playwright needs
|
|
41
|
+
agentlab version
|
|
42
|
+
|
|
43
|
+
Session (a background daemon per directory keeps the browser open between commands):
|
|
44
|
+
agentlab start [--project <dir>] [--device mobile-390] [--headed] [--slow-mo <ms>] [--auth auto|saved|fresh] [--trace]
|
|
45
|
+
agentlab observe [--limit <n>]
|
|
46
|
+
agentlab click <ref> e.g. agentlab click e3; or --name <name> [--role <role>] for any targeted action
|
|
47
|
+
agentlab fill <ref> <text>
|
|
48
|
+
agentlab press <key> [ref] Enter, Escape, Tab, Shift+Tab, Control+a …
|
|
49
|
+
agentlab select <ref> <option>... by label or value (native <select>)
|
|
50
|
+
agentlab check <ref> | uncheck <ref>
|
|
51
|
+
agentlab scroll [up|down|left|right] [ref] [--amount <px>] no direction: bring <ref> into view
|
|
52
|
+
agentlab swipe <left|right|up|down> [ref] [--amount <px>] touch swipe (the finger's direction)
|
|
53
|
+
agentlab back | forward
|
|
54
|
+
agentlab hover <ref>
|
|
55
|
+
agentlab upload <ref> <file>... project-relative files inside uploads.allow
|
|
56
|
+
agentlab drag <ref> (--to <ref> | --dx <px> --dy <px>)
|
|
57
|
+
agentlab tabs list tabs; tab open </path> | tab switch <t2> | tab close [t2]
|
|
58
|
+
agentlab inspect [F2 | e10] session findings with evidence and reproduction steps
|
|
59
|
+
agentlab sweep [/route] [--devices mobile-320,tablet-768]
|
|
60
|
+
responsive sweep at 320/390/768/1440 px, one isolated context each
|
|
61
|
+
agentlab scan [/route] [--scenario <a,b>] [--devices <ids>] [--explore | --no-explore]
|
|
62
|
+
stateful scan of agentlab.json scan.scenarios in the running session
|
|
63
|
+
(menus, drawers, dialogs…); exit 1 when the scan policy fails
|
|
64
|
+
agentlab auth save | status | clear saved sign-in state (owner-only, git-ignored, never printed)
|
|
65
|
+
agentlab bundle [--note <why>] write a secret-free failure bundle for the running session
|
|
66
|
+
agentlab status
|
|
67
|
+
agentlab log [-f] ordered action log for the running session
|
|
68
|
+
agentlab stop close the browser; stop only the services the lab started
|
|
69
|
+
|
|
70
|
+
Live dashboard (served by the session on 127.0.0.1 with a per-session token; --no-ui disables it):
|
|
71
|
+
agentlab ui [--no-open] print the dashboard URL and open it when a display is available
|
|
72
|
+
|
|
73
|
+
MCP (stdio server over the same commands; start returns the dashboard URL):
|
|
74
|
+
agentlab mcp [--headed] [--no-ui]
|
|
75
|
+
|
|
76
|
+
Scripted:
|
|
77
|
+
agentlab run <flow.json> [--headed] [--device <id>] [--slow-mo <ms>] [--ui [--no-open]]
|
|
78
|
+
agentlab scan --project <dir> [/route] [--scenario <a,b>] [--devices <ids>] [--explore] [--headed] [--auth auto|saved|fresh] [--ui [--no-open]]
|
|
79
|
+
start, scan, stop: HTML and JSON reports; exit 0 pass, 1 policy fail, 2 could not run
|
|
80
|
+
agentlab devices
|
|
81
|
+
|
|
82
|
+
CI (non-interactive, provider-neutral; docs/ci.md). Each starts the project, runs, and stops only what it started:
|
|
83
|
+
agentlab test [--project <dir>] everything the profile's "ci" section selects (flows, routes to sweep, scenarios to scan)
|
|
84
|
+
agentlab sweep --project <dir> [/route ...] CI sweep of routes (without --project: the running session, as above)
|
|
85
|
+
agentlab scenario --project <dir> [name ...] CI scan of declared scenarios (without --project: scan in the running session)
|
|
86
|
+
agentlab report <out-dir|ci-result.json> [--format text|json|html|junit] [--out <file>]
|
|
87
|
+
re-render a saved result; no browser
|
|
88
|
+
shared flags: --devices a,b --flows a.json,b.json --routes /a,/b --scenarios A,B --fail-on high|medium|low|none
|
|
89
|
+
--fail-on-heuristic --scenario-errors fail|report (default fail) --out <dir> (default ./agentlab-results)
|
|
90
|
+
--format text,json,html,junit (default all; ci-result.json is always written) --trace off|on-failure|always (default off)
|
|
91
|
+
--evidence off|on-failure|always (default on-failure) --timeout 90s|15m (default 30m)
|
|
92
|
+
--auth fresh|saved|env (env: AGENTLAB_AUTH_STATE; default env when set, else fresh)
|
|
93
|
+
--validate-only --headed --json (print the result JSON instead of the text report)
|
|
94
|
+
exit codes: 0 pass, 1 policy failed (a flow failed, or a finding at/above --fail-on), 2 could not run (invalid profile, missing env or
|
|
95
|
+
browser, unknown selection, start failure), 3 timed out (partial result written), 130/143/129 cancelled by SIGINT/SIGTERM/SIGHUP
|
|
96
|
+
(services stopped, partial result written). Artifacts in --out: ci-result.json, summary.txt, report.html, junit.xml, scan/,
|
|
97
|
+
frames/, bundles/, traces/. Secrets are redacted from every output; a leak aborts the write with exit 2.
|
|
98
|
+
|
|
99
|
+
Failure bundles (under <state>/bundles; kept 14 days, newest 20; AGENTLAB_BUNDLE_DAYS, AGENTLAB_KEEP_BUNDLES):
|
|
100
|
+
agentlab bundles [list] | show <id> | rm <id> | prune [--older-than <days>] [--dir <bundles dir>]
|
|
101
|
+
agentlab replay <bundle dir|bundle.json> [--project <dir>] [--headed] [--allow-consequential] [--secret <step>=<ENV_NAME>]... [--until <step>]
|
|
102
|
+
re-run the recorded actions against the live project: exit 0 reproduced, 1 not reproduced,
|
|
103
|
+
2 could not run, 3 diverged or blocked. start --trace records a sanitized trace for bundles.
|
|
104
|
+
|
|
105
|
+
Every command accepts --json for the full structured result.
|
|
106
|
+
State lives in ./.agentlab in an initialised project, otherwise under ${'$'}XDG_STATE_HOME/agentlab
|
|
107
|
+
(override with AGENTLAB_HOME).`;
|
|
108
|
+
// `--trace` is a switch for `start`, and takes off|on-failure|always for the CI commands: the second form is
|
|
109
|
+
// renamed before parsing so both keep working.
|
|
110
|
+
const TRACE_MODES = ['off', 'on-failure', 'always'];
|
|
111
|
+
const argv = process.argv.slice(2).flatMap((a, i, all) => {
|
|
112
|
+
if (a.startsWith('--trace='))
|
|
113
|
+
return [`--trace-mode=${a.slice(8)}`];
|
|
114
|
+
if (a === '--trace' && TRACE_MODES.includes(all[i + 1] ?? ''))
|
|
115
|
+
return ['--trace-mode'];
|
|
116
|
+
return [a];
|
|
117
|
+
});
|
|
118
|
+
const { values: flags, positionals } = parseArgs({
|
|
119
|
+
args: argv,
|
|
120
|
+
allowPositionals: true,
|
|
121
|
+
allowNegative: true,
|
|
122
|
+
options: {
|
|
123
|
+
project: { type: 'string' },
|
|
124
|
+
device: { type: 'string' },
|
|
125
|
+
headless: { type: 'boolean', default: false },
|
|
126
|
+
'slow-mo': { type: 'string' },
|
|
127
|
+
limit: { type: 'string' },
|
|
128
|
+
role: { type: 'string' },
|
|
129
|
+
devices: { type: 'string' },
|
|
130
|
+
scenario: { type: 'string' },
|
|
131
|
+
explore: { type: 'boolean' },
|
|
132
|
+
headed: { type: 'boolean', default: false },
|
|
133
|
+
name: { type: 'string' },
|
|
134
|
+
auth: { type: 'string' },
|
|
135
|
+
amount: { type: 'string' },
|
|
136
|
+
to: { type: 'string' },
|
|
137
|
+
dx: { type: 'string' },
|
|
138
|
+
dy: { type: 'string' },
|
|
139
|
+
yes: { type: 'boolean', short: 'y', default: false },
|
|
140
|
+
print: { type: 'boolean', default: false },
|
|
141
|
+
force: { type: 'boolean', default: false },
|
|
142
|
+
launch: { type: 'boolean', default: true },
|
|
143
|
+
'with-deps': { type: 'boolean', default: false },
|
|
144
|
+
json: { type: 'boolean', default: false },
|
|
145
|
+
follow: { type: 'boolean', short: 'f', default: false },
|
|
146
|
+
ui: { type: 'boolean' },
|
|
147
|
+
trace: { type: 'boolean', default: false },
|
|
148
|
+
'trace-mode': { type: 'string' },
|
|
149
|
+
flows: { type: 'string' },
|
|
150
|
+
routes: { type: 'string' },
|
|
151
|
+
scenarios: { type: 'string' },
|
|
152
|
+
'fail-on': { type: 'string' },
|
|
153
|
+
'fail-on-heuristic': { type: 'boolean' },
|
|
154
|
+
'scenario-errors': { type: 'string' },
|
|
155
|
+
out: { type: 'string' },
|
|
156
|
+
format: { type: 'string' },
|
|
157
|
+
evidence: { type: 'string' },
|
|
158
|
+
timeout: { type: 'string' },
|
|
159
|
+
'validate-only': { type: 'boolean', default: false },
|
|
160
|
+
note: { type: 'string' },
|
|
161
|
+
dir: { type: 'string' },
|
|
162
|
+
'older-than': { type: 'string' },
|
|
163
|
+
until: { type: 'string' },
|
|
164
|
+
secret: { type: 'string', multiple: true },
|
|
165
|
+
'allow-consequential': { type: 'boolean', default: false },
|
|
166
|
+
open: { type: 'boolean', default: true },
|
|
167
|
+
version: { type: 'boolean', short: 'v', default: false },
|
|
168
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
const dir = stateDir();
|
|
172
|
+
const json = flags.json;
|
|
173
|
+
const require = createRequire(import.meta.url);
|
|
174
|
+
const PACKAGE_ROOT = fileURLToPath(new URL('../../', import.meta.url));
|
|
175
|
+
function printResponse(res) {
|
|
176
|
+
if (json)
|
|
177
|
+
process.stdout.write(JSON.stringify(res.ok ? res.result : { error: res.error }, null, 2) + '\n');
|
|
178
|
+
else if (res.ok)
|
|
179
|
+
process.stdout.write((res.text ?? '') + '\n');
|
|
180
|
+
else
|
|
181
|
+
process.stderr.write(`error ${formatError(res.error)}\n`);
|
|
182
|
+
const r = res.result;
|
|
183
|
+
const actionFailed = res.ok && (r?.outcome === 'error' || (r?.verdict?.result !== undefined && r.verdict.result !== 'pass'));
|
|
184
|
+
process.exit(res.ok && !actionFailed ? 0 : 1);
|
|
185
|
+
}
|
|
186
|
+
function fail(error) {
|
|
187
|
+
printResponse({ ok: false, error });
|
|
188
|
+
}
|
|
189
|
+
function usage(message) {
|
|
190
|
+
fail({ code: 'invalid_request', message, hint: 'Run `agentlab help`.', recoverable: true });
|
|
191
|
+
}
|
|
192
|
+
const toNumber = (v, name, signed = false) => {
|
|
193
|
+
if (v === undefined)
|
|
194
|
+
return undefined;
|
|
195
|
+
const n = Number(v);
|
|
196
|
+
if (!Number.isFinite(n) || (!signed && n < 0))
|
|
197
|
+
usage(`--${name} must be a ${signed ? '' : 'non-negative '}number`);
|
|
198
|
+
return n;
|
|
199
|
+
};
|
|
200
|
+
async function call(command, args = {}) {
|
|
201
|
+
const state = readState(dir);
|
|
202
|
+
if (!state || daemonCheck(state) !== 'same') {
|
|
203
|
+
if (state)
|
|
204
|
+
clearState(dir);
|
|
205
|
+
fail({ code: 'no_session', message: 'No session is running in this directory', hint: 'Run `agentlab start --project <dir>` first.', recoverable: false });
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
printResponse(await rpc(state.socket, command, args));
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
fail({ code: 'no_session', message: `Cannot reach the session daemon: ${err.message}`, hint: 'Run `agentlab stop`, then start again.', recoverable: false });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** --name/--role, or a ref taken from the positionals. */
|
|
215
|
+
function targetArgs(rest, required, command) {
|
|
216
|
+
if (flags.name !== undefined)
|
|
217
|
+
return { name: flags.name, ...(flags.role ? { role: flags.role } : {}) };
|
|
218
|
+
const ref = rest[0] && /^e\d+$/.test(rest[0]) ? rest.shift() : undefined;
|
|
219
|
+
if (!ref && required)
|
|
220
|
+
usage(`${command} needs a ref (e.g. e3) or --name`);
|
|
221
|
+
return ref ? { ref } : {};
|
|
222
|
+
}
|
|
223
|
+
async function start() {
|
|
224
|
+
if (flags.auth !== undefined && !['auto', 'saved', 'fresh'].includes(flags.auth))
|
|
225
|
+
usage('--auth must be auto, saved or fresh');
|
|
226
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
227
|
+
const existing = readState(dir);
|
|
228
|
+
if (existing && daemonCheck(existing) === 'same') {
|
|
229
|
+
fail({ code: 'session_exists', message: `A session daemon (pid ${existing.pid}) is already running here`, hint: 'Use it, or run `agentlab stop` first.', recoverable: false });
|
|
230
|
+
}
|
|
231
|
+
if (existing)
|
|
232
|
+
clearState(dir);
|
|
233
|
+
const log = openSync(paths(dir).log, 'a', 0o600);
|
|
234
|
+
const daemon = spawn(process.execPath, [fileURLToPath(new URL('../daemon/main.js', import.meta.url)), dir, ...(flags.ui === false ? ['--no-ui'] : [])], {
|
|
235
|
+
detached: true,
|
|
236
|
+
stdio: ['ignore', log, log],
|
|
237
|
+
env: process.env,
|
|
238
|
+
});
|
|
239
|
+
daemon.unref();
|
|
240
|
+
// Wait for the daemon's socket.
|
|
241
|
+
const deadline = Date.now() + 10_000;
|
|
242
|
+
let exited = false;
|
|
243
|
+
daemon.once('exit', () => { exited = true; });
|
|
244
|
+
while (!(readState(dir)?.pid === daemon.pid && existsSync(paths(dir).socket))) {
|
|
245
|
+
if (exited || Date.now() > deadline) {
|
|
246
|
+
fail({ code: 'startup_failed', message: `Session daemon did not start; see ${paths(dir).log}`, recoverable: false });
|
|
247
|
+
}
|
|
248
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
249
|
+
}
|
|
250
|
+
const res = await rpc(paths(dir).socket, 'start', {
|
|
251
|
+
project: resolve(flags.project ?? '.'),
|
|
252
|
+
...(flags.device ? { device: flags.device } : {}),
|
|
253
|
+
headed: flags.headed === true,
|
|
254
|
+
...(flags['slow-mo'] ? { slowMoMs: toNumber(flags['slow-mo'], 'slow-mo') } : {}),
|
|
255
|
+
...(flags.auth ? { auth: flags.auth } : {}),
|
|
256
|
+
...(flags.trace ? { trace: true } : {}),
|
|
257
|
+
});
|
|
258
|
+
if (res.ok && !json)
|
|
259
|
+
res.text += `\n\naction log: agentlab log -f (or tail -f ${paths(dir).log})${flags.ui === false ? '' : '\nwatch live: agentlab ui'}`;
|
|
260
|
+
printResponse(res);
|
|
261
|
+
}
|
|
262
|
+
function showDashboard(url, open, write) {
|
|
263
|
+
write(`dashboard: ${url}\n`);
|
|
264
|
+
if (!open)
|
|
265
|
+
return;
|
|
266
|
+
if (openInBrowser(url))
|
|
267
|
+
write('opened in your browser\n');
|
|
268
|
+
else
|
|
269
|
+
write(`no desktop display detected (DISPLAY/WAYLAND_DISPLAY unset); open the URL in a browser on this machine,\nor forward the port: ssh -L ${new URL(url).port}:127.0.0.1:${new URL(url).port} <this host>\n`);
|
|
270
|
+
}
|
|
271
|
+
async function ui() {
|
|
272
|
+
const state = readState(dir);
|
|
273
|
+
if (!state || daemonCheck(state) !== 'same') {
|
|
274
|
+
// No CLI session here: an MCP server started from this directory may be serving one.
|
|
275
|
+
const mcp = readDashboardRecord(dir);
|
|
276
|
+
if (mcp) {
|
|
277
|
+
if (json)
|
|
278
|
+
process.stdout.write(JSON.stringify({ url: mcp.url, source: 'mcp', opened: flags.open && hasDisplay() && openInBrowser(mcp.url) }) + '\n');
|
|
279
|
+
else
|
|
280
|
+
showDashboard(mcp.url, flags.open, (s) => process.stdout.write(s));
|
|
281
|
+
process.exit(0);
|
|
282
|
+
}
|
|
283
|
+
fail({ code: 'no_session', message: 'No session is running in this directory', hint: 'Run `agentlab start --project <dir>` first (or run this where the MCP server was started).', recoverable: false });
|
|
284
|
+
}
|
|
285
|
+
if (!state.dashboard) {
|
|
286
|
+
fail({ code: 'invalid_request', message: 'This session was started with --no-ui, so it has no dashboard', hint: 'Stop it and start again without --no-ui.', recoverable: false });
|
|
287
|
+
}
|
|
288
|
+
if (json)
|
|
289
|
+
process.stdout.write(JSON.stringify({ url: state.dashboard.url, opened: flags.open && hasDisplay() && openInBrowser(state.dashboard.url) }) + '\n');
|
|
290
|
+
else
|
|
291
|
+
showDashboard(state.dashboard.url, flags.open, (s) => process.stdout.write(s));
|
|
292
|
+
process.exit(0);
|
|
293
|
+
}
|
|
294
|
+
/** `run --ui`: serve the dashboard for this in-process flow and give a viewer a chance to connect first. */
|
|
295
|
+
async function runWithDashboard(flow) {
|
|
296
|
+
const feed = new SessionFeed();
|
|
297
|
+
let lab;
|
|
298
|
+
const dashboard = await Dashboard.listen({
|
|
299
|
+
feed, source: () => lab,
|
|
300
|
+
control: (op, by) => { if (!lab)
|
|
301
|
+
throw new LabError('no_session', 'The flow has not started yet'); return lab.supervise(op, by); },
|
|
302
|
+
input: (i) => { if (!lab)
|
|
303
|
+
throw new LabError('no_session', 'The flow has not started yet'); return lab.humanInput(i); },
|
|
304
|
+
});
|
|
305
|
+
const err = (s) => process.stderr.write(s);
|
|
306
|
+
onTermination(async (signal) => { await lab?.close(`interrupted by ${signal}`); await dashboard.close().catch(() => undefined); }, { stateDir: dir });
|
|
307
|
+
showDashboard(dashboard.url, flags.open, err);
|
|
308
|
+
const deadline = Date.now() + 30_000;
|
|
309
|
+
while (!dashboard.getStats().viewers.events && Date.now() < deadline)
|
|
310
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
311
|
+
err(dashboard.getStats().viewers.events ? 'dashboard connected; running\n' : 'no dashboard connected after 30 s; running anyway\n');
|
|
312
|
+
try {
|
|
313
|
+
return await runFlow(flow, {
|
|
314
|
+
headed: flags.headed === true, device: flags.device, slowMoMs: toNumber(flags['slow-mo'], 'slow-mo'), json, stateDir: dir,
|
|
315
|
+
lab: { onEvent: (e) => feed.apply(e), evidenceFrames: true }, onLab: (l) => { lab = l; },
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
await holdIfTerminating();
|
|
320
|
+
await new Promise((r) => setTimeout(r, 300)); // let the final status reach the viewer
|
|
321
|
+
await dashboard.close();
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function scanArgs(route) {
|
|
325
|
+
return {
|
|
326
|
+
...(route ? { route } : {}), ...(flags.scenario ? { scenarios: flags.scenario } : {}), ...(flags.devices ? { devices: flags.devices } : {}),
|
|
327
|
+
...(flags.explore !== undefined ? { explore: flags.explore } : {}),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* `scan --project`: start the project (headless unless --headed), scan it, stop what was started.
|
|
332
|
+
* Exit 0 when the policy passes, 1 when it fails, 2 when the scan could not run.
|
|
333
|
+
*/
|
|
334
|
+
async function scanStandalone(route) {
|
|
335
|
+
if (flags.auth !== undefined && !['auto', 'saved', 'fresh'].includes(flags.auth))
|
|
336
|
+
usage('--auth must be auto, saved or fresh');
|
|
337
|
+
const feed = flags.ui ? new SessionFeed() : undefined;
|
|
338
|
+
let lab;
|
|
339
|
+
const dashboard = feed ? await Dashboard.listen({
|
|
340
|
+
feed, source: () => lab,
|
|
341
|
+
control: (op, by) => lab.supervise(op, by),
|
|
342
|
+
input: (i) => lab.humanInput(i),
|
|
343
|
+
}) : undefined;
|
|
344
|
+
if (dashboard)
|
|
345
|
+
showDashboard(dashboard.url, flags.open, (s) => process.stderr.write(s));
|
|
346
|
+
lab = new Lab({ stateDir: dir, ...(feed ? { onEvent: (e) => feed.apply(e), evidenceFrames: true } : {}) });
|
|
347
|
+
let code = 2;
|
|
348
|
+
onTermination(async (signal) => { await lab?.close(`interrupted by ${signal}`); await dashboard?.close().catch(() => undefined); }, { stateDir: dir });
|
|
349
|
+
try {
|
|
350
|
+
await lab.start({
|
|
351
|
+
project: resolve(flags.project), ...(flags.device ? { device: flags.device } : {}), headed: flags.headed === true,
|
|
352
|
+
...(flags.auth ? { auth: flags.auth } : {}),
|
|
353
|
+
});
|
|
354
|
+
const a = scanArgs(route);
|
|
355
|
+
const list = (v) => typeof v === 'string' ? v.split(',').map((x) => x.trim()).filter(Boolean) : undefined;
|
|
356
|
+
const result = await lab.scan({ route: a.route, scenarios: list(a.scenarios), devices: list(a.devices), explore: a.explore });
|
|
357
|
+
process.stdout.write(json ? JSON.stringify(scanSummary(result), null, 2) + '\n' : formatScan(result) + '\n');
|
|
358
|
+
code = result.verdict.result === 'pass' ? 0 : 1;
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
process.stderr.write(`error ${formatError(LabError.from(err).toJSON())}\n`);
|
|
362
|
+
}
|
|
363
|
+
finally {
|
|
364
|
+
await lab.close('scan finished');
|
|
365
|
+
if (dashboard) {
|
|
366
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
367
|
+
await dashboard.close();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
await holdIfTerminating();
|
|
371
|
+
process.exit(code);
|
|
372
|
+
}
|
|
373
|
+
/** The CI commands (`test`, `sweep --project`, `scenario --project`): see docs/ci.md. */
|
|
374
|
+
function ci(command, positional) {
|
|
375
|
+
return runCi({
|
|
376
|
+
command, positionals: positional, project: resolve(flags.project ?? '.'), stateDir: dir, validateOnly: flags['validate-only'], headed: flags.headed === true, json,
|
|
377
|
+
...(flags.devices ? { devices: flags.devices } : {}), ...(flags.flows !== undefined ? { flows: flags.flows } : {}),
|
|
378
|
+
...(flags.routes !== undefined ? { routes: flags.routes } : {}), ...(flags.scenarios !== undefined ? { scenarios: flags.scenarios } : {}),
|
|
379
|
+
...(flags['fail-on'] ? { failOn: flags['fail-on'] } : {}), ...(flags['fail-on-heuristic'] !== undefined ? { failOnHeuristic: flags['fail-on-heuristic'] } : {}),
|
|
380
|
+
...(flags['scenario-errors'] ? { scenarioErrors: flags['scenario-errors'] } : {}), ...(flags.out ? { out: flags.out } : {}),
|
|
381
|
+
...(flags.format ? { format: flags.format } : {}), ...(flags['trace-mode'] ? { trace: flags['trace-mode'] } : {}),
|
|
382
|
+
...(flags.evidence ? { evidence: flags.evidence } : {}), ...(flags.timeout ? { timeout: flags.timeout } : {}), ...(flags.auth ? { auth: flags.auth } : {}),
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
async function stop() {
|
|
386
|
+
const state = readState(dir);
|
|
387
|
+
if (!state) {
|
|
388
|
+
const notes = await reapOrphans(dir);
|
|
389
|
+
process.stdout.write(['no session running', ...notes].join('; ') + '\n');
|
|
390
|
+
process.exit(0);
|
|
391
|
+
}
|
|
392
|
+
const daemon = daemonCheck(state);
|
|
393
|
+
if (daemon === 'same') {
|
|
394
|
+
try {
|
|
395
|
+
const res = await rpc(state.socket, 'stop');
|
|
396
|
+
// Wait for the daemon to exit so a following `start` never races it.
|
|
397
|
+
for (let i = 0; i < 100 && daemonCheck(state) === 'same'; i++)
|
|
398
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
399
|
+
printResponse(res);
|
|
400
|
+
}
|
|
401
|
+
catch { /* unreachable daemon: fall through to recovery */ }
|
|
402
|
+
if (daemonCheck(state) === 'same') {
|
|
403
|
+
try {
|
|
404
|
+
process.kill(state.pid, 'SIGTERM');
|
|
405
|
+
}
|
|
406
|
+
catch { /* gone */ }
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const notes = [describeDaemon(state, daemon), ...await recoverServices(state)];
|
|
410
|
+
clearState(dir);
|
|
411
|
+
notes.push(...await reapOrphans(dir));
|
|
412
|
+
process.stdout.write(notes.join('; ') + '\n');
|
|
413
|
+
process.exit(0);
|
|
414
|
+
}
|
|
415
|
+
function describeDaemon(state, check) {
|
|
416
|
+
switch (check) {
|
|
417
|
+
case 'same': return `session daemon ${state.pid} did not answer and was sent SIGTERM`;
|
|
418
|
+
case 'gone': return `session daemon ${state.pid} had already exited`;
|
|
419
|
+
case 'reused': return `stale record: pid ${state.pid} now belongs to another process, which was not signalled`;
|
|
420
|
+
case 'unverifiable': return `record for pid ${state.pid} has no process identity (older format); nothing was signalled`;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Recovery for a daemon that died without cleanup: stop only owned process groups whose identity still
|
|
425
|
+
* matches. A one-shot service's stop command is named for the person to run; its containers cannot be
|
|
426
|
+
* proved to still be the ones the lab started.
|
|
427
|
+
*/
|
|
428
|
+
async function recoverServices(state) {
|
|
429
|
+
const records = state.services ?? (state.server ? [{ name: 'web', mode: 'process', ...state.server }] : []);
|
|
430
|
+
return stopRecordedServices(records);
|
|
431
|
+
}
|
|
432
|
+
/** `clean`: stop what crashed sessions left running (ownership records) and prune old runs. */
|
|
433
|
+
async function clean() {
|
|
434
|
+
const notes = await reapOrphans(dir);
|
|
435
|
+
const state = readState(dir);
|
|
436
|
+
const removed = pruneRuns(dir, keepRuns(), liveSessionIds(dir).add(state?.sessionId ?? ''));
|
|
437
|
+
const lines = [...notes, removed.length ? `pruned ${removed.length} old run${removed.length > 1 ? 's' : ''} (keeping the latest ${keepRuns()})` : 'no old runs to prune'];
|
|
438
|
+
if (!notes.length)
|
|
439
|
+
lines.unshift('no services left behind by earlier sessions');
|
|
440
|
+
if (json)
|
|
441
|
+
process.stdout.write(JSON.stringify({ reaped: notes, prunedRuns: removed }, null, 2) + '\n');
|
|
442
|
+
else
|
|
443
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
444
|
+
process.exit(0);
|
|
445
|
+
}
|
|
446
|
+
function contractsLine() {
|
|
447
|
+
const c = CONTRACT_VERSIONS;
|
|
448
|
+
return `contracts: profile ${c.profile} (reads ${c.profileSupported[0]}–${c.profileSupported.at(-1)}), results ${c.results}, mcp tools ${c.mcpTools}, report ${c.report}, bundle ${c.bundle}, events ${c.events}`;
|
|
449
|
+
}
|
|
450
|
+
async function version() {
|
|
451
|
+
const pwDir = dirname(require.resolve('playwright/package.json'));
|
|
452
|
+
const pw = JSON.parse(readFileSync(join(pwDir, 'package.json'), 'utf8')).version;
|
|
453
|
+
const { chromium } = await import('playwright');
|
|
454
|
+
const exe = chromium.executablePath();
|
|
455
|
+
const info = { agentlab: productVersion(), node: process.versions.node, playwright: pw, chromium: { executable: exe, installed: existsSync(exe) }, installedAt: PACKAGE_ROOT, stateDir: dir, contracts: CONTRACT_VERSIONS };
|
|
456
|
+
if (json)
|
|
457
|
+
process.stdout.write(JSON.stringify(info, null, 2) + '\n');
|
|
458
|
+
else {
|
|
459
|
+
process.stdout.write(`agentlab ${info.agentlab}\nnode ${info.node}\nplaywright ${pw}; chromium ${info.chromium.installed ? 'installed' : 'NOT installed (run `agentlab install-browser`)'} at ${exe}\ninstalled at ${PACKAGE_ROOT}\n${contractsLine()}\n`);
|
|
460
|
+
}
|
|
461
|
+
process.exit(0);
|
|
462
|
+
}
|
|
463
|
+
function installBrowser() {
|
|
464
|
+
// Playwright's own CLI from this package's dependency, so the browser build matches the pinned version.
|
|
465
|
+
const cli = join(dirname(require.resolve('playwright/package.json')), 'cli.js');
|
|
466
|
+
const args = [cli, 'install', ...(flags['with-deps'] ? ['--with-deps'] : []), 'chromium'];
|
|
467
|
+
process.stderr.write(`running: ${process.execPath} ${args.join(' ')}\n`);
|
|
468
|
+
const r = spawnSync(process.execPath, args, { stdio: 'inherit' });
|
|
469
|
+
process.exit(r.status ?? 1);
|
|
470
|
+
}
|
|
471
|
+
async function doctor() {
|
|
472
|
+
const state = readState(dir);
|
|
473
|
+
const report = await runDoctor({
|
|
474
|
+
cwd: process.cwd(), ...(flags.project ? { project: resolve(flags.project) } : {}), stateDir: dir, launch: flags.launch,
|
|
475
|
+
...(state ? { daemon: { pid: state.pid, check: daemonCheck(state) } } : {}),
|
|
476
|
+
});
|
|
477
|
+
process.stdout.write(json ? JSON.stringify(report, null, 2) + '\n' : `agentlab ${productVersion()} doctor\n${formatDoctor(report)}\n`);
|
|
478
|
+
process.exit(report.ok ? 0 : 1);
|
|
479
|
+
}
|
|
480
|
+
async function init(target) {
|
|
481
|
+
const project = resolve(target ?? '.');
|
|
482
|
+
const proposal = await proposeProfile(project);
|
|
483
|
+
if (json && (flags.print || !flags.yes)) {
|
|
484
|
+
process.stdout.write(JSON.stringify(proposal, null, 2) + '\n');
|
|
485
|
+
process.exit(0);
|
|
486
|
+
}
|
|
487
|
+
if (!json)
|
|
488
|
+
process.stdout.write(formatProposal(proposal) + '\n');
|
|
489
|
+
if (flags.print)
|
|
490
|
+
process.exit(0);
|
|
491
|
+
if (proposal.existing && !flags.force) {
|
|
492
|
+
process.stdout.write(`\nagentlab.json already exists; nothing written. Re-run with --force to replace it.\n`);
|
|
493
|
+
process.exit(0);
|
|
494
|
+
}
|
|
495
|
+
let write = flags.yes;
|
|
496
|
+
if (!write) {
|
|
497
|
+
if (!process.stdin.isTTY) {
|
|
498
|
+
process.stdout.write('\nNothing written (not interactive). Review the proposal, then re-run with --yes to save it.\n');
|
|
499
|
+
process.exit(0);
|
|
500
|
+
}
|
|
501
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
502
|
+
const answer = await rl.question(`\nWrite ${join(project, 'agentlab.json')}${proposal.gitignore.ignoresState ? '' : ' and add .agentlab/ to .gitignore'}? [y/N] `);
|
|
503
|
+
rl.close();
|
|
504
|
+
write = /^y(es)?$/i.test(answer.trim());
|
|
505
|
+
}
|
|
506
|
+
if (!write) {
|
|
507
|
+
process.stdout.write('Nothing written.\n');
|
|
508
|
+
process.exit(0);
|
|
509
|
+
}
|
|
510
|
+
const saved = await writeProposal(proposal, { force: flags.force });
|
|
511
|
+
const out = { written: saved.profilePath, gitignoreUpdated: saved.gitignoreUpdated };
|
|
512
|
+
process.stdout.write(json ? JSON.stringify(out) + '\n'
|
|
513
|
+
: `wrote ${saved.profilePath}${saved.gitignoreUpdated ? '; added .agentlab/ to .gitignore' : ''}\nnext: agentlab doctor, then agentlab start\n`);
|
|
514
|
+
process.exit(0);
|
|
515
|
+
}
|
|
516
|
+
/** A copy of a profile for display, with env values whose names look secret replaced. */
|
|
517
|
+
function maskSecretEnv(profile) {
|
|
518
|
+
const copy = structuredClone(profile);
|
|
519
|
+
const services = (copy.services ?? {});
|
|
520
|
+
for (const svc of [...Object.values(services), copy.web]) {
|
|
521
|
+
for (const k of Object.keys(svc?.env ?? {}))
|
|
522
|
+
if (SECRET_NAME.test(k))
|
|
523
|
+
svc.env[k] = '‹redacted›';
|
|
524
|
+
}
|
|
525
|
+
return copy;
|
|
526
|
+
}
|
|
527
|
+
const SECRET_NAME = /secret|token|passw|api[_-]?key|auth|cookie|session|private|credential/i;
|
|
528
|
+
function bundlesDir() {
|
|
529
|
+
return resolve(flags.dir ?? join(dir, 'bundles'));
|
|
530
|
+
}
|
|
531
|
+
/** `agentlab bundles [list] | show <id> | rm <id> | prune`. */
|
|
532
|
+
async function bundles(sub = 'list', id) {
|
|
533
|
+
const root = bundlesDir();
|
|
534
|
+
const say = (s) => process.stdout.write(s + '\n');
|
|
535
|
+
try {
|
|
536
|
+
switch (sub) {
|
|
537
|
+
case 'list': {
|
|
538
|
+
const all = listBundles(root);
|
|
539
|
+
if (json)
|
|
540
|
+
say(JSON.stringify({ dir: root, bundles: all }, null, 2));
|
|
541
|
+
else if (!all.length)
|
|
542
|
+
say(`no bundles in ${root}`);
|
|
543
|
+
else
|
|
544
|
+
for (const b of all)
|
|
545
|
+
say(`${b.id} ${b.createdAt.slice(0, 19)} ${b.failure.padEnd(14)} ${b.actions} actions, ${b.findings} findings${b.trace ? ', trace' : ''} ${b.reason.slice(0, 80)}`);
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
case 'show': {
|
|
549
|
+
if (!id)
|
|
550
|
+
return usage('bundles show needs a bundle id');
|
|
551
|
+
const b = loadBundle(join(root, id));
|
|
552
|
+
if (json) {
|
|
553
|
+
say(JSON.stringify(b, null, 2));
|
|
554
|
+
break;
|
|
555
|
+
}
|
|
556
|
+
say(`${b.id} ${b.retention.createdAt} (expires ${b.retention.expiresAt.slice(0, 10)})\nreason: ${b.reason}\nfailure: ${JSON.stringify(b.failure)}`);
|
|
557
|
+
say(`project ${b.project.name} device ${b.session.device} auth ${b.session.auth} start ${b.session.startRoute} route now ${b.route}`);
|
|
558
|
+
say(`agentlab ${b.product.version} ${b.environment.platform}/${b.environment.arch} node ${b.environment.node} chromium ${b.environment.chromium}${b.environment.ci ? ' CI' : ''}`);
|
|
559
|
+
for (const a of b.actions) {
|
|
560
|
+
say(a.actor === 'person' ? ` ${a.index} a person: ${a.description}`
|
|
561
|
+
: ` ${a.index} ${a.action}${a.target ? ` ${a.target.role} ${JSON.stringify(a.target.name)}` : ''}${a.secret ? ' ‹secret›' : ''} ${a.routeBefore ?? '?'} → ${a.routeAfter ?? '?'} ${a.outcome === 'error' ? `ERROR ${a.error?.code}` : 'ok'}${a.consequential ? ' (consequential)' : ''}`);
|
|
562
|
+
}
|
|
563
|
+
if (b.actionsOmitted)
|
|
564
|
+
say(` (${b.actionsOmitted} earlier action(s) dropped from the log)`);
|
|
565
|
+
say(`${b.findings.length} findings, ${b.consoleErrors.length} console errors, ${b.failedRequests.length} failed requests, ${b.frames.length} frames${b.trace ? ', sanitized trace' : b.traceDropped ? `, trace left out (${b.traceDropped})` : ''}`);
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
case 'rm':
|
|
569
|
+
if (!id)
|
|
570
|
+
return usage('bundles rm needs a bundle id');
|
|
571
|
+
removeBundle(root, id);
|
|
572
|
+
say(`removed ${id}`);
|
|
573
|
+
break;
|
|
574
|
+
case 'prune': {
|
|
575
|
+
const days = toNumber(flags['older-than'], 'older-than');
|
|
576
|
+
const removed = pruneBundles(root, { ...(days !== undefined ? { maxAgeDays: days } : {}) });
|
|
577
|
+
const limits = retentionLimits();
|
|
578
|
+
say(json ? JSON.stringify({ removed }) : `removed ${removed.length} bundle(s)${removed.length ? `: ${removed.join(', ')}` : ''} (keeping the newest ${limits.keep}, at most ${days ?? limits.maxAgeDays} days old)`);
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
default: return usage('bundles takes list, show <id>, rm <id> or prune');
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
catch (err) {
|
|
585
|
+
process.stderr.write(`error ${formatError(LabError.from(err).toJSON())}\n`);
|
|
586
|
+
process.exit(2);
|
|
587
|
+
}
|
|
588
|
+
process.exit(0);
|
|
589
|
+
}
|
|
590
|
+
/** `agentlab replay <bundle>`: exit 0 reproduced, 1 not reproduced, 2 could not run, 3 diverged or blocked. */
|
|
591
|
+
async function replay(target) {
|
|
592
|
+
if (!target)
|
|
593
|
+
return usage('replay needs a bundle directory or bundle.json');
|
|
594
|
+
const secrets = {};
|
|
595
|
+
for (const spec of flags.secret ?? []) {
|
|
596
|
+
const m = /^(\d+)=(.+)$/.exec(spec);
|
|
597
|
+
if (!m)
|
|
598
|
+
return usage('--secret takes <step>=<ENV_NAME>, e.g. --secret 2=MY_PASSWORD');
|
|
599
|
+
secrets[Number(m[1])] = m[2];
|
|
600
|
+
}
|
|
601
|
+
const until = toNumber(flags.until, 'until');
|
|
602
|
+
let running;
|
|
603
|
+
onTermination(async (signal) => { await running?.close(`interrupted by ${signal}`); }, { stateDir: dir });
|
|
604
|
+
try {
|
|
605
|
+
const result = await replayBundle({
|
|
606
|
+
bundle: target, ...(flags.project ? { project: flags.project } : {}), stateDir: dir, headed: flags.headed,
|
|
607
|
+
allowConsequential: flags['allow-consequential'], secrets, ...(until !== undefined ? { until } : {}),
|
|
608
|
+
onLab: (l) => { running = l; },
|
|
609
|
+
});
|
|
610
|
+
await holdIfTerminating();
|
|
611
|
+
process.stdout.write(json ? JSON.stringify(result, null, 2) + '\n' : formatReplay(result) + '\n');
|
|
612
|
+
process.exit(replayExitCode(result));
|
|
613
|
+
}
|
|
614
|
+
catch (err) {
|
|
615
|
+
process.stderr.write(`error ${formatError(LabError.from(err).toJSON())}\n`);
|
|
616
|
+
process.exit(2);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
async function migrate() {
|
|
620
|
+
let file = resolve(flags.project ?? '.');
|
|
621
|
+
if (existsSync(file) && statSync(file).isDirectory())
|
|
622
|
+
file = join(file, PROFILE_FILE);
|
|
623
|
+
let migration;
|
|
624
|
+
try {
|
|
625
|
+
let raw;
|
|
626
|
+
try {
|
|
627
|
+
raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
628
|
+
}
|
|
629
|
+
catch (err) {
|
|
630
|
+
// Never echo the parser's message: V8 quotes the file's text around the error, which may hold values.
|
|
631
|
+
const at = /position (\d+)/.exec(err.message)?.[1];
|
|
632
|
+
const why = err.code ? `cannot read it (${err.code})` : `not valid JSON${at ? ` (at character ${at})` : ''}`;
|
|
633
|
+
throw new LabError('invalid_profile', `${file}: ${why}`, { hint: 'Pass --project <dir|file> for the agentlab.json to migrate.' });
|
|
634
|
+
}
|
|
635
|
+
migration = migrateProfile(raw);
|
|
636
|
+
}
|
|
637
|
+
catch (err) {
|
|
638
|
+
fail(LabError.from(err).toJSON());
|
|
639
|
+
}
|
|
640
|
+
const { changed, from, to, notes, profile } = migration;
|
|
641
|
+
if (!changed) {
|
|
642
|
+
process.stdout.write(json ? JSON.stringify({ changed, from, to }) + '\n' : `${basename(file)} is already schemaVersion ${to}; nothing to do\n`);
|
|
643
|
+
process.exit(0);
|
|
644
|
+
}
|
|
645
|
+
const text = JSON.stringify(profile, null, 2) + '\n';
|
|
646
|
+
const backup = `${file}.v1.bak`;
|
|
647
|
+
// What is shown masks secret-looking env values; the file written keeps them as they were.
|
|
648
|
+
const shown = maskSecretEnv(profile);
|
|
649
|
+
if (json && (flags.print || !flags.yes)) {
|
|
650
|
+
process.stdout.write(JSON.stringify({ changed, from, to, notes, profile: shown }, null, 2) + '\n');
|
|
651
|
+
process.exit(0);
|
|
652
|
+
}
|
|
653
|
+
if (!json)
|
|
654
|
+
process.stdout.write(`${file}: schemaVersion ${from} → ${to}\n${notes.map((n) => ` - ${n}`).join('\n')}\n\n${JSON.stringify(shown, null, 2)}\n`);
|
|
655
|
+
if (flags.print)
|
|
656
|
+
process.exit(0);
|
|
657
|
+
if (existsSync(backup) && !flags.force) {
|
|
658
|
+
fail({ code: 'invalid_request', message: `${backup} already exists; nothing written`, hint: 'Move it away, or re-run with --force to overwrite the backup.', recoverable: true });
|
|
659
|
+
}
|
|
660
|
+
let write = flags.yes;
|
|
661
|
+
if (!write) {
|
|
662
|
+
if (!process.stdin.isTTY) {
|
|
663
|
+
process.stdout.write('\nNothing written (not interactive). Review the result, then re-run with --yes to save it.\n');
|
|
664
|
+
process.exit(0);
|
|
665
|
+
}
|
|
666
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
667
|
+
const answer = await rl.question(`\nRewrite ${file} (backup: ${backup})? [y/N] `);
|
|
668
|
+
rl.close();
|
|
669
|
+
write = /^y(es)?$/i.test(answer.trim());
|
|
670
|
+
}
|
|
671
|
+
if (!write) {
|
|
672
|
+
process.stdout.write('Nothing written.\n');
|
|
673
|
+
process.exit(0);
|
|
674
|
+
}
|
|
675
|
+
// Back up the original bytes, then replace the file atomically (same directory, same mode).
|
|
676
|
+
copyFileSync(file, backup);
|
|
677
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
678
|
+
writeFileSync(tmp, text);
|
|
679
|
+
chmodSync(tmp, statSync(file).mode & 0o777);
|
|
680
|
+
renameSync(tmp, file);
|
|
681
|
+
process.stdout.write(json ? JSON.stringify({ changed, from, to, notes, written: file, backup }) + '\n' : `wrote ${file}; original kept as ${backup}\nnext: agentlab doctor\n`);
|
|
682
|
+
process.exit(0);
|
|
683
|
+
}
|
|
684
|
+
async function auth(sub) {
|
|
685
|
+
if (sub === 'save')
|
|
686
|
+
return call('auth_save');
|
|
687
|
+
if (sub !== 'status' && sub !== 'clear')
|
|
688
|
+
usage('auth needs save, status or clear');
|
|
689
|
+
let profile;
|
|
690
|
+
try {
|
|
691
|
+
profile = await loadProfile(resolve(flags.project ?? '.'));
|
|
692
|
+
}
|
|
693
|
+
catch (err) {
|
|
694
|
+
fail(LabError.from(err).toJSON());
|
|
695
|
+
}
|
|
696
|
+
const status = authStatus(profile.auth, profile.root);
|
|
697
|
+
if (sub === 'clear') {
|
|
698
|
+
rmSync(profile.auth.file, { force: true });
|
|
699
|
+
process.stdout.write(json ? JSON.stringify({ cleared: status.exists, file: status.file }) + '\n' : status.exists ? `removed ${status.file}\n` : `no saved sign-in state at ${status.file}\n`);
|
|
700
|
+
process.exit(0);
|
|
701
|
+
}
|
|
702
|
+
if (json)
|
|
703
|
+
process.stdout.write(JSON.stringify(status, null, 2) + '\n');
|
|
704
|
+
else if (!status.exists)
|
|
705
|
+
process.stdout.write(`no saved sign-in state (${status.file})\n`);
|
|
706
|
+
else {
|
|
707
|
+
process.stdout.write(`${status.file}: ${status.cookies ?? '?'} cookies, ${status.origins ?? '?'} origins with storage, mode ${status.mode}${status.ownerOnly ? '' : ' (TOO OPEN)'}` +
|
|
708
|
+
`${status.expired ? ', expired' : ''}${status.ignored === false ? ', NOT ignored by git' : ''}${status.problem ? `, ${status.problem}` : ''}\n`);
|
|
709
|
+
}
|
|
710
|
+
process.exit(0);
|
|
711
|
+
}
|
|
712
|
+
async function main() {
|
|
713
|
+
const [command, ...rest] = positionals;
|
|
714
|
+
if (flags.version || command === 'version' || command === '--version')
|
|
715
|
+
return version();
|
|
716
|
+
if (flags.help || !command || command === 'help') {
|
|
717
|
+
process.stdout.write(HELP + '\n');
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
switch (command) {
|
|
721
|
+
case 'init': return init(rest[0]);
|
|
722
|
+
case 'migrate': return migrate();
|
|
723
|
+
case 'doctor': return doctor();
|
|
724
|
+
case 'install-browser': return installBrowser();
|
|
725
|
+
case 'start': return start();
|
|
726
|
+
case 'stop':
|
|
727
|
+
case 'close': return stop();
|
|
728
|
+
case 'clean': return clean();
|
|
729
|
+
case 'ui': return ui();
|
|
730
|
+
case 'scan': return flags.project !== undefined ? scanStandalone(rest[0]) : call('scan', scanArgs(rest[0]));
|
|
731
|
+
case 'sweep':
|
|
732
|
+
return flags.project !== undefined ? ci('sweep', rest) : call('sweep', { ...(rest[0] ? { route: rest[0] } : {}), ...(flags.devices ? { devices: flags.devices } : {}) });
|
|
733
|
+
case 'scenario':
|
|
734
|
+
return flags.project !== undefined ? ci('scenario', rest) : call('scan', { ...(rest.length ? { scenarios: rest.join(',') } : {}), ...(flags.devices ? { devices: flags.devices } : {}) });
|
|
735
|
+
case 'test': return ci('test', rest);
|
|
736
|
+
case 'report': return runReport(rest[0], flags.format, flags.out);
|
|
737
|
+
case 'status': return call('status');
|
|
738
|
+
case 'bundle': return call('bundle', flags.note ? { note: flags.note } : {});
|
|
739
|
+
case 'bundles': return bundles(rest[0], rest[1]);
|
|
740
|
+
case 'replay': return replay(rest[0]);
|
|
741
|
+
case 'tabs': return call('tabs');
|
|
742
|
+
case 'tab': {
|
|
743
|
+
const [sub, arg] = rest;
|
|
744
|
+
if (sub === 'open')
|
|
745
|
+
return arg ? call('open_tab', { path: arg }) : usage('tab open needs a path, e.g. /help');
|
|
746
|
+
if (sub === 'switch')
|
|
747
|
+
return arg ? call('switch_tab', { tab: arg }) : usage('tab switch needs a tab id, e.g. t2');
|
|
748
|
+
if (sub === 'close')
|
|
749
|
+
return call('close_tab', arg ? { tab: arg } : {});
|
|
750
|
+
return usage('tab needs open, switch or close');
|
|
751
|
+
}
|
|
752
|
+
case 'auth': return auth(rest[0]);
|
|
753
|
+
case 'inspect': {
|
|
754
|
+
const [id] = rest;
|
|
755
|
+
return call('inspect', id ? (/^e\d+$/.test(id) ? { ref: id } : { id }) : {});
|
|
756
|
+
}
|
|
757
|
+
case 'observe': return call('observe', flags.limit ? { limit: toNumber(flags.limit, 'limit') } : {});
|
|
758
|
+
case 'click':
|
|
759
|
+
case 'fill':
|
|
760
|
+
case 'act': {
|
|
761
|
+
const action = command === 'act' ? rest.shift() : command;
|
|
762
|
+
if (action !== 'click' && action !== 'fill')
|
|
763
|
+
usage('act needs click or fill');
|
|
764
|
+
const target = targetArgs(rest, true, action);
|
|
765
|
+
const value = action === 'fill' ? rest.shift() : undefined;
|
|
766
|
+
if (action === 'fill' && value === undefined)
|
|
767
|
+
usage('fill needs the text to enter');
|
|
768
|
+
return call(action, { ...target, ...(value !== undefined ? { value } : {}) });
|
|
769
|
+
}
|
|
770
|
+
case 'press': {
|
|
771
|
+
const key = rest.shift();
|
|
772
|
+
if (!key)
|
|
773
|
+
usage('press needs a key, e.g. Enter');
|
|
774
|
+
return call('press', { key, ...targetArgs(rest, false, command) });
|
|
775
|
+
}
|
|
776
|
+
case 'select': {
|
|
777
|
+
const target = targetArgs(rest, true, command);
|
|
778
|
+
if (!rest.length)
|
|
779
|
+
usage('select needs at least one option label or value');
|
|
780
|
+
return call('select', { ...target, values: rest });
|
|
781
|
+
}
|
|
782
|
+
case 'check':
|
|
783
|
+
case 'uncheck':
|
|
784
|
+
case 'hover': return call(command, targetArgs(rest, true, command));
|
|
785
|
+
case 'scroll':
|
|
786
|
+
case 'swipe': {
|
|
787
|
+
const direction = rest[0] && ['up', 'down', 'left', 'right'].includes(rest[0]) ? rest.shift() : undefined;
|
|
788
|
+
if (command === 'swipe' && !direction)
|
|
789
|
+
usage('swipe needs a direction: left, right, up or down');
|
|
790
|
+
const target = targetArgs(rest, false, command);
|
|
791
|
+
if (!direction && !target.ref && !target.name)
|
|
792
|
+
usage('scroll needs a direction, a ref, or both');
|
|
793
|
+
return call(command, { ...(direction ? { direction } : {}), ...target, ...(flags.amount ? { amount: toNumber(flags.amount, 'amount') } : {}) });
|
|
794
|
+
}
|
|
795
|
+
case 'back':
|
|
796
|
+
case 'forward': return call(command);
|
|
797
|
+
case 'upload': {
|
|
798
|
+
const target = targetArgs(rest, true, command);
|
|
799
|
+
if (!rest.length)
|
|
800
|
+
usage('upload needs at least one file');
|
|
801
|
+
return call('upload', { ...target, files: rest });
|
|
802
|
+
}
|
|
803
|
+
case 'drag': {
|
|
804
|
+
const target = targetArgs(rest, true, command);
|
|
805
|
+
const toRef = flags.to ?? rest.shift();
|
|
806
|
+
if (!toRef && flags.dx === undefined && flags.dy === undefined)
|
|
807
|
+
usage('drag needs --to <ref>, or --dx/--dy');
|
|
808
|
+
return call('drag', { ...target, ...(toRef ? { toRef } : {}), ...(flags.dx !== undefined ? { dx: toNumber(flags.dx, 'dx', true) } : {}), ...(flags.dy !== undefined ? { dy: toNumber(flags.dy, 'dy', true) } : {}) });
|
|
809
|
+
}
|
|
810
|
+
case 'log': {
|
|
811
|
+
const file = paths(dir).log;
|
|
812
|
+
if (!existsSync(file)) {
|
|
813
|
+
process.stdout.write('no log yet\n');
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
if (!flags.follow) {
|
|
817
|
+
process.stdout.write(readFileSync(file, 'utf8'));
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
spawn('tail', ['-n', '+1', '-f', file], { stdio: 'inherit' });
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
case 'mcp':
|
|
824
|
+
// stdout belongs to the MCP protocol from here on.
|
|
825
|
+
return runMcpServer({ stateDir: dir, headless: flags.headed !== true, ui: flags.ui !== false });
|
|
826
|
+
case 'devices':
|
|
827
|
+
for (const d of Object.values(DEVICES))
|
|
828
|
+
process.stdout.write(`${d.id}\t${d.label}\n`);
|
|
829
|
+
return;
|
|
830
|
+
case 'run': {
|
|
831
|
+
const flow = rest[0];
|
|
832
|
+
if (!flow)
|
|
833
|
+
usage('run needs a flow file');
|
|
834
|
+
let running;
|
|
835
|
+
if (!flags.ui)
|
|
836
|
+
onTermination(async (signal) => { await running?.close(`interrupted by ${signal}`); }, { stateDir: dir });
|
|
837
|
+
const ok = flags.ui ? await runWithDashboard(flow) : await runFlow(flow, {
|
|
838
|
+
headed: flags.headed === true, device: flags.device, slowMoMs: toNumber(flags['slow-mo'], 'slow-mo'), json, stateDir: dir,
|
|
839
|
+
onLab: (l) => { running = l; },
|
|
840
|
+
});
|
|
841
|
+
await holdIfTerminating();
|
|
842
|
+
process.exit(ok ? 0 : 1);
|
|
843
|
+
}
|
|
844
|
+
case 'state-dir':
|
|
845
|
+
process.stdout.write(`${dir}\n(user state root: ${userStateRoot()})\n`);
|
|
846
|
+
return;
|
|
847
|
+
default:
|
|
848
|
+
usage(`Unknown command "${command}"`);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
main().catch((err) => {
|
|
852
|
+
process.stderr.write(`agentlab: ${err instanceof Error ? err.stack : String(err)}\n`);
|
|
853
|
+
process.exit(1);
|
|
854
|
+
});
|
|
855
|
+
//# sourceMappingURL=main.js.map
|