amalgm 0.1.93 → 0.1.94
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 +158 -21
- package/runtime/scripts/amalgm-mcp/browser/auth-tools.js +21 -2
- package/runtime/scripts/amalgm-mcp/browser/capture.js +60 -102
- package/runtime/scripts/amalgm-mcp/browser/cdp.js +18 -11
- package/runtime/scripts/amalgm-mcp/browser/engine.js +38 -3
- package/runtime/scripts/amalgm-mcp/browser/recorder.js +27 -13
- package/runtime/scripts/amalgm-mcp/browser/tools.js +29 -6
- package/runtime/scripts/amalgm-mcp/fs/rest.js +136 -0
- package/runtime/scripts/amalgm-mcp/server/routes/files.js +4 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-capture.test.js +47 -34
- package/runtime/scripts/amalgm-mcp/tests/browser-transport.test.js +155 -0
- package/runtime/scripts/amalgm-mcp/tests/core-tools.test.js +2 -1
- package/runtime/scripts/platform-context.txt +7 -6
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const test = require('node:test');
|
|
5
|
+
|
|
6
|
+
// Transport semantics introduced after the 2026-06-11 stress tests: in
|
|
7
|
+
// attached mode every command re-pins the session's webview target inside
|
|
8
|
+
// the same batch. The daemon's tab selection is process state that silently
|
|
9
|
+
// falls back to "the active tab" when the selected target dies — that
|
|
10
|
+
// fallback is how one session read another session's page.
|
|
11
|
+
|
|
12
|
+
const attach = require('../browser/attach');
|
|
13
|
+
const { attachedSessions, knownSessions } = require('../browser/sessions');
|
|
14
|
+
|
|
15
|
+
// Patch attach.run before engine destructures it (engine binds at require).
|
|
16
|
+
const calls = [];
|
|
17
|
+
let nextResults = [];
|
|
18
|
+
const realRun = attach.run;
|
|
19
|
+
attach.run = async (session, commandArgs, options) => {
|
|
20
|
+
calls.push({ session, commandArgs });
|
|
21
|
+
const next = nextResults.shift();
|
|
22
|
+
if (next instanceof Error) throw next;
|
|
23
|
+
return next ?? { success: true, data: null };
|
|
24
|
+
};
|
|
25
|
+
const engine = require('../browser/engine');
|
|
26
|
+
|
|
27
|
+
test('eval retries a top-level return as a function body', async () => {
|
|
28
|
+
calls.length = 0;
|
|
29
|
+
nextResults = [
|
|
30
|
+
new Error('Eval failed: Illegal return statement'),
|
|
31
|
+
{ success: true, data: { origin: 'x', result: 42 } },
|
|
32
|
+
];
|
|
33
|
+
const { result } = await engine.evaluate({ script: 'return 21 * 2', session: 'wrap-test' }, {});
|
|
34
|
+
assert.equal(result, 42);
|
|
35
|
+
assert.equal(calls.length, 2);
|
|
36
|
+
assert.deepEqual(calls[0].commandArgs, ['eval', 'return 21 * 2']);
|
|
37
|
+
assert.deepEqual(calls[1].commandArgs, ['eval', '(() => { return 21 * 2 })()']);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('eval does not rewrite genuine script errors', async () => {
|
|
41
|
+
calls.length = 0;
|
|
42
|
+
nextResults = [new Error('Eval failed: ReferenceError: nope is not defined')];
|
|
43
|
+
await assert.rejects(
|
|
44
|
+
() => engine.evaluate({ script: 'nope()', session: 'wrap-test' }, {}),
|
|
45
|
+
/ReferenceError/,
|
|
46
|
+
);
|
|
47
|
+
assert.equal(calls.length, 1, 'only Illegal return triggers the wrapped retry');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('select drives native dropdowns through the real select command', async () => {
|
|
51
|
+
calls.length = 0;
|
|
52
|
+
nextResults = [{ success: true, data: null }];
|
|
53
|
+
const result = await engine.select({ target: '#mode', value: 'gamma', session: 'select-test' }, {});
|
|
54
|
+
assert.deepEqual(calls[0].commandArgs, ['select', '#mode', 'gamma']);
|
|
55
|
+
assert.equal(result.selected, '#mode');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('passthrough refuses tab hops in attached mode, allows listing', async () => {
|
|
59
|
+
const saved = process.env.AMALGM_BROWSER_BACKEND;
|
|
60
|
+
process.env.AMALGM_BROWSER_BACKEND = 'attached';
|
|
61
|
+
try {
|
|
62
|
+
await assert.rejects(
|
|
63
|
+
() => engine.passthrough({ args: ['tab', 't3'], session: 'hop-test' }, {}),
|
|
64
|
+
/Tab switching is managed per session/,
|
|
65
|
+
);
|
|
66
|
+
// Bare listing is diagnostics, not a hop — it goes through to the daemon.
|
|
67
|
+
calls.length = 0;
|
|
68
|
+
nextResults = [{ success: true, data: { tabs: [] } }];
|
|
69
|
+
await engine.passthrough({ args: ['tab'], session: 'hop-test' }, {});
|
|
70
|
+
assert.deepEqual(calls[0].commandArgs, ['tab']);
|
|
71
|
+
} finally {
|
|
72
|
+
if (saved === undefined) delete process.env.AMALGM_BROWSER_BACKEND;
|
|
73
|
+
else process.env.AMALGM_BROWSER_BACKEND = saved;
|
|
74
|
+
attach.run = realRun;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// The pin itself: execute() turns every attached call into a batch that
|
|
79
|
+
// re-selects the session's webview first, and maps the single-command result
|
|
80
|
+
// back to its unbatched shape.
|
|
81
|
+
|
|
82
|
+
test('attached commands re-pin the webview target inside the batch', async (t) => {
|
|
83
|
+
const fs = require('node:fs');
|
|
84
|
+
const os = require('node:os');
|
|
85
|
+
const path = require('node:path');
|
|
86
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-pin-test-'));
|
|
87
|
+
const stub = path.join(dir, 'stub.sh');
|
|
88
|
+
// The stub answers `batch` by echoing stdin back as batch entries.
|
|
89
|
+
fs.writeFileSync(stub, `#!/bin/sh
|
|
90
|
+
input=$(cat)
|
|
91
|
+
node -e '
|
|
92
|
+
const cmds = JSON.parse(process.argv[1]);
|
|
93
|
+
console.log(JSON.stringify({ success: true, data: cmds.map((c) => ({ command: c, success: true, result: { echoed: c } })) }));
|
|
94
|
+
' "$input"
|
|
95
|
+
`, { mode: 0o755 });
|
|
96
|
+
|
|
97
|
+
const savedBin = process.env.AMALGM_AGENT_BROWSER_BIN;
|
|
98
|
+
const savedBackend = process.env.AMALGM_BROWSER_BACKEND;
|
|
99
|
+
const savedCdp = process.env.AMALGM_BROWSER_CDP_URL;
|
|
100
|
+
process.env.AMALGM_AGENT_BROWSER_BIN = stub;
|
|
101
|
+
process.env.AMALGM_BROWSER_BACKEND = 'attached';
|
|
102
|
+
process.env.AMALGM_BROWSER_CDP_URL = '9242';
|
|
103
|
+
attachedSessions.add('pin-test');
|
|
104
|
+
knownSessions.set('pin-test', { webviewTabId: 't7' });
|
|
105
|
+
try {
|
|
106
|
+
const single = await attach.run('pin-test', ['get', 'url'], { timeoutMs: 5000 });
|
|
107
|
+
assert.deepEqual(single.data, { echoed: ['get', 'url'] }, 'single result keeps its unbatched shape');
|
|
108
|
+
|
|
109
|
+
const entries = await attach.runBatch('pin-test', [['open', 'https://example.com'], ['get', 'url']], { timeoutMs: 5000 });
|
|
110
|
+
assert.equal(entries.length, 2, 'the pin step is internal — callers see their own commands only');
|
|
111
|
+
assert.deepEqual(entries[0].command, ['open', 'https://example.com']);
|
|
112
|
+
} finally {
|
|
113
|
+
attachedSessions.delete('pin-test');
|
|
114
|
+
knownSessions.delete('pin-test');
|
|
115
|
+
if (savedBin === undefined) delete process.env.AMALGM_AGENT_BROWSER_BIN; else process.env.AMALGM_AGENT_BROWSER_BIN = savedBin;
|
|
116
|
+
if (savedBackend === undefined) delete process.env.AMALGM_BROWSER_BACKEND; else process.env.AMALGM_BROWSER_BACKEND = savedBackend;
|
|
117
|
+
if (savedCdp === undefined) delete process.env.AMALGM_BROWSER_CDP_URL; else process.env.AMALGM_BROWSER_CDP_URL = savedCdp;
|
|
118
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('a dead pin is classified as a lost target; page errors are not', () => {
|
|
123
|
+
// The pin failure carries the "surface target lost" prefix so the retry
|
|
124
|
+
// path re-attaches. Ordinary page errors must NOT look like lost
|
|
125
|
+
// connections — a spurious re-attach on "Element not found" would tear
|
|
126
|
+
// down and rebuild the surface on every typo'd selector.
|
|
127
|
+
assert.equal(attach.looksLikeLostConnection(new Error('surface target lost (tab t7: tab not found)')), true);
|
|
128
|
+
assert.equal(attach.looksLikeLostConnection(new Error('WebSocket connection closed')), true);
|
|
129
|
+
assert.equal(attach.looksLikeLostConnection(new Error('Element not found: #submit')), false);
|
|
130
|
+
assert.equal(attach.looksLikeLostConnection(new Error('Timed out waiting for selector')), false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('pinFailure reclassifies envelope-level batch rejections', () => {
|
|
134
|
+
// agent-browser --bail rejects with the serialized entries as the message
|
|
135
|
+
// (live-verified shape). A failed pin step inside must become "surface
|
|
136
|
+
// target lost" so the retry path re-attaches.
|
|
137
|
+
const envelope = new Error(JSON.stringify([
|
|
138
|
+
{ command: ['tab', 't16'], error: 'Tab t16 not found; run `agent-browser tab` to list open tabs', result: null },
|
|
139
|
+
]));
|
|
140
|
+
const classified = attach.pinFailure(envelope, 't16');
|
|
141
|
+
assert.match(classified.message, /surface target lost/);
|
|
142
|
+
assert.equal(attach.looksLikeLostConnection(classified), true);
|
|
143
|
+
|
|
144
|
+
// A failing page step inside the same shape stays a page error.
|
|
145
|
+
const pageError = new Error(JSON.stringify([
|
|
146
|
+
{ command: ['tab', 't16'], error: null, result: { ok: true } },
|
|
147
|
+
{ command: ['click', '#gone'], error: 'Element not found', result: null },
|
|
148
|
+
]));
|
|
149
|
+
assert.equal(attach.pinFailure(pageError, 't16'), pageError);
|
|
150
|
+
|
|
151
|
+
// Headless (no pin) and non-JSON messages pass through untouched.
|
|
152
|
+
const plain = new Error('agent-browser exited with code 1');
|
|
153
|
+
assert.equal(attach.pinFailure(plain, null), plain);
|
|
154
|
+
assert.equal(attach.pinFailure(plain, 't16'), plain);
|
|
155
|
+
});
|
|
@@ -54,6 +54,7 @@ test('browser is a single toolbox entry holding every browser action', () => {
|
|
|
54
54
|
'click',
|
|
55
55
|
'fill',
|
|
56
56
|
'press',
|
|
57
|
+
'select',
|
|
57
58
|
'eval',
|
|
58
59
|
'wait',
|
|
59
60
|
'cli',
|
|
@@ -106,7 +107,7 @@ test('browser actions carry capability tags so UIs can section one card', () =>
|
|
|
106
107
|
.filter((tool) => tool.capability === capability)
|
|
107
108
|
.map((tool) => tool.name);
|
|
108
109
|
|
|
109
|
-
assert.equal(byCapability('core').length,
|
|
110
|
+
assert.equal(byCapability('core').length, 11);
|
|
110
111
|
assert.equal(byCapability('computer-use').every((name) => name.startsWith('cua_')), true);
|
|
111
112
|
assert.deepEqual(byCapability('recording'), ['record_start', 'record_stop', 'record_list']);
|
|
112
113
|
assert.deepEqual(byCapability('auth'), ['auth_list', 'auth_link_create', 'auth_save', 'auth_load']);
|
|
@@ -121,19 +121,20 @@ One persistent browser per chat session. When the Amalgm desktop app is open, th
|
|
|
121
121
|
|
|
122
122
|
Browser actions are MCP tools named `toolbox__browser_<action>` (your harness may add its own prefix, e.g. `mcp__amalgm__toolbox__browser_open`).
|
|
123
123
|
|
|
124
|
-
The loop: `open` a URL → `snapshot` to see interactive elements as @refs → `click`/`fill`/`press` by @ref → re-`snapshot` after the page changes.
|
|
124
|
+
The loop: `open` a URL → `snapshot` to see interactive elements as @refs → `click`/`fill`/`select`/`press` by @ref → re-`snapshot` after the page changes.
|
|
125
125
|
|
|
126
126
|
- `open` — go to a URL (the session persists across calls; navigate once, then interact)
|
|
127
127
|
- `snapshot` — read the page: every interactive element gets a stable @ref
|
|
128
128
|
- `click` / `fill` / `press` — act on @refs, CSS selectors, or `text=Label` targets
|
|
129
|
-
- `
|
|
129
|
+
- `select` — choose an option in a native dropdown (clicks and key presses cannot drive those)
|
|
130
|
+
- `eval` — run JavaScript in the page and get the result (bare expressions or `return` bodies both work)
|
|
130
131
|
- `wait` — wait for a selector, URL, or delay
|
|
131
|
-
- `screenshot` — PNG when you need pixels (snapshot is cheaper for driving)
|
|
132
|
-
- `cli` — the full agent-browser command surface: scroll, hover, back,
|
|
133
|
-
- `close` — end the session
|
|
132
|
+
- `screenshot` — PNG when you need pixels (snapshot is cheaper for driving); image coordinates match cua input 1:1
|
|
133
|
+
- `cli` — the full agent-browser command surface: scroll, hover, back, cookies, storage, network, traces, find-by-role, and more
|
|
134
|
+
- `close` — end the session (in the desktop app this also tidies away its split view)
|
|
134
135
|
- `cua_screenshot` / `cua_click` / `cua_type` / … — coordinate-based control for vision loops
|
|
135
136
|
- `record_start` / `record_stop` / `record_list` — capture the session to a local WebM video (QA evidence)
|
|
136
|
-
- `auth_list` / `auth_link_create` / `auth_save` / `auth_load` — reuse logins:
|
|
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
|
|
137
138
|
|
|
138
139
|
Recordings and auth bundles stay on this computer. Use the browser to test apps, scrape data, or automate web interactions.
|
|
139
140
|
|