amalgm 0.1.89 → 0.1.90
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/cli.js +10 -5
- package/runtime/scripts/amalgm-mcp/browser/engine.js +290 -146
- package/runtime/scripts/amalgm-mcp/server/core-tools.js +16 -28
- package/runtime/scripts/amalgm-mcp/state/snapshot.js +4 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-routing.test.js +133 -0
- package/runtime/scripts/amalgm-mcp/tests/core-tools.test.js +67 -12
- package/runtime/scripts/amalgm-mcp/toolbox/loadout-context.js +30 -0
- package/runtime/scripts/amalgm-mcp/toolbox/system-catalog.js +6 -2
- package/runtime/scripts/amalgm-mcp/browser/electron-bridge.js +0 -281
|
@@ -1,60 +1,122 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Browser engine: the verb set behind the browser
|
|
5
|
-
* backend routing.
|
|
4
|
+
* Browser engine: the verb set behind the browser toolbox entry, with
|
|
5
|
+
* deterministic backend routing by entrypoint.
|
|
6
6
|
*
|
|
7
|
-
* Backends
|
|
8
|
-
* 1. "attached"
|
|
9
|
-
* app's
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
7
|
+
* Backends — exactly two:
|
|
8
|
+
* 1. "attached" — agent-browser connected over CDP to a visible chromium.
|
|
9
|
+
* On desktop that is the Amalgm app's own webContents: the app enables
|
|
10
|
+
* remote debugging on localhost and advertises its CDP URL in
|
|
11
|
+
* electron-browser-bridge.json under the runtime state dir. The engine
|
|
12
|
+
* attaches, asks the UI (via a browser_surfaces state event) to open the
|
|
13
|
+
* in-tab split view, and selects that webview target — so commands drive
|
|
14
|
+
* the surface the user is watching. An explicit AMALGM_BROWSER_CDP_URL
|
|
15
|
+
* attaches to any other CDP endpoint the same way.
|
|
16
|
+
* 2. "cli" — standalone agent-browser chromium, headless. The deterministic
|
|
17
|
+
* path for npm-only machines, servers, and the web entrypoint.
|
|
18
|
+
*
|
|
19
|
+
* The rule: a CDP advertisement (env or fresh bridge file) ⇒ attached;
|
|
20
|
+
* otherwise ⇒ cli. AMALGM_BROWSER_BACKEND forces a backend for debugging.
|
|
16
21
|
*
|
|
17
22
|
* Sessions map to durable profiles; the default session for a tool call is
|
|
18
23
|
* the caller's chat session, so one conversation keeps one browser.
|
|
19
24
|
*/
|
|
20
25
|
|
|
21
|
-
const electron = require('./electron-bridge');
|
|
22
26
|
const cli = require('./cli');
|
|
23
27
|
|
|
24
28
|
const attachedSessions = new Set();
|
|
25
29
|
const knownSessions = new Map();
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
const SURFACE_EVENT_RESOURCE = 'browser_surfaces';
|
|
32
|
+
const WEBVIEW_MARKER_PREFIX = 'amalgm-bsid-';
|
|
33
|
+
const WEBVIEW_WAIT_MS = 15_000;
|
|
34
|
+
const WEBVIEW_POLL_MS = 500;
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Backend routing
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
function pidIsRunning(pid) {
|
|
41
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
42
|
+
try {
|
|
43
|
+
process.kill(pid, 0);
|
|
44
|
+
return true;
|
|
45
|
+
} catch (err) {
|
|
46
|
+
return Boolean(err && err.code === 'EPERM');
|
|
47
|
+
}
|
|
30
48
|
}
|
|
31
49
|
|
|
32
|
-
|
|
33
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Bridge advertisement candidates, most specific first. Every runtime
|
|
52
|
+
* generation can find the file written by the desktop app:
|
|
53
|
+
* - Electron-spawned and LaunchAgent runtimes have AMALGM_RUNTIME_STATE_DIR;
|
|
54
|
+
* - runtimes with only AMALGM_DIR (+ label) derive the same path;
|
|
55
|
+
* - bare dev runtimes fall back to the unscoped roots.
|
|
56
|
+
*/
|
|
57
|
+
function bridgeFileCandidates() {
|
|
58
|
+
const path = require('path');
|
|
59
|
+
const os = require('os');
|
|
60
|
+
const dirs = [];
|
|
61
|
+
if (process.env.AMALGM_RUNTIME_STATE_DIR) dirs.push(process.env.AMALGM_RUNTIME_STATE_DIR);
|
|
62
|
+
const amalgmDir = process.env.AMALGM_DIR;
|
|
63
|
+
const label = process.env.AMALGM_RUNTIME_LABEL || process.env.AMALGM_BRANCH;
|
|
64
|
+
if (amalgmDir && label) dirs.push(path.join(amalgmDir, 'runtimes', label));
|
|
65
|
+
if (amalgmDir) dirs.push(amalgmDir);
|
|
66
|
+
dirs.push(path.join(os.homedir(), '.amalgm'));
|
|
67
|
+
return [...new Set(dirs)].map((dir) => path.join(dir, 'electron-browser-bridge.json'));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readBridgeAdvertisement() {
|
|
71
|
+
const fs = require('fs');
|
|
72
|
+
for (const file of bridgeFileCandidates()) {
|
|
73
|
+
try {
|
|
74
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
75
|
+
if (typeof data?.cdpUrl !== 'string' || !data.cdpUrl) continue;
|
|
76
|
+
if (typeof data?.pid === 'number' && !pidIsRunning(data.pid)) continue;
|
|
77
|
+
return { cdpUrl: data.cdpUrl, cdpPort: data.cdpPort, label: data.label, file };
|
|
78
|
+
} catch {}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Reduce a CDP url/port advertisement to the value agent-browser's --cdp flag takes. */
|
|
84
|
+
function cdpFlagValue(input) {
|
|
85
|
+
const raw = String(input || '').trim();
|
|
86
|
+
if (!raw) return null;
|
|
87
|
+
if (/^\d+$/.test(raw)) return raw;
|
|
34
88
|
try {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
const file = path.join(cli.amalgmDir(), 'electron-browser-bridge.json');
|
|
38
|
-
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
39
|
-
if (typeof data?.cdpUrl === 'string' && data.cdpUrl) return data.cdpUrl;
|
|
40
|
-
if (Number.isInteger(data?.cdpPort) && data.cdpPort > 0) return String(data.cdpPort);
|
|
89
|
+
const url = new URL(raw);
|
|
90
|
+
if (url.port) return url.port;
|
|
41
91
|
} catch {}
|
|
42
|
-
return
|
|
92
|
+
return raw;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The CDP endpoint for attached mode, as a --cdp flag value. agent-browser's
|
|
97
|
+
* `connect` command launches a managed browser; raw --cdp attach is the mode
|
|
98
|
+
* that discovers Electron <webview> targets, so that is all we use.
|
|
99
|
+
*/
|
|
100
|
+
function cdpEndpoint() {
|
|
101
|
+
if (process.env.AMALGM_BROWSER_CDP_URL) return cdpFlagValue(process.env.AMALGM_BROWSER_CDP_URL);
|
|
102
|
+
const advertisement = readBridgeAdvertisement();
|
|
103
|
+
if (!advertisement) return null;
|
|
104
|
+
if (advertisement.cdpPort) return String(advertisement.cdpPort);
|
|
105
|
+
return cdpFlagValue(advertisement.cdpUrl);
|
|
43
106
|
}
|
|
44
107
|
|
|
45
108
|
function mode() {
|
|
46
109
|
const requested = (process.env.AMALGM_BROWSER_BACKEND || '').toLowerCase();
|
|
47
|
-
if (
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
if (electron.isConfigured() || isPackagedDesktopRuntime()) {
|
|
51
|
-
return cdpUrl() ? 'attached' : 'electron';
|
|
52
|
-
}
|
|
53
|
-
return 'cli';
|
|
110
|
+
if (['agent-browser', 'external', 'cli', 'headless'].includes(requested)) return 'cli';
|
|
111
|
+
if (['attached', 'cdp', 'electron'].includes(requested)) return 'attached';
|
|
112
|
+
return cdpEndpoint() ? 'attached' : 'cli';
|
|
54
113
|
}
|
|
55
114
|
|
|
56
|
-
|
|
57
|
-
|
|
115
|
+
/** Options every cli call needs for the current backend. */
|
|
116
|
+
function cliOptions(session, extra = {}) {
|
|
117
|
+
const options = { session, ...extra };
|
|
118
|
+
if (mode() === 'attached') options.cdp = cdpEndpoint();
|
|
119
|
+
return options;
|
|
58
120
|
}
|
|
59
121
|
|
|
60
122
|
function sessionFor(args = {}, context = {}) {
|
|
@@ -94,34 +156,154 @@ function sessionInfo(session, extra = {}) {
|
|
|
94
156
|
return info;
|
|
95
157
|
}
|
|
96
158
|
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Attached mode: surface open + webview target selection
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Ask the UI to open the in-tab browser surface for this session. Published
|
|
165
|
+
* on the runtime state-event bus; ChatView subscribes to browser_surfaces
|
|
166
|
+
* and mounts the split-view webview with a marker URL the target scan below
|
|
167
|
+
* can recognize.
|
|
168
|
+
*/
|
|
169
|
+
function requestSurfaceOpen(session, context) {
|
|
170
|
+
try {
|
|
171
|
+
const { appendStateEvent } = require('../state/events');
|
|
172
|
+
appendStateEvent({
|
|
173
|
+
resource: SURFACE_EVENT_RESOURCE,
|
|
174
|
+
op: 'insert',
|
|
175
|
+
id: session,
|
|
176
|
+
value: {
|
|
177
|
+
browserSessionId: session,
|
|
178
|
+
callerSessionId: context?.callerSessionId || null,
|
|
179
|
+
marker: `${WEBVIEW_MARKER_PREFIX}${session}`,
|
|
180
|
+
requestedAt: new Date().toISOString(),
|
|
181
|
+
},
|
|
182
|
+
source: 'browser-engine',
|
|
183
|
+
});
|
|
184
|
+
return true;
|
|
185
|
+
} catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function listWebviewTabs(session) {
|
|
191
|
+
const tabs = await cli.runCli(['tab'], cliOptions(session, { timeoutMs: 15_000 }));
|
|
192
|
+
const list = tabs?.data?.tabs || tabs?.tabs || [];
|
|
193
|
+
return list.filter((tab) => tab.type === 'webview');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function matchWebview(webviews, session) {
|
|
197
|
+
const marker = `${WEBVIEW_MARKER_PREFIX}${session}`;
|
|
198
|
+
const byMarker = webviews.find((tab) => String(tab.url || '').includes(marker)
|
|
199
|
+
|| String(tab.title || '').includes(marker));
|
|
200
|
+
if (byMarker) return byMarker;
|
|
201
|
+
const previous = knownSessions.get(session)?.webviewTabId;
|
|
202
|
+
if (previous) {
|
|
203
|
+
const byPrevious = webviews.find((tab) => tab.tabId === previous);
|
|
204
|
+
if (byPrevious) return byPrevious;
|
|
205
|
+
}
|
|
206
|
+
if (webviews.length === 1) return webviews[0];
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
97
210
|
/**
|
|
98
|
-
* In attached mode, connect the session to the
|
|
99
|
-
*
|
|
100
|
-
*
|
|
211
|
+
* In attached mode, connect the session to the advertised chromium on first
|
|
212
|
+
* use and select the in-tab webview target so commands drive the surface the
|
|
213
|
+
* user is looking at. Fails closed: if no webview target can be found we
|
|
214
|
+
* error with instructions instead of silently driving the outer app window.
|
|
101
215
|
*/
|
|
102
|
-
async function ensureReady(session) {
|
|
216
|
+
async function ensureReady(session, context) {
|
|
103
217
|
if (mode() !== 'attached' || attachedSessions.has(session)) return;
|
|
104
|
-
|
|
105
|
-
|
|
218
|
+
|
|
219
|
+
let webviews = await listWebviewTabs(session);
|
|
220
|
+
let selected = matchWebview(webviews, session);
|
|
221
|
+
|
|
222
|
+
if (!selected) {
|
|
223
|
+
requestSurfaceOpen(session, context);
|
|
224
|
+
const deadline = Date.now() + WEBVIEW_WAIT_MS;
|
|
225
|
+
while (!selected && Date.now() < deadline) {
|
|
226
|
+
await new Promise((resolve) => setTimeout(resolve, WEBVIEW_POLL_MS));
|
|
227
|
+
webviews = await listWebviewTabs(session);
|
|
228
|
+
selected = matchWebview(webviews, session);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!selected && webviews.length === 0 && process.env.AMALGM_BROWSER_CDP_URL) {
|
|
233
|
+
// Explicit external CDP endpoints (plain chromium) have page targets,
|
|
234
|
+
// not webviews — drive whatever the daemon selected by default.
|
|
235
|
+
attachedSessions.add(session);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!selected) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
'Browser surface did not open in the desktop app. '
|
|
242
|
+
+ 'Retry, or force a headless browser with AMALGM_BROWSER_BACKEND=cli.',
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
await cli.runCli(['tab', selected.tabId], cliOptions(session, { timeoutMs: 15_000 }));
|
|
247
|
+
await focusWebviewHost(session, selected);
|
|
248
|
+
knownSessions.set(session, { ...knownSessions.get(session), webviewTabId: selected.tabId });
|
|
249
|
+
attachedSessions.add(session);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Chromium drops CDP Input events to an Electron <webview> guest until the
|
|
254
|
+
* guest widget has focus (verified live: clicks report success but never
|
|
255
|
+
* land). One focus pass after target selection fixes input for the session:
|
|
256
|
+
* focus the <webview> element from the embedder page, then self-focus the
|
|
257
|
+
* guest. Best-effort — failures leave snapshot/eval working regardless.
|
|
258
|
+
*/
|
|
259
|
+
async function focusWebviewHost(session, selected) {
|
|
260
|
+
const marker = `${WEBVIEW_MARKER_PREFIX}${session}`;
|
|
106
261
|
try {
|
|
107
|
-
const tabs = await cli.runCli(['tab'],
|
|
262
|
+
const tabs = await cli.runCli(['tab'], cliOptions(session, { timeoutMs: 15_000 }));
|
|
108
263
|
const list = tabs?.data?.tabs || tabs?.tabs || [];
|
|
109
|
-
const
|
|
110
|
-
if (
|
|
264
|
+
const outer = list.find((tab) => tab.type === 'page');
|
|
265
|
+
if (outer) {
|
|
266
|
+
const script = `(() => {
|
|
267
|
+
const views = [...document.querySelectorAll('webview')];
|
|
268
|
+
const target = views.find((w) => (w.getAttribute('src') || '').includes(${JSON.stringify(marker)}))
|
|
269
|
+
|| views.find((w) => (w.src || '') === ${JSON.stringify(String(selected.url || ''))})
|
|
270
|
+
|| views[views.length - 1];
|
|
271
|
+
if (target) target.focus();
|
|
272
|
+
return views.length;
|
|
273
|
+
})()`;
|
|
274
|
+
await cli.runCli(['tab', outer.tabId], cliOptions(session, { timeoutMs: 15_000 }));
|
|
275
|
+
await cli.runCli(['eval', script], cliOptions(session, { timeoutMs: 15_000 }));
|
|
276
|
+
await cli.runCli(['tab', selected.tabId], cliOptions(session, { timeoutMs: 15_000 }));
|
|
277
|
+
}
|
|
278
|
+
await cli.runCli(['eval', 'window.focus()'], cliOptions(session, { timeoutMs: 15_000 }));
|
|
111
279
|
} catch {
|
|
112
|
-
//
|
|
280
|
+
// Snapshot/eval still work without focus; input may need a retry.
|
|
113
281
|
}
|
|
114
|
-
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function looksLikeLostConnection(err) {
|
|
285
|
+
return /clos|connect|target|socket|detach/i.test(String(err?.message || err || ''));
|
|
115
286
|
}
|
|
116
287
|
|
|
117
288
|
async function run(session, commandArgs, options = {}) {
|
|
118
|
-
|
|
119
|
-
|
|
289
|
+
const { context, ...cliExtra } = options;
|
|
290
|
+
await ensureReady(session, context);
|
|
291
|
+
try {
|
|
292
|
+
return await cli.runCli(commandArgs, cliOptions(session, cliExtra));
|
|
293
|
+
} catch (err) {
|
|
294
|
+
if (mode() === 'attached' && attachedSessions.has(session) && looksLikeLostConnection(err)) {
|
|
295
|
+
// The app restarted or the webview remounted; re-attach once.
|
|
296
|
+
attachedSessions.delete(session);
|
|
297
|
+
await ensureReady(session, context);
|
|
298
|
+
return cli.runCli(commandArgs, cliOptions(session, cliExtra));
|
|
299
|
+
}
|
|
300
|
+
throw err;
|
|
301
|
+
}
|
|
120
302
|
}
|
|
121
303
|
|
|
122
304
|
async function readText(session, commandArgs, timeoutMs) {
|
|
123
305
|
await ensureReady(session);
|
|
124
|
-
return cli.readText(commandArgs, session, timeoutMs);
|
|
306
|
+
return cli.readText(commandArgs, session, timeoutMs, mode() === 'attached' ? cdpEndpoint() : null);
|
|
125
307
|
}
|
|
126
308
|
|
|
127
309
|
/**
|
|
@@ -141,20 +323,12 @@ function findArgs(target, action, value) {
|
|
|
141
323
|
return command;
|
|
142
324
|
}
|
|
143
325
|
|
|
144
|
-
function electronLocator(target) {
|
|
145
|
-
if (target.startsWith('@')) return { ref: target };
|
|
146
|
-
if (target.startsWith('text=')) return { text: target.slice('text='.length) };
|
|
147
|
-
return { selector: target };
|
|
148
|
-
}
|
|
149
|
-
|
|
150
326
|
// ---------------------------------------------------------------------------
|
|
151
327
|
// Core verbs
|
|
152
328
|
// ---------------------------------------------------------------------------
|
|
153
329
|
|
|
154
330
|
async function state(args = {}, context) {
|
|
155
331
|
const session = sessionFor(args, context);
|
|
156
|
-
if (usesElectronBridge()) return electron.stateBrowser({ browserSessionId: session }, context);
|
|
157
|
-
|
|
158
332
|
let url = '';
|
|
159
333
|
let title = '';
|
|
160
334
|
try { url = await readText(session, ['get', 'url']); } catch {}
|
|
@@ -164,39 +338,24 @@ async function state(args = {}, context) {
|
|
|
164
338
|
|
|
165
339
|
async function open(args = {}, context) {
|
|
166
340
|
const session = sessionFor(args, context);
|
|
167
|
-
|
|
168
|
-
const result = await electron.navigateBrowser({ url: args.url, browserSessionId: session }, context);
|
|
169
|
-
return sessionInfo(session, { url: result.url || args.url, title: result.title || '' });
|
|
170
|
-
}
|
|
171
|
-
await run(session, ['open', args.url]);
|
|
341
|
+
await run(session, ['open', args.url], { context });
|
|
172
342
|
return state(args, context);
|
|
173
343
|
}
|
|
174
344
|
|
|
175
345
|
async function snapshot(args = {}, context) {
|
|
176
346
|
const session = sessionFor(args, context);
|
|
177
|
-
|
|
178
|
-
const result = await electron.snapshotBrowser({ browserSessionId: session }, context);
|
|
179
|
-
return sessionInfo(session, {
|
|
180
|
-
url: result.url,
|
|
181
|
-
title: result.title,
|
|
182
|
-
snapshotText: result.snapshotText || result.snapshot || '',
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
const result = await run(session, ['snapshot', '-i'], { timeoutMs: 30_000 });
|
|
347
|
+
const result = await run(session, ['snapshot', '-i'], { timeoutMs: 30_000, context });
|
|
186
348
|
const current = await state(args, context);
|
|
187
349
|
return { ...current, snapshotText: cli.asText(result) };
|
|
188
350
|
}
|
|
189
351
|
|
|
190
352
|
async function screenshot(args = {}, context) {
|
|
191
353
|
const session = sessionFor(args, context);
|
|
192
|
-
if (usesElectronBridge()) {
|
|
193
|
-
return electron.screenshotBrowser({ browserSessionId: session, fullPage: args.fullPage }, context);
|
|
194
|
-
}
|
|
195
354
|
const fs = require('fs');
|
|
196
355
|
const path = require('path');
|
|
197
356
|
const crypto = require('crypto');
|
|
198
357
|
const file = path.join(cli.screenshotDir(), `${session}-${Date.now()}-${crypto.randomUUID()}.png`);
|
|
199
|
-
await run(session, ['screenshot', ...(args.fullPage ? ['--full'] : []), file]);
|
|
358
|
+
await run(session, ['screenshot', ...(args.fullPage ? ['--full'] : []), file], { context });
|
|
200
359
|
const buffer = fs.readFileSync(file);
|
|
201
360
|
return {
|
|
202
361
|
...sessionInfo(session),
|
|
@@ -210,13 +369,9 @@ async function screenshot(args = {}, context) {
|
|
|
210
369
|
async function click(args = {}, context) {
|
|
211
370
|
const session = sessionFor(args, context);
|
|
212
371
|
const target = String(args.target || '');
|
|
213
|
-
if (usesElectronBridge()) {
|
|
214
|
-
await electron.clickBrowser({ ...electronLocator(target), browserSessionId: session }, context);
|
|
215
|
-
return sessionInfo(session, { clicked: target });
|
|
216
|
-
}
|
|
217
372
|
await run(session, target.startsWith('text=')
|
|
218
373
|
? findArgs(target, 'click')
|
|
219
|
-
: ['click', target]);
|
|
374
|
+
: ['click', target], { context });
|
|
220
375
|
return sessionInfo(session, { clicked: target });
|
|
221
376
|
}
|
|
222
377
|
|
|
@@ -224,35 +379,22 @@ async function fill(args = {}, context) {
|
|
|
224
379
|
const session = sessionFor(args, context);
|
|
225
380
|
const target = String(args.target || '');
|
|
226
381
|
const text = String(args.text ?? '');
|
|
227
|
-
if (usesElectronBridge()) {
|
|
228
|
-
await electron.typeBrowser({ ...electronLocator(target), text, browserSessionId: session }, context);
|
|
229
|
-
if (args.submit) await electron.pressBrowser({ key: 'Enter', browserSessionId: session }, context);
|
|
230
|
-
return sessionInfo(session, { filled: target, submitted: Boolean(args.submit) });
|
|
231
|
-
}
|
|
232
382
|
await run(session, target.startsWith('text=')
|
|
233
383
|
? findArgs(target, 'fill', text)
|
|
234
|
-
: ['fill', target, text]);
|
|
235
|
-
if (args.submit) await run(session, ['press', 'Enter']);
|
|
384
|
+
: ['fill', target, text], { context });
|
|
385
|
+
if (args.submit) await run(session, ['press', 'Enter'], { context });
|
|
236
386
|
return sessionInfo(session, { filled: target, submitted: Boolean(args.submit) });
|
|
237
387
|
}
|
|
238
388
|
|
|
239
389
|
async function press(args = {}, context) {
|
|
240
390
|
const session = sessionFor(args, context);
|
|
241
|
-
|
|
242
|
-
await electron.pressBrowser({ key: args.key, browserSessionId: session }, context);
|
|
243
|
-
return sessionInfo(session, { pressed: args.key });
|
|
244
|
-
}
|
|
245
|
-
await run(session, ['press', args.key]);
|
|
391
|
+
await run(session, ['press', args.key], { context });
|
|
246
392
|
return sessionInfo(session, { pressed: args.key });
|
|
247
393
|
}
|
|
248
394
|
|
|
249
395
|
async function evaluate(args = {}, context) {
|
|
250
396
|
const session = sessionFor(args, context);
|
|
251
|
-
|
|
252
|
-
const { result } = await electron.evaluateBrowser({ script: args.script, browserSessionId: session }, context);
|
|
253
|
-
return { ...sessionInfo(session), result };
|
|
254
|
-
}
|
|
255
|
-
const result = await run(session, ['eval', args.script || '']);
|
|
397
|
+
const result = await run(session, ['eval', args.script || ''], { context });
|
|
256
398
|
const data = result?.data ?? result;
|
|
257
399
|
// agent-browser wraps eval results as { origin, result }.
|
|
258
400
|
const value = data && typeof data === 'object' && 'result' in data ? data.result : data;
|
|
@@ -268,10 +410,6 @@ async function wait(args = {}, context) {
|
|
|
268
410
|
return sessionInfo(session, { waited: `${args.ms}ms` });
|
|
269
411
|
}
|
|
270
412
|
if (args.url) {
|
|
271
|
-
if (usesElectronBridge()) {
|
|
272
|
-
await electron.waitForURLBrowser({ url: args.url, timeoutMs, browserSessionId: session }, context);
|
|
273
|
-
return sessionInfo(session, { matched: args.url });
|
|
274
|
-
}
|
|
275
413
|
const deadline = Date.now() + timeoutMs;
|
|
276
414
|
while (Date.now() < deadline) {
|
|
277
415
|
const current = await readText(session, ['get', 'url']);
|
|
@@ -281,11 +419,7 @@ async function wait(args = {}, context) {
|
|
|
281
419
|
throw new Error(`Timed out waiting for URL ${args.url}`);
|
|
282
420
|
}
|
|
283
421
|
if (args.selector) {
|
|
284
|
-
|
|
285
|
-
await electron.waitForSelectorBrowser({ selector: args.selector, timeoutMs, browserSessionId: session }, context);
|
|
286
|
-
return sessionInfo(session, { matched: args.selector });
|
|
287
|
-
}
|
|
288
|
-
await run(session, ['wait', args.selector], { timeoutMs });
|
|
422
|
+
await run(session, ['wait', args.selector], { timeoutMs, context });
|
|
289
423
|
return sessionInfo(session, { matched: args.selector });
|
|
290
424
|
}
|
|
291
425
|
throw new Error('wait requires one of: selector, url, ms');
|
|
@@ -293,19 +427,13 @@ async function wait(args = {}, context) {
|
|
|
293
427
|
|
|
294
428
|
async function tabs(args = {}, context) {
|
|
295
429
|
const session = sessionFor(args, context);
|
|
296
|
-
if (usesElectronBridge()) {
|
|
297
|
-
if (args.action === 'select' && args.tab) {
|
|
298
|
-
return electron.tabsSelectBrowser({ tabId: args.tab, browserSessionId: session }, context);
|
|
299
|
-
}
|
|
300
|
-
return electron.tabsListBrowser(context);
|
|
301
|
-
}
|
|
302
430
|
if (args.action === 'select' && args.tab) {
|
|
303
|
-
return run(session, ['tab', args.tab]);
|
|
431
|
+
return run(session, ['tab', args.tab], { context });
|
|
304
432
|
}
|
|
305
433
|
if (args.action === 'new') {
|
|
306
|
-
return run(session, ['tab', 'new', ...(args.url ? [args.url] : [])]);
|
|
434
|
+
return run(session, ['tab', 'new', ...(args.url ? [args.url] : [])], { context });
|
|
307
435
|
}
|
|
308
|
-
const result = await run(session, ['tab']);
|
|
436
|
+
const result = await run(session, ['tab'], { context });
|
|
309
437
|
return { ...sessionInfo(session), tabs: result?.data?.tabs || result?.tabs || [] };
|
|
310
438
|
}
|
|
311
439
|
|
|
@@ -316,47 +444,65 @@ async function tabs(args = {}, context) {
|
|
|
316
444
|
*/
|
|
317
445
|
async function passthrough(args = {}, context) {
|
|
318
446
|
const session = sessionFor(args, context);
|
|
319
|
-
if (usesElectronBridge()) {
|
|
320
|
-
throw new Error(
|
|
321
|
-
'browser_cli needs the agent-browser backend. The desktop bridge does not expose it; '
|
|
322
|
-
+ 'set AMALGM_BROWSER_BACKEND=agent-browser or enable the CDP bridge (cdpUrl).',
|
|
323
|
-
);
|
|
324
|
-
}
|
|
325
447
|
const commandArgs = (args.args || []).map(String);
|
|
326
448
|
if (!commandArgs.length) throw new Error('args is required, e.g. ["scroll", "down"] or ["get", "text", "@e1"]');
|
|
327
|
-
const result = await run(session, commandArgs, { timeoutMs: args.timeoutMs || 60_000 });
|
|
449
|
+
const result = await run(session, commandArgs, { timeoutMs: args.timeoutMs || 60_000, context });
|
|
328
450
|
return { ...sessionInfo(session), result: result?.data ?? result };
|
|
329
451
|
}
|
|
330
452
|
|
|
453
|
+
/** Schema convention is 1 left / 2 middle / 3 right; agent-browser's mouse
|
|
454
|
+
* commands take left|middle|right strings. */
|
|
455
|
+
function mouseButton(value) {
|
|
456
|
+
const raw = String(value ?? '').toLowerCase();
|
|
457
|
+
if (raw === 'left' || raw === 'middle' || raw === 'right') return raw;
|
|
458
|
+
if (raw === '2') return 'middle';
|
|
459
|
+
if (raw === '3') return 'right';
|
|
460
|
+
return 'left';
|
|
461
|
+
}
|
|
462
|
+
|
|
331
463
|
async function cua(action, args = {}, context) {
|
|
332
464
|
const session = sessionFor(args, context);
|
|
333
|
-
|
|
465
|
+
const options = { context };
|
|
334
466
|
|
|
335
467
|
const x = Math.round(Number(args.x || 0));
|
|
336
468
|
const y = Math.round(Number(args.y || 0));
|
|
337
|
-
if (action === 'cua_move') await run(session, ['mouse', 'move', x, y]);
|
|
469
|
+
if (action === 'cua_move') await run(session, ['mouse', 'move', x, y], options);
|
|
338
470
|
if (action === 'cua_click') {
|
|
339
|
-
|
|
340
|
-
await run(session, ['mouse', '
|
|
341
|
-
await run(session, ['mouse', '
|
|
471
|
+
const button = mouseButton(args.button);
|
|
472
|
+
await run(session, ['mouse', 'move', x, y], options);
|
|
473
|
+
await run(session, ['mouse', 'down', button], options);
|
|
474
|
+
await run(session, ['mouse', 'up', button], options);
|
|
342
475
|
}
|
|
343
476
|
if (action === 'cua_double_click') {
|
|
344
|
-
await run(session, ['mouse', 'move', x, y]);
|
|
477
|
+
await run(session, ['mouse', 'move', x, y], options);
|
|
345
478
|
for (let i = 0; i < 2; i += 1) {
|
|
346
|
-
await run(session, ['mouse', 'down',
|
|
347
|
-
await run(session, ['mouse', 'up',
|
|
479
|
+
await run(session, ['mouse', 'down', 'left'], options);
|
|
480
|
+
await run(session, ['mouse', 'up', 'left'], options);
|
|
348
481
|
}
|
|
482
|
+
// agent-browser's mouse events always carry clickCount:1, so the renderer
|
|
483
|
+
// never coalesces the pair above into a native dblclick. Dispatch the
|
|
484
|
+
// dblclick event directly at the point; the two real clicks before it
|
|
485
|
+
// keep focus/selection side effects honest.
|
|
486
|
+
await run(session, ['eval', `(() => {
|
|
487
|
+
const el = document.elementFromPoint(${x}, ${y});
|
|
488
|
+
if (!el) return false;
|
|
489
|
+
el.dispatchEvent(new MouseEvent('dblclick', {
|
|
490
|
+
bubbles: true, cancelable: true, view: window,
|
|
491
|
+
clientX: ${x}, clientY: ${y}, button: 0, detail: 2,
|
|
492
|
+
}));
|
|
493
|
+
return true;
|
|
494
|
+
})()`], options);
|
|
349
495
|
}
|
|
350
|
-
if (action === 'cua_scroll') await run(session, ['mouse', 'wheel', args.scrollY || 0, args.scrollX || 0]);
|
|
351
|
-
if (action === 'cua_type') await run(session, ['keyboard', 'type', args.text || '']);
|
|
352
|
-
if (action === 'cua_keypress') await run(session, ['press', (args.keys || []).join('+')]);
|
|
496
|
+
if (action === 'cua_scroll') await run(session, ['mouse', 'wheel', args.scrollY || 0, args.scrollX || 0], options);
|
|
497
|
+
if (action === 'cua_type') await run(session, ['keyboard', 'type', args.text || ''], options);
|
|
498
|
+
if (action === 'cua_keypress') await run(session, ['press', (args.keys || []).join('+')], options);
|
|
353
499
|
if (action === 'cua_drag') {
|
|
354
500
|
const points = Array.isArray(args.path) ? args.path : [];
|
|
355
501
|
if (points.length < 2) throw new Error('drag path must contain at least two points');
|
|
356
|
-
await run(session, ['mouse', 'move', points[0].x, points[0].y]);
|
|
357
|
-
await run(session, ['mouse', 'down',
|
|
358
|
-
for (const point of points.slice(1)) await run(session, ['mouse', 'move', point.x, point.y]);
|
|
359
|
-
await run(session, ['mouse', 'up',
|
|
502
|
+
await run(session, ['mouse', 'move', points[0].x, points[0].y], options);
|
|
503
|
+
await run(session, ['mouse', 'down', 'left'], options);
|
|
504
|
+
for (const point of points.slice(1)) await run(session, ['mouse', 'move', point.x, point.y], options);
|
|
505
|
+
await run(session, ['mouse', 'up', 'left'], options);
|
|
360
506
|
}
|
|
361
507
|
return sessionInfo(session, { ok: true });
|
|
362
508
|
}
|
|
@@ -367,14 +513,12 @@ async function cua(action, args = {}, context) {
|
|
|
367
513
|
|
|
368
514
|
async function saveState(args = {}, context) {
|
|
369
515
|
const session = sessionFor(args, context);
|
|
370
|
-
if (usesElectronBridge()) return electron.saveStateBrowser({ ...args, browserSessionId: session }, context);
|
|
371
|
-
|
|
372
516
|
const fs = require('fs');
|
|
373
517
|
const os = require('os');
|
|
374
518
|
const path = require('path');
|
|
375
519
|
const crypto = require('crypto');
|
|
376
520
|
const file = args.path || path.join(os.tmpdir(), `amalgm-browser-state-${session}-${Date.now()}-${crypto.randomUUID()}.json`);
|
|
377
|
-
await run(session, ['state', 'save', file]);
|
|
521
|
+
await run(session, ['state', 'save', file], { context });
|
|
378
522
|
const stateData = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
379
523
|
if (!args.path) {
|
|
380
524
|
try { fs.rmSync(file, { force: true }); } catch {}
|
|
@@ -390,8 +534,6 @@ async function saveState(args = {}, context) {
|
|
|
390
534
|
|
|
391
535
|
async function loadState(args = {}, context) {
|
|
392
536
|
const session = sessionFor(args, context);
|
|
393
|
-
if (usesElectronBridge()) return electron.loadStateBrowser({ ...args, browserSessionId: session }, context);
|
|
394
|
-
|
|
395
537
|
const fs = require('fs');
|
|
396
538
|
const os = require('os');
|
|
397
539
|
const path = require('path');
|
|
@@ -404,7 +546,7 @@ async function loadState(args = {}, context) {
|
|
|
404
546
|
fs.writeFileSync(file, `${JSON.stringify(args.state, null, 2)}\n`, { mode: 0o600 });
|
|
405
547
|
temporary = true;
|
|
406
548
|
}
|
|
407
|
-
await run(session, ['state', 'load', file]);
|
|
549
|
+
await run(session, ['state', 'load', file], { context });
|
|
408
550
|
if (temporary) {
|
|
409
551
|
try { fs.rmSync(file, { force: true }); } catch {}
|
|
410
552
|
}
|
|
@@ -413,9 +555,9 @@ async function loadState(args = {}, context) {
|
|
|
413
555
|
|
|
414
556
|
async function close(args = {}, context) {
|
|
415
557
|
const session = sessionFor(args, context);
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
558
|
+
// Attached mode drives the user's visible surface — never close it from
|
|
559
|
+
// the tool; just forget the session bookkeeping.
|
|
560
|
+
if (mode() !== 'attached') {
|
|
419
561
|
await cli.runCli(['close'], { session });
|
|
420
562
|
}
|
|
421
563
|
attachedSessions.delete(session);
|
|
@@ -425,11 +567,13 @@ async function close(args = {}, context) {
|
|
|
425
567
|
/** Resolve the CDP websocket URL for a session — recorder needs it. */
|
|
426
568
|
async function cdpUrlForSession(session) {
|
|
427
569
|
await ensureReady(session);
|
|
428
|
-
const result = await cli.runCli(['get', 'cdp-url'],
|
|
570
|
+
const result = await cli.runCli(['get', 'cdp-url'], cliOptions(session, { timeoutMs: 15_000 }));
|
|
429
571
|
return result?.data?.cdpUrl || result?.cdpUrl || null;
|
|
430
572
|
}
|
|
431
573
|
|
|
432
574
|
module.exports = {
|
|
575
|
+
bridgeFileCandidates,
|
|
576
|
+
cdpEndpoint,
|
|
433
577
|
cdpUrlForSession,
|
|
434
578
|
click,
|
|
435
579
|
close,
|
|
@@ -442,6 +586,7 @@ module.exports = {
|
|
|
442
586
|
open,
|
|
443
587
|
passthrough,
|
|
444
588
|
press,
|
|
589
|
+
readBridgeAdvertisement,
|
|
445
590
|
saveState,
|
|
446
591
|
screenshot,
|
|
447
592
|
sessionFor,
|
|
@@ -449,6 +594,5 @@ module.exports = {
|
|
|
449
594
|
snapshot,
|
|
450
595
|
state,
|
|
451
596
|
tabs,
|
|
452
|
-
usesElectronBridge,
|
|
453
597
|
wait,
|
|
454
598
|
};
|