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
|
+
// Local live dashboard. Serves a monitoring page, the session's typed event stream (SSE) and the
|
|
2
|
+
// active device's viewport (MJPEG), bound to 127.0.0.1 and gated by per-session access tokens.
|
|
3
|
+
// Contains no session logic: state comes from SessionFeed, frames from the Lab's screencast, and
|
|
4
|
+
// supervision requests (pause, takeover, stop, a person's input) are forwarded to the session owner.
|
|
5
|
+
//
|
|
6
|
+
// Two tokens: the view token (in the URL agents receive) reads; only the control token (the URL a
|
|
7
|
+
// person gets from `agentlab ui`) may POST control requests and input.
|
|
8
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { readFile } from 'node:fs/promises';
|
|
11
|
+
import { createServer } from 'node:http';
|
|
12
|
+
import { LabError } from '../core/schema.js';
|
|
13
|
+
export const DEFAULT_SCREENCAST = { maxFps: 5, maxWidth: 800, quality: 60 };
|
|
14
|
+
const BOUNDARY = 'agentlab-frame';
|
|
15
|
+
const PART = `\r\n--${BOUNDARY}\r\nContent-Type: image/jpeg\r\n\r\n`;
|
|
16
|
+
/** A slow viewer skips frames rather than buffering them without limit. */
|
|
17
|
+
const MAX_BUFFERED = 512 * 1024;
|
|
18
|
+
const HEARTBEAT_MS = 15_000;
|
|
19
|
+
/** Open streams per dashboard; more are refused (503) rather than served without limit. */
|
|
20
|
+
const MAX_EVENT_STREAMS = 32;
|
|
21
|
+
const MAX_VIEWERS = 8;
|
|
22
|
+
const MAX_BODY = 8 * 1024;
|
|
23
|
+
const STATIC_DIR = new URL('../../assets/dashboard/', import.meta.url);
|
|
24
|
+
const STATIC = {
|
|
25
|
+
'/': { file: 'index.html', type: 'text/html; charset=utf-8' },
|
|
26
|
+
'/app.js': { file: 'app.js', type: 'text/javascript; charset=utf-8' },
|
|
27
|
+
'/app.css': { file: 'app.css', type: 'text/css; charset=utf-8' },
|
|
28
|
+
};
|
|
29
|
+
const CSP = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' blob:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
|
|
30
|
+
const newToken = () => randomBytes(24).toString('base64url');
|
|
31
|
+
export class Dashboard {
|
|
32
|
+
opts;
|
|
33
|
+
server;
|
|
34
|
+
assets = new Map();
|
|
35
|
+
/** Full access, including control; only ever given to the person (`agentlab ui`). */
|
|
36
|
+
token = newToken();
|
|
37
|
+
/** Read-only access; this is the URL placed in start and status results that agents see. */
|
|
38
|
+
viewToken = newToken();
|
|
39
|
+
events = new Set();
|
|
40
|
+
viewers = new Set();
|
|
41
|
+
lastFrame;
|
|
42
|
+
cast;
|
|
43
|
+
castOpts;
|
|
44
|
+
stats = { running: false, starts: 0, stops: 0, framesCaptured: 0, framesSent: 0, bytesSent: 0 };
|
|
45
|
+
unsubscribe;
|
|
46
|
+
heartbeat;
|
|
47
|
+
closed = false;
|
|
48
|
+
port = 0;
|
|
49
|
+
constructor(opts) {
|
|
50
|
+
this.opts = opts;
|
|
51
|
+
this.castOpts = { ...DEFAULT_SCREENCAST, ...opts.screencast };
|
|
52
|
+
for (const { file } of Object.values(STATIC))
|
|
53
|
+
this.assets.set(file, readFileSync(new URL(file, STATIC_DIR)));
|
|
54
|
+
this.server = createServer((req, res) => this.route(req, res));
|
|
55
|
+
this.unsubscribe = opts.feed.subscribe((m) => this.onMessage(m));
|
|
56
|
+
}
|
|
57
|
+
static async listen(opts) {
|
|
58
|
+
const d = new Dashboard(opts);
|
|
59
|
+
await new Promise((resolve, reject) => {
|
|
60
|
+
d.server.once('error', reject);
|
|
61
|
+
d.server.listen(opts.port ?? 0, '127.0.0.1', () => resolve());
|
|
62
|
+
});
|
|
63
|
+
d.port = d.server.address().port;
|
|
64
|
+
d.heartbeat = setInterval(() => { for (const res of d.events)
|
|
65
|
+
res.write(': ping\n\n'); }, HEARTBEAT_MS);
|
|
66
|
+
d.heartbeat.unref();
|
|
67
|
+
return d;
|
|
68
|
+
}
|
|
69
|
+
/** The person's URL (control and view). The token is in the fragment, so it is never sent in requests or Referer headers. */
|
|
70
|
+
get url() {
|
|
71
|
+
return `http://127.0.0.1:${this.port}/#token=${this.token}`;
|
|
72
|
+
}
|
|
73
|
+
/** A view-only URL: monitoring works, control requests are refused. This is what agents are given. */
|
|
74
|
+
get viewUrl() {
|
|
75
|
+
return `http://127.0.0.1:${this.port}/#token=${this.viewToken}`;
|
|
76
|
+
}
|
|
77
|
+
get origin() {
|
|
78
|
+
return `http://127.0.0.1:${this.port}`;
|
|
79
|
+
}
|
|
80
|
+
getStats() {
|
|
81
|
+
return { viewers: { events: this.events.size, frames: this.viewers.size }, screencast: { ...this.stats } };
|
|
82
|
+
}
|
|
83
|
+
/** Stop frame capture, end every stream and close the listening socket. Idempotent. */
|
|
84
|
+
async close() {
|
|
85
|
+
if (this.closed)
|
|
86
|
+
return;
|
|
87
|
+
this.closed = true;
|
|
88
|
+
this.unsubscribe();
|
|
89
|
+
if (this.heartbeat)
|
|
90
|
+
clearInterval(this.heartbeat);
|
|
91
|
+
this.endStreams();
|
|
92
|
+
await this.stopCast();
|
|
93
|
+
this.server.closeAllConnections();
|
|
94
|
+
await new Promise((resolve) => this.server.close(() => resolve()));
|
|
95
|
+
}
|
|
96
|
+
// ---------- feed ----------
|
|
97
|
+
onMessage(m) {
|
|
98
|
+
if (m.event.type === 'reset') {
|
|
99
|
+
// A new session: previous viewers and the old tokens lose access.
|
|
100
|
+
this.token = newToken();
|
|
101
|
+
this.viewToken = newToken();
|
|
102
|
+
this.lastFrame = undefined;
|
|
103
|
+
this.endStreams();
|
|
104
|
+
void this.stopCast();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const frame = `id: ${m.seq}\nevent: feed\ndata: ${JSON.stringify(m)}\n\n`;
|
|
108
|
+
for (const res of this.events)
|
|
109
|
+
res.write(frame);
|
|
110
|
+
if (m.event.type === 'status') {
|
|
111
|
+
const state = m.event.status.state;
|
|
112
|
+
if (state === 'active')
|
|
113
|
+
void this.ensureCast();
|
|
114
|
+
if (state === 'ended' || state === 'failed') {
|
|
115
|
+
void this.stopCast().then(() => { for (const res of this.viewers)
|
|
116
|
+
res.end(); this.viewers.clear(); });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
endStreams() {
|
|
121
|
+
for (const res of [...this.events, ...this.viewers])
|
|
122
|
+
res.end();
|
|
123
|
+
this.events.clear();
|
|
124
|
+
this.viewers.clear();
|
|
125
|
+
}
|
|
126
|
+
// ---------- screencast ----------
|
|
127
|
+
async ensureCast() {
|
|
128
|
+
if (this.cast || this.closed || !this.viewers.size)
|
|
129
|
+
return;
|
|
130
|
+
const source = this.opts.source();
|
|
131
|
+
if (!source?.active)
|
|
132
|
+
return;
|
|
133
|
+
const pending = source.screencast((jpeg) => this.onFrame(jpeg), this.castOpts).catch(() => undefined);
|
|
134
|
+
this.cast = pending;
|
|
135
|
+
const stop = await pending;
|
|
136
|
+
if (!stop) {
|
|
137
|
+
if (this.cast === pending)
|
|
138
|
+
this.cast = undefined;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
this.stats.starts++;
|
|
142
|
+
this.stats.running = true;
|
|
143
|
+
// Every viewer left while capture was starting (a concurrent stopCast already handled a replaced one).
|
|
144
|
+
if (this.cast === pending && !this.viewers.size)
|
|
145
|
+
await this.stopCast(pending);
|
|
146
|
+
}
|
|
147
|
+
async stopCast(which = this.cast) {
|
|
148
|
+
if (!which)
|
|
149
|
+
return;
|
|
150
|
+
if (this.cast === which)
|
|
151
|
+
this.cast = undefined;
|
|
152
|
+
const stop = await which;
|
|
153
|
+
if (!stop)
|
|
154
|
+
return;
|
|
155
|
+
await stop();
|
|
156
|
+
this.stats.stops++;
|
|
157
|
+
this.stats.running = !!this.cast;
|
|
158
|
+
}
|
|
159
|
+
onFrame(jpeg) {
|
|
160
|
+
this.lastFrame = jpeg;
|
|
161
|
+
this.stats.framesCaptured++;
|
|
162
|
+
for (const res of this.viewers)
|
|
163
|
+
this.sendFrame(res, jpeg);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Chromium shows a multipart part only once the next part has begun, so every frame is followed at
|
|
167
|
+
* once by the next part's boundary and headers (otherwise an idle page's last frame never shows).
|
|
168
|
+
*/
|
|
169
|
+
sendFrame(res, jpeg) {
|
|
170
|
+
if (res.writableLength > MAX_BUFFERED)
|
|
171
|
+
return;
|
|
172
|
+
res.write(jpeg);
|
|
173
|
+
res.write(PART);
|
|
174
|
+
this.stats.framesSent++;
|
|
175
|
+
this.stats.bytesSent += jpeg.length;
|
|
176
|
+
}
|
|
177
|
+
// ---------- HTTP ----------
|
|
178
|
+
route(req, res) {
|
|
179
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
180
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
181
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
182
|
+
// DNS rebinding and cross-site requests: only this exact loopback origin may talk to the dashboard.
|
|
183
|
+
const hosts = [`127.0.0.1:${this.port}`, `localhost:${this.port}`];
|
|
184
|
+
if (!hosts.includes(req.headers.host ?? ''))
|
|
185
|
+
return void res.writeHead(403).end('forbidden host\n');
|
|
186
|
+
const origin = req.headers.origin;
|
|
187
|
+
if (origin !== undefined && !hosts.some((h) => origin === `http://${h}`))
|
|
188
|
+
return void res.writeHead(403).end('forbidden origin\n');
|
|
189
|
+
const url = new URL(req.url ?? '/', this.origin);
|
|
190
|
+
if (req.method === 'POST' && (url.pathname === '/api/control' || url.pathname === '/api/input'))
|
|
191
|
+
return void this.post(req, res, url.pathname);
|
|
192
|
+
if (req.method !== 'GET')
|
|
193
|
+
return void res.writeHead(405, { allow: 'GET' }).end();
|
|
194
|
+
const asset = STATIC[url.pathname];
|
|
195
|
+
if (asset) {
|
|
196
|
+
// Static code only; no session data is served without the token.
|
|
197
|
+
res.writeHead(200, { 'content-type': asset.type, 'content-security-policy': CSP, 'x-frame-options': 'DENY' });
|
|
198
|
+
return void res.end(this.assets.get(asset.file));
|
|
199
|
+
}
|
|
200
|
+
if (!url.pathname.startsWith('/api/'))
|
|
201
|
+
return void res.writeHead(404).end();
|
|
202
|
+
if (!this.authorized(req, url)) {
|
|
203
|
+
return void res.writeHead(401, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'missing or invalid dashboard token; open the URL printed by `agentlab ui`' }));
|
|
204
|
+
}
|
|
205
|
+
if (url.pathname === '/api/state')
|
|
206
|
+
return this.json(res, { ...this.opts.feed.snapshot(), stats: this.getStats(), canControl: this.canControl(req, url) });
|
|
207
|
+
if (url.pathname === '/api/events')
|
|
208
|
+
return this.openEvents(req, res, url);
|
|
209
|
+
if (url.pathname === '/api/viewport')
|
|
210
|
+
return this.openViewport(res);
|
|
211
|
+
const frame = /^\/api\/frames\/(F\d{1,6})(?:-(\d{1,2}))?\.jpg$/.exec(url.pathname);
|
|
212
|
+
if (frame)
|
|
213
|
+
return void this.sendFile(res, frame[2] === undefined ? this.opts.feed.frameFile(frame[1]) : this.opts.feed.extraFrameFile(frame[1], Number(frame[2])));
|
|
214
|
+
const scanFrame = /^\/api\/scans\/(R\d{1,6})\/(\d{1,4})\/(s\d{1,4})\.jpg$/.exec(url.pathname);
|
|
215
|
+
if (scanFrame)
|
|
216
|
+
return void this.sendFile(res, this.opts.feed.scanFrameFile(scanFrame[1], Number(scanFrame[2]), scanFrame[3]));
|
|
217
|
+
const sweep = /^\/api\/sweeps\/(S\d{1,6})\/([a-z0-9-]{1,40})\.jpg$/.exec(url.pathname);
|
|
218
|
+
if (sweep)
|
|
219
|
+
return void this.sendFile(res, this.opts.feed.sweepFrameFile(sweep[1], sweep[2]));
|
|
220
|
+
res.writeHead(404).end();
|
|
221
|
+
}
|
|
222
|
+
authorized(req, url) {
|
|
223
|
+
const header = req.headers.authorization;
|
|
224
|
+
const given = header?.startsWith('Bearer ') ? header.slice(7) : url.searchParams.get('token');
|
|
225
|
+
return !!given && (same(given, this.token) || same(given, this.viewToken));
|
|
226
|
+
}
|
|
227
|
+
/** Whether this request carries the control token (the page shows control buttons only then). */
|
|
228
|
+
canControl(req, url) {
|
|
229
|
+
const header = req.headers.authorization;
|
|
230
|
+
const given = header?.startsWith('Bearer ') ? header.slice(7) : url.searchParams.get('token');
|
|
231
|
+
return !!given && !!this.opts.control && same(given, this.token);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* POST /api/control {op} and /api/input {type, …}. Only the control token, only in an Authorization
|
|
235
|
+
* header (never a query string), only from this exact origin, only JSON, and bodies of at most 8 KB.
|
|
236
|
+
* A cross-site page cannot send the header without a CORS preflight, which is never answered.
|
|
237
|
+
*/
|
|
238
|
+
post(req, res, path) {
|
|
239
|
+
const reply = (status, body) => res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(body));
|
|
240
|
+
const header = req.headers.authorization;
|
|
241
|
+
if (!header?.startsWith('Bearer ') || !same(header.slice(7), this.token))
|
|
242
|
+
return void reply(401, { error: 'control needs the dashboard URL from `agentlab ui` (this one is view-only or expired)' });
|
|
243
|
+
if (req.headers.origin !== this.origin && req.headers.origin !== `http://localhost:${this.port}`)
|
|
244
|
+
return void reply(403, { error: 'forbidden origin' });
|
|
245
|
+
const site = req.headers['sec-fetch-site'];
|
|
246
|
+
if (site !== undefined && site !== 'same-origin')
|
|
247
|
+
return void reply(403, { error: 'forbidden site' });
|
|
248
|
+
if (!/^application\/json\b/.test(req.headers['content-type'] ?? ''))
|
|
249
|
+
return void reply(415, { error: 'send application/json' });
|
|
250
|
+
const handler = path === '/api/control' ? this.opts.control : this.opts.input;
|
|
251
|
+
if (!handler)
|
|
252
|
+
return void reply(404, { error: 'this dashboard has no session to control' });
|
|
253
|
+
let size = 0;
|
|
254
|
+
const chunks = [];
|
|
255
|
+
let aborted = false;
|
|
256
|
+
req.on('data', (c) => {
|
|
257
|
+
size += c.length;
|
|
258
|
+
if (size > MAX_BODY && !aborted) {
|
|
259
|
+
aborted = true;
|
|
260
|
+
res.setHeader('connection', 'close');
|
|
261
|
+
res.once('finish', () => req.destroy());
|
|
262
|
+
reply(413, { error: 'request too large' });
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (!aborted)
|
|
266
|
+
chunks.push(c);
|
|
267
|
+
});
|
|
268
|
+
req.on('end', () => {
|
|
269
|
+
if (aborted)
|
|
270
|
+
return;
|
|
271
|
+
let body;
|
|
272
|
+
try {
|
|
273
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
274
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
275
|
+
throw new Error('not an object');
|
|
276
|
+
body = parsed;
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return void reply(400, { error: 'body must be a JSON object' });
|
|
280
|
+
}
|
|
281
|
+
void (async () => {
|
|
282
|
+
try {
|
|
283
|
+
if (path === '/api/control') {
|
|
284
|
+
if (typeof body.op !== 'string')
|
|
285
|
+
return reply(400, { error: 'op is required' });
|
|
286
|
+
reply(200, { control: await this.opts.control(body.op, 'dashboard') });
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
await this.opts.input(body);
|
|
290
|
+
reply(200, { ok: true });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
const e = LabError.from(err).toJSON();
|
|
295
|
+
reply(e.code === 'invalid_control' ? 409 : e.code === 'invalid_request' ? 400 : e.code === 'no_session' || e.code === 'browser_closed' ? 410 : 500, { error: e.message, code: e.code });
|
|
296
|
+
}
|
|
297
|
+
})();
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
json(res, body) {
|
|
301
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(body));
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Server-sent events. A new connection gets a snapshot; a reconnect with Last-Event-ID gets only the
|
|
305
|
+
* messages it missed, or a fresh snapshot when those are no longer buffered.
|
|
306
|
+
*/
|
|
307
|
+
openEvents(req, res, url) {
|
|
308
|
+
if (this.events.size >= MAX_EVENT_STREAMS)
|
|
309
|
+
return void res.writeHead(503, { 'retry-after': '5' }).end('too many open dashboards\n');
|
|
310
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', connection: 'keep-alive', 'x-accel-buffering': 'no' });
|
|
311
|
+
res.write('retry: 1000\n\n');
|
|
312
|
+
const last = req.headers['last-event-id'] ?? url.searchParams.get('since');
|
|
313
|
+
const missed = last !== undefined && last !== null && /^\d+$/.test(String(last)) ? this.opts.feed.since(Number(last)) : undefined;
|
|
314
|
+
if (missed) {
|
|
315
|
+
for (const m of missed)
|
|
316
|
+
res.write(`id: ${m.seq}\nevent: feed\ndata: ${JSON.stringify(m)}\n\n`);
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
const snap = this.opts.feed.snapshot();
|
|
320
|
+
res.write(`id: ${snap.seq}\nevent: snapshot\ndata: ${JSON.stringify(snap)}\n\n`);
|
|
321
|
+
}
|
|
322
|
+
this.events.add(res);
|
|
323
|
+
res.on('close', () => { this.events.delete(res); });
|
|
324
|
+
}
|
|
325
|
+
/** MJPEG stream of the live viewport. Capture runs only while at least one of these is open. */
|
|
326
|
+
openViewport(res) {
|
|
327
|
+
if (this.viewers.size >= MAX_VIEWERS)
|
|
328
|
+
return void res.writeHead(503, { 'retry-after': '5' }).end('too many viewport viewers\n');
|
|
329
|
+
res.writeHead(200, { 'content-type': `multipart/x-mixed-replace; boundary=${BOUNDARY}`, connection: 'close' });
|
|
330
|
+
res.write(PART);
|
|
331
|
+
this.viewers.add(res);
|
|
332
|
+
res.on('close', () => {
|
|
333
|
+
this.viewers.delete(res);
|
|
334
|
+
if (!this.viewers.size)
|
|
335
|
+
void this.stopCast();
|
|
336
|
+
});
|
|
337
|
+
if (this.lastFrame)
|
|
338
|
+
this.sendFrame(res, this.lastFrame);
|
|
339
|
+
const state = this.opts.feed.state;
|
|
340
|
+
if (state === 'ended' || state === 'failed') {
|
|
341
|
+
// Nothing more will come: show the final frame and finish.
|
|
342
|
+
this.viewers.delete(res);
|
|
343
|
+
res.end();
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
void this.ensureCast();
|
|
347
|
+
}
|
|
348
|
+
/** Only files the feed recorded (evidence, sweep and scan frames in the run directory) are ever served. */
|
|
349
|
+
async sendFile(res, file) {
|
|
350
|
+
if (!file)
|
|
351
|
+
return void res.writeHead(404).end();
|
|
352
|
+
try {
|
|
353
|
+
const data = await readFile(file);
|
|
354
|
+
res.writeHead(200, { 'content-type': 'image/jpeg', 'content-length': data.length }).end(data);
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
res.writeHead(404).end();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function same(given, token) {
|
|
362
|
+
const a = Buffer.from(given);
|
|
363
|
+
const b = Buffer.from(token);
|
|
364
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
365
|
+
}
|
|
366
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Local stdio MCP server. A thin adapter: tools, schemas and behaviour all come from the command
|
|
2
|
+
// table in src/core/commands.ts; this file only translates to and from MCP messages.
|
|
3
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
6
|
+
import { COMMANDS, SessionHost, dispatch } from '../core/commands.js';
|
|
7
|
+
import { SessionFeed } from '../core/feed.js';
|
|
8
|
+
import { formatError } from '../core/format.js';
|
|
9
|
+
import { productVersion } from '../core/versions.js';
|
|
10
|
+
import { Dashboard } from '../dashboard/server.js';
|
|
11
|
+
import { clearDashboardRecord, writeDashboardRecord } from '../daemon/state.js';
|
|
12
|
+
const INSTRUCTIONS = `Agent Device Lab drives a local web app in a Chromium mobile profile.
|
|
13
|
+
1. start {project}: starts or reuses the services declared in the project's agentlab.json (in dependency order) and returns the first observation. auth "fresh" ignores saved sign-in state.
|
|
14
|
+
2. observe returns visible controls with refs (e.g. e12). Actions take a ref, or an exact role + name: click, fill, press, select, check, uncheck, hover, upload, drag; scroll and swipe (the page or a region); back and forward.
|
|
15
|
+
3. Every action returns only what changed ("changed"), plus notes, settling and new usability findings. Pop-ups and new tabs open as tabs: the new tab becomes active; tabs / switch_tab / close_tab manage them.
|
|
16
|
+
4. sweep {route?} checks one route at 320, 390, 768 and 1440 px in isolated contexts. scan runs the project's declared UI states (drawers, menus, dialogs, tabs; scan.scenarios in agentlab.json), optionally exploring safe ones, and returns a verdict, problem groups and report paths. Confirmed findings rest on a measurement (hit test, clipping, a WCAG rule); heuristic ones are warnings.
|
|
17
|
+
5. inspect lists findings (overflow, clipping, obstruction, tap targets, wrapping, layout shift…) with evidence and reproduction steps; inspect {id} for one.
|
|
18
|
+
6. auth_save stores the session's sign-in state for later sessions (never returned). bundle {note?} writes a secret-free failure bundle (action log, findings, frames) that "agentlab replay" can re-run. stop closes the browser and stops only the services the lab started.
|
|
19
|
+
Refs from a previous page or another tab are stale; observe again rather than guessing. Upload files must be inside the project's uploads.allow directories.`;
|
|
20
|
+
const DASHBOARD_NOTE = '\nThe start result includes a local dashboard URL (live viewport, timeline, findings) for a person to watch; share it with the user if they want to follow along.';
|
|
21
|
+
export async function runMcpServer(opts) {
|
|
22
|
+
const version = productVersion();
|
|
23
|
+
const ui = opts.ui ?? true;
|
|
24
|
+
const feed = new SessionFeed();
|
|
25
|
+
// A new session rotates the dashboard's tokens (old viewers lose access), so the person's control URL
|
|
26
|
+
// is re-published whenever one starts; it would otherwise stop working after the agent's second start.
|
|
27
|
+
let announce;
|
|
28
|
+
const host = new SessionHost({
|
|
29
|
+
stateDir: opts.stateDir, evidenceFrames: ui,
|
|
30
|
+
onEvent: (e) => { feed.apply(e); if (e.kind === 'starting')
|
|
31
|
+
announce?.(); },
|
|
32
|
+
}, { headless: opts.headless });
|
|
33
|
+
// Frames and events go to the dashboard only; tool results carry at most its URL.
|
|
34
|
+
const dashboard = ui ? await Dashboard.listen({
|
|
35
|
+
feed, source: () => host.lab,
|
|
36
|
+
control: (op, by) => host.supervise(op, by),
|
|
37
|
+
input: (i) => host.lab.humanInput(i),
|
|
38
|
+
}) : undefined;
|
|
39
|
+
if (dashboard) {
|
|
40
|
+
// The agent's results carry the view-only URL. The person's control URL goes to stderr (the MCP
|
|
41
|
+
// client's log) and to an owner-only record that `agentlab ui` reads; it never reaches tool results.
|
|
42
|
+
host.dashboardUrl = () => dashboard.viewUrl;
|
|
43
|
+
let published = '';
|
|
44
|
+
announce = () => {
|
|
45
|
+
if (dashboard.url === published)
|
|
46
|
+
return;
|
|
47
|
+
published = dashboard.url;
|
|
48
|
+
writeDashboardRecord(opts.stateDir, dashboard.url);
|
|
49
|
+
process.stderr.write(`agentlab: dashboard with controls: ${dashboard.url}\n (or run \`agentlab ui\` in ${process.cwd()})\n`);
|
|
50
|
+
};
|
|
51
|
+
announce();
|
|
52
|
+
}
|
|
53
|
+
const tools = COMMANDS.filter((c) => c.surfaces.includes('mcp'));
|
|
54
|
+
const server = new Server({ name: 'agent-device-lab', version }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS + (dashboard ? DASHBOARD_NOTE : '') });
|
|
55
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
56
|
+
tools: tools.map((c) => ({ name: c.name, description: c.description, inputSchema: c.inputSchema })),
|
|
57
|
+
}));
|
|
58
|
+
// One page, one session: tool calls run strictly in order even if a client pipelines them.
|
|
59
|
+
let queue = Promise.resolve();
|
|
60
|
+
server.setRequestHandler(CallToolRequestSchema, (request) => {
|
|
61
|
+
const task = queue.then(async () => {
|
|
62
|
+
const out = await dispatch(host, 'mcp', request.params.name, request.params.arguments ?? {});
|
|
63
|
+
if (!out.ok) {
|
|
64
|
+
return { isError: true, content: [{ type: 'text', text: `error ${formatError(out.error)}` }], structuredContent: { error: out.error } };
|
|
65
|
+
}
|
|
66
|
+
const failed = out.output.result.outcome === 'error';
|
|
67
|
+
return { content: [{ type: 'text', text: out.output.text }], structuredContent: out.output.result, ...(failed ? { isError: true } : {}) };
|
|
68
|
+
});
|
|
69
|
+
queue = task.catch(() => undefined);
|
|
70
|
+
return task;
|
|
71
|
+
});
|
|
72
|
+
let closing = false;
|
|
73
|
+
const shutdown = async (reason) => {
|
|
74
|
+
if (closing)
|
|
75
|
+
return;
|
|
76
|
+
closing = true;
|
|
77
|
+
await host.lab.close(reason).catch(() => undefined);
|
|
78
|
+
await dashboard?.close().catch(() => undefined);
|
|
79
|
+
if (dashboard)
|
|
80
|
+
clearDashboardRecord(opts.stateDir);
|
|
81
|
+
process.exit(0);
|
|
82
|
+
};
|
|
83
|
+
server.onclose = () => void shutdown('mcp client disconnected');
|
|
84
|
+
process.stdin.on('end', () => void shutdown('mcp client disconnected'));
|
|
85
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP'])
|
|
86
|
+
process.on(sig, () => void shutdown(`mcp server received ${sig}`));
|
|
87
|
+
await server.connect(new StdioServerTransport());
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=server.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-device-lab",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Lets a coding agent start your web app, use it at phone, tablet or desktop size, and report what breaks, while you watch.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"agent",
|
|
7
|
+
"coding-agent",
|
|
8
|
+
"mcp",
|
|
9
|
+
"playwright",
|
|
10
|
+
"responsive",
|
|
11
|
+
"mobile",
|
|
12
|
+
"desktop",
|
|
13
|
+
"testing",
|
|
14
|
+
"dev-server",
|
|
15
|
+
"claude-code",
|
|
16
|
+
"codex"
|
|
17
|
+
],
|
|
18
|
+
"license": "Apache-2.0",
|
|
19
|
+
"author": "Colony Innovations (https://github.com/Colony-Innovations)",
|
|
20
|
+
"homepage": "https://github.com/Colony-Innovations/agent-device-lab#readme",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/Colony-Innovations/agent-device-lab.git"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/Colony-Innovations/agent-device-lab/issues"
|
|
27
|
+
},
|
|
28
|
+
"funding": {
|
|
29
|
+
"type": "patreon",
|
|
30
|
+
"url": "https://www.patreon.com/Ikafa"
|
|
31
|
+
},
|
|
32
|
+
"type": "module",
|
|
33
|
+
"bin": {
|
|
34
|
+
"agentlab": "bin/agentlab.js"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"bin/",
|
|
38
|
+
"dist/**/*.js",
|
|
39
|
+
"assets/dashboard/",
|
|
40
|
+
"README.md",
|
|
41
|
+
"LICENSE",
|
|
42
|
+
"NOTICE"
|
|
43
|
+
],
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=22"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "tsc -p tsconfig.json",
|
|
49
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
50
|
+
"test": "npm run build && node --test --test-concurrency=1 \"test/**/*.test.mjs\"",
|
|
51
|
+
"flow:clean": "npm run build && node bin/agentlab.js run flows/clean.flow.json",
|
|
52
|
+
"flow:defect": "npm run build && node bin/agentlab.js run flows/mobile-defect.flow.json",
|
|
53
|
+
"prepack": "npm run build",
|
|
54
|
+
"verify:package": "node scripts/verify-package.mjs"
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"@modelcontextprotocol/sdk": "1.32.1",
|
|
58
|
+
"playwright": "1.63.0"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@playwright/mcp": "0.0.82",
|
|
62
|
+
"@types/node": "22.20.4",
|
|
63
|
+
"typescript": "7.0.2"
|
|
64
|
+
}
|
|
65
|
+
}
|