amalgm 0.1.104 → 0.1.105
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 +34 -10
- package/runtime/scripts/amalgm-mcp/browser/engine.js +26 -3
- package/runtime/scripts/amalgm-mcp/browser/typing.js +99 -2
- package/runtime/scripts/amalgm-mcp/tests/browser-transport.test.js +79 -0
- package/runtime/scripts/platform-context.txt +1 -1
package/package.json
CHANGED
|
@@ -101,11 +101,10 @@ async function surfaceRect(session, hintUrl) {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
/**
|
|
104
|
-
* Attached mode's product promise is a
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* surface) and poll until it has real on-screen geometry.
|
|
104
|
+
* Attached mode's product promise is a compositor-backed session surface.
|
|
105
|
+
* The surface may be visible to the user or held in the automation compositor
|
|
106
|
+
* slot; either way it must have real layout geometry. When it does not, ask
|
|
107
|
+
* the UI to mount it and poll until geometry appears.
|
|
109
108
|
*/
|
|
110
109
|
async function ensureSurfaceVisible(session, hintUrl, context, waitMs = WEBVIEW_WAIT_MS) {
|
|
111
110
|
let found = await surfaceRect(session, hintUrl);
|
|
@@ -216,6 +215,28 @@ async function focusSurface(session, surface) {
|
|
|
216
215
|
}
|
|
217
216
|
}
|
|
218
217
|
|
|
218
|
+
function commandNeedsOwnedFocus(command) {
|
|
219
|
+
const action = String(command?.[0] || '');
|
|
220
|
+
if (action === 'mouse' || action === 'click' || action === 'select') return true;
|
|
221
|
+
if (action === 'focus' || action === 'fill' || action === 'press' || action === 'keyboard') return true;
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Focus is owned by the browser session, not by the user's currently selected
|
|
227
|
+
* Amalgm tab. Before commands that need native guest focus, reacquire focus on
|
|
228
|
+
* this session's stamped surface. Reads and evals do not disturb the user.
|
|
229
|
+
*/
|
|
230
|
+
async function acquireOwnedFocus(session, context, surface, hintUrl = '') {
|
|
231
|
+
if (!surface || surface.external) return;
|
|
232
|
+
if (surface.embedderWsUrl) {
|
|
233
|
+
const visible = await ensureSurfaceVisible(session, hintUrl, context);
|
|
234
|
+
if (!visible) throw new Error(SURFACE_HIDDEN_MESSAGE);
|
|
235
|
+
surface.embedderWsUrl = visible.pageWsUrl || surface.embedderWsUrl;
|
|
236
|
+
}
|
|
237
|
+
await focusSurface(session, surface);
|
|
238
|
+
}
|
|
239
|
+
|
|
219
240
|
/**
|
|
220
241
|
* Connect the session to the advertised chromium on first use: select the
|
|
221
242
|
* in-tab webview in the daemon, stamp the guest over a persistent raw-CDP
|
|
@@ -277,10 +298,10 @@ async function ensureReady(session, context) {
|
|
|
277
298
|
);
|
|
278
299
|
}
|
|
279
300
|
|
|
280
|
-
// The webview target existing is not enough — the
|
|
281
|
-
//
|
|
282
|
-
// window itself may be hidden or minimized;
|
|
283
|
-
//
|
|
301
|
+
// The webview target existing is not enough — the session surface must be
|
|
302
|
+
// compositor-backed, or input drops and captures have nothing to render.
|
|
303
|
+
// The app window itself may be hidden or minimized; the app keeps attached
|
|
304
|
+
// surfaces compositing while the browser tool owns them.
|
|
284
305
|
const visible = await ensureSurfaceVisible(session, selected.url, context);
|
|
285
306
|
if (!visible) throw new Error(SURFACE_HIDDEN_MESSAGE);
|
|
286
307
|
|
|
@@ -369,6 +390,8 @@ async function execute(session, commands, options = {}, single) {
|
|
|
369
390
|
const { context, ...cliExtra } = options;
|
|
370
391
|
const surface = await ensureReady(session, context);
|
|
371
392
|
const guarded = Boolean(surface) && !surface.external;
|
|
393
|
+
const needsOwnedFocus = guarded && commands.some(commandNeedsOwnedFocus);
|
|
394
|
+
if (needsOwnedFocus) await acquireOwnedFocus(session, context, surface);
|
|
372
395
|
|
|
373
396
|
const attempt = async () => {
|
|
374
397
|
const batchCommands = guarded ? [guardCommand(session), ...commands] : commands;
|
|
@@ -399,7 +422,8 @@ async function execute(session, commands, options = {}, single) {
|
|
|
399
422
|
// The app restarted, the webview remounted, or the daemon drifted off
|
|
400
423
|
// this session's page; re-attach once against current reality.
|
|
401
424
|
detach(session);
|
|
402
|
-
await ensureReady(session, context);
|
|
425
|
+
const nextSurface = await ensureReady(session, context);
|
|
426
|
+
if (needsOwnedFocus) await acquireOwnedFocus(session, context, nextSurface);
|
|
403
427
|
return attempt();
|
|
404
428
|
}
|
|
405
429
|
throw err;
|
|
@@ -190,7 +190,8 @@ async function passthrough(args = {}, context) {
|
|
|
190
190
|
const session = sessionFor(args, context);
|
|
191
191
|
const commandArgs = (args.args || []).map(String);
|
|
192
192
|
if (!commandArgs.length) throw new Error('args is required, e.g. ["scroll", "down"] or ["get", "text", "@e1"]');
|
|
193
|
-
|
|
193
|
+
const attached = sessions.backendFor(session) === 'attached';
|
|
194
|
+
if (attached && commandArgs[0] === 'tab' && commandArgs.length > 1) {
|
|
194
195
|
// In the desktop app a session IS its surface; hopping targets is how a
|
|
195
196
|
// session ends up driving another session's page. Listing stays allowed.
|
|
196
197
|
throw new Error(
|
|
@@ -198,6 +199,16 @@ async function passthrough(args = {}, context) {
|
|
|
198
199
|
+ 'Use a different `session` id for a second page.',
|
|
199
200
|
);
|
|
200
201
|
}
|
|
202
|
+
if (attached && commandArgs[0] === 'keyboard' && commandArgs[1] === 'type') {
|
|
203
|
+
const text = commandArgs.slice(2).join(' ');
|
|
204
|
+
const result = await run(session, ['eval', typing.insertTextScript(text)], { timeoutMs: args.timeoutMs || 60_000, context });
|
|
205
|
+
return { ...sessionInfo(session), result: result?.data ?? result };
|
|
206
|
+
}
|
|
207
|
+
if (attached && commandArgs[0] === 'press') {
|
|
208
|
+
const key = commandArgs.slice(1).join('+');
|
|
209
|
+
const result = await run(session, ['eval', typing.keyScript(key)], { timeoutMs: args.timeoutMs || 60_000, context });
|
|
210
|
+
return { ...sessionInfo(session), result: result?.data ?? result };
|
|
211
|
+
}
|
|
201
212
|
const result = await run(session, commandArgs, { timeoutMs: args.timeoutMs || 60_000, context });
|
|
202
213
|
return { ...sessionInfo(session), result: result?.data ?? result };
|
|
203
214
|
}
|
|
@@ -252,8 +263,20 @@ async function cua(action, args = {}, context) {
|
|
|
252
263
|
], options);
|
|
253
264
|
}
|
|
254
265
|
if (action === 'cua_scroll') await run(session, ['mouse', 'wheel', args.scrollY || 0, args.scrollX || 0], options);
|
|
255
|
-
if (action === 'cua_type')
|
|
256
|
-
|
|
266
|
+
if (action === 'cua_type') {
|
|
267
|
+
if (backendFor(session) === 'attached') {
|
|
268
|
+
await run(session, ['eval', typing.insertTextScript(args.text || '')], options);
|
|
269
|
+
} else {
|
|
270
|
+
await run(session, ['keyboard', 'type', args.text || ''], options);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (action === 'cua_keypress') {
|
|
274
|
+
if (backendFor(session) === 'attached') {
|
|
275
|
+
await run(session, ['eval', typing.keyScript((args.keys || []).join('+'))], options);
|
|
276
|
+
} else {
|
|
277
|
+
await run(session, ['press', (args.keys || []).join('+')], options);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
257
280
|
if (action === 'cua_drag') {
|
|
258
281
|
const points = Array.isArray(args.path) ? args.path : [];
|
|
259
282
|
if (points.length < 2) throw new Error('drag path must contain at least two points');
|
|
@@ -46,6 +46,64 @@ function setValueScript(text) {
|
|
|
46
46
|
})()`;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Insert text at the focused field/caret without using browser-window
|
|
51
|
+
* keyboard focus. This is the attached-mode implementation for cua_type and
|
|
52
|
+
* CLI keyboard typing: if the page did not focus an editable element, fail
|
|
53
|
+
* closed instead of letting real keystrokes leak into Amalgm's own UI.
|
|
54
|
+
*/
|
|
55
|
+
function insertTextScript(text) {
|
|
56
|
+
return `(() => {
|
|
57
|
+
const el = document.activeElement;
|
|
58
|
+
if (!el || el === document.body || el === document.documentElement) {
|
|
59
|
+
throw new Error('focus did not land on an editable field');
|
|
60
|
+
}
|
|
61
|
+
const text = ${JSON.stringify(String(text))};
|
|
62
|
+
const inputEvent = (inputType, data = text) => new InputEvent('input', {
|
|
63
|
+
bubbles: true,
|
|
64
|
+
data,
|
|
65
|
+
inputType,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (el.isContentEditable) {
|
|
69
|
+
const selection = window.getSelection();
|
|
70
|
+
if (selection && selection.rangeCount) {
|
|
71
|
+
const range = selection.getRangeAt(0);
|
|
72
|
+
range.deleteContents();
|
|
73
|
+
const node = document.createTextNode(text);
|
|
74
|
+
range.insertNode(node);
|
|
75
|
+
range.setStartAfter(node);
|
|
76
|
+
range.setEndAfter(node);
|
|
77
|
+
selection.removeAllRanges();
|
|
78
|
+
selection.addRange(range);
|
|
79
|
+
} else {
|
|
80
|
+
el.textContent = (el.textContent || '') + text;
|
|
81
|
+
}
|
|
82
|
+
el.dispatchEvent(inputEvent('insertText'));
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype
|
|
87
|
+
: el instanceof HTMLInputElement ? HTMLInputElement.prototype
|
|
88
|
+
: null;
|
|
89
|
+
if (!proto && !('value' in el)) {
|
|
90
|
+
throw new Error('focused element <' + el.tagName.toLowerCase() + '> is not editable');
|
|
91
|
+
}
|
|
92
|
+
const value = String(el.value || '');
|
|
93
|
+
const start = typeof el.selectionStart === 'number' ? el.selectionStart : value.length;
|
|
94
|
+
const end = typeof el.selectionEnd === 'number' ? el.selectionEnd : start;
|
|
95
|
+
const next = value.slice(0, start) + text + value.slice(end);
|
|
96
|
+
const setter = proto && Object.getOwnPropertyDescriptor(proto, 'value').set;
|
|
97
|
+
if (setter) setter.call(el, next); else el.value = next;
|
|
98
|
+
const caret = start + text.length;
|
|
99
|
+
if (typeof el.setSelectionRange === 'function') {
|
|
100
|
+
try { el.setSelectionRange(caret, caret); } catch {}
|
|
101
|
+
}
|
|
102
|
+
el.dispatchEvent(inputEvent('insertText'));
|
|
103
|
+
return true;
|
|
104
|
+
})()`;
|
|
105
|
+
}
|
|
106
|
+
|
|
49
107
|
/** "KeyA" for letters, "Digit1" for digits, the key's own name otherwise. */
|
|
50
108
|
function codeFor(key) {
|
|
51
109
|
if (/^[a-z]$/i.test(key)) return `Key${key.toUpperCase()}`;
|
|
@@ -77,10 +135,49 @@ function keyScript(combo) {
|
|
|
77
135
|
return `(() => {
|
|
78
136
|
const el = document.activeElement || document.body;
|
|
79
137
|
const init = ${JSON.stringify(init)};
|
|
138
|
+
const isInput = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement;
|
|
139
|
+
const isEditable = isInput || el.isContentEditable;
|
|
140
|
+
const inputEvent = (inputType, data = null) => new InputEvent('input', {
|
|
141
|
+
bubbles: true,
|
|
142
|
+
data,
|
|
143
|
+
inputType,
|
|
144
|
+
});
|
|
145
|
+
const setValue = (next, start, end = start) => {
|
|
146
|
+
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype
|
|
147
|
+
: el instanceof HTMLInputElement ? HTMLInputElement.prototype
|
|
148
|
+
: null;
|
|
149
|
+
const setter = proto && Object.getOwnPropertyDescriptor(proto, 'value').set;
|
|
150
|
+
if (setter) setter.call(el, next); else el.value = next;
|
|
151
|
+
if (typeof el.setSelectionRange === 'function') {
|
|
152
|
+
try { el.setSelectionRange(start, end); } catch {}
|
|
153
|
+
}
|
|
154
|
+
};
|
|
80
155
|
const proceed = el.dispatchEvent(new KeyboardEvent('keydown', init));
|
|
81
156
|
if (proceed) {
|
|
82
157
|
el.dispatchEvent(new KeyboardEvent('keypress', init));
|
|
83
|
-
if (init.key === '
|
|
158
|
+
if ((init.ctrlKey || init.metaKey) && init.key.toLowerCase() === 'a' && isEditable) {
|
|
159
|
+
if (isInput && typeof el.select === 'function') {
|
|
160
|
+
el.select();
|
|
161
|
+
} else {
|
|
162
|
+
const range = document.createRange();
|
|
163
|
+
range.selectNodeContents(el);
|
|
164
|
+
const selection = window.getSelection();
|
|
165
|
+
selection.removeAllRanges();
|
|
166
|
+
selection.addRange(range);
|
|
167
|
+
}
|
|
168
|
+
} else if ((init.key === 'Backspace' || init.key === 'Delete') && isInput) {
|
|
169
|
+
const value = String(el.value || '');
|
|
170
|
+
const selectionStart = typeof el.selectionStart === 'number' ? el.selectionStart : value.length;
|
|
171
|
+
const selectionEnd = typeof el.selectionEnd === 'number' ? el.selectionEnd : selectionStart;
|
|
172
|
+
let start = selectionStart;
|
|
173
|
+
let end = selectionEnd;
|
|
174
|
+
if (start === end && init.key === 'Backspace' && start > 0) start -= 1;
|
|
175
|
+
if (start === end && init.key === 'Delete' && end < value.length) end += 1;
|
|
176
|
+
if (start !== end) {
|
|
177
|
+
setValue(value.slice(0, start) + value.slice(end), start);
|
|
178
|
+
el.dispatchEvent(inputEvent(init.key === 'Backspace' ? 'deleteContentBackward' : 'deleteContentForward'));
|
|
179
|
+
}
|
|
180
|
+
} else if (init.key === 'Enter') {
|
|
84
181
|
if (el instanceof HTMLButtonElement || el instanceof HTMLAnchorElement) {
|
|
85
182
|
el.click();
|
|
86
183
|
} else if (el instanceof HTMLInputElement && el.form) {
|
|
@@ -93,4 +190,4 @@ function keyScript(combo) {
|
|
|
93
190
|
})()`;
|
|
94
191
|
}
|
|
95
192
|
|
|
96
|
-
module.exports = { codeFor, keyScript, setValueScript };
|
|
193
|
+
module.exports = { codeFor, insertTextScript, keyScript, setValueScript };
|
|
@@ -78,6 +78,39 @@ test('passthrough refuses tab hops in attached mode, allows listing', async () =
|
|
|
78
78
|
}
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
test('attached text entry is translated to in-page scripts instead of real keyboard events', async () => {
|
|
82
|
+
const saved = process.env.AMALGM_BROWSER_BACKEND;
|
|
83
|
+
process.env.AMALGM_BROWSER_BACKEND = 'attached';
|
|
84
|
+
try {
|
|
85
|
+
calls.length = 0;
|
|
86
|
+
nextResults = [
|
|
87
|
+
{ success: true, data: { result: true } },
|
|
88
|
+
{ success: true, data: { result: true } },
|
|
89
|
+
{ success: true, data: { result: true } },
|
|
90
|
+
{ success: true, data: { result: true } },
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
await engine.cua('cua_type', { text: 'cyanheads', session: 'typing-test' }, {});
|
|
94
|
+
await engine.cua('cua_keypress', { keys: ['Control', 'a'], session: 'typing-test' }, {});
|
|
95
|
+
await engine.passthrough({ args: ['keyboard', 'type', 'safe text'], session: 'typing-test' }, {});
|
|
96
|
+
await engine.passthrough({ args: ['press', 'Enter'], session: 'typing-test' }, {});
|
|
97
|
+
|
|
98
|
+
assert.deepEqual(calls[0].commandArgs[0], 'eval');
|
|
99
|
+
assert.match(calls[0].commandArgs[1], /insertText/);
|
|
100
|
+
assert.deepEqual(calls[1].commandArgs[0], 'eval');
|
|
101
|
+
assert.match(calls[1].commandArgs[1], /KeyboardEvent/);
|
|
102
|
+
assert.deepEqual(calls[2].commandArgs[0], 'eval');
|
|
103
|
+
assert.match(calls[2].commandArgs[1], /safe text/);
|
|
104
|
+
assert.deepEqual(calls[3].commandArgs[0], 'eval');
|
|
105
|
+
assert.match(calls[3].commandArgs[1], /requestSubmit|KeyboardEvent/);
|
|
106
|
+
assert.equal(calls.some((call) => call.commandArgs[0] === 'keyboard'), false);
|
|
107
|
+
assert.equal(calls.some((call) => call.commandArgs[0] === 'press'), false);
|
|
108
|
+
} finally {
|
|
109
|
+
if (saved === undefined) delete process.env.AMALGM_BROWSER_BACKEND;
|
|
110
|
+
else process.env.AMALGM_BROWSER_BACKEND = saved;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
81
114
|
// The guard itself: execute() turns every attached call into a batch that
|
|
82
115
|
// leads with the stamp-check eval, and maps the single-command result back to
|
|
83
116
|
// its unbatched shape. The guard never appears in caller-visible results.
|
|
@@ -123,6 +156,52 @@ console.log(JSON.stringify({ success: true, data: cmds.map((c) => ({ command: c,
|
|
|
123
156
|
}
|
|
124
157
|
});
|
|
125
158
|
|
|
159
|
+
test('attached focus-dependent commands reacquire the session surface focus', async () => {
|
|
160
|
+
const fs = require('node:fs');
|
|
161
|
+
const os = require('node:os');
|
|
162
|
+
const path = require('node:path');
|
|
163
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-focus-test-'));
|
|
164
|
+
const stub = path.join(dir, 'stub.sh');
|
|
165
|
+
fs.writeFileSync(stub, `#!/bin/sh
|
|
166
|
+
input=$(cat)
|
|
167
|
+
node -e '
|
|
168
|
+
const cmds = JSON.parse(process.argv[1]);
|
|
169
|
+
console.log(JSON.stringify({ success: true, data: cmds.map((c) => ({ command: c, success: true, result: true })) }));
|
|
170
|
+
' "$input"
|
|
171
|
+
`, { mode: 0o755 });
|
|
172
|
+
|
|
173
|
+
const focusCalls = [];
|
|
174
|
+
const savedBin = process.env.AMALGM_AGENT_BROWSER_BIN;
|
|
175
|
+
const savedBackend = process.env.AMALGM_BROWSER_BACKEND;
|
|
176
|
+
const savedCdp = process.env.AMALGM_BROWSER_CDP_URL;
|
|
177
|
+
process.env.AMALGM_AGENT_BROWSER_BIN = stub;
|
|
178
|
+
process.env.AMALGM_BROWSER_BACKEND = 'attached';
|
|
179
|
+
process.env.AMALGM_BROWSER_CDP_URL = '9242';
|
|
180
|
+
surfaces.set('focus-test', {
|
|
181
|
+
tabId: 't9',
|
|
182
|
+
wsUrl: 'ws://x/guest',
|
|
183
|
+
client: {
|
|
184
|
+
async call(method, params) {
|
|
185
|
+
focusCalls.push([method, params?.expression || '']);
|
|
186
|
+
return {};
|
|
187
|
+
},
|
|
188
|
+
close() {},
|
|
189
|
+
},
|
|
190
|
+
external: false,
|
|
191
|
+
});
|
|
192
|
+
try {
|
|
193
|
+
await attach.run('focus-test', ['mouse', 'move', 5, 7], { timeoutMs: 5000 });
|
|
194
|
+
assert.deepEqual(focusCalls, [['Runtime.evaluate', 'window.focus()']]);
|
|
195
|
+
} finally {
|
|
196
|
+
surfaces.delete('focus-test');
|
|
197
|
+
knownSessions.delete('focus-test');
|
|
198
|
+
if (savedBin === undefined) delete process.env.AMALGM_AGENT_BROWSER_BIN; else process.env.AMALGM_AGENT_BROWSER_BIN = savedBin;
|
|
199
|
+
if (savedBackend === undefined) delete process.env.AMALGM_BROWSER_BACKEND; else process.env.AMALGM_BROWSER_BACKEND = savedBackend;
|
|
200
|
+
if (savedCdp === undefined) delete process.env.AMALGM_BROWSER_CDP_URL; else process.env.AMALGM_BROWSER_CDP_URL = savedCdp;
|
|
201
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
126
205
|
test('a guard failure is classified as a lost target; page errors are not', () => {
|
|
127
206
|
// Guard failures and dead connections re-attach. Ordinary page errors must
|
|
128
207
|
// NOT look like lost connections — a spurious re-attach on "Element not
|
|
@@ -132,7 +132,7 @@ The loop: `open` a URL → `snapshot` to see interactive elements as @refs → `
|
|
|
132
132
|
- `screenshot` — PNG when you need pixels (snapshot is cheaper for driving); image coordinates match cua input 1:1
|
|
133
133
|
- `cli` — the full agent-browser command surface: scroll, hover, back, cookies, storage, network, traces, find-by-role, and more
|
|
134
134
|
- `close` — end the session (in the desktop app this also tidies away its split view)
|
|
135
|
-
- `cua_screenshot` / `cua_click` / `cua_type` / … — coordinate-based control for vision loops
|
|
135
|
+
- `cua_screenshot` / `cua_click` / `cua_type` / … — coordinate-based control for vision loops. In the desktop app, text/key CUA actions are scoped inside the target page so they cannot type into Amalgm's own chat UI.
|
|
136
136
|
- `record_start` / `record_stop` / `record_list` — capture the session to a local WebM video (QA evidence)
|
|
137
137
|
- `auth_list` / `auth_link_create` / `auth_save` / `auth_load` — reuse logins: in the desktop app the login page opens directly in the split view for the user to sign in; from the web, share the minted link. Save/load encrypted auth bundles afterward
|
|
138
138
|
|