amalgm 0.1.91 → 0.1.92
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/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/browser/attach.js +238 -0
- package/runtime/scripts/amalgm-mcp/browser/auth-tools.js +26 -40
- package/runtime/scripts/amalgm-mcp/browser/backend.js +100 -0
- package/runtime/scripts/amalgm-mcp/browser/capture.js +167 -0
- package/runtime/scripts/amalgm-mcp/browser/cdp.js +177 -0
- package/runtime/scripts/amalgm-mcp/browser/engine.js +34 -349
- package/runtime/scripts/amalgm-mcp/browser/index.js +28 -0
- package/runtime/scripts/amalgm-mcp/browser/recorder-tools.js +4 -4
- package/runtime/scripts/amalgm-mcp/browser/recorder.js +60 -72
- package/runtime/scripts/amalgm-mcp/browser/sessions.js +64 -0
- package/runtime/scripts/amalgm-mcp/browser/tools.js +35 -31
- package/runtime/scripts/amalgm-mcp/server/core-tools.js +4 -14
- package/runtime/scripts/amalgm-mcp/server/mcp.js +24 -1
- package/runtime/scripts/amalgm-mcp/tests/browser-capture.test.js +87 -0
- package/runtime/scripts/amalgm-mcp/tests/core-tools.test.js +59 -29
- package/runtime/scripts/amalgm-mcp/tests/platform-context.test.js +46 -0
- package/runtime/scripts/amalgm-mcp/tests/system-catalog.test.js +4 -4
- package/runtime/scripts/amalgm-mcp/tests/toolbox-service.test.js +2 -2
- package/runtime/scripts/amalgm-mcp/toolbox/loadout-context.js +33 -10
- package/runtime/scripts/chat-core/tool-shape.js +27 -10
- package/runtime/scripts/platform-context.txt +21 -20
|
@@ -3,15 +3,18 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Session recorder: capture any browser session to a local WebM file.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Frames come from capture.js (CDP Page.startScreencast on a target that
|
|
7
|
+
* actually rasters — the page itself headless, the app window cropped to the
|
|
8
|
+
* webview rectangle when attached) and pipe into ffmpeg. Recordings are
|
|
9
|
+
* local-only: nothing leaves this computer, and in attached mode the crop
|
|
10
|
+
* runs inside ffmpeg before any frame reaches disk, so the file only ever
|
|
11
|
+
* contains the page — not the app around it.
|
|
12
|
+
*
|
|
13
|
+
* This is deliberately NOT agent-browser's `record` command:
|
|
9
14
|
* - `record` needs ffmpeg on the daemon's PATH at daemon-spawn time, and
|
|
10
15
|
* silently "starts" even when ffmpeg is missing (fails only on stop).
|
|
11
16
|
* - `record` calls Target.createBrowserContext, which Electron forbids — so
|
|
12
17
|
* it can never capture the visible in-tab webview.
|
|
13
|
-
* A raw screencast works against every CDP target we care about: launched
|
|
14
|
-
* chromium AND the desktop app's splitscreen webview.
|
|
15
18
|
*
|
|
16
19
|
* Storage: recordings belong to the Amalgm project the work is for —
|
|
17
20
|
* <project>/.amalgm/recordings/ when a project path is known, otherwise
|
|
@@ -23,9 +26,13 @@ const path = require('path');
|
|
|
23
26
|
const { spawn } = require('child_process');
|
|
24
27
|
|
|
25
28
|
const cli = require('./cli');
|
|
26
|
-
const
|
|
29
|
+
const backend = require('./backend');
|
|
30
|
+
const capture = require('./capture');
|
|
31
|
+
const sessions = require('./sessions');
|
|
32
|
+
|
|
33
|
+
const active = new Map(); // session -> { cdp, ffmpeg, file, frames, startedAt }
|
|
27
34
|
|
|
28
|
-
const
|
|
35
|
+
const FFMPEG_CLOSE_TIMEOUT_MS = 10_000;
|
|
29
36
|
|
|
30
37
|
function ffmpegBinary() {
|
|
31
38
|
if (process.env.AMALGM_FFMPEG) return process.env.AMALGM_FFMPEG;
|
|
@@ -88,34 +95,8 @@ function recordingFile(args, context, session) {
|
|
|
88
95
|
return path.join(recordingDir(args, context, session), `${name}-${stamp}.webm`);
|
|
89
96
|
}
|
|
90
97
|
|
|
91
|
-
/**
|
|
92
|
-
* Page.startScreencast only works on a page-level CDP connection. Launched
|
|
93
|
-
* sessions report a browser-level ws URL, so resolve the active page target
|
|
94
|
-
* through the /json/list endpoint on the same debugger port.
|
|
95
|
-
*/
|
|
96
|
-
async function pageWsUrl(wsUrl) {
|
|
97
|
-
if (!/\/devtools\/browser\//.test(wsUrl)) return wsUrl;
|
|
98
|
-
const { hostname, port } = new URL(wsUrl);
|
|
99
|
-
const list = await new Promise((resolve, reject) => {
|
|
100
|
-
const http = require('http');
|
|
101
|
-
const req = http.get({ hostname, port, path: '/json/list', timeout: 5_000 }, (res) => {
|
|
102
|
-
let raw = '';
|
|
103
|
-
res.on('data', (chunk) => { raw += chunk.toString(); });
|
|
104
|
-
res.on('end', () => {
|
|
105
|
-
try { resolve(JSON.parse(raw)); } catch (err) { reject(err); }
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
req.on('error', reject);
|
|
109
|
-
req.on('timeout', () => req.destroy(new Error('Timed out listing CDP targets')));
|
|
110
|
-
});
|
|
111
|
-
const page = list.find((t) => t.type === 'webview' && t.webSocketDebuggerUrl)
|
|
112
|
-
|| list.find((t) => t.type === 'page' && t.webSocketDebuggerUrl && !t.url.startsWith('devtools://'));
|
|
113
|
-
if (!page) throw new Error('No recordable page target found on the CDP endpoint.');
|
|
114
|
-
return page.webSocketDebuggerUrl;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
98
|
async function start(args = {}, context = {}) {
|
|
118
|
-
const session =
|
|
99
|
+
const session = sessions.sessionFor(args, context);
|
|
119
100
|
if (active.has(session)) {
|
|
120
101
|
throw new Error(`Recording already active for session "${session}". Call record_stop first.`);
|
|
121
102
|
}
|
|
@@ -124,30 +105,29 @@ async function start(args = {}, context = {}) {
|
|
|
124
105
|
// and a clean refusal beats a half-started recording.
|
|
125
106
|
const binary = ensureFfmpeg();
|
|
126
107
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
108
|
+
// Where frames come from — and, in attached mode, the crop that keeps the
|
|
109
|
+
// surrounding app UI out of the file. Resolved with hard deadlines.
|
|
110
|
+
const source = await capture.plan(session, context);
|
|
130
111
|
|
|
131
|
-
const WebSocket = require('ws');
|
|
132
112
|
const file = recordingFile(args, context, session);
|
|
133
113
|
const fps = Math.min(Math.max(Number(args.fps) || 10, 1), 30);
|
|
114
|
+
const filters = [source.crop, 'pad=ceil(iw/2)*2:ceil(ih/2)*2'].filter(Boolean).join(',');
|
|
134
115
|
|
|
135
116
|
const ffmpeg = spawn(binary, [
|
|
136
117
|
'-y', '-f', 'image2pipe', '-c:v', 'mjpeg', '-framerate', String(fps), '-i', 'pipe:0',
|
|
137
|
-
'-vf',
|
|
118
|
+
'-vf', filters, '-c:v', 'libvpx', '-pix_fmt', 'yuv420p', file,
|
|
138
119
|
], { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
139
120
|
let ffmpegErr = '';
|
|
140
121
|
ffmpeg.stderr.on('data', (chunk) => { ffmpegErr = (ffmpegErr + chunk.toString()).slice(-2000); });
|
|
141
122
|
|
|
142
|
-
const
|
|
143
|
-
const recording = { ws, ffmpeg, file, frames: 0, startedAt: Date.now(), ffmpegErr: () => ffmpegErr };
|
|
123
|
+
const recording = { cdp: null, ffmpeg, file, frames: 0, startedAt: Date.now(), ffmpegErr: () => ffmpegErr };
|
|
144
124
|
|
|
145
125
|
// Failures after the preflight (binary swapped out, EMFILE, ffmpeg dying
|
|
146
126
|
// mid-recording and EPIPE-ing stdin) end this recording, not the server.
|
|
147
127
|
ffmpeg.on('error', (err) => {
|
|
148
128
|
ffmpegErr = `${err.message}${ffmpegErr ? ` ${ffmpegErr}` : ''}`;
|
|
149
129
|
if (active.get(session) === recording) active.delete(session);
|
|
150
|
-
try {
|
|
130
|
+
try { recording.cdp?.close(); } catch {}
|
|
151
131
|
});
|
|
152
132
|
ffmpeg.stdin.on('error', () => {
|
|
153
133
|
// Swallow stream errors; stop() reports the failure via exit code + stderr.
|
|
@@ -155,54 +135,62 @@ async function start(args = {}, context = {}) {
|
|
|
155
135
|
|
|
156
136
|
active.set(session, recording);
|
|
157
137
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
138
|
+
try {
|
|
139
|
+
recording.cdp = await capture.connectCdp(source.wsUrl);
|
|
140
|
+
recording.cdp.on('Page.screencastFrame', (params) => {
|
|
141
|
+
recording.frames += 1;
|
|
142
|
+
try { ffmpeg.stdin.write(Buffer.from(params.data, 'base64')); } catch {}
|
|
143
|
+
recording.cdp.send('Page.screencastFrameAck', { sessionId: params.sessionId });
|
|
144
|
+
});
|
|
145
|
+
await recording.cdp.call('Page.enable').catch(() => {});
|
|
146
|
+
await recording.cdp.call('Page.startScreencast', { format: 'jpeg', quality: 70, everyNthFrame: 1 });
|
|
147
|
+
} catch (err) {
|
|
163
148
|
active.delete(session);
|
|
149
|
+
try { recording.cdp?.close(); } catch {}
|
|
164
150
|
try { ffmpeg.kill('SIGKILL'); } catch {}
|
|
165
151
|
throw err;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
let id = 0;
|
|
169
|
-
const send = (method, params = {}) => ws.send(JSON.stringify({ id: ++id, method, params }));
|
|
170
|
-
ws.on('message', (raw) => {
|
|
171
|
-
let msg;
|
|
172
|
-
try { msg = JSON.parse(raw); } catch { return; }
|
|
173
|
-
if (msg.method === 'Page.screencastFrame') {
|
|
174
|
-
recording.frames += 1;
|
|
175
|
-
try { ffmpeg.stdin.write(Buffer.from(msg.params.data, 'base64')); } catch {}
|
|
176
|
-
send('Page.screencastFrameAck', { sessionId: msg.params.sessionId });
|
|
177
|
-
}
|
|
178
|
-
});
|
|
179
|
-
send('Page.enable');
|
|
180
|
-
send('Page.startScreencast', { format: 'jpeg', quality: 70, everyNthFrame: 1 });
|
|
152
|
+
}
|
|
181
153
|
|
|
182
|
-
return {
|
|
154
|
+
return {
|
|
155
|
+
session,
|
|
156
|
+
path: file,
|
|
157
|
+
fps,
|
|
158
|
+
mimeType: 'video/webm',
|
|
159
|
+
backend: backend.mode(),
|
|
160
|
+
cropped: Boolean(source.crop),
|
|
161
|
+
};
|
|
183
162
|
}
|
|
184
163
|
|
|
185
164
|
async function stop(args = {}, context = {}) {
|
|
186
|
-
const session =
|
|
165
|
+
const session = sessions.sessionFor(args, context);
|
|
187
166
|
const recording = active.get(session);
|
|
188
167
|
if (!recording) throw new Error(`No active recording for session "${session}".`);
|
|
189
168
|
active.delete(session);
|
|
190
169
|
|
|
191
|
-
try {
|
|
192
|
-
|
|
193
|
-
} catch {}
|
|
194
|
-
try { recording.ws.close(); } catch {}
|
|
170
|
+
try { recording.cdp?.send('Page.stopScreencast'); } catch {}
|
|
171
|
+
try { recording.cdp?.close(); } catch {}
|
|
195
172
|
|
|
173
|
+
// ffmpeg gets a bounded window to flush and finalize the container; a
|
|
174
|
+
// wedged encoder is killed rather than wedging record_stop.
|
|
196
175
|
const exitCode = await new Promise((resolve) => {
|
|
197
|
-
|
|
198
|
-
|
|
176
|
+
const killTimer = setTimeout(() => {
|
|
177
|
+
try { recording.ffmpeg.kill('SIGKILL'); } catch {}
|
|
178
|
+
}, FFMPEG_CLOSE_TIMEOUT_MS);
|
|
179
|
+
recording.ffmpeg.on('close', (code) => {
|
|
180
|
+
clearTimeout(killTimer);
|
|
181
|
+
resolve(code);
|
|
182
|
+
});
|
|
183
|
+
try { recording.ffmpeg.stdin.end(); } catch {
|
|
184
|
+
clearTimeout(killTimer);
|
|
185
|
+
resolve(-1);
|
|
186
|
+
}
|
|
199
187
|
});
|
|
200
188
|
|
|
201
189
|
const durationMs = Date.now() - recording.startedAt;
|
|
202
190
|
if (recording.frames === 0 || exitCode !== 0) {
|
|
203
191
|
try { fs.rmSync(recording.file, { force: true }); } catch {}
|
|
204
192
|
throw new Error(recording.frames === 0
|
|
205
|
-
? 'No frames captured —
|
|
193
|
+
? 'No frames captured — nothing repainted while recording (the screencast only emits on visual change).'
|
|
206
194
|
: `ffmpeg failed (exit ${exitCode}): ${recording.ffmpegErr()}`);
|
|
207
195
|
}
|
|
208
196
|
|
|
@@ -219,7 +207,7 @@ async function stop(args = {}, context = {}) {
|
|
|
219
207
|
}
|
|
220
208
|
|
|
221
209
|
function list(args = {}, context = {}) {
|
|
222
|
-
const session =
|
|
210
|
+
const session = sessions.sessionFor(args, context);
|
|
223
211
|
const dir = recordingDir(args, context, session);
|
|
224
212
|
let entries = [];
|
|
225
213
|
try {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Browser session identity and registry.
|
|
5
|
+
*
|
|
6
|
+
* A session is 1:1 with a durable profile directory; the default session id
|
|
7
|
+
* is the caller's chat session, so one conversation keeps one browser across
|
|
8
|
+
* tool calls without anyone passing ids around. Parallel sessions stay
|
|
9
|
+
* isolated because each gets its own agent-browser daemon and profile.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const cli = require('./cli');
|
|
13
|
+
const backend = require('./backend');
|
|
14
|
+
|
|
15
|
+
/** Sessions that completed the attached-mode handshake (webview selected + focused). */
|
|
16
|
+
const attachedSessions = new Set();
|
|
17
|
+
|
|
18
|
+
/** Per-session bookkeeping: webviewTabId, lastActiveAt, last url/title. */
|
|
19
|
+
const knownSessions = new Map();
|
|
20
|
+
|
|
21
|
+
function sessionFor(args = {}, context = {}) {
|
|
22
|
+
return cli.safeId(
|
|
23
|
+
args.session
|
|
24
|
+
|| args.profile
|
|
25
|
+
|| args.browserProfileId
|
|
26
|
+
|| args.browserSessionId
|
|
27
|
+
|| args.sessionId
|
|
28
|
+
|| args.tabId
|
|
29
|
+
|| context?.callerSessionId
|
|
30
|
+
|| cli.DEFAULT_SESSION,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function rememberProfile(session) {
|
|
35
|
+
try {
|
|
36
|
+
require('./profiles').touchBrowserProfile({
|
|
37
|
+
id: session,
|
|
38
|
+
name: session,
|
|
39
|
+
source: 'agent-browser',
|
|
40
|
+
profilePath: cli.profileDir(session),
|
|
41
|
+
}, { source: 'agent-browser' });
|
|
42
|
+
} catch {}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sessionInfo(session, extra = {}) {
|
|
46
|
+
rememberProfile(session);
|
|
47
|
+
const info = {
|
|
48
|
+
session,
|
|
49
|
+
browserSessionId: session,
|
|
50
|
+
browserProfileId: session,
|
|
51
|
+
backend: backend.mode(),
|
|
52
|
+
...extra,
|
|
53
|
+
};
|
|
54
|
+
knownSessions.set(session, { ...knownSessions.get(session), ...info, lastActiveAt: Date.now() });
|
|
55
|
+
return info;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = {
|
|
59
|
+
attachedSessions,
|
|
60
|
+
knownSessions,
|
|
61
|
+
rememberProfile,
|
|
62
|
+
sessionFor,
|
|
63
|
+
sessionInfo,
|
|
64
|
+
};
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Browser
|
|
4
|
+
* Browser core actions — the thin surface.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Ten verbs built on the agent-browser CLI (vercel-labs/agent-browser). The
|
|
7
|
+
* loop is: open → snapshot (@refs) → click/fill (@ref) → re-snapshot.
|
|
8
8
|
* Everything else agent-browser can do (scroll, hover, cookies, network
|
|
9
|
-
* mocking, traces, diffs, React inspection, ...) is reachable through
|
|
10
|
-
*
|
|
9
|
+
* mocking, traces, diffs, React inspection, ...) is reachable through `cli`
|
|
10
|
+
* without widening this schema.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Action names are short verbs — the toolbox layer prefixes them, so agents
|
|
13
|
+
* see toolbox__browser_open, toolbox__browser_snapshot, and so on.
|
|
14
|
+
*
|
|
15
|
+
* Visibility is environmental, never an argument: in the Amalgm desktop app
|
|
16
|
+
* these drive the visible in-tab split-view browser; everywhere else they
|
|
17
|
+
* drive a headless chromium. Same actions, same results.
|
|
14
18
|
*/
|
|
15
19
|
|
|
16
20
|
const { textResult, errorResult } = require('../lib/tool-result');
|
|
@@ -26,7 +30,7 @@ const sessionProperty = {
|
|
|
26
30
|
const targetProperty = {
|
|
27
31
|
target: {
|
|
28
32
|
type: 'string',
|
|
29
|
-
description: 'Element target: a snapshot ref like "@e12" (preferred — run
|
|
33
|
+
description: 'Element target: a snapshot ref like "@e12" (preferred — run snapshot first), a CSS selector, or "text=Visible label".',
|
|
30
34
|
},
|
|
31
35
|
};
|
|
32
36
|
|
|
@@ -38,8 +42,8 @@ function line(result, extra = '') {
|
|
|
38
42
|
|
|
39
43
|
module.exports = [
|
|
40
44
|
{
|
|
41
|
-
name: '
|
|
42
|
-
description: 'Open a URL
|
|
45
|
+
name: 'open',
|
|
46
|
+
description: 'Open a URL. The browser persists between calls — open once, then interact. Follow with snapshot to see the page as @refs. (In the Amalgm desktop app the user watches this browser in a split view.)',
|
|
43
47
|
inputSchema: {
|
|
44
48
|
type: 'object',
|
|
45
49
|
properties: { url: { type: 'string', description: 'URL to open' }, ...sessionProperty },
|
|
@@ -50,15 +54,15 @@ module.exports = [
|
|
|
50
54
|
try {
|
|
51
55
|
const result = await engine.open(args, ctx);
|
|
52
56
|
return textResult(line(result, `Opened: ${result.title || args.url}`)
|
|
53
|
-
+ '\n\nNext:
|
|
57
|
+
+ '\n\nNext: snapshot lists interactive elements as @refs.');
|
|
54
58
|
} catch (err) {
|
|
55
59
|
return errorResult(`Open failed: ${err.message}`);
|
|
56
60
|
}
|
|
57
61
|
},
|
|
58
62
|
},
|
|
59
63
|
{
|
|
60
|
-
name: '
|
|
61
|
-
description: '
|
|
64
|
+
name: 'snapshot',
|
|
65
|
+
description: 'Read the page: an accessibility snapshot with a stable @ref for every interactive element. This is how you see the page — pass the refs to click and fill.',
|
|
62
66
|
inputSchema: { type: 'object', properties: sessionProperty },
|
|
63
67
|
async handler(args, ctx) {
|
|
64
68
|
try {
|
|
@@ -71,22 +75,22 @@ module.exports = [
|
|
|
71
75
|
},
|
|
72
76
|
},
|
|
73
77
|
{
|
|
74
|
-
name: '
|
|
75
|
-
description: 'Screenshot the
|
|
78
|
+
name: 'screenshot',
|
|
79
|
+
description: 'Screenshot the page (PNG). Prefer snapshot for driving the page; use this to verify visuals. fullPage captures the whole scroll height when headless; in the desktop app you always get the visible surface.',
|
|
76
80
|
inputSchema: {
|
|
77
81
|
type: 'object',
|
|
78
82
|
properties: {
|
|
79
|
-
fullPage: { type: 'boolean', description: 'Capture the full scrollable page instead of the viewport' },
|
|
83
|
+
fullPage: { type: 'boolean', description: 'Capture the full scrollable page instead of the viewport (headless only)' },
|
|
80
84
|
...sessionProperty,
|
|
81
85
|
},
|
|
82
86
|
},
|
|
83
87
|
async handler(args, ctx) {
|
|
84
88
|
try {
|
|
85
|
-
const
|
|
89
|
+
const shot = await engine.screenshot(args, ctx);
|
|
86
90
|
return {
|
|
87
91
|
content: [
|
|
88
|
-
{ type: 'image', data:
|
|
89
|
-
{ type: 'text', text: `Screenshot (${
|
|
92
|
+
{ type: 'image', data: shot.base64, mimeType: 'image/png' },
|
|
93
|
+
{ type: 'text', text: `Screenshot (${shot.bytes} bytes, ${shot.mode})` },
|
|
90
94
|
],
|
|
91
95
|
};
|
|
92
96
|
} catch (err) {
|
|
@@ -95,7 +99,7 @@ module.exports = [
|
|
|
95
99
|
},
|
|
96
100
|
},
|
|
97
101
|
{
|
|
98
|
-
name: '
|
|
102
|
+
name: 'click',
|
|
99
103
|
description: 'Click an element. Target with a snapshot @ref, CSS selector, or "text=Label".',
|
|
100
104
|
inputSchema: {
|
|
101
105
|
type: 'object',
|
|
@@ -106,14 +110,14 @@ module.exports = [
|
|
|
106
110
|
if (!args.target) return errorResult('target is required (e.g. "@e3", "#submit", "text=Sign in")');
|
|
107
111
|
try {
|
|
108
112
|
const result = await engine.click(args, ctx);
|
|
109
|
-
return textResult(`Clicked ${args.target}\nSession: ${result.session}\n\nRe-run
|
|
113
|
+
return textResult(`Clicked ${args.target}\nSession: ${result.session}\n\nRe-run snapshot if the page changed.`);
|
|
110
114
|
} catch (err) {
|
|
111
115
|
return errorResult(`Click failed: ${err.message}`);
|
|
112
116
|
}
|
|
113
117
|
},
|
|
114
118
|
},
|
|
115
119
|
{
|
|
116
|
-
name: '
|
|
120
|
+
name: 'fill',
|
|
117
121
|
description: 'Clear a field and type into it. Optionally press Enter to submit.',
|
|
118
122
|
inputSchema: {
|
|
119
123
|
type: 'object',
|
|
@@ -136,7 +140,7 @@ module.exports = [
|
|
|
136
140
|
},
|
|
137
141
|
},
|
|
138
142
|
{
|
|
139
|
-
name: '
|
|
143
|
+
name: 'press',
|
|
140
144
|
description: 'Press a key or combination at the focused element, e.g. "Enter", "Escape", "Tab", "Control+a".',
|
|
141
145
|
inputSchema: {
|
|
142
146
|
type: 'object',
|
|
@@ -154,8 +158,8 @@ module.exports = [
|
|
|
154
158
|
},
|
|
155
159
|
},
|
|
156
160
|
{
|
|
157
|
-
name: '
|
|
158
|
-
description: 'Run JavaScript in the page and return the result
|
|
161
|
+
name: 'eval',
|
|
162
|
+
description: 'Run JavaScript in the page and return the result — the escape hatch for reading or mutating anything the page knows.',
|
|
159
163
|
inputSchema: {
|
|
160
164
|
type: 'object',
|
|
161
165
|
properties: { script: { type: 'string', description: 'JavaScript to evaluate' }, ...sessionProperty },
|
|
@@ -173,7 +177,7 @@ module.exports = [
|
|
|
173
177
|
},
|
|
174
178
|
},
|
|
175
179
|
{
|
|
176
|
-
name: '
|
|
180
|
+
name: 'wait',
|
|
177
181
|
description: 'Wait for a CSS selector to appear, a URL substring/wildcard to match, or a fixed number of milliseconds.',
|
|
178
182
|
inputSchema: {
|
|
179
183
|
type: 'object',
|
|
@@ -195,7 +199,7 @@ module.exports = [
|
|
|
195
199
|
},
|
|
196
200
|
},
|
|
197
201
|
{
|
|
198
|
-
name: '
|
|
202
|
+
name: 'cli',
|
|
199
203
|
description: 'Run any agent-browser CLI command against this session — the full surface beyond the core verbs: ["scroll","down"], ["hover","@e3"], ["back"], ["tab"], ["get","html","@e1"], ["cookies","get"], ["network","requests"], ["find","role","button","click","--name","Submit"], and more. Run ["--help"] for the command list.',
|
|
200
204
|
inputSchema: {
|
|
201
205
|
type: 'object',
|
|
@@ -212,18 +216,18 @@ module.exports = [
|
|
|
212
216
|
const output = typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
|
|
213
217
|
return textResult(output.slice(0, 15_000) || 'ok');
|
|
214
218
|
} catch (err) {
|
|
215
|
-
return errorResult(`
|
|
219
|
+
return errorResult(`cli failed: ${err.message}`);
|
|
216
220
|
}
|
|
217
221
|
},
|
|
218
222
|
},
|
|
219
223
|
{
|
|
220
|
-
name: '
|
|
221
|
-
description: '
|
|
224
|
+
name: 'close',
|
|
225
|
+
description: 'End the browser session. Headless browsers shut down; the visible desktop surface stays open for the user. A fresh session starts on the next browser call.',
|
|
222
226
|
inputSchema: { type: 'object', properties: sessionProperty },
|
|
223
227
|
async handler(args, ctx) {
|
|
224
228
|
try {
|
|
225
229
|
await engine.close(args, ctx);
|
|
226
|
-
return textResult('Browser closed.');
|
|
230
|
+
return textResult('Browser session closed.');
|
|
227
231
|
} catch (err) {
|
|
228
232
|
return errorResult(`Close failed: ${err.message}`);
|
|
229
233
|
}
|
|
@@ -8,10 +8,6 @@
|
|
|
8
8
|
* circular dependency on the MCP server.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
function withCapability(tools, capability) {
|
|
12
|
-
return tools.map((tool) => ({ ...tool, capability }));
|
|
13
|
-
}
|
|
14
|
-
|
|
15
11
|
function group(id, name, description, tools, extra = {}) {
|
|
16
12
|
return {
|
|
17
13
|
id,
|
|
@@ -64,19 +60,13 @@ const CORE_TOOL_GROUPS = [
|
|
|
64
60
|
),
|
|
65
61
|
// Browser is ONE toolbox entry. Users enable/disable it per agent as a
|
|
66
62
|
// single tool; fine-grained control stays available because loadouts can
|
|
67
|
-
// hold individual action ids (`browser.cua_click`).
|
|
68
|
-
//
|
|
69
|
-
// recording / auth) without splitting the entry.
|
|
63
|
+
// hold individual action ids (`browser.cua_click`). Action list, capability
|
|
64
|
+
// sections, and description live with the implementation in ../browser.
|
|
70
65
|
group(
|
|
71
66
|
'browser',
|
|
72
67
|
'Browser',
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
...withCapability(require('../browser/tools'), 'core'),
|
|
76
|
-
...withCapability(require('../browser/cua-tools'), 'computer-use'),
|
|
77
|
-
...withCapability(require('../browser/recorder-tools'), 'recording'),
|
|
78
|
-
...withCapability(require('../browser/auth-tools'), 'auth'),
|
|
79
|
-
],
|
|
68
|
+
require('../browser').DESCRIPTION,
|
|
69
|
+
require('../browser').ACTIONS,
|
|
80
70
|
),
|
|
81
71
|
];
|
|
82
72
|
|
|
@@ -24,6 +24,27 @@ const { buildMcpRequestContext } = require('./mcp-request-context');
|
|
|
24
24
|
|
|
25
25
|
const TOOLS = DEFAULT_STATIC_MCP_TOOLS;
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* One greppable line whenever a session's tool registry changes shape. If a
|
|
29
|
+
* client ever reports "No such tool available" again, this proves what the
|
|
30
|
+
* server actually served (the 2026-06-10 partial-registry incident was
|
|
31
|
+
* unattributable because nothing logged tools/list).
|
|
32
|
+
*/
|
|
33
|
+
const lastToolListHash = new Map();
|
|
34
|
+
function logToolList(ctx, tools) {
|
|
35
|
+
try {
|
|
36
|
+
const names = tools.map((tool) => tool.name).sort();
|
|
37
|
+
const hash = require('crypto').createHash('sha1').update(names.join('\n')).digest('hex').slice(0, 8);
|
|
38
|
+
const session = ctx?.callerSessionId || 'unknown';
|
|
39
|
+
if (lastToolListHash.get(session) === hash) return;
|
|
40
|
+
lastToolListHash.set(session, hash);
|
|
41
|
+
if (lastToolListHash.size > 500) {
|
|
42
|
+
lastToolListHash.delete(lastToolListHash.keys().next().value);
|
|
43
|
+
}
|
|
44
|
+
console.error(`[amalgm-mcp] tools/list session=${session} count=${tools.length} names-sha1=${hash}`);
|
|
45
|
+
} catch {}
|
|
46
|
+
}
|
|
47
|
+
|
|
27
48
|
function createMcpServer(options = {}) {
|
|
28
49
|
const surface = options.surface || createToolboxMcpSurface();
|
|
29
50
|
const buildRequestContext = options.buildRequestContext || buildMcpRequestContext;
|
|
@@ -34,7 +55,9 @@ function createMcpServer(options = {}) {
|
|
|
34
55
|
|
|
35
56
|
server.setRequestHandler(ListToolsRequestSchema, async (_request, extra) => {
|
|
36
57
|
const ctx = await buildRequestContext(extra);
|
|
37
|
-
|
|
58
|
+
const tools = await surface.listTools(ctx);
|
|
59
|
+
logToolList(ctx, tools);
|
|
60
|
+
return { tools };
|
|
38
61
|
});
|
|
39
62
|
|
|
40
63
|
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const test = require('node:test');
|
|
5
|
+
const http = require('node:http');
|
|
6
|
+
|
|
7
|
+
const capture = require('../browser/capture');
|
|
8
|
+
|
|
9
|
+
test('clipFor turns a webview rect into integer CSS-px clip bounds', () => {
|
|
10
|
+
const clip = capture.clipFor({ x: 411.5, y: 52.25, width: 868.75, height: 781.5, vw: 1280, vh: 834 });
|
|
11
|
+
assert.deepEqual(clip, { x: 412, y: 52, width: 869, height: 782, scale: 1 });
|
|
12
|
+
// Negative origins (mid-animation surfaces) clamp to the viewport.
|
|
13
|
+
assert.equal(capture.clipFor({ x: -4, y: 0, width: 100, height: 100 }).x, 0);
|
|
14
|
+
// Retina displays render captures at devicePixelRatio; scale divides it
|
|
15
|
+
// back out so image pixels map 1:1 onto cua input coordinates.
|
|
16
|
+
assert.equal(capture.clipFor({ x: 0, y: 0, width: 100, height: 100, dpr: 2 }).scale, 0.5);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('cropFilterFor crops by viewport fraction so any frame resolution works', () => {
|
|
20
|
+
const filter = capture.cropFilterFor({ x: 320, y: 0, width: 640, height: 600, vw: 1280, vh: 800 });
|
|
21
|
+
// width 640/1280=0.5, height 600/800=0.75, x 320/1280=0.25, y 0.
|
|
22
|
+
assert.equal(filter, 'crop=floor(iw*0.500000/2)*2:floor(ih*0.750000/2)*2:floor(iw*0.250000):floor(ih*0.000000)');
|
|
23
|
+
// Fractions clamp into [0, 1] even for slightly-out-of-viewport rects.
|
|
24
|
+
assert.match(capture.cropFilterFor({ x: -10, y: 0, width: 2000, height: 100, vw: 1000, vh: 500 }),
|
|
25
|
+
/^crop=floor\(iw\*1\.000000\/2\)\*2/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('pageTargets keeps only capturable page targets', () => {
|
|
29
|
+
const targets = [
|
|
30
|
+
{ type: 'page', url: 'devtools://devtools/bundled/devtools_app.html', webSocketDebuggerUrl: 'ws://x/1' },
|
|
31
|
+
{ type: 'webview', url: 'https://example.com/', webSocketDebuggerUrl: 'ws://x/2' },
|
|
32
|
+
{ type: 'page', url: 'http://localhost:3100/', webSocketDebuggerUrl: 'ws://x/3' },
|
|
33
|
+
{ type: 'page', url: 'http://localhost:3100/popup' },
|
|
34
|
+
];
|
|
35
|
+
assert.deepEqual(capture.pageTargets(targets).map((t) => t.webSocketDebuggerUrl), ['ws://x/3']);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('rectScript matches by marker, then current URL, then the only webview', () => {
|
|
39
|
+
const script = capture.rectScript('amalgm-bsid-abc', 'https://example.com/');
|
|
40
|
+
assert.match(script, /amalgm-bsid-abc/);
|
|
41
|
+
assert.match(script, /https:\/\/example\.com\//);
|
|
42
|
+
// No current URL → the byUrl branch is disabled, not matching everything.
|
|
43
|
+
const bare = capture.rectScript('amalgm-bsid-abc', '');
|
|
44
|
+
assert.match(bare, /"" \? views\.find/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('targetListUrl accepts bare ports and ws urls', () => {
|
|
48
|
+
assert.equal(capture.targetListUrl('9242'), 'http://127.0.0.1:9242/json/list');
|
|
49
|
+
assert.equal(
|
|
50
|
+
capture.targetListUrl('ws://127.0.0.1:9242/devtools/browser/abc'),
|
|
51
|
+
'http://127.0.0.1:9242/json/list',
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('deadline rejects instead of hanging', async () => {
|
|
56
|
+
await assert.rejects(
|
|
57
|
+
() => capture.deadline(new Promise(() => {}), 50, 'Page.captureScreenshot'),
|
|
58
|
+
/Page\.captureScreenshot timed out after 50ms/,
|
|
59
|
+
);
|
|
60
|
+
assert.equal(await capture.deadline(Promise.resolve('ok'), 50, 'fast'), 'ok');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a CDP endpoint that accepts but never answers cannot wedge a capture', async () => {
|
|
64
|
+
// Plain HTTP server: the websocket upgrade never completes, so connectCdp
|
|
65
|
+
// must give up on its own deadline rather than waiting forever — this is
|
|
66
|
+
// the daemon-wedge class of failure, now structurally impossible.
|
|
67
|
+
const server = http.createServer(() => {});
|
|
68
|
+
server.on('upgrade', () => { /* swallow the upgrade — never respond */ });
|
|
69
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
70
|
+
const { port } = server.address();
|
|
71
|
+
const startedAt = Date.now();
|
|
72
|
+
await assert.rejects(
|
|
73
|
+
() => capture.connectCdp(`ws://127.0.0.1:${port}/devtools/page/never`),
|
|
74
|
+
/CDP connect timed out/,
|
|
75
|
+
);
|
|
76
|
+
assert.equal(Date.now() - startedAt < 15_000, true);
|
|
77
|
+
server.close();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('listTargets rejects cleanly when nothing listens on the port', async () => {
|
|
81
|
+
// Find a free port, then close it so the request must fail fast.
|
|
82
|
+
const server = http.createServer(() => {});
|
|
83
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
84
|
+
const { port } = server.address();
|
|
85
|
+
await new Promise((resolve) => server.close(resolve));
|
|
86
|
+
await assert.rejects(() => capture.listTargets(String(port)));
|
|
87
|
+
});
|