agent-device-lab 0.0.0-stage → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +10 -0
- package/README.md +150 -2
- package/assets/dashboard/app.css +238 -0
- package/assets/dashboard/app.js +976 -0
- package/assets/dashboard/index.html +120 -0
- package/bin/agentlab.js +8 -0
- package/dist/cli/ci.js +443 -0
- package/dist/cli/flow.js +127 -0
- package/dist/cli/main.js +855 -0
- package/dist/cli/signals.js +38 -0
- package/dist/core/action-log.js +125 -0
- package/dist/core/auth.js +175 -0
- package/dist/core/bundle.js +432 -0
- package/dist/core/checks.js +137 -0
- package/dist/core/ci.js +389 -0
- package/dist/core/commands.js +366 -0
- package/dist/core/control.js +217 -0
- package/dist/core/detectors.js +392 -0
- package/dist/core/devices.js +83 -0
- package/dist/core/doctor.js +203 -0
- package/dist/core/explore-safety.js +110 -0
- package/dist/core/extract.js +1445 -0
- package/dist/core/feed.js +493 -0
- package/dist/core/findings.js +179 -0
- package/dist/core/format.js +311 -0
- package/dist/core/init.js +607 -0
- package/dist/core/lab.js +1979 -0
- package/dist/core/migrate.js +35 -0
- package/dist/core/observation.js +161 -0
- package/dist/core/ownership.js +142 -0
- package/dist/core/process-group.js +206 -0
- package/dist/core/process-identity.js +56 -0
- package/dist/core/profile.js +816 -0
- package/dist/core/project-runner.js +359 -0
- package/dist/core/replay.js +303 -0
- package/dist/core/reproduction.js +36 -0
- package/dist/core/scan-policy.js +156 -0
- package/dist/core/scan-report.js +145 -0
- package/dist/core/scan.js +455 -0
- package/dist/core/schema.js +39 -0
- package/dist/core/services.js +134 -0
- package/dist/core/steps.js +80 -0
- package/dist/core/sweep.js +138 -0
- package/dist/core/trace-sanitize.js +641 -0
- package/dist/core/url-redact.js +25 -0
- package/dist/core/versions.js +29 -0
- package/dist/core/zip.js +264 -0
- package/dist/daemon/main.js +160 -0
- package/dist/daemon/state.js +93 -0
- package/dist/dashboard/open.js +25 -0
- package/dist/dashboard/server.js +366 -0
- package/dist/mcp/server.js +89 -0
- package/package.json +63 -4
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { formatAction, formatClose, formatInspect, formatObservation, formatScan, formatStart, formatSweep, formatTabs } from './format.js';
|
|
2
|
+
import { scanSummary } from './scan.js';
|
|
3
|
+
import { SWEEP_DEVICES } from './devices.js';
|
|
4
|
+
import { Lab } from './lab.js';
|
|
5
|
+
import { LabError } from './schema.js';
|
|
6
|
+
import { stopReason } from './control.js';
|
|
7
|
+
/** Bounds applied when a property sets none, so no argument is unbounded (MCP input is untrusted). */
|
|
8
|
+
const DEFAULT_MAX_LENGTH = 4096;
|
|
9
|
+
const DEFAULT_MAX_ITEMS = 100;
|
|
10
|
+
/**
|
|
11
|
+
* Owns the current Lab. A Lab is single-use; after a session ends, the next `start` gets a fresh one.
|
|
12
|
+
* Adapters may also force options (e.g. headless for a benchmark) that callers cannot override.
|
|
13
|
+
*/
|
|
14
|
+
export class SessionHost {
|
|
15
|
+
options;
|
|
16
|
+
forced;
|
|
17
|
+
current;
|
|
18
|
+
/**
|
|
19
|
+
* Set by an adapter that serves the live dashboard. Start and status results carry this URL, which is
|
|
20
|
+
* view-only: control (pause, takeover, stop) needs the person's URL from `agentlab ui`.
|
|
21
|
+
*/
|
|
22
|
+
dashboardUrl;
|
|
23
|
+
/** A person stopped the session from the dashboard: no new session starts in this process. */
|
|
24
|
+
halted;
|
|
25
|
+
constructor(options, forced = {}) {
|
|
26
|
+
this.options = options;
|
|
27
|
+
this.forced = forced;
|
|
28
|
+
this.current = new Lab(options);
|
|
29
|
+
}
|
|
30
|
+
get lab() {
|
|
31
|
+
return this.current;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A person's supervision request (from the dashboard). After a stop or emergency stop this process
|
|
35
|
+
* starts no further session, so an agent cannot simply start again behind the person's back.
|
|
36
|
+
*/
|
|
37
|
+
supervise(op, by = 'dashboard') {
|
|
38
|
+
const state = this.current.supervise(op, by);
|
|
39
|
+
if (op === 'stop' || op === 'emergency-stop')
|
|
40
|
+
this.halted = stopReason(op, by);
|
|
41
|
+
return state;
|
|
42
|
+
}
|
|
43
|
+
/** A Lab ready for `start`: the current one if unused, otherwise a new one once the old has ended. */
|
|
44
|
+
forStart() {
|
|
45
|
+
if (this.halted) {
|
|
46
|
+
throw new LabError('session_stopped', `Not starting: the previous session was ${this.halted}`, {
|
|
47
|
+
hint: 'Ask the user before continuing; they can restart the MCP server or the CLI session themselves.',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const s = this.current.status();
|
|
51
|
+
if (s.session && !s.active)
|
|
52
|
+
this.current = new Lab(this.options);
|
|
53
|
+
return this.current;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const schema = (properties, required = []) => ({ type: 'object', properties, ...(required.length ? { required } : {}), additionalProperties: false });
|
|
57
|
+
const target = {
|
|
58
|
+
ref: { type: 'string', description: 'Session ref from the latest observation, e.g. e12' },
|
|
59
|
+
role: { type: 'string', description: 'Role, used with name when no ref is given' },
|
|
60
|
+
name: { type: 'string', description: 'Exact accessible name, used when no ref is given' },
|
|
61
|
+
};
|
|
62
|
+
const optionalTarget = {
|
|
63
|
+
ref: { type: 'string', description: 'Session ref from the latest observation (optional)' },
|
|
64
|
+
role: { type: 'string', description: 'Role, used with name when no ref is given' },
|
|
65
|
+
name: { type: 'string', description: 'Exact accessible name, used when no ref is given' },
|
|
66
|
+
};
|
|
67
|
+
const DIRECTIONS = ['up', 'down', 'left', 'right'];
|
|
68
|
+
/**
|
|
69
|
+
* Run one action through the Lab. A role+name target is resolved against the latest observation
|
|
70
|
+
* (never guessed). `targeted` actions need a target; others (press, scroll, swipe…) may omit it.
|
|
71
|
+
*/
|
|
72
|
+
async function act(host, action, args, targeted = true) {
|
|
73
|
+
const lab = host.lab;
|
|
74
|
+
// Refused before the target is resolved, so a paused or taken-over session says so instead of not_found.
|
|
75
|
+
lab.admit(action, 'act');
|
|
76
|
+
let ref = args.ref;
|
|
77
|
+
if (!ref && args.name !== undefined)
|
|
78
|
+
ref = lab.findRef({ role: args.role, name: args.name });
|
|
79
|
+
if (!ref && targeted)
|
|
80
|
+
throw new LabError('invalid_request', `${action} needs a ref, or a name (optionally with role)`);
|
|
81
|
+
const request = {
|
|
82
|
+
action, ...(ref ? { ref } : {}),
|
|
83
|
+
...pick(args, ['value', 'key', 'values', 'amount', 'toRef', 'dx', 'dy', 'files', 'tab', 'path']),
|
|
84
|
+
...(args.direction ? { direction: args.direction } : {}),
|
|
85
|
+
};
|
|
86
|
+
const result = await lab.act(request);
|
|
87
|
+
return { result: result, text: formatAction(result) };
|
|
88
|
+
}
|
|
89
|
+
function pick(args, keys) {
|
|
90
|
+
return Object.fromEntries(keys.filter((k) => args[k] !== undefined).map((k) => [k, args[k]]));
|
|
91
|
+
}
|
|
92
|
+
const action = (name, description, properties, required = [], targeted = true) => ({
|
|
93
|
+
name, description, surfaces: ['cli', 'mcp'], kind: 'act', inputSchema: schema(properties, required), run: (host, args) => act(host, name, args, targeted),
|
|
94
|
+
});
|
|
95
|
+
export const COMMANDS = [
|
|
96
|
+
{
|
|
97
|
+
name: 'start',
|
|
98
|
+
kind: 'act',
|
|
99
|
+
description: 'Start or reuse the project web server from its agentlab.json profile, open a Chromium session with the device profile, and return the first observation.',
|
|
100
|
+
surfaces: ['cli', 'mcp'],
|
|
101
|
+
inputSchema: schema({
|
|
102
|
+
project: { type: 'string', description: 'Project directory or path to agentlab.json' },
|
|
103
|
+
device: { type: 'string', description: 'Device profile id (default: from the profile)', maxLength: 100 },
|
|
104
|
+
headed: { type: 'boolean', description: 'Open a visible browser window (default false: watch in the dashboard instead)' },
|
|
105
|
+
slowMoMs: { type: 'number', description: 'Delay each browser operation, for watching', minimum: 0, maximum: 10_000 },
|
|
106
|
+
auth: { type: 'string', enum: ['auto', 'saved', 'fresh'], description: 'Saved sign-in state: auto (default: use it if saved), saved (require it), fresh (signed-out session)' },
|
|
107
|
+
trace: { type: 'boolean', description: 'Record a Playwright trace so a failure bundle can include a sanitized copy (default false)' },
|
|
108
|
+
}, ['project']),
|
|
109
|
+
async run(host, args) {
|
|
110
|
+
const result = await host.forStart().start({
|
|
111
|
+
project: args.project,
|
|
112
|
+
device: args.device,
|
|
113
|
+
// `mcp --headed` (forced.headless === false) shows the window unless the caller says otherwise.
|
|
114
|
+
headed: host.forced.headless ? false : (args.headed ?? (host.forced.headless === false ? true : undefined)),
|
|
115
|
+
slowMoMs: args.slowMoMs,
|
|
116
|
+
auth: args.auth,
|
|
117
|
+
trace: args.trace,
|
|
118
|
+
});
|
|
119
|
+
const url = host.dashboardUrl?.();
|
|
120
|
+
return {
|
|
121
|
+
result: { ...result, ...(url ? { dashboard: { url } } : {}) },
|
|
122
|
+
text: formatStart(result) + (url ? `\ndashboard: ${url} (live viewport, timeline and findings for a person watching)` : ''),
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: 'observe',
|
|
128
|
+
kind: 'observe',
|
|
129
|
+
description: 'Return the compact state: route, headings, open dialog, visible interactive controls with session refs, page messages, layout flags, finding count, and console/network counts.',
|
|
130
|
+
surfaces: ['cli', 'mcp'],
|
|
131
|
+
inputSchema: schema({ limit: { type: 'number', description: 'Maximum controls to return (default 40)', minimum: 1, maximum: 500 } }),
|
|
132
|
+
async run(host, args) {
|
|
133
|
+
const result = await host.lab.observe({ limit: args.limit });
|
|
134
|
+
return { result: result, text: formatObservation(result) };
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: 'click',
|
|
139
|
+
kind: 'act',
|
|
140
|
+
description: 'Tap (touch profiles) or click a control from the latest observation, by ref or unambiguous role+name. Verifies the event reached the control, waits for the UI to settle, and returns what changed plus any new usability findings.',
|
|
141
|
+
surfaces: ['cli', 'mcp'],
|
|
142
|
+
inputSchema: schema({ ...target }),
|
|
143
|
+
run: (host, args) => act(host, 'click', args),
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: 'fill',
|
|
147
|
+
kind: 'act',
|
|
148
|
+
description: 'Replace the text of a text field from the latest observation, by ref or unambiguous role+name, then return what changed.',
|
|
149
|
+
surfaces: ['cli', 'mcp'],
|
|
150
|
+
inputSchema: schema({ ...target, value: { type: 'string', description: 'Text to enter', maxLength: 20_000 } }, ['value']),
|
|
151
|
+
run: (host, args) => act(host, 'fill', args),
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: 'inspect',
|
|
155
|
+
kind: 'read',
|
|
156
|
+
description: 'Usability findings recorded in this session (layout overflow, clipped controls, controls only reachable by a sideways pan) with measured evidence and reproduction steps. Pass id for one finding, or ref for a control and its findings.',
|
|
157
|
+
surfaces: ['cli', 'mcp'],
|
|
158
|
+
inputSchema: schema({
|
|
159
|
+
id: { type: 'string', description: 'Finding id, e.g. F2' },
|
|
160
|
+
ref: { type: 'string', description: 'Control ref from the latest observation' },
|
|
161
|
+
}),
|
|
162
|
+
async run(host, args) {
|
|
163
|
+
const result = host.lab.inspect({ id: args.id, ref: args.ref });
|
|
164
|
+
return { result: result, text: formatInspect(result) };
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: 'sweep',
|
|
169
|
+
kind: 'act',
|
|
170
|
+
description: 'Responsive sweep: load a route (default: the current one) at 320, 390, 768 and 1440 CSS px, one isolated browser context per width with the session\'s cookies and storage, and check layout (heuristic) and whether each control can be reached without a sideways pan or an obstruction (confirmed). Returns per-width results, findings and a report path; the session page is untouched.',
|
|
171
|
+
surfaces: ['cli', 'mcp'],
|
|
172
|
+
inputSchema: schema({
|
|
173
|
+
route: { type: 'string', description: 'Path to sweep, e.g. /app/discover (default: current route)' },
|
|
174
|
+
devices: { type: 'string', description: `Comma-separated device ids (default: ${SWEEP_DEVICES.join(',')})` },
|
|
175
|
+
}),
|
|
176
|
+
async run(host, args) {
|
|
177
|
+
const devices = typeof args.devices === 'string' ? args.devices.split(',').map((d) => d.trim()).filter(Boolean) : undefined;
|
|
178
|
+
const result = await host.lab.sweep({ route: args.route, devices });
|
|
179
|
+
return { result: result, text: formatSweep(result) };
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
name: 'scan',
|
|
184
|
+
kind: 'act',
|
|
185
|
+
description: 'Stateful responsive scan: run the project\'s declared scenarios (agentlab.json scan.scenarios: a route, setup steps such as opening a drawer or a dialog, devices and checks) or one route as loaded, each device in its own isolated browser context, optionally exploring menus, disclosures, tabs and dialogs that are safe to open. Checks layout, clipping, fixed-bar collisions, dialog overflow, tap targets (WCAG 2.2), label wrapping between nearby widths and layout shifts. Returns the verdict, problem groups (inspect ids for details) and HTML/JSON report paths; the session page is untouched.',
|
|
186
|
+
surfaces: ['cli', 'mcp'],
|
|
187
|
+
inputSchema: schema({
|
|
188
|
+
scenarios: { type: 'string', description: 'Comma-separated scenario names (default: all declared)' },
|
|
189
|
+
route: { type: 'string', description: 'Scan this route as loaded instead of the declared scenarios' },
|
|
190
|
+
devices: { type: 'string', description: 'Comma-separated device ids, overriding the scenarios\' devices' },
|
|
191
|
+
explore: { type: 'boolean', description: 'Explore safe state-opening controls (default: the profile\'s scan.explore.enabled)' },
|
|
192
|
+
}),
|
|
193
|
+
async run(host, args) {
|
|
194
|
+
const list = (v) => typeof v === 'string' ? v.split(',').map((d) => d.trim()).filter(Boolean) : undefined;
|
|
195
|
+
const result = await host.lab.scan({
|
|
196
|
+
scenarios: list(args.scenarios), devices: list(args.devices), route: args.route, explore: args.explore,
|
|
197
|
+
});
|
|
198
|
+
return { result: scanSummary(result), text: formatScan(result) };
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
name: 'bundle',
|
|
203
|
+
kind: 'read',
|
|
204
|
+
description: 'Write a failure bundle for the session so far: the action log (replayable by `agentlab replay`), recent observations, findings, console and network failures, evidence frames and, when the session records a trace, a sanitized trace. Secrets are removed. Returns the bundle\'s id, folder, files and counts, never its contents.',
|
|
205
|
+
surfaces: ['cli', 'mcp'],
|
|
206
|
+
inputSchema: schema({ note: { type: 'string', description: 'Why the bundle is being written', maxLength: 500 } }),
|
|
207
|
+
async run(host, args) {
|
|
208
|
+
const r = await host.lab.bundle({ reason: args.note ?? 'requested' });
|
|
209
|
+
const c = r.counts;
|
|
210
|
+
const trace = r.trace === 'included' ? ', sanitized trace' : typeof r.trace === 'object' ? `, trace left out (${r.trace.dropped})` : '';
|
|
211
|
+
return {
|
|
212
|
+
result: { id: r.id, dir: r.dir, files: r.files, counts: c, trace: r.trace },
|
|
213
|
+
text: `bundle ${r.id} → ${r.dir}\n${c.actions} actions, ${c.observations} observations, ${c.findings} findings, ${c.consoleErrors} console errors, ${c.failedRequests} failed requests, ${c.frames} frames${trace}\nreplay: agentlab replay ${r.dir}`,
|
|
214
|
+
};
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
name: 'status',
|
|
219
|
+
kind: 'read',
|
|
220
|
+
description: 'Report whether a session is active, its device, server ownership and current route.',
|
|
221
|
+
surfaces: ['cli'],
|
|
222
|
+
inputSchema: schema({}),
|
|
223
|
+
async run(host) {
|
|
224
|
+
const s = host.lab.status();
|
|
225
|
+
const url = host.dashboardUrl?.();
|
|
226
|
+
const text = (s.session
|
|
227
|
+
? `session ${s.session.id} ${s.active ? 'active' : `ended (${s.endedReason})`} device ${s.session.device.id} route ${s.route ?? '?'} gen ${s.gen} server ${s.server?.url} (${s.server?.owned ? 'owned' : 'reused'})`
|
|
228
|
+
: 'no session') + (url ? `\ndashboard: ${url}` : '');
|
|
229
|
+
return { result: { ...s, ...(url ? { dashboard: { url } } : {}) }, text };
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: 'stop',
|
|
234
|
+
kind: 'act',
|
|
235
|
+
description: 'Close the browser and stop the web server only if this session started it. Findings remain in the run directory.',
|
|
236
|
+
surfaces: ['cli', 'mcp'],
|
|
237
|
+
inputSchema: schema({}),
|
|
238
|
+
async run(host, _args, surface) {
|
|
239
|
+
// From the terminal, stop is the owner's safety operation: it goes through even while a person has
|
|
240
|
+
// paused or taken over (the daemon socket is owner-only). An agent's MCP stop still needs control.
|
|
241
|
+
if (surface === 'cli' && host.lab.control.mode !== 'agent')
|
|
242
|
+
return stopAsOwner(host);
|
|
243
|
+
host.lab.admit('stop', 'act');
|
|
244
|
+
const result = await host.lab.close('requested');
|
|
245
|
+
return { result: result, text: formatClose(result) };
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
action('press', 'Press a key or chord (Enter, Escape, Tab, ArrowDown, Shift+Tab, Control+a) on a control (focused first) or on whatever has focus. Keys typed into password fields are masked in logs.', { key: { type: 'string', description: 'Key name or chord', maxLength: 64 }, ...optionalTarget }, ['key'], false),
|
|
249
|
+
action('select', 'Choose options in a native <select> by label or value, then return what changed.', { ...target, values: { type: 'array', items: { type: 'string', maxLength: 1000 }, minItems: 1, maxItems: 100, description: 'Option labels or values' } }, ['values']),
|
|
250
|
+
action('check', 'Check a checkbox, radio button or switch with a verified tap; a no-op if it is already checked. Confirms the state changed.', { ...target }),
|
|
251
|
+
action('uncheck', 'Uncheck a checkbox or switch with a verified tap; a no-op if it is already unchecked.', { ...target }),
|
|
252
|
+
action('scroll', 'Scroll the page, or the scrollable region containing a control, in a direction (down = reveal what is below), or bring a control into view when no direction is given. Reports how far it actually moved.', { direction: { type: 'string', enum: DIRECTIONS, description: 'Where to look next' }, amount: { type: 'number', description: 'CSS px (default 80% of the view)', minimum: 1, maximum: 100_000 }, ...optionalTarget }, [], false),
|
|
253
|
+
action('swipe', 'Touch swipe (the finger moves in this direction) from a control or the centre of the view, as real touch input. Needs a touch device profile.', { direction: { type: 'string', enum: DIRECTIONS, description: 'Finger movement' }, amount: { type: 'number', description: 'CSS px (default 60% of the view)', minimum: 1, maximum: 100_000 }, ...optionalTarget }, ['direction'], false),
|
|
254
|
+
action('back', 'Go back in the active tab\'s history, then return what changed.', {}, [], false),
|
|
255
|
+
action('forward', 'Go forward in the active tab\'s history, then return what changed.', {}, [], false),
|
|
256
|
+
action('hover', 'Move the pointer over a control (after scrolling it into view) and return what appeared.', { ...target }),
|
|
257
|
+
action('upload', 'Give files to a file input, or to a button that opens a file chooser. Files are project-relative and must be inside the profile\'s uploads.allow directories.', { ...target, files: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 20, description: 'Project-relative file paths' } }, ['files']),
|
|
258
|
+
action('drag', 'Drag a control onto another control (toRef) or by an offset (dx, dy CSS px), with a real pointer press, move and release.', { ...target, toRef: { type: 'string', description: 'Ref of the drop target' }, dx: { type: 'number', description: 'Horizontal offset', minimum: -100_000, maximum: 100_000 }, dy: { type: 'number', description: 'Vertical offset', minimum: -100_000, maximum: 100_000 } }),
|
|
259
|
+
{
|
|
260
|
+
name: 'tabs',
|
|
261
|
+
kind: 'read',
|
|
262
|
+
description: 'List open tabs (pop-ups and target=_blank links become tabs); the active one is observed and acted on.',
|
|
263
|
+
surfaces: ['cli', 'mcp'],
|
|
264
|
+
inputSchema: schema({}),
|
|
265
|
+
async run(host) {
|
|
266
|
+
const tabs = await host.lab.tabList();
|
|
267
|
+
return { result: { tabs }, text: formatTabs(tabs) };
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
action('open_tab', 'Open a new tab at a path on the app\'s origin and switch to it.', { path: { type: 'string', description: 'Path, e.g. /help' } }, ['path'], false),
|
|
271
|
+
action('switch_tab', 'Make another tab the active one and observe it.', { tab: { type: 'string', description: 'Tab id, e.g. t2' } }, ['tab'], false),
|
|
272
|
+
action('close_tab', 'Close a tab (default: the active one) and switch to its opener.', { tab: { type: 'string', description: 'Tab id (default: active)' } }, [], false),
|
|
273
|
+
{
|
|
274
|
+
name: 'auth_save',
|
|
275
|
+
kind: 'act',
|
|
276
|
+
description: 'Save this session\'s sign-in state (cookies and localStorage) so later sessions start signed in. Written owner-only to a git-ignored file; the state itself is never returned.',
|
|
277
|
+
surfaces: ['cli', 'mcp'],
|
|
278
|
+
inputSchema: schema({}),
|
|
279
|
+
async run(host) {
|
|
280
|
+
const r = await host.lab.saveAuth();
|
|
281
|
+
return { result: r, text: `saved sign-in state: ${r.cookies} cookies, ${r.origins} origins with storage → ${r.file} (owner-only; start with auth "fresh" to ignore it)` };
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
];
|
|
285
|
+
export function getCommand(name, surface = 'cli') {
|
|
286
|
+
const cmd = COMMANDS.find((c) => c.name === name && c.surfaces.includes(surface));
|
|
287
|
+
if (!cmd) {
|
|
288
|
+
throw new LabError('invalid_request', `Unknown command "${name}"`, {
|
|
289
|
+
hint: `Commands: ${COMMANDS.filter((c) => c.surfaces.includes(surface)).map((c) => c.name).join(', ')}`,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return cmd;
|
|
293
|
+
}
|
|
294
|
+
/** Validate arguments against a command's flat schema. Returns a cleaned copy. */
|
|
295
|
+
export function validateArgs(cmd, args) {
|
|
296
|
+
const input = (typeof args === 'object' && args !== null && !Array.isArray(args) ? args : {});
|
|
297
|
+
const out = {};
|
|
298
|
+
for (const [key, value] of Object.entries(input)) {
|
|
299
|
+
if (value === undefined)
|
|
300
|
+
continue;
|
|
301
|
+
const prop = cmd.inputSchema.properties[key];
|
|
302
|
+
if (!prop)
|
|
303
|
+
throw new LabError('invalid_request', `${cmd.name}: unknown argument "${key}"`);
|
|
304
|
+
const bad = (why) => new LabError('invalid_request', `${cmd.name}: "${key}" ${why}`);
|
|
305
|
+
if (prop.type === 'array') {
|
|
306
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === prop.items.type))
|
|
307
|
+
throw bad(`must be a list of ${prop.items.type}s`);
|
|
308
|
+
if (prop.minItems && value.length < prop.minItems)
|
|
309
|
+
throw bad(`needs at least ${prop.minItems} item(s)`);
|
|
310
|
+
const maxItems = prop.maxItems ?? DEFAULT_MAX_ITEMS;
|
|
311
|
+
if (value.length > maxItems)
|
|
312
|
+
throw bad(`takes at most ${maxItems} items`);
|
|
313
|
+
const maxLength = prop.items.maxLength ?? DEFAULT_MAX_LENGTH;
|
|
314
|
+
if (value.some((v) => v.length > maxLength))
|
|
315
|
+
throw bad(`items must be at most ${maxLength} characters`);
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
if (typeof value !== prop.type)
|
|
319
|
+
throw bad(`must be a ${prop.type}`);
|
|
320
|
+
if (prop.type === 'string') {
|
|
321
|
+
if (prop.enum && !prop.enum.includes(value))
|
|
322
|
+
throw bad(`must be one of ${prop.enum.join(', ')}`);
|
|
323
|
+
const maxLength = prop.maxLength ?? DEFAULT_MAX_LENGTH;
|
|
324
|
+
if (value.length > maxLength)
|
|
325
|
+
throw bad(`must be at most ${maxLength} characters`);
|
|
326
|
+
}
|
|
327
|
+
if (prop.type === 'number') {
|
|
328
|
+
if (!Number.isFinite(value))
|
|
329
|
+
throw bad('must be a finite number');
|
|
330
|
+
if (prop.minimum !== undefined && value < prop.minimum)
|
|
331
|
+
throw bad(`must be at least ${prop.minimum}`);
|
|
332
|
+
if (prop.maximum !== undefined && value > prop.maximum)
|
|
333
|
+
throw bad(`must be at most ${prop.maximum}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
out[key] = value;
|
|
337
|
+
}
|
|
338
|
+
for (const key of cmd.inputSchema.required ?? []) {
|
|
339
|
+
if (out[key] === undefined)
|
|
340
|
+
throw new LabError('invalid_request', `${cmd.name}: "${key}" is required`);
|
|
341
|
+
}
|
|
342
|
+
return out;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* `agentlab stop` while the session is paused, held by a person, or already stopping: recorded as a stop
|
|
346
|
+
* from the terminal. The command in flight (if any) finishes first, as with the dashboard's stop.
|
|
347
|
+
*/
|
|
348
|
+
async function stopAsOwner(host) {
|
|
349
|
+
const lab = host.lab;
|
|
350
|
+
if (lab.status().active && lab.control.mode !== 'stopping' && lab.control.mode !== 'stopped')
|
|
351
|
+
host.supervise('stop', 'terminal');
|
|
352
|
+
await lab.control.whenIdle();
|
|
353
|
+
const result = await lab.close(stopReason('stop', 'terminal'));
|
|
354
|
+
return { result: result, text: formatClose(result) };
|
|
355
|
+
}
|
|
356
|
+
/** Run a command by name for an adapter: validation, dispatch and error normalisation in one place. */
|
|
357
|
+
export async function dispatch(host, surface, name, args) {
|
|
358
|
+
try {
|
|
359
|
+
const cmd = getCommand(name, surface);
|
|
360
|
+
return { ok: true, output: await cmd.run(host, validateArgs(cmd, args), surface) };
|
|
361
|
+
}
|
|
362
|
+
catch (err) {
|
|
363
|
+
return { ok: false, error: LabError.from(err).toJSON() };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
//# sourceMappingURL=commands.js.map
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { LabError } from './schema.js';
|
|
2
|
+
export const CONTROL_OPS = ['pause', 'pause-next', 'resume', 'takeover', 'return', 'stop', 'emergency-stop'];
|
|
3
|
+
/**
|
|
4
|
+
* How a supervision stop is recorded. A stop from the terminal is the owner channel, but anything with a
|
|
5
|
+
* shell as this user can issue it, so it is labelled by where it came from rather than as a person's.
|
|
6
|
+
*/
|
|
7
|
+
export function stopReason(op, by = 'dashboard') {
|
|
8
|
+
if (by === 'terminal')
|
|
9
|
+
return op === 'stop' ? 'stopped from the terminal' : 'emergency stop from the terminal';
|
|
10
|
+
return `${op === 'stop' ? 'stopped' : 'emergency stop'} by a person (${by})`;
|
|
11
|
+
}
|
|
12
|
+
const iso = () => new Date().toISOString();
|
|
13
|
+
export class Supervisor {
|
|
14
|
+
s = { mode: 'agent', interrupt: false, since: iso(), observeRequired: false, humanInteractions: 0 };
|
|
15
|
+
listeners = new Set();
|
|
16
|
+
waiters = [];
|
|
17
|
+
idleWaiters = [];
|
|
18
|
+
get state() {
|
|
19
|
+
return structuredClone(this.s);
|
|
20
|
+
}
|
|
21
|
+
get mode() {
|
|
22
|
+
return this.s.mode;
|
|
23
|
+
}
|
|
24
|
+
/** A person is watching or holding the page: human input is recorded only then. */
|
|
25
|
+
get recordingHuman() {
|
|
26
|
+
return this.s.mode === 'paused' || this.s.mode === 'human';
|
|
27
|
+
}
|
|
28
|
+
onChange(fn) {
|
|
29
|
+
this.listeners.add(fn);
|
|
30
|
+
return () => { this.listeners.delete(fn); };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Apply a person's request. Throws `invalid_control` for a request that makes no sense in the current
|
|
34
|
+
* mode (e.g. resume while the agent already has control), so the dashboard can say why.
|
|
35
|
+
*/
|
|
36
|
+
request(op, by = 'dashboard') {
|
|
37
|
+
const s = this.s;
|
|
38
|
+
const busy = !!s.busy;
|
|
39
|
+
const invalid = (why) => new LabError('invalid_control', `Cannot ${op}: ${why}`, { details: { mode: s.mode } });
|
|
40
|
+
if (s.mode === 'stopped')
|
|
41
|
+
throw invalid('the session has stopped');
|
|
42
|
+
switch (op) {
|
|
43
|
+
case 'pause':
|
|
44
|
+
case 'pause-next': {
|
|
45
|
+
const interrupt = op === 'pause';
|
|
46
|
+
if (s.mode === 'agent')
|
|
47
|
+
this.set(busy ? { mode: 'pausing', pending: 'paused', interrupt } : { mode: 'paused', interrupt: false }, by);
|
|
48
|
+
else if (s.mode === 'pausing' && s.pending === 'paused')
|
|
49
|
+
this.set({ mode: 'pausing', pending: 'paused', interrupt: s.interrupt || interrupt }, by);
|
|
50
|
+
else
|
|
51
|
+
throw invalid(`the session is ${describe(s)}`);
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
case 'takeover':
|
|
55
|
+
if (s.mode === 'agent' || s.mode === 'pausing' || s.mode === 'paused') {
|
|
56
|
+
this.set(busy ? { mode: 'pausing', pending: 'human', interrupt: true } : { mode: 'human', interrupt: false }, by);
|
|
57
|
+
}
|
|
58
|
+
else
|
|
59
|
+
throw invalid(`the session is ${describe(s)}`);
|
|
60
|
+
break;
|
|
61
|
+
case 'resume':
|
|
62
|
+
if (s.mode === 'paused' || (s.mode === 'pausing' && s.pending === 'paused')) {
|
|
63
|
+
this.set({ mode: 'agent', interrupt: false, observeRequired: s.observeRequired || s.humanInteractions > 0 }, by);
|
|
64
|
+
}
|
|
65
|
+
else
|
|
66
|
+
throw invalid(s.mode === 'human' ? 'a person has control; use return to hand it back' : `the session is ${describe(s)}`);
|
|
67
|
+
break;
|
|
68
|
+
case 'return':
|
|
69
|
+
if (s.mode === 'human' || (s.mode === 'pausing' && s.pending === 'human')) {
|
|
70
|
+
// Whatever the person did, refs from before are no longer trustworthy.
|
|
71
|
+
this.set({ mode: 'agent', interrupt: false, observeRequired: s.mode === 'human' || s.humanInteractions > 0 }, by);
|
|
72
|
+
}
|
|
73
|
+
else
|
|
74
|
+
throw invalid(`the session is ${describe(s)}`);
|
|
75
|
+
break;
|
|
76
|
+
case 'stop':
|
|
77
|
+
if (s.mode === 'stopping')
|
|
78
|
+
throw invalid('it is already stopping');
|
|
79
|
+
this.set(busy ? { mode: 'stopping', pending: 'stop', interrupt: true } : { mode: 'stopped', interrupt: true }, by);
|
|
80
|
+
break;
|
|
81
|
+
case 'emergency-stop':
|
|
82
|
+
this.set({ mode: 'stopped', interrupt: true }, by);
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
this.emit(op, by);
|
|
86
|
+
return this.state;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Refuse an agent command that may not run now. Read-only commands always run; observe runs unless a
|
|
90
|
+
* person holds the page; anything that acts needs agent control and, after a hand-back, a fresh observe.
|
|
91
|
+
*/
|
|
92
|
+
admit(command, kind) {
|
|
93
|
+
if (kind === 'read')
|
|
94
|
+
return;
|
|
95
|
+
const s = this.s;
|
|
96
|
+
const details = { control: { mode: s.mode, ...(s.by ? { by: s.by } : {}), since: s.since } };
|
|
97
|
+
if (s.mode === 'stopping' || s.mode === 'stopped') {
|
|
98
|
+
throw new LabError('session_stopped', `${command} refused: a person stopped this session`, {
|
|
99
|
+
hint: 'Nothing was run. The session is closing or closed; ask the user before starting another.', details
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if (s.mode === 'human' || (s.mode === 'pausing' && s.pending === 'human')) {
|
|
103
|
+
throw new LabError('human_control', `${command} refused: a person has taken control of the browser`, {
|
|
104
|
+
hint: 'Nothing was run and nothing is queued. Wait for the person to hand control back, then observe (refs from before will be stale).', details,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
if (kind === 'observe')
|
|
108
|
+
return;
|
|
109
|
+
if (s.mode === 'paused' || s.mode === 'pausing') {
|
|
110
|
+
throw new LabError('session_paused', `${command} refused: a person paused the session`, {
|
|
111
|
+
hint: 'Nothing was run and nothing is queued. observe, inspect and status still work; retry the action after the person resumes.', details,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
// Stopping acts on no ref, so stale refs do not matter: after a hand-back it needs no fresh observe.
|
|
115
|
+
if (s.observeRequired && command !== 'stop') {
|
|
116
|
+
throw new LabError('observation_required', `${command} refused: a person used the browser since the last observation`, {
|
|
117
|
+
hint: 'Refs issued before are stale. Run observe, then act on fresh refs.', details,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** The agent observed after a hand-back: acting is allowed again. */
|
|
122
|
+
observed() {
|
|
123
|
+
if (this.s.mode !== 'agent' || !this.s.observeRequired)
|
|
124
|
+
return;
|
|
125
|
+
this.set({ mode: 'agent', interrupt: false, observeRequired: false, humanInteractions: 0 }, this.s.by);
|
|
126
|
+
this.emit('observed', this.s.by);
|
|
127
|
+
}
|
|
128
|
+
/** A person interacted with the page while it was paused or under their control. */
|
|
129
|
+
humanInteraction() {
|
|
130
|
+
if (this.recordingHuman)
|
|
131
|
+
this.s.humanInteractions++;
|
|
132
|
+
}
|
|
133
|
+
/** A session operation began. Nested operations (a scan inside a flow step) keep the outer label. */
|
|
134
|
+
begin(command) {
|
|
135
|
+
this.s.busy ??= { command, since: iso() };
|
|
136
|
+
}
|
|
137
|
+
/** The operation in flight finished: a pending pause, takeover or stop takes effect now. */
|
|
138
|
+
end() {
|
|
139
|
+
if (!this.s.busy)
|
|
140
|
+
return;
|
|
141
|
+
delete this.s.busy;
|
|
142
|
+
const s = this.s;
|
|
143
|
+
if (s.mode === 'pausing')
|
|
144
|
+
this.set({ mode: s.pending === 'human' ? 'human' : 'paused', interrupt: false }, s.by);
|
|
145
|
+
else if (s.mode === 'stopping')
|
|
146
|
+
this.set({ mode: 'stopped', interrupt: true }, s.by);
|
|
147
|
+
else {
|
|
148
|
+
this.flushIdle();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
this.emit('settled', s.by);
|
|
152
|
+
this.flushIdle();
|
|
153
|
+
}
|
|
154
|
+
get busy() {
|
|
155
|
+
return !!this.s.busy;
|
|
156
|
+
}
|
|
157
|
+
/** Resolves when no operation is in flight. */
|
|
158
|
+
whenIdle() {
|
|
159
|
+
if (!this.s.busy)
|
|
160
|
+
return Promise.resolve();
|
|
161
|
+
return new Promise((r) => this.idleWaiters.push(r));
|
|
162
|
+
}
|
|
163
|
+
/** For long commands between units of work: true when they should stop now (pause, takeover or stop). */
|
|
164
|
+
shouldInterrupt() {
|
|
165
|
+
return this.s.interrupt && (this.s.mode === 'pausing' || this.s.mode === 'stopping' || this.s.mode === 'stopped');
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* For a scripted runner (a flow) that has no one to return an error to: wait until the agent has
|
|
169
|
+
* control. Rejects with session_stopped when a person stops the session.
|
|
170
|
+
*/
|
|
171
|
+
waitForTurn() {
|
|
172
|
+
const s = this.s.mode;
|
|
173
|
+
if (s === 'agent')
|
|
174
|
+
return Promise.resolve();
|
|
175
|
+
if (s === 'stopping' || s === 'stopped')
|
|
176
|
+
return Promise.reject(new LabError('session_stopped', 'A person stopped this session'));
|
|
177
|
+
return new Promise((resolve, reject) => this.waiters.push({ resolve, reject }));
|
|
178
|
+
}
|
|
179
|
+
set(next, by) {
|
|
180
|
+
const keep = { observeRequired: this.s.observeRequired, humanInteractions: this.s.humanInteractions, ...(this.s.busy ? { busy: this.s.busy } : {}) };
|
|
181
|
+
const modeChanged = next.mode !== this.s.mode;
|
|
182
|
+
this.s = { ...keep, ...next, ...(by ? { by } : {}), since: modeChanged ? iso() : this.s.since };
|
|
183
|
+
if (next.mode === 'agent' && modeChanged)
|
|
184
|
+
this.s.humanInteractions = 0;
|
|
185
|
+
if (!next.pending)
|
|
186
|
+
delete this.s.pending;
|
|
187
|
+
this.wake();
|
|
188
|
+
}
|
|
189
|
+
wake() {
|
|
190
|
+
const m = this.s.mode;
|
|
191
|
+
if (m === 'agent') {
|
|
192
|
+
for (const w of this.waiters.splice(0))
|
|
193
|
+
w.resolve();
|
|
194
|
+
}
|
|
195
|
+
else if (m === 'stopping' || m === 'stopped') {
|
|
196
|
+
for (const w of this.waiters.splice(0))
|
|
197
|
+
w.reject(new LabError('session_stopped', 'A person stopped this session'));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
flushIdle() {
|
|
201
|
+
for (const r of this.idleWaiters.splice(0))
|
|
202
|
+
r();
|
|
203
|
+
}
|
|
204
|
+
emit(op, by) {
|
|
205
|
+
const change = { op, ...(by ? { by } : {}), state: this.state };
|
|
206
|
+
for (const fn of this.listeners)
|
|
207
|
+
fn(change);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function describe(s) {
|
|
211
|
+
if (s.mode === 'pausing')
|
|
212
|
+
return s.pending === 'human' ? 'waiting to hand control to a person' : 'already pausing';
|
|
213
|
+
if (s.mode === 'human')
|
|
214
|
+
return 'under a person\'s control';
|
|
215
|
+
return s.mode;
|
|
216
|
+
}
|
|
217
|
+
//# sourceMappingURL=control.js.map
|