agent-device-lab 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +142 -2
  4. package/assets/dashboard/app.css +238 -0
  5. package/assets/dashboard/app.js +976 -0
  6. package/assets/dashboard/index.html +120 -0
  7. package/bin/agentlab.js +8 -0
  8. package/dist/cli/ci.js +443 -0
  9. package/dist/cli/flow.js +127 -0
  10. package/dist/cli/main.js +855 -0
  11. package/dist/cli/signals.js +38 -0
  12. package/dist/core/action-log.js +125 -0
  13. package/dist/core/auth.js +175 -0
  14. package/dist/core/bundle.js +432 -0
  15. package/dist/core/checks.js +137 -0
  16. package/dist/core/ci.js +389 -0
  17. package/dist/core/commands.js +366 -0
  18. package/dist/core/control.js +217 -0
  19. package/dist/core/detectors.js +392 -0
  20. package/dist/core/devices.js +83 -0
  21. package/dist/core/doctor.js +203 -0
  22. package/dist/core/explore-safety.js +110 -0
  23. package/dist/core/extract.js +1445 -0
  24. package/dist/core/feed.js +493 -0
  25. package/dist/core/findings.js +179 -0
  26. package/dist/core/format.js +311 -0
  27. package/dist/core/init.js +607 -0
  28. package/dist/core/lab.js +1979 -0
  29. package/dist/core/migrate.js +35 -0
  30. package/dist/core/observation.js +161 -0
  31. package/dist/core/ownership.js +142 -0
  32. package/dist/core/process-group.js +206 -0
  33. package/dist/core/process-identity.js +56 -0
  34. package/dist/core/profile.js +816 -0
  35. package/dist/core/project-runner.js +359 -0
  36. package/dist/core/replay.js +303 -0
  37. package/dist/core/reproduction.js +36 -0
  38. package/dist/core/scan-policy.js +156 -0
  39. package/dist/core/scan-report.js +145 -0
  40. package/dist/core/scan.js +455 -0
  41. package/dist/core/schema.js +39 -0
  42. package/dist/core/services.js +134 -0
  43. package/dist/core/steps.js +80 -0
  44. package/dist/core/sweep.js +138 -0
  45. package/dist/core/trace-sanitize.js +641 -0
  46. package/dist/core/url-redact.js +25 -0
  47. package/dist/core/versions.js +29 -0
  48. package/dist/core/zip.js +264 -0
  49. package/dist/daemon/main.js +160 -0
  50. package/dist/daemon/state.js +93 -0
  51. package/dist/dashboard/open.js +25 -0
  52. package/dist/dashboard/server.js +366 -0
  53. package/dist/mcp/server.js +89 -0
  54. package/package.json +58 -4
@@ -0,0 +1,203 @@
1
+ import { accessSync, constants, existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { tmpdir } from 'node:os';
4
+ import { delimiter, dirname, join, relative } from 'node:path';
5
+ import { authStatus, gitIgnores } from './auth.js';
6
+ import { PROFILE_FILE, loadProfile } from './profile.js';
7
+ import { probeReadiness, probeTcp } from './project-runner.js';
8
+ import { missingEnv } from './services.js';
9
+ import { LabError } from './schema.js';
10
+ export const MIN_NODE_MAJOR = 22;
11
+ export async function runDoctor(opts) {
12
+ const checks = [];
13
+ const add = (...c) => checks.push(...c);
14
+ const major = Number(process.versions.node.split('.')[0]);
15
+ add(major >= MIN_NODE_MAJOR
16
+ ? { name: 'node', status: 'ok', detail: `Node.js ${process.versions.node} (needs ${MIN_NODE_MAJOR} or newer)` }
17
+ : { name: 'node', status: 'fail', detail: `Node.js ${process.versions.node} is too old`, hint: `Install Node.js ${MIN_NODE_MAJOR} or newer.` });
18
+ add(...await browserChecks(opts.launch !== false));
19
+ const display = process.env.DISPLAY || process.env.WAYLAND_DISPLAY;
20
+ add(display
21
+ ? { name: 'display', status: 'ok', detail: `headed browser available (${process.env.WAYLAND_DISPLAY ? `WAYLAND_DISPLAY=${process.env.WAYLAND_DISPLAY}` : `DISPLAY=${process.env.DISPLAY}`})` }
22
+ : { name: 'display', status: 'warn', detail: 'no DISPLAY or WAYLAND_DISPLAY: headless only',
23
+ hint: onPath('xvfb-run') ? 'Use --headless, or run headed under xvfb-run.' : 'Use --headless (xvfb-run is not installed).' });
24
+ add(writable('state directory', opts.stateDir));
25
+ add(writable('temp directory', tmpdir()));
26
+ if (opts.daemon) {
27
+ add(opts.daemon.check === 'same'
28
+ ? { name: 'session', status: 'warn', detail: `a session daemon (pid ${opts.daemon.pid}) is already running for this directory`, hint: 'Use it, or `agentlab stop` first.' }
29
+ : { name: 'session', status: 'warn', detail: `stale session record (pid ${opts.daemon.pid}: ${opts.daemon.check}); it is cleared on the next start or stop` });
30
+ }
31
+ const target = opts.project ?? (existsSync(join(opts.cwd, PROFILE_FILE)) ? opts.cwd : undefined);
32
+ if (!target) {
33
+ add({ name: 'project', status: 'warn', detail: `no ${PROFILE_FILE} in ${opts.cwd}`, hint: 'Run `agentlab init` in the project, or pass --project <dir>.' });
34
+ return { ok: !checks.some((c) => c.status === 'fail'), checks };
35
+ }
36
+ let profile;
37
+ try {
38
+ profile = await loadProfile(target);
39
+ }
40
+ catch (err) {
41
+ const e = LabError.from(err);
42
+ add({ name: e.code === 'profile_too_new' ? 'profile version' : 'project', status: 'fail', detail: e.message, ...(e.hint ? { hint: e.hint } : {}) });
43
+ return { ok: false, checks };
44
+ }
45
+ add({ name: 'project', status: 'ok', detail: `${profile.name}: ${profile.services.length} service(s), app ${profile.app.url}${profile.startPath} (${relative(opts.cwd, profile.profilePath) || profile.profilePath})` });
46
+ if (profile.migration)
47
+ add({ name: 'profile version', status: 'warn', detail: 'agentlab.json is schemaVersion 1', hint: 'Run `agentlab migrate` to rewrite it as schemaVersion 2.' });
48
+ add(...await serviceChecks(profile));
49
+ add(...projectFileChecks(profile));
50
+ return { ok: !checks.some((c) => c.status === 'fail'), checks, profile: profile.profilePath };
51
+ }
52
+ /** True on Linux with musl libc (Alpine): Node's diagnostic report has no glibc runtime version there. */
53
+ export function isMusl(header = process.report?.getReport()?.header) {
54
+ return process.platform === 'linux' && !header?.glibcVersionRuntime;
55
+ }
56
+ async function browserChecks(launch) {
57
+ const require = createRequire(import.meta.url);
58
+ let pwVersion = '?';
59
+ try {
60
+ pwVersion = JSON.parse(readFileSync(require.resolve('playwright/package.json'), 'utf8')).version;
61
+ }
62
+ catch { /* reported below */ }
63
+ const { chromium } = await import('playwright');
64
+ const exe = chromium.executablePath();
65
+ // Playwright's Chromium is built for glibc. On musl it downloads fine and then fails to spawn with a bare ENOENT.
66
+ if (isMusl()) {
67
+ return [{ name: 'browser', status: 'fail', detail: `this Linux uses musl libc (Alpine): Playwright's Chromium ${pwVersion} is built for glibc and cannot start here`,
68
+ hint: 'Use a Debian or Ubuntu based image or host (for Node.js: node:22-bookworm-slim). See docs/install.md.' }];
69
+ }
70
+ if (!existsSync(exe)) {
71
+ return [{ name: 'browser', status: 'fail', detail: `Chromium for Playwright ${pwVersion} is not installed (${exe})`, hint: 'Run `agentlab install-browser`.' }];
72
+ }
73
+ if (!launch)
74
+ return [{ name: 'browser', status: 'ok', detail: `Chromium for Playwright ${pwVersion} at ${exe} (launch not tested)` }];
75
+ try {
76
+ const browser = await chromium.launch({ headless: true, timeout: 30_000, handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false });
77
+ const version = browser.version();
78
+ await browser.close();
79
+ return [{ name: 'browser', status: 'ok', detail: `Chromium ${version} (Playwright ${pwVersion}) launches headless` }];
80
+ }
81
+ catch (err) {
82
+ const msg = err.message.split('\n').find((l) => l.trim()) ?? String(err);
83
+ return [{ name: 'browser', status: 'fail', detail: `Chromium is installed but did not launch: ${msg.slice(0, 200)}`,
84
+ hint: /shared librar|libnss|libatk|dependencies/i.test(err.message)
85
+ ? 'System libraries are missing: run `sudo npx playwright@' + pwVersion + ' install-deps chromium`.'
86
+ : 'Run `agentlab install-browser` again, or check the error above.' }];
87
+ }
88
+ }
89
+ async function serviceChecks(profile) {
90
+ const checks = [];
91
+ for (const m of missingEnv(profile.services)) {
92
+ checks.push({ name: `env ${m.service}`, status: 'fail', detail: `not set: ${m.names.join(', ')}`, hint: 'Export them where agentlab runs (only names are checked, never values).' });
93
+ }
94
+ for (const s of profile.services) {
95
+ const name = `service ${s.name}`;
96
+ if (!existsSync(s.cwd) || !statSync(s.cwd).isDirectory()) {
97
+ checks.push({ name, status: 'fail', detail: `working directory ${s.cwd} does not exist` });
98
+ continue;
99
+ }
100
+ const exe = firstWord(s.command);
101
+ if (exe && !exe.includes('/') && !onPath(exe) && !SHELL_WORDS.has(exe)) {
102
+ checks.push({ name, status: 'fail', detail: `"${exe}" (from "${s.command}") is not on PATH`, hint: 'Install it, or fix the service command.' });
103
+ }
104
+ const secretLiterals = Object.keys(s.env).filter((k) => /(SECRET|TOKEN|PASSWORD|PASSWD|API_?KEY|PRIVATE)/i.test(k));
105
+ if (secretLiterals.length) {
106
+ checks.push({ name, status: 'warn', detail: `env gives literal values for ${secretLiterals.join(', ')} in agentlab.json`, hint: 'Move secrets to requiredEnv (names only) and export them instead.' });
107
+ }
108
+ if (!s.reuseExisting && (s.readiness.kind === 'log' || s.readiness.kind === 'alive')) {
109
+ checks.push({ name, status: 'warn', detail: 'reuseExisting has no effect: a log/alive readiness cannot detect a running instance' });
110
+ }
111
+ const probe = probeReadiness(s.readiness, 1500);
112
+ if (probe) {
113
+ const r = await probe;
114
+ const at = s.readiness.kind === 'http' ? `${s.readiness.url}${s.readiness.path}` : s.readiness.kind === 'tcp' ? `${s.readiness.host}:${s.readiness.port}` : '';
115
+ if (r.kind === 'healthy') {
116
+ checks.push(s.reuseExisting
117
+ ? { name, status: 'ok', detail: `already running at ${at}: will be reused and left running` }
118
+ : { name, status: 'fail', detail: `port conflict: something is already ready at ${at} and reuseExisting is false`, hint: 'Stop it, or set "reuseExisting": true.' });
119
+ }
120
+ else if (r.kind === 'unhealthy') {
121
+ checks.push({ name, status: 'fail', detail: `port conflict: ${at} answers HTTP ${r.status}, not ${s.readiness.kind === 'http' ? s.readiness.status : 200}`, hint: 'Another process holds the port; the lab will not start a second one or stop it.' });
122
+ }
123
+ else {
124
+ checks.push({ name, status: 'ok', detail: `${at} is free; "${s.command}" will be started` });
125
+ }
126
+ }
127
+ else if (s.url) {
128
+ const u = new URL(s.url);
129
+ const r = await probeTcp(u.hostname, Number(u.port || (u.protocol === 'https:' ? 443 : 80)), 1000);
130
+ checks.push(r.kind === 'healthy'
131
+ ? { name, status: 'warn', detail: `${u.host} is already in use; with ${s.readiness.kind} readiness the lab cannot tell whether it is this service` }
132
+ : { name, status: 'ok', detail: `${u.host} is free; "${s.command}" will be started` });
133
+ }
134
+ else {
135
+ checks.push({ name, status: 'ok', detail: `"${s.command}" will be started (readiness: ${s.readiness.kind})` });
136
+ }
137
+ }
138
+ return checks;
139
+ }
140
+ function projectFileChecks(profile) {
141
+ const checks = [];
142
+ const auth = authStatus(profile.auth, profile.root);
143
+ const rel = relative(profile.root, auth.file) || auth.file;
144
+ if (!auth.exists)
145
+ checks.push({ name: 'auth', status: 'ok', detail: `no saved sign-in state (${rel})` });
146
+ else if (auth.ownerOnly === false)
147
+ checks.push({ name: 'auth', status: 'fail', detail: `${rel} is mode ${auth.mode}, readable by others`, hint: `chmod 600 ${auth.file}` });
148
+ else if (auth.problem)
149
+ checks.push({ name: 'auth', status: 'warn', detail: `${rel}: ${auth.problem}; it will be removed on next use` });
150
+ else if (auth.expired)
151
+ checks.push({ name: 'auth', status: 'warn', detail: `${rel} has expired; it will be removed on next use` });
152
+ else
153
+ checks.push({ name: 'auth', status: 'ok', detail: `saved sign-in state ${rel} (${auth.cookies} cookies, owner-only)` });
154
+ if (auth.ignored === false)
155
+ checks.push({ name: 'auth', status: 'fail', detail: `git would track ${rel}`, hint: 'Run `agentlab init` or add .agentlab/ to .gitignore.' });
156
+ const state = gitIgnores(profile.root, join(profile.root, '.agentlab', 'runs'));
157
+ if (state === false)
158
+ checks.push({ name: 'gitignore', status: 'warn', detail: '.agentlab/ (runs, logs, saved sign-in) is not ignored by git', hint: 'Add ".agentlab/" to .gitignore (agentlab init does this).' });
159
+ for (const a of profile.uploads.allow) {
160
+ if (!existsSync(a))
161
+ checks.push({ name: 'uploads', status: 'warn', detail: `uploads.allow entry ${relative(profile.root, a)} does not exist` });
162
+ }
163
+ return checks;
164
+ }
165
+ /** A directory can be written, or created because its nearest existing ancestor can be. Never creates it. */
166
+ function writable(name, dir) {
167
+ let probe = dir;
168
+ while (!existsSync(probe) && dirname(probe) !== probe)
169
+ probe = dirname(probe);
170
+ try {
171
+ accessSync(probe, constants.W_OK);
172
+ return { name, status: 'ok', detail: existsSync(dir) ? `${dir} is writable` : `${dir} will be created (under ${probe})` };
173
+ }
174
+ catch {
175
+ return { name, status: 'fail', detail: `${probe} is not writable, so ${dir} cannot be used`, hint: 'Fix the permissions, or set AGENTLAB_HOME to a writable directory.' };
176
+ }
177
+ }
178
+ const SHELL_WORDS = new Set(['cd', 'exec', 'env', 'export', 'true', 'false', 'test', '[', 'set', 'sh', 'bash']);
179
+ function firstWord(command) {
180
+ // Skip leading VAR=value assignments.
181
+ return command.trim().split(/\s+/).find((w) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w));
182
+ }
183
+ export function onPath(exe) {
184
+ for (const dir of (process.env.PATH ?? '').split(delimiter)) {
185
+ if (!dir)
186
+ continue;
187
+ try {
188
+ accessSync(join(dir, exe), constants.X_OK);
189
+ return true;
190
+ }
191
+ catch { /* keep looking */ }
192
+ }
193
+ return false;
194
+ }
195
+ export function formatDoctor(r) {
196
+ const mark = { ok: 'ok ', warn: 'warn', fail: 'FAIL' };
197
+ const lines = r.checks.map((c) => `${mark[c.status]} ${c.name}: ${c.detail}${c.hint ? `\n → ${c.hint}` : ''}`);
198
+ const fails = r.checks.filter((c) => c.status === 'fail').length;
199
+ const warns = r.checks.filter((c) => c.status === 'warn').length;
200
+ lines.push('', fails ? `${fails} problem(s) to fix${warns ? `, ${warns} warning(s)` : ''}` : `ready${warns ? ` (${warns} warning(s))` : ''}`);
201
+ return lines.join('\n');
202
+ }
203
+ //# sourceMappingURL=doctor.js.map
@@ -0,0 +1,110 @@
1
+ import { matchesControl } from './detectors.js';
2
+ // Which controls automatic exploration may activate. Pure, so every rule is unit-tested. The order
3
+ // matters: a project deny entry and the hard blocks win over everything, a project allow entry wins
4
+ // over the name and form heuristics, and only controls whose attributes say what they open are
5
+ // explored on their own. Anything else that looks like it might open UI is skipped with the reason.
6
+ /** Names that suggest the action changes data, spends money, signs out or leaves: never activated automatically. */
7
+ const CONSEQUENTIAL = /\b(delete|remove|destroy|erase|purge|discard|clear all|archive|trash|unsubscribe|deactivate|close account|cancel|log ?out|sign ?out|pay|purchase|buy|checkout|check out|place order|order now|confirm|submit|send|publish|post|save|apply|book|reserve|transfer|withdraw|refund|charge|donate|subscribe|upgrade|downgrade|reset|revoke|block|report|approve|reject|accept|decline|invite|upload|attach|install|download|print|call|dial|share)\b/i;
8
+ /** The word that makes a control's name suggest a consequential action, if any (also used to gate replay). */
9
+ export function consequentialWord(name) {
10
+ return CONSEQUENTIAL.exec(name)?.[0];
11
+ }
12
+ /** Names that hint a control opens something, without attributes that say so: skipped as ambiguous. */
13
+ const MAYBE_OPENS = /\b(menu|filters?|sort|more|options|settings|details|show|expand|open|view|edit|actions?|toggle|drawer|panel|categories|account|profile|help|info|language|notifications?)\b|^[^\p{L}\p{N}]*$/iu;
14
+ const POPUP_KIND = { menu: 'menu', true: 'menu', listbox: 'listbox', tree: 'tree', grid: 'grid', dialog: 'dialog' };
15
+ /** Lower sorts first: panels that open over the page before toggles that only change a filter. */
16
+ export const KIND_PRIORITY = { 'allow-listed': 0, dialog: 1, menu: 2, listbox: 3, tree: 3, grid: 3, disclosure: 4, accordion: 5, tab: 6, toggle: 7 };
17
+ /**
18
+ * The exploration verdict for one observed control, or undefined when it is not a candidate and not
19
+ * worth recording (an ordinary link, a text field, an already open disclosure).
20
+ */
21
+ export function classifyControl(c, t, cfg, route) {
22
+ const base = { role: c.role, name: c.name, ...(c.context ? { context: c.context } : {}) };
23
+ const skip = (reason) => ({ ...base, verdict: 'skip', reason });
24
+ const explore = (kind, reason) => ({ ...base, verdict: 'explore', kind, reason });
25
+ if (c.disabled)
26
+ return undefined;
27
+ const textual = ['textbox', 'searchbox', 'combobox', 'spinbutton', 'slider'].includes(c.role) && !t?.haspopup;
28
+ if (textual)
29
+ return undefined; // typing is not exploration
30
+ // 1. The project says never.
31
+ if (cfg.deny.some((m) => matchesControl(m, c, route)))
32
+ return skip('on the project\'s explore.deny list');
33
+ if (!t)
34
+ return skip('its attributes could not be read, so its safety cannot be determined');
35
+ // 2. Hard blocks: leaving the app, files, downloads, new windows.
36
+ if (t.file)
37
+ return skip('opens a file chooser (upload)');
38
+ if (t.link === 'external')
39
+ return skip('external link: exploration stays on the app\'s origin');
40
+ if (t.download)
41
+ return skip('downloads a file');
42
+ if (t.newTab)
43
+ return skip('opens a new tab or window');
44
+ // 3. The project says this control only opens UI.
45
+ if (cfg.allow.some((m) => matchesControl(m, c, route)))
46
+ return explore('allow-listed', 'on the project\'s explore.allow list');
47
+ // 4. Consequential by name, or by what it does to a form.
48
+ const named = CONSEQUENTIAL.exec(c.name);
49
+ if (named)
50
+ return skip(`its name suggests a consequential action ("${named[0]}")`);
51
+ if (t.submits)
52
+ return skip(`would submit the form${t.formName ? ` "${t.formName}"` : ''}`);
53
+ if (t.resets)
54
+ return skip('resets a form');
55
+ if (t.link === 'same-origin') {
56
+ return t.haspopup || t.expanded === 'false' ? skip('link to another page: exploration stays on this route') : undefined;
57
+ }
58
+ // 5. Attributes that say what it opens.
59
+ if (t.haspopup && t.haspopup !== 'false')
60
+ return explore(POPUP_KIND[t.haspopup] ?? 'menu', `aria-haspopup="${t.haspopup}"`);
61
+ if (t.expanded === 'false')
62
+ return explore(t.tag === 'summary' ? 'accordion' : 'disclosure', `aria-expanded="false"${t.controlsHidden ? ' and aria-controls names a hidden panel' : ''}`);
63
+ if (t.expanded === 'true')
64
+ return undefined; // already open; closing it is how the scan restores the state
65
+ if (t.closedDetails)
66
+ return explore('accordion', 'summary of a closed <details>');
67
+ if ((t.role === 'tab' || c.role === 'tab') && t.selected !== 'true')
68
+ return explore('tab', 'an unselected tab (role="tab")');
69
+ if (t.controlsHidden)
70
+ return explore('disclosure', 'aria-controls names a hidden panel');
71
+ if (t.pressed === 'false' && c.role === 'button')
72
+ return explore('toggle', 'a toggle button (aria-pressed="false") that filters or changes the view');
73
+ // 6. Looks like it could open something, but nothing says what it does.
74
+ if (c.role === 'menuitem' || c.role === 'menuitemcheckbox' || c.role === 'menuitemradio')
75
+ return skip('a menu item performs an action; its purpose cannot be determined');
76
+ if ((c.role === 'button' || c.role === 'link') && MAYBE_OPENS.test(c.name)) {
77
+ return skip('purpose ambiguous: no aria-haspopup, aria-expanded, aria-controls or tab role says what it opens (add it to scan.explore.allow if it only opens UI)');
78
+ }
79
+ return undefined;
80
+ }
81
+ /** Candidates first by what they open, then in page order. */
82
+ export function orderCandidates(decisions) {
83
+ return decisions.map((d, i) => ({ d, i })).sort((a, b) => (KIND_PRIORITY[a.d.kind ?? ''] ?? 9) - (KIND_PRIORITY[b.d.kind ?? ''] ?? 9) || a.i - b.i).map((x) => x.d);
84
+ }
85
+ /**
86
+ * A state's identity for restoring: route, open dialog, headings, and each control's role, name and
87
+ * open, pressed, checked and selected state. Values typed into fields are not part of it.
88
+ */
89
+ export function stateSignature(o) {
90
+ const controls = o.controls.map((c) => [c.role, c.name, c.context ?? '', c.expanded ?? '', c.pressed ?? '', c.checked ?? '', c.selected ?? ''].join('\u0001'));
91
+ return [o.route, o.dialog ?? '', o.headings.join('\u0002'), controls.join('\u0002')].join('\u0003');
92
+ }
93
+ /** The guard for exploring contexts: requests that could change data, or navigations off the origin. */
94
+ export function blockedRequest(method, url, navigation, origin) {
95
+ let u;
96
+ try {
97
+ u = new URL(url);
98
+ }
99
+ catch {
100
+ return `${method} ${url.slice(0, 60)}`;
101
+ }
102
+ if (u.protocol === 'data:' || u.protocol === 'blob:')
103
+ return undefined;
104
+ if (!['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase()))
105
+ return `${method.toUpperCase()} ${u.origin === origin ? u.pathname : u.origin + u.pathname}`;
106
+ if (navigation && u.origin !== origin)
107
+ return `navigation to ${u.origin}`;
108
+ return undefined;
109
+ }
110
+ //# sourceMappingURL=explore-safety.js.map