amalgm 0.1.194 → 0.1.195
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 +90 -59
- package/runtime/scripts/amalgm-mcp/browser/backend.js +39 -3
- package/runtime/scripts/amalgm-mcp/browser/capture.js +65 -56
- package/runtime/scripts/amalgm-mcp/browser/cdp.js +11 -10
- package/runtime/scripts/amalgm-mcp/browser/cli.js +1 -1
- package/runtime/scripts/amalgm-mcp/browser/engine.js +3 -2
- package/runtime/scripts/amalgm-mcp/browser/recorder-tools.js +3 -2
- package/runtime/scripts/amalgm-mcp/browser/recorder.js +6 -8
- package/runtime/scripts/amalgm-mcp/browser/sessions.js +2 -2
- package/runtime/scripts/amalgm-mcp/tests/browser-capture.test.js +12 -6
- package/runtime/scripts/amalgm-mcp/tests/browser-routing.test.js +45 -9
- package/runtime/scripts/amalgm-mcp/tests/browser-transport.test.js +1 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +12 -0
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +12 -9
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Attached mode — drive the browser surface the user is watching.
|
|
5
5
|
*
|
|
6
6
|
* The desktop app advertises its chromium over CDP; the UI mounts the in-tab
|
|
7
|
-
* split-view
|
|
7
|
+
* split-view native page with a marker URL. This module owns the attach
|
|
8
8
|
* handshake and the command transport, built around one rule:
|
|
9
9
|
*
|
|
10
10
|
* A session's identity is established ONCE at attach and VERIFIED on every
|
|
@@ -17,17 +17,17 @@
|
|
|
17
17
|
* ref". Both verified live. So instead of a per-command `tab` pin, attach:
|
|
18
18
|
*
|
|
19
19
|
* 1. Asks the UI to open the surface (a browser_surfaces state event).
|
|
20
|
-
* 2. Selects the session's
|
|
21
|
-
* 3. Opens a persistent raw-CDP connection to
|
|
20
|
+
* 2. Selects the session's native page in the daemon ONCE (refs don't exist yet).
|
|
21
|
+
* 3. Opens a persistent raw-CDP connection to that target and stamps it:
|
|
22
22
|
* `window.__amalgmBsid = session` now, plus the same script on every new
|
|
23
23
|
* document — the stamp survives navigation and dies with the target.
|
|
24
24
|
* 4. Prepends a guard eval to every batch: stamp mismatch throws, the batch
|
|
25
25
|
* bails before the action runs, and the session re-attaches once. eval
|
|
26
26
|
* does not reset refs, so @refs stay live across calls.
|
|
27
27
|
*
|
|
28
|
-
* The persistent
|
|
28
|
+
* The persistent page connection doubles as the capture anchor: screenshots
|
|
29
29
|
* and recordings connect straight to the verified target instead of hunting
|
|
30
|
-
* the target list per capture (the hunt's "no
|
|
30
|
+
* the target list per capture (the hunt's "no surfaces yet → first page
|
|
31
31
|
* target" fallback is how cua_screenshot once captured the app's own chat UI).
|
|
32
32
|
*/
|
|
33
33
|
|
|
@@ -37,16 +37,16 @@ const cdp = require('./cdp');
|
|
|
37
37
|
const { surfaces, knownSessions, backendFor } = require('./sessions');
|
|
38
38
|
|
|
39
39
|
const SURFACE_EVENT_RESOURCE = 'browser_surfaces';
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
const
|
|
40
|
+
const SURFACE_MARKER_PREFIX = 'amalgm-bsid-';
|
|
41
|
+
const SURFACE_WAIT_MS = 15_000;
|
|
42
|
+
const SURFACE_POLL_MS = 500;
|
|
43
43
|
const GUARD_ERROR = 'amalgm-surface-guard';
|
|
44
44
|
const SURFACE_HIDDEN_MESSAGE = 'The browser surface is hidden in the desktop app and did not reopen. '
|
|
45
45
|
+ 'Retry; if it keeps failing, close this browser session and open again '
|
|
46
46
|
+ '(a fresh surface will mount), or force a headless browser with AMALGM_BROWSER_BACKEND=cli.';
|
|
47
47
|
|
|
48
48
|
function marker(session) {
|
|
49
|
-
return `${
|
|
49
|
+
return `${SURFACE_MARKER_PREFIX}${session}`;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
function stampScript(session) {
|
|
@@ -87,7 +87,7 @@ function requestSurfaceOpen(session, context) {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
|
-
* The session's
|
|
90
|
+
* The session's surface rectangle in the app window, or null when the
|
|
91
91
|
* splitview is hidden (0x0) or gone. Raw CDP, deadline-bounded.
|
|
92
92
|
*/
|
|
93
93
|
async function surfaceRect(session, hintUrl) {
|
|
@@ -106,13 +106,13 @@ async function surfaceRect(session, hintUrl) {
|
|
|
106
106
|
* slot; either way it must have real layout geometry. When it does not, ask
|
|
107
107
|
* the UI to mount it and poll until geometry appears.
|
|
108
108
|
*/
|
|
109
|
-
async function ensureSurfaceVisible(session, hintUrl, context, waitMs =
|
|
109
|
+
async function ensureSurfaceVisible(session, hintUrl, context, waitMs = SURFACE_WAIT_MS) {
|
|
110
110
|
let found = await surfaceRect(session, hintUrl);
|
|
111
111
|
if (found) return found;
|
|
112
112
|
requestSurfaceOpen(session, context);
|
|
113
113
|
const deadlineAt = Date.now() + waitMs;
|
|
114
114
|
while (Date.now() < deadlineAt) {
|
|
115
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
115
|
+
await new Promise((resolve) => setTimeout(resolve, SURFACE_POLL_MS));
|
|
116
116
|
found = await surfaceRect(session, hintUrl);
|
|
117
117
|
if (found) return found;
|
|
118
118
|
}
|
|
@@ -120,12 +120,12 @@ async function ensureSurfaceVisible(session, hintUrl, context, waitMs = WEBVIEW_
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
/**
|
|
123
|
-
* This session's candidate
|
|
123
|
+
* This session's candidate surfaces out of a target/tab list. Marker in the
|
|
124
124
|
* url or title identifies fresh surfaces exactly; the live URL the embedder
|
|
125
125
|
* DOM reports for the session's marked element covers surfaces that
|
|
126
126
|
* navigated away from the marker (e.g. after an MCP restart) — but two
|
|
127
127
|
* sessions can browse the same URL, so URL matches are candidates to be
|
|
128
|
-
* confirmed by stamp, never picked blindly. And never "the only
|
|
128
|
+
* confirmed by stamp, never picked blindly. And never "the only surface":
|
|
129
129
|
* a displaced session adopting whatever surface remains is how one chat's
|
|
130
130
|
* browser ends up reading another chat's page.
|
|
131
131
|
*/
|
|
@@ -155,10 +155,21 @@ async function readStamp(wsUrl) {
|
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
function candidateHasMarker(candidate, session) {
|
|
159
|
+
const markerText = marker(session);
|
|
160
|
+
return String(candidate?.url || '').includes(markerText)
|
|
161
|
+
|| String(candidate?.title || '').includes(markerText);
|
|
162
|
+
}
|
|
163
|
+
|
|
158
164
|
/** Resolve same-URL ambiguity: the candidate whose page carries this
|
|
159
|
-
* session's stamp wins. A
|
|
160
|
-
async function
|
|
161
|
-
if (
|
|
165
|
+
* session's stamp wins. A fresh marker target is already unambiguous. */
|
|
166
|
+
async function confirmSurfaceCandidates(candidates, session) {
|
|
167
|
+
if (
|
|
168
|
+
candidates.length === 1
|
|
169
|
+
&& (candidateHasMarker(candidates[0], session) || candidates[0].type === 'webview')
|
|
170
|
+
) {
|
|
171
|
+
return candidates[0];
|
|
172
|
+
}
|
|
162
173
|
for (const candidate of candidates) {
|
|
163
174
|
if (await readStamp(candidate.webSocketDebuggerUrl) === session) return candidate;
|
|
164
175
|
}
|
|
@@ -166,13 +177,13 @@ async function confirmGuestCandidates(candidates, session) {
|
|
|
166
177
|
}
|
|
167
178
|
|
|
168
179
|
/**
|
|
169
|
-
* Stamp the
|
|
180
|
+
* Stamp the surface and keep the connection open. The on-new-document script
|
|
170
181
|
* lives exactly as long as this CDP session — which is exactly as long as
|
|
171
|
-
* the stamp should live: the connection drops when the target dies (
|
|
182
|
+
* the stamp should live: the connection drops when the target dies (surface
|
|
172
183
|
* remount, app restart), the surface record is dropped with it, and the next
|
|
173
184
|
* command re-attaches against the new reality.
|
|
174
185
|
*/
|
|
175
|
-
async function
|
|
186
|
+
async function stampSurface(session, wsUrl) {
|
|
176
187
|
const client = await cdp.connectCdp(wsUrl);
|
|
177
188
|
try {
|
|
178
189
|
await client.call('Page.enable').catch(() => {});
|
|
@@ -186,10 +197,8 @@ async function stampGuest(session, wsUrl) {
|
|
|
186
197
|
}
|
|
187
198
|
|
|
188
199
|
/**
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
* land). One focus pass over raw CDP — the embedder focuses the element, the
|
|
192
|
-
* guest focuses its window. Best-effort: reads work unfocused regardless.
|
|
200
|
+
* Give the marked host and native page one best-effort focus pass before
|
|
201
|
+
* input. The main process also focuses a visible WebContentsView directly.
|
|
193
202
|
*/
|
|
194
203
|
async function focusSurface(session, surface) {
|
|
195
204
|
try {
|
|
@@ -198,9 +207,9 @@ async function focusSurface(session, surface) {
|
|
|
198
207
|
try {
|
|
199
208
|
await embedder.call('Runtime.evaluate', {
|
|
200
209
|
expression: `(() => {
|
|
201
|
-
const view = [...document.querySelectorAll('
|
|
210
|
+
const view = [...document.querySelectorAll('[data-amalgm-surface]')]
|
|
202
211
|
.find((w) => (w.getAttribute('data-amalgm-surface') || '').includes(${JSON.stringify(marker(session))}));
|
|
203
|
-
if (view) view.focus();
|
|
212
|
+
if (view) view.focus({ preventScroll: true });
|
|
204
213
|
return Boolean(view);
|
|
205
214
|
})()`,
|
|
206
215
|
returnByValue: true,
|
|
@@ -224,7 +233,7 @@ function commandNeedsOwnedFocus(command) {
|
|
|
224
233
|
|
|
225
234
|
/**
|
|
226
235
|
* Focus is owned by the browser session, not by the user's currently selected
|
|
227
|
-
* Amalgm tab. Before commands that need native
|
|
236
|
+
* Amalgm tab. Before commands that need native page focus, reacquire focus on
|
|
228
237
|
* this session's stamped surface. Reads and evals do not disturb the user.
|
|
229
238
|
*/
|
|
230
239
|
async function acquireOwnedFocus(session, context, surface, hintUrl = '') {
|
|
@@ -238,8 +247,8 @@ async function acquireOwnedFocus(session, context, surface, hintUrl = '') {
|
|
|
238
247
|
}
|
|
239
248
|
|
|
240
249
|
/**
|
|
241
|
-
* Connect the session to the advertised
|
|
242
|
-
* in-tab
|
|
250
|
+
* Connect the session to the advertised Chromium on first use: select the
|
|
251
|
+
* in-tab native page in the daemon, stamp it over a persistent raw-CDP
|
|
243
252
|
* connection, focus it. The returned record is the session's one source of
|
|
244
253
|
* identity — commands verify against the stamp, captures connect to the
|
|
245
254
|
* target it names.
|
|
@@ -249,11 +258,14 @@ async function ensureReady(session, context) {
|
|
|
249
258
|
const live = surfaces.get(session);
|
|
250
259
|
if (live) return live;
|
|
251
260
|
|
|
252
|
-
// Explicit external CDP endpoints (plain
|
|
253
|
-
//
|
|
261
|
+
// Explicit external CDP endpoints (plain Chromium) have no marked Amalgm
|
|
262
|
+
// host element. WebContentsView targets are also type:"page", so target
|
|
263
|
+
// type alone can no longer distinguish an external browser from Electron.
|
|
254
264
|
if (process.env.AMALGM_BROWSER_CDP_URL) {
|
|
255
265
|
const targets = await cdp.listTargets(backend.cdpEndpoint()).catch(() => []);
|
|
256
|
-
|
|
266
|
+
const markedTarget = targets.some((target) => candidateHasMarker(target, session));
|
|
267
|
+
const markedHost = await surfaceRect(session, '');
|
|
268
|
+
if (!targets.some((target) => target.type === 'webview') && !markedTarget && !markedHost) {
|
|
257
269
|
const external = { external: true, client: null, tabId: null, wsUrl: null };
|
|
258
270
|
surfaces.set(session, external);
|
|
259
271
|
return external;
|
|
@@ -264,24 +276,30 @@ async function ensureReady(session, context) {
|
|
|
264
276
|
// navigated away from the marker URL (data-amalgm-surface survives where
|
|
265
277
|
// target URLs do not).
|
|
266
278
|
const domSrc = async () => (await surfaceRect(session, ''))?.rect?.src || null;
|
|
267
|
-
const
|
|
279
|
+
const surfaceTabs = async () => {
|
|
268
280
|
const tabs = await cli.runCli(['tab'], cliOptions(session, { timeoutMs: 15_000 }));
|
|
269
|
-
|
|
281
|
+
const targetTypes = new Set(backend.surfaceTargetTypes());
|
|
282
|
+
return (tabs?.data?.tabs || tabs?.tabs || []).filter((tab) =>
|
|
283
|
+
targetTypes.has(tab.type)
|
|
284
|
+
);
|
|
270
285
|
};
|
|
271
286
|
|
|
272
|
-
let candidates = identityCandidates(await
|
|
287
|
+
let candidates = identityCandidates(await surfaceTabs(), session, await domSrc());
|
|
273
288
|
if (!candidates.length) {
|
|
274
289
|
requestSurfaceOpen(session, context);
|
|
275
|
-
const deadlineAt = Date.now() +
|
|
290
|
+
const deadlineAt = Date.now() + SURFACE_WAIT_MS;
|
|
276
291
|
while (!candidates.length && Date.now() < deadlineAt) {
|
|
277
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
278
|
-
candidates = identityCandidates(await
|
|
292
|
+
await new Promise((resolve) => setTimeout(resolve, SURFACE_POLL_MS));
|
|
293
|
+
candidates = identityCandidates(await surfaceTabs(), session, await domSrc());
|
|
279
294
|
}
|
|
280
295
|
}
|
|
281
296
|
|
|
282
297
|
// Same-URL candidates are told apart by the stamp their pages carry from a
|
|
283
298
|
// previous attach: pin each in turn and read it back through the daemon.
|
|
284
|
-
let selected = candidates.length === 1
|
|
299
|
+
let selected = candidates.length === 1
|
|
300
|
+
&& (candidateHasMarker(candidates[0], session) || candidates[0].type === 'webview')
|
|
301
|
+
? candidates[0]
|
|
302
|
+
: null;
|
|
285
303
|
for (const candidate of candidates) {
|
|
286
304
|
if (selected) break;
|
|
287
305
|
await cli.runCli(['tab', candidate.tabId], cliOptions(session, { timeoutMs: 15_000 }));
|
|
@@ -298,43 +316,48 @@ async function ensureReady(session, context) {
|
|
|
298
316
|
);
|
|
299
317
|
}
|
|
300
318
|
|
|
301
|
-
// The
|
|
319
|
+
// The native page target existing is not enough — the session surface must be
|
|
302
320
|
// compositor-backed, or input drops and captures have nothing to render.
|
|
303
321
|
// The app window itself may be hidden or minimized; the app keeps attached
|
|
304
322
|
// surfaces compositing while the browser tool owns them.
|
|
305
323
|
const visible = await ensureSurfaceVisible(session, selected.url, context);
|
|
306
324
|
if (!visible) throw new Error(SURFACE_HIDDEN_MESSAGE);
|
|
307
325
|
|
|
308
|
-
// Select the session's
|
|
326
|
+
// Select the session's page in the daemon — once. Refs don't exist yet,
|
|
309
327
|
// so this is the one place a `tab` command is allowed.
|
|
310
328
|
await cli.runCli(['tab', selected.tabId], cliOptions(session, { timeoutMs: 15_000 }));
|
|
311
329
|
|
|
312
|
-
// The
|
|
313
|
-
// stamp-confirmed when several
|
|
314
|
-
const
|
|
330
|
+
// The surface's raw-CDP target, by the same identity as the daemon tab —
|
|
331
|
+
// stamp-confirmed when several pages share the URL.
|
|
332
|
+
const advertisedTargetTypes = new Set(backend.surfaceTargetTypes());
|
|
333
|
+
const target = await confirmSurfaceCandidates(identityCandidates(
|
|
334
|
+
// Rev 5 exposes guest targets as `webview`; rev 6 exposes
|
|
335
|
+
// WebContentsView pages as `page`. The bridge advertisement selects one
|
|
336
|
+
// contract, so app renderer pages never enter the candidate set.
|
|
315
337
|
(await cdp.listTargets(backend.cdpEndpoint()))
|
|
316
|
-
.filter((t) => t.type
|
|
338
|
+
.filter((t) => advertisedTargetTypes.has(t.type) && t.webSocketDebuggerUrl),
|
|
317
339
|
session,
|
|
318
340
|
visible.rect?.src || null,
|
|
319
341
|
), session);
|
|
320
|
-
if (!
|
|
342
|
+
if (!target) throw new Error(SURFACE_HIDDEN_MESSAGE);
|
|
321
343
|
|
|
322
|
-
const client = await
|
|
344
|
+
const client = await stampSurface(session, target.webSocketDebuggerUrl);
|
|
323
345
|
const surface = {
|
|
324
346
|
tabId: selected.tabId,
|
|
325
|
-
targetId:
|
|
326
|
-
|
|
347
|
+
targetId: target.id,
|
|
348
|
+
targetType: target.type,
|
|
349
|
+
wsUrl: target.webSocketDebuggerUrl,
|
|
327
350
|
embedderWsUrl: visible.pageWsUrl || null,
|
|
328
351
|
client,
|
|
329
352
|
external: false,
|
|
330
353
|
};
|
|
331
|
-
// The connection dropping IS the detach signal:
|
|
354
|
+
// The connection dropping IS the detach signal: surface remount, app
|
|
332
355
|
// restart, surface close — all of them invalidate this identity at once.
|
|
333
356
|
client.onClose(() => {
|
|
334
357
|
if (surfaces.get(session) === surface) surfaces.delete(session);
|
|
335
358
|
});
|
|
336
359
|
surfaces.set(session, surface);
|
|
337
|
-
knownSessions.set(session, { ...knownSessions.get(session),
|
|
360
|
+
knownSessions.set(session, { ...knownSessions.get(session), surfaceTabId: selected.tabId });
|
|
338
361
|
|
|
339
362
|
await focusSurface(session, surface);
|
|
340
363
|
return surface;
|
|
@@ -419,7 +442,7 @@ async function execute(session, commands, options = {}, single) {
|
|
|
419
442
|
return await attempt();
|
|
420
443
|
} catch (err) {
|
|
421
444
|
if (guarded && looksLikeLostConnection(err)) {
|
|
422
|
-
// The app restarted, the
|
|
445
|
+
// The app restarted, the surface remounted, or the daemon drifted off
|
|
423
446
|
// this session's page; re-attach once against current reality.
|
|
424
447
|
detach(session);
|
|
425
448
|
const nextSurface = await ensureReady(session, context);
|
|
@@ -450,12 +473,12 @@ async function readText(session, commandArgs, timeoutMs) {
|
|
|
450
473
|
}
|
|
451
474
|
|
|
452
475
|
/**
|
|
453
|
-
* The CDP websocket url for raster capture — the surface's own
|
|
476
|
+
* The CDP websocket url for raster capture — the surface's own page target,
|
|
454
477
|
* established at attach. Requires the surface on screen (the one physical
|
|
455
|
-
* precondition for
|
|
478
|
+
* precondition for attached pixels); ensureSurfaceVisible self-heals a
|
|
456
479
|
* backgrounded tab first. Null for external plain-chromium endpoints.
|
|
457
480
|
*/
|
|
458
|
-
async function
|
|
481
|
+
async function surfaceTarget(session, context) {
|
|
459
482
|
let surface = await ensureReady(session, context);
|
|
460
483
|
if (surface?.external) return null;
|
|
461
484
|
const visible = await ensureSurfaceVisible(session, '', context);
|
|
@@ -463,12 +486,19 @@ async function guestWsUrl(session, context) {
|
|
|
463
486
|
// The target may have died between ensureReady and now — the close handler
|
|
464
487
|
// clears the record, so a second look re-attaches against the new target.
|
|
465
488
|
surface = surfaces.get(session) || await ensureReady(session, context);
|
|
466
|
-
return
|
|
489
|
+
return {
|
|
490
|
+
wsUrl: surface.wsUrl,
|
|
491
|
+
type: surface.targetType || 'webview',
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function surfaceWsUrl(session, context) {
|
|
496
|
+
return (await surfaceTarget(session, context))?.wsUrl || null;
|
|
467
497
|
}
|
|
468
498
|
|
|
469
499
|
/**
|
|
470
500
|
* Tell the UI this session's surface is done — the renderer unmounts the
|
|
471
|
-
* splitview/tab, so ended sessions stop accumulating
|
|
501
|
+
* splitview/tab, so ended sessions stop accumulating page renderers (each is
|
|
472
502
|
* a full renderer process).
|
|
473
503
|
*/
|
|
474
504
|
function requestSurfaceClose(session) {
|
|
@@ -490,13 +520,14 @@ module.exports = {
|
|
|
490
520
|
GUARD_ERROR,
|
|
491
521
|
SURFACE_EVENT_RESOURCE,
|
|
492
522
|
SURFACE_HIDDEN_MESSAGE,
|
|
493
|
-
|
|
523
|
+
SURFACE_MARKER_PREFIX,
|
|
494
524
|
cliOptions,
|
|
495
525
|
detach,
|
|
496
526
|
ensureReady,
|
|
497
527
|
ensureSurfaceVisible,
|
|
498
528
|
guardCommand,
|
|
499
|
-
|
|
529
|
+
surfaceWsUrl,
|
|
530
|
+
surfaceTarget,
|
|
500
531
|
looksLikeLostConnection,
|
|
501
532
|
marker,
|
|
502
533
|
identityCandidates,
|
|
@@ -58,9 +58,29 @@ function readBridgeAdvertisement() {
|
|
|
58
58
|
for (const file of bridgeFileCandidates()) {
|
|
59
59
|
try {
|
|
60
60
|
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
61
|
-
|
|
61
|
+
const cdpUrl = typeof data?.cdp?.url === 'string'
|
|
62
|
+
? data.cdp.url
|
|
63
|
+
: data?.cdpUrl;
|
|
64
|
+
const cdpPort = Number.isInteger(data?.cdp?.port)
|
|
65
|
+
? data.cdp.port
|
|
66
|
+
: data?.cdpPort;
|
|
67
|
+
if (typeof cdpUrl !== 'string' || !cdpUrl) continue;
|
|
62
68
|
if (typeof data?.pid === 'number' && !pidIsRunning(data.pid)) continue;
|
|
63
|
-
return {
|
|
69
|
+
return {
|
|
70
|
+
cdpUrl,
|
|
71
|
+
cdpPort,
|
|
72
|
+
label: data.label,
|
|
73
|
+
surfaceProtocol: Number.isInteger(data?.browserSurfaceProtocol)
|
|
74
|
+
? data.browserSurfaceProtocol
|
|
75
|
+
: 5,
|
|
76
|
+
surfaceTargetType: data?.browserSurfaceTargetType === 'page'
|
|
77
|
+
? 'page'
|
|
78
|
+
: 'webview',
|
|
79
|
+
surfaceTargetTypes: Array.isArray(data?.browserSurfaceTargetTypes)
|
|
80
|
+
? data.browserSurfaceTargetTypes.filter((value) => value === 'page' || value === 'webview')
|
|
81
|
+
: null,
|
|
82
|
+
file,
|
|
83
|
+
};
|
|
64
84
|
} catch {}
|
|
65
85
|
}
|
|
66
86
|
return null;
|
|
@@ -81,7 +101,7 @@ function cdpFlagValue(input) {
|
|
|
81
101
|
/**
|
|
82
102
|
* The CDP endpoint for attached mode, as a --cdp flag value. agent-browser's
|
|
83
103
|
* `connect` command launches a managed browser; raw --cdp attach is the mode
|
|
84
|
-
* that discovers Electron
|
|
104
|
+
* that discovers Electron page targets, so that is all we use.
|
|
85
105
|
*/
|
|
86
106
|
function cdpEndpoint() {
|
|
87
107
|
if (process.env.AMALGM_BROWSER_CDP_URL) return cdpFlagValue(process.env.AMALGM_BROWSER_CDP_URL);
|
|
@@ -91,6 +111,21 @@ function cdpEndpoint() {
|
|
|
91
111
|
return cdpFlagValue(advertisement.cdpUrl);
|
|
92
112
|
}
|
|
93
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Target kinds spoken by the advertised desktop shell. Missing protocol
|
|
116
|
+
* means the rev-5 webview contract. Explicit operator endpoints are allowed
|
|
117
|
+
* to be plain Chromium or either Electron generation, so probe both.
|
|
118
|
+
*/
|
|
119
|
+
function surfaceTargetTypes() {
|
|
120
|
+
if (process.env.AMALGM_BROWSER_CDP_URL) return ['page', 'webview'];
|
|
121
|
+
const advertisement = readBridgeAdvertisement();
|
|
122
|
+
if (!advertisement) return [];
|
|
123
|
+
if (advertisement.surfaceTargetTypes?.length) {
|
|
124
|
+
return [...new Set(advertisement.surfaceTargetTypes)];
|
|
125
|
+
}
|
|
126
|
+
return [advertisement.surfaceTargetType];
|
|
127
|
+
}
|
|
128
|
+
|
|
94
129
|
/** The debug override, or null when the owner's stamp decides. */
|
|
95
130
|
function forcedMode() {
|
|
96
131
|
const requested = (process.env.AMALGM_BROWSER_BACKEND || '').toLowerCase();
|
|
@@ -121,4 +156,5 @@ module.exports = {
|
|
|
121
156
|
forcedMode,
|
|
122
157
|
modeFor,
|
|
123
158
|
readBridgeAdvertisement,
|
|
159
|
+
surfaceTargetTypes,
|
|
124
160
|
};
|
|
@@ -5,18 +5,13 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Pixels come straight from the session's own page:
|
|
7
7
|
* - cli (headless) sessions capture their page target;
|
|
8
|
-
* - attached sessions capture the
|
|
9
|
-
* established at attach (attach.
|
|
8
|
+
* - attached sessions capture the native page target whose identity was
|
|
9
|
+
* established at attach (attach.surfaceTarget) — never a target-list hunt,
|
|
10
10
|
* never a "first page target" fallback (that fallback is how
|
|
11
11
|
* cua_screenshot once returned the app's own chat UI).
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* window may be hidden or minimized — the desktop app disables background
|
|
16
|
-
* throttling while a surface is attached, so frames keep flowing. Only a
|
|
17
|
-
* surface in a background tab (display:none) cannot produce pixels, and
|
|
18
|
-
* ensureSurfaceVisible self-heals that by asking the UI to refocus the
|
|
19
|
-
* surface tab before any capture is attempted.
|
|
13
|
+
* Background automation surfaces remain composited in a private native
|
|
14
|
+
* parking window, so captures keep working without covering the user's tab.
|
|
20
15
|
*
|
|
21
16
|
* Every exchange carries a hard deadline (cdp.js): a capture may fail, it
|
|
22
17
|
* can never wedge a session.
|
|
@@ -48,34 +43,75 @@ async function pageTargetWsUrl(endpoint) {
|
|
|
48
43
|
|
|
49
44
|
/**
|
|
50
45
|
* Where to capture from for this session:
|
|
51
|
-
* { wsUrl,
|
|
52
|
-
*
|
|
46
|
+
* { wsUrl, attached, legacyWebview } — attached output is normalized to
|
|
47
|
+
* CSS pixels so coordinates map 1:1 onto cua_* input. The legacy flag
|
|
48
|
+
* preserves rev-5's transparent-PNG compatibility during rolling updates.
|
|
53
49
|
*/
|
|
54
50
|
async function plan(session, context) {
|
|
55
51
|
if (backendFor(session) !== 'attached') {
|
|
56
52
|
const raw = await daemonCdpUrl(session);
|
|
57
53
|
if (!raw) throw new Error('Could not resolve a CDP endpoint for this browser session.');
|
|
58
54
|
const wsUrl = /\/devtools\/browser\//.test(raw) ? await pageTargetWsUrl(raw) : raw;
|
|
59
|
-
return { wsUrl,
|
|
55
|
+
return { wsUrl, attached: false };
|
|
60
56
|
}
|
|
61
57
|
|
|
62
|
-
// Desktop app: the session's own
|
|
58
|
+
// Desktop app: the session's own native page, identity from attach.
|
|
63
59
|
// Null only for explicit external CDP endpoints (plain chromium) — there
|
|
64
60
|
// the driven page is the capture source.
|
|
65
|
-
const
|
|
66
|
-
if (
|
|
67
|
-
|
|
61
|
+
const target = await attach.surfaceTarget(session, context);
|
|
62
|
+
if (target) {
|
|
63
|
+
return {
|
|
64
|
+
wsUrl: target.wsUrl,
|
|
65
|
+
attached: true,
|
|
66
|
+
legacyWebview: target.type === 'webview',
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
wsUrl: await pageTargetWsUrl(backend.cdpEndpoint()),
|
|
71
|
+
attached: false,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Rev-5 Electron webview guests can return a transparent PNG base even when
|
|
77
|
+
* the page looks white. Keep the old white-flatten pass only for an attached
|
|
78
|
+
* target positively identified as `webview`; native pages and headless
|
|
79
|
+
* Chromium skip the process entirely.
|
|
80
|
+
*/
|
|
81
|
+
function flattenPng(buffer) {
|
|
82
|
+
const { spawn } = require('child_process');
|
|
83
|
+
return cdp.deadline(new Promise((resolve) => {
|
|
84
|
+
const child = spawn(cli.ffmpegBinary(), [
|
|
85
|
+
'-y', '-f', 'image2pipe', '-c:v', 'png', '-i', 'pipe:0',
|
|
86
|
+
'-filter_complex', '[0:v]format=rgba,split[fg1][fg2];[fg1]drawbox=color=white:t=fill[bg];'
|
|
87
|
+
+ '[bg][fg2]overlay=format=auto,format=rgb24',
|
|
88
|
+
'-frames:v', '1', '-f', 'image2pipe', '-c:v', 'png', 'pipe:1',
|
|
89
|
+
], { stdio: ['pipe', 'pipe', 'ignore'] });
|
|
90
|
+
const chunks = [];
|
|
91
|
+
child.stdout.on('data', (chunk) => chunks.push(chunk));
|
|
92
|
+
child.on('error', () => resolve(buffer));
|
|
93
|
+
child.on('close', (code) => {
|
|
94
|
+
const output = Buffer.concat(chunks);
|
|
95
|
+
resolve(code === 0 && output.length ? output : buffer);
|
|
96
|
+
});
|
|
97
|
+
child.stdin.on('error', () => {});
|
|
98
|
+
child.stdin.end(buffer);
|
|
99
|
+
}), 10_000, 'screenshot flatten').catch(() => buffer);
|
|
68
100
|
}
|
|
69
101
|
|
|
70
|
-
|
|
102
|
+
function shouldFlattenCapture(source) {
|
|
103
|
+
return source?.attached === true && source?.legacyWebview === true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Why an attached capture starved, in words an agent can act on. */
|
|
71
107
|
function starvationReason() {
|
|
72
|
-
return 'the browser surface is
|
|
73
|
-
+ '
|
|
108
|
+
return 'the browser surface compositor is unavailable while the app is closing '
|
|
109
|
+
+ 'or recreating that surface. Retry once; if the app is gone, reopen it. '
|
|
74
110
|
+ 'AMALGM_BROWSER_BACKEND=cli forces a headless browser instead';
|
|
75
111
|
}
|
|
76
112
|
|
|
77
113
|
/**
|
|
78
|
-
* The
|
|
114
|
+
* The attached surface viewport as a CSS-pixel clip. Captures render at the device
|
|
79
115
|
* scale factor; scale divides it back out so the image is CSS-pixel sized
|
|
80
116
|
* and its coordinates map 1:1 onto cua_click/cua_move input.
|
|
81
117
|
*/
|
|
@@ -95,7 +131,7 @@ async function cssViewportClip(client) {
|
|
|
95
131
|
* failure — an actionable refusal at start instead of an empty file at stop.
|
|
96
132
|
*/
|
|
97
133
|
async function assertCapturable(client, source) {
|
|
98
|
-
if (!source.
|
|
134
|
+
if (!source.attached) return; // headless page targets raster unconditionally
|
|
99
135
|
try {
|
|
100
136
|
await client.call('Page.captureScreenshot', {
|
|
101
137
|
format: 'jpeg',
|
|
@@ -107,42 +143,13 @@ async function assertCapturable(client, source) {
|
|
|
107
143
|
}
|
|
108
144
|
}
|
|
109
145
|
|
|
110
|
-
/**
|
|
111
|
-
* Electron <webview> guests raster over a transparent base, so a page
|
|
112
|
-
* without its own background captures with alpha — which viewers and
|
|
113
|
-
* encoders composite as black. (CDP's background override is a no-op on
|
|
114
|
-
* guests; verified live.) Flatten guest PNGs onto white with the bundled
|
|
115
|
-
* ffmpeg — the pixels are intact under the alpha. Headless page targets
|
|
116
|
-
* raster opaque and skip this entirely.
|
|
117
|
-
*/
|
|
118
|
-
function flattenPng(buffer) {
|
|
119
|
-
const { spawn } = require('child_process');
|
|
120
|
-
return cdp.deadline(new Promise((resolve) => {
|
|
121
|
-
const child = spawn(cli.ffmpegBinary(), [
|
|
122
|
-
'-y', '-f', 'image2pipe', '-c:v', 'png', '-i', 'pipe:0',
|
|
123
|
-
'-filter_complex', '[0:v]format=rgba,split[fg1][fg2];[fg1]drawbox=color=white:t=fill[bg];'
|
|
124
|
-
+ '[bg][fg2]overlay=format=auto,format=rgb24',
|
|
125
|
-
'-frames:v', '1', '-f', 'image2pipe', '-c:v', 'png', 'pipe:1',
|
|
126
|
-
], { stdio: ['pipe', 'pipe', 'ignore'] });
|
|
127
|
-
const chunks = [];
|
|
128
|
-
child.stdout.on('data', (chunk) => chunks.push(chunk));
|
|
129
|
-
child.on('error', () => resolve(buffer)); // no ffmpeg — alpha beats no image
|
|
130
|
-
child.on('close', (code) => {
|
|
131
|
-
const out = Buffer.concat(chunks);
|
|
132
|
-
resolve(code === 0 && out.length ? out : buffer);
|
|
133
|
-
});
|
|
134
|
-
child.stdin.on('error', () => {});
|
|
135
|
-
child.stdin.end(buffer);
|
|
136
|
-
}), 10_000, 'screenshot flatten').catch(() => buffer);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
146
|
async function screenshot(session, args = {}, context) {
|
|
140
147
|
const fullPage = Boolean(args.fullPage);
|
|
141
148
|
const source = await plan(session, context);
|
|
142
149
|
const client = await cdp.connectCdp(source.wsUrl);
|
|
143
150
|
try {
|
|
144
151
|
const params = { format: 'png' };
|
|
145
|
-
if (source.
|
|
152
|
+
if (source.attached || !fullPage) {
|
|
146
153
|
// Every backend normalizes to CSS pixels — a dpr-2 display would
|
|
147
154
|
// otherwise return a 2x image whose coordinates no longer match the
|
|
148
155
|
// cua_* input space.
|
|
@@ -165,19 +172,20 @@ async function screenshot(session, args = {}, context) {
|
|
|
165
172
|
try {
|
|
166
173
|
({ data } = await client.call('Page.captureScreenshot', params, SCREENSHOT_TIMEOUT_MS));
|
|
167
174
|
} catch (err) {
|
|
168
|
-
if (source.
|
|
175
|
+
if (source.attached && /timed out/i.test(String(err.message || ''))) {
|
|
169
176
|
throw new Error(`Screenshot starved: ${starvationReason()}.`);
|
|
170
177
|
}
|
|
171
178
|
throw err;
|
|
172
179
|
}
|
|
173
180
|
if (!data) throw new Error('Empty screenshot response');
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
181
|
+
const rawImage = Buffer.from(data, 'base64');
|
|
182
|
+
const image = shouldFlattenCapture(source)
|
|
183
|
+
? await flattenPng(rawImage)
|
|
184
|
+
: rawImage;
|
|
177
185
|
return {
|
|
178
186
|
base64: image.toString('base64'),
|
|
179
187
|
bytes: image.length,
|
|
180
|
-
mode: source.
|
|
188
|
+
mode: source.attached ? 'visible surface' : (fullPage ? 'full page' : 'viewport'),
|
|
181
189
|
};
|
|
182
190
|
} finally {
|
|
183
191
|
client.close();
|
|
@@ -195,6 +203,7 @@ module.exports = {
|
|
|
195
203
|
plan,
|
|
196
204
|
rectScript: cdp.rectScript,
|
|
197
205
|
screenshot,
|
|
206
|
+
shouldFlattenCapture,
|
|
198
207
|
starvationReason,
|
|
199
208
|
targetListUrl: cdp.targetListUrl,
|
|
200
209
|
};
|
|
@@ -116,14 +116,14 @@ function pageTargets(targets) {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
/**
|
|
119
|
-
* Locate the session's
|
|
119
|
+
* Locate the session's browser-surface marker inside a candidate app window.
|
|
120
120
|
* Identity, strongest first:
|
|
121
121
|
* - data-amalgm-surface attribute — stamped once at mount, survives
|
|
122
122
|
* navigation (the src attribute is rewritten on every navigate, so the
|
|
123
123
|
* marker URL it starts with does not last);
|
|
124
|
-
* - marker still in the
|
|
124
|
+
* - marker still in the live URL — fresh, never-navigated surfaces;
|
|
125
125
|
* - exact current URL — old-app fallback, needs a fresh hint.
|
|
126
|
-
* Never "the only
|
|
126
|
+
* Never "the only surface": adopting an unidentified surface is how one
|
|
127
127
|
* chat's session ends up driving another chat's page.
|
|
128
128
|
*
|
|
129
129
|
* A session can match several elements — remounts (chat-tab switches) leave
|
|
@@ -132,24 +132,25 @@ function pageTargets(targets) {
|
|
|
132
132
|
* capturable surface used to be reported as "hidden".
|
|
133
133
|
*
|
|
134
134
|
* Returns layout geometry only when a matched element has on-screen size,
|
|
135
|
-
* plus the embedder's visibilityState and the
|
|
135
|
+
* plus the embedder's visibilityState and the surface's live URL (to re-find
|
|
136
136
|
* the daemon-side target after a restart).
|
|
137
137
|
*/
|
|
138
138
|
function rectScript(markerText, currentUrl) {
|
|
139
139
|
const urlLiteral = JSON.stringify(String(currentUrl || ''));
|
|
140
140
|
return `(() => {
|
|
141
|
-
const views = [...document.querySelectorAll('
|
|
141
|
+
const views = [...document.querySelectorAll('[data-amalgm-surface]')];
|
|
142
|
+
const liveUrl = (w) => w.getAttribute('data-amalgm-url') || w.src || w.getAttribute('src') || '';
|
|
142
143
|
const matches = [
|
|
143
144
|
...views.filter((w) => (w.getAttribute('data-amalgm-surface') || '').includes(${JSON.stringify(markerText)})),
|
|
144
|
-
...views.filter((w) => (w
|
|
145
|
-
...(${urlLiteral} ? views.filter((w) => (w
|
|
145
|
+
...views.filter((w) => liveUrl(w).includes(${JSON.stringify(markerText)})),
|
|
146
|
+
...(${urlLiteral} ? views.filter((w) => liveUrl(w) === ${urlLiteral}) : []),
|
|
146
147
|
];
|
|
147
148
|
if (!matches.length) return null;
|
|
148
149
|
const sized = matches.map((w) => ({ w, r: w.getBoundingClientRect() }));
|
|
149
150
|
const match = sized.find(({ r }) => r.width && r.height);
|
|
150
151
|
if (!match) return null;
|
|
151
152
|
const { w, r } = match;
|
|
152
|
-
return JSON.stringify({ x: r.x, y: r.y, width: r.width, height: r.height, vw: window.innerWidth, vh: window.innerHeight, dpr: window.devicePixelRatio || 1, src: w
|
|
153
|
+
return JSON.stringify({ x: r.x, y: r.y, width: r.width, height: r.height, vw: window.innerWidth, vh: window.innerHeight, dpr: window.devicePixelRatio || 1, src: liveUrl(w), embedderVisibility: document.visibilityState });
|
|
153
154
|
})()`;
|
|
154
155
|
}
|
|
155
156
|
|
|
@@ -164,8 +165,8 @@ async function embedderRect(cdp, markerText, currentUrl) {
|
|
|
164
165
|
}
|
|
165
166
|
|
|
166
167
|
/**
|
|
167
|
-
* Find the app window hosting the session's
|
|
168
|
-
*
|
|
168
|
+
* Find the app window hosting the session's marker and its on-screen
|
|
169
|
+
* rectangle. Null when the surface is hidden (or no marker
|
|
169
170
|
* matches) — callers decide whether to reopen or fail.
|
|
170
171
|
*/
|
|
171
172
|
async function findSurfaceRect(endpoint, markerText, currentUrl) {
|
|
@@ -135,7 +135,7 @@ function baseArgs(session, json = true, cdp = null) {
|
|
|
135
135
|
'--screenshot-dir', screenshotDir(),
|
|
136
136
|
];
|
|
137
137
|
// Raw CDP attach (drives an existing chromium — e.g. the Amalgm desktop
|
|
138
|
-
// app's
|
|
138
|
+
// app's native browser page — instead of launching one). Must be passed on
|
|
139
139
|
// every command: `connect` launches a managed browser, which is not what
|
|
140
140
|
// attached mode wants.
|
|
141
141
|
if (cdp) args.push('--cdp', String(cdp));
|
|
@@ -463,7 +463,7 @@ async function close(args = {}, context) {
|
|
|
463
463
|
try { await recorder.stop({ ...args, session }, context); } catch {}
|
|
464
464
|
}
|
|
465
465
|
// Tear down this session's daemon. In attached mode this only disconnects
|
|
466
|
-
// from the app's
|
|
466
|
+
// from the app's Chromium (verified live: the app, its native pages, and other
|
|
467
467
|
// sessions' daemons survive). Without this, every attached session leaks a
|
|
468
468
|
// daemon process for the life of the machine.
|
|
469
469
|
try {
|
|
@@ -473,7 +473,7 @@ async function close(args = {}, context) {
|
|
|
473
473
|
}
|
|
474
474
|
// Tell the UI to unmount the splitview/tab. Each surface is a full
|
|
475
475
|
// renderer process; ended sessions must not accumulate them (ten zombie
|
|
476
|
-
//
|
|
476
|
+
// browser surfaces were live in the app when this was written).
|
|
477
477
|
if (sessions.surfaces.has(session) || sessions.knownSessions.has(session)) {
|
|
478
478
|
attach.requestSurfaceClose(session);
|
|
479
479
|
}
|
|
@@ -498,6 +498,7 @@ module.exports = {
|
|
|
498
498
|
cdpEndpoint: backend.cdpEndpoint,
|
|
499
499
|
modeFor: backend.modeFor,
|
|
500
500
|
readBridgeAdvertisement: backend.readBridgeAdvertisement,
|
|
501
|
+
surfaceTargetTypes: backend.surfaceTargetTypes,
|
|
501
502
|
// Session façade (sessions.js).
|
|
502
503
|
backendFor: sessions.backendFor,
|
|
503
504
|
ensureCookieSourceLoaded,
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Recorder actions — session recording as a first-class primitive any agent
|
|
5
5
|
* can use (QA bots especially). Captures the browser session — including the
|
|
6
|
-
* desktop app's
|
|
7
|
-
* page — to a local WebM stored with the Amalgm project the work
|
|
6
|
+
* desktop app's native in-tab page, cropped so the file shows only the
|
|
7
|
+
* page directly — to a local WebM stored with the Amalgm project the work
|
|
8
|
+
* belongs to.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
const { textResult, errorResult } = require('../lib/tool-result');
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Session recorder: capture any browser session to a local WebM file.
|
|
5
5
|
*
|
|
6
6
|
* Frames come from capture.js (CDP Page.startScreencast on the session's own
|
|
7
|
-
* page target — the headless page
|
|
7
|
+
* page target — the headless page or the attached native surface itself)
|
|
8
8
|
* and pipe into ffmpeg. The frames are the page and nothing else, so no
|
|
9
9
|
* cropping is ever needed; recordings are local-only and nothing leaves this
|
|
10
10
|
* computer.
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* - `record` needs ffmpeg on the daemon's PATH at daemon-spawn time, and
|
|
14
14
|
* silently "starts" even when ffmpeg is missing (fails only on stop).
|
|
15
15
|
* - `record` calls Target.createBrowserContext, which Electron forbids — so
|
|
16
|
-
* it can never capture the
|
|
16
|
+
* it can never capture the in-tab native surface.
|
|
17
17
|
*
|
|
18
18
|
* Storage: recordings belong to the Amalgm project the work is for —
|
|
19
19
|
* <project>/.amalgm/recordings/ when a project path is known, otherwise
|
|
@@ -171,11 +171,9 @@ async function start(args = {}, context = {}) {
|
|
|
171
171
|
|
|
172
172
|
const file = recordingFile(args, context, session);
|
|
173
173
|
const fps = clampFps(args.fps);
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
// (opaque headless frames pass through unchanged), then pad to the even
|
|
178
|
-
// dimensions VP8 wants.
|
|
174
|
+
// Composite every frame onto white for legacy transparent sources, then
|
|
175
|
+
// pad to the even dimensions VP8 wants. Opaque native-page frames pass
|
|
176
|
+
// through unchanged.
|
|
179
177
|
const filters = '[0:v]format=rgba,split[fg1][fg2];[fg1]drawbox=color=white:t=fill[bg];'
|
|
180
178
|
+ '[bg][fg2]overlay=format=auto,pad=ceil(iw/2)*2:ceil(ih/2)*2,format=yuv420p';
|
|
181
179
|
|
|
@@ -246,7 +244,7 @@ async function start(args = {}, context = {}) {
|
|
|
246
244
|
fps,
|
|
247
245
|
mimeType: 'video/webm',
|
|
248
246
|
backend: sessions.backendFor(session),
|
|
249
|
-
source: source.
|
|
247
|
+
source: source.attached ? 'visible surface' : 'page',
|
|
250
248
|
};
|
|
251
249
|
}
|
|
252
250
|
|
|
@@ -15,13 +15,13 @@ const backend = require('./backend');
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Attached surfaces: session -> { tabId, targetId, wsUrl, embedderWsUrl,
|
|
18
|
-
* client, external }. An entry exists exactly while its
|
|
18
|
+
* client, external }. An entry exists exactly while its page CDP connection
|
|
19
19
|
* is open — the connection's close handler removes it, so presence means
|
|
20
20
|
* live. attach.js owns the lifecycle.
|
|
21
21
|
*/
|
|
22
22
|
const surfaces = new Map();
|
|
23
23
|
|
|
24
|
-
/** Per-session bookkeeping:
|
|
24
|
+
/** Per-session bookkeeping: surfaceTabId, lastActiveAt, last url/title. */
|
|
25
25
|
const knownSessions = new Map();
|
|
26
26
|
|
|
27
27
|
/**
|
|
@@ -36,7 +36,7 @@ test('rectScript identifies the session surface and never guesses', () => {
|
|
|
36
36
|
// No current URL → the byUrl branch is disabled, not matching everything.
|
|
37
37
|
const bare = capture.rectScript('amalgm-bsid-abc', '');
|
|
38
38
|
assert.match(bare, /"" \? views\.filter/);
|
|
39
|
-
// "The only
|
|
39
|
+
// "The only surface" is how one chat's capture reads another chat's page —
|
|
40
40
|
// an unidentified surface must resolve to null, never to a guess.
|
|
41
41
|
assert.doesNotMatch(script, /views\.length === 1/);
|
|
42
42
|
// Remounts leave stale 0x0 copies of a session's surface behind; the match
|
|
@@ -70,9 +70,9 @@ test('rectScript prefers the on-screen copy over a stale 0x0 duplicate', async (
|
|
|
70
70
|
assert.equal(rect.src, 'https://example.com/');
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
-
test('starvationReason names the real precondition: the surface
|
|
73
|
+
test('starvationReason names the real precondition: the surface compositor', () => {
|
|
74
74
|
const reason = capture.starvationReason();
|
|
75
|
-
assert.match(reason, /
|
|
75
|
+
assert.match(reason, /surface compositor is unavailable/);
|
|
76
76
|
assert.match(reason, /AMALGM_BROWSER_BACKEND=cli/);
|
|
77
77
|
// Window state is irrelevant since the app keeps attached surfaces
|
|
78
78
|
// compositing while hidden — the message must not send users window-hunting.
|
|
@@ -83,11 +83,17 @@ test('assertCapturable converts a starved probe into a named refusal', async ()
|
|
|
83
83
|
const starvedClient = { call: () => new Promise(() => {}) };
|
|
84
84
|
const probe = capture.assertCapturable(
|
|
85
85
|
{ call: (m, p, t) => capture.deadline(new Promise(() => {}), 50, m) },
|
|
86
|
-
{
|
|
86
|
+
{ attached: true },
|
|
87
87
|
);
|
|
88
|
-
await assert.rejects(() => probe, /Cannot capture this surface: .*
|
|
88
|
+
await assert.rejects(() => probe, /Cannot capture this surface: .*surface compositor is unavailable/);
|
|
89
89
|
// Headless page targets raster unconditionally — no probe, no rejection.
|
|
90
|
-
await capture.assertCapturable(starvedClient, {
|
|
90
|
+
await capture.assertCapturable(starvedClient, { attached: false });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('only a positively identified rev-5 webview uses PNG flattening', () => {
|
|
94
|
+
assert.equal(capture.shouldFlattenCapture({ attached: true, legacyWebview: true }), true);
|
|
95
|
+
assert.equal(capture.shouldFlattenCapture({ attached: true, legacyWebview: false }), false);
|
|
96
|
+
assert.equal(capture.shouldFlattenCapture({ attached: false, legacyWebview: true }), false);
|
|
91
97
|
});
|
|
92
98
|
|
|
93
99
|
test('targetListUrl accepts bare ports and ws urls', () => {
|
|
@@ -95,6 +95,42 @@ test('ownership routing: a fresh advertisement attaches desktop-born chats ONLY'
|
|
|
95
95
|
});
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
test('bridge compatibility: missing protocol means rev-5 webview, rev-6 means page', () => {
|
|
99
|
+
const legacyDir = tempDir('bridge-v5');
|
|
100
|
+
writeAdvertisement(legacyDir, {
|
|
101
|
+
cdpUrl: 'ws://127.0.0.1:9242/devtools/browser/legacy',
|
|
102
|
+
cdpPort: 9242,
|
|
103
|
+
pid: process.pid,
|
|
104
|
+
});
|
|
105
|
+
withEnv({ AMALGM_RUNTIME_STATE_DIR: legacyDir }, () => {
|
|
106
|
+
assert.equal(engine.readBridgeAdvertisement().surfaceProtocol, 5);
|
|
107
|
+
assert.deepEqual(engine.surfaceTargetTypes(), ['webview']);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const nativeDir = tempDir('bridge-v6');
|
|
111
|
+
writeAdvertisement(nativeDir, {
|
|
112
|
+
browserSurfaceProtocol: 6,
|
|
113
|
+
browserSurfaceTargetType: 'page',
|
|
114
|
+
browserSurfaceTargetTypes: ['page', 'webview'],
|
|
115
|
+
cdp: {
|
|
116
|
+
url: 'ws://127.0.0.1:9342/devtools/browser/native',
|
|
117
|
+
port: 9342,
|
|
118
|
+
},
|
|
119
|
+
pid: process.pid,
|
|
120
|
+
});
|
|
121
|
+
withEnv({ AMALGM_RUNTIME_STATE_DIR: nativeDir }, () => {
|
|
122
|
+
assert.equal(engine.readBridgeAdvertisement().surfaceProtocol, 6);
|
|
123
|
+
assert.deepEqual(engine.surfaceTargetTypes(), ['page', 'webview']);
|
|
124
|
+
assert.equal(engine.cdpEndpoint(), '9342');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('bridge compatibility: explicit CDP probes both Electron generations', () => {
|
|
129
|
+
withEnv({ AMALGM_BROWSER_CDP_URL: 'ws://127.0.0.1:9442/devtools/browser/operator' }, () => {
|
|
130
|
+
assert.deepEqual(engine.surfaceTargetTypes(), ['page', 'webview']);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
98
134
|
test('ownership routing: stale advertisement (dead pid) means headless', () => {
|
|
99
135
|
const stateDir = tempDir('stale');
|
|
100
136
|
writeAdvertisement(stateDir, {
|
|
@@ -171,17 +207,17 @@ test('entrypoint routing: candidate order is state dir, derived label dir, AMALG
|
|
|
171
207
|
|
|
172
208
|
// ---------------------------------------------------------------------------
|
|
173
209
|
// Session → surface routing. The 2026-06-10 stress tests caught two parallel
|
|
174
|
-
// chats reading each other's pages: a displaced session (its
|
|
175
|
-
// a chat-tab switch) fell back to "the only
|
|
210
|
+
// chats reading each other's pages: a displaced session (its surface died on
|
|
211
|
+
// a chat-tab switch) fell back to "the only surface" — someone else's.
|
|
176
212
|
// ---------------------------------------------------------------------------
|
|
177
213
|
|
|
178
214
|
test('surface routing: marker first, then the embedder DOM src as candidates', () => {
|
|
179
215
|
const attach = require('../browser/attach');
|
|
180
216
|
|
|
181
|
-
const fresh = { tabId: 't2', type: '
|
|
182
|
-
const navigated = { tabId: 't3', type: '
|
|
183
|
-
const twin = { tabId: 't5', type: '
|
|
184
|
-
const other = { tabId: 't4', type: '
|
|
217
|
+
const fresh = { tabId: 't2', type: 'page', url: 'about:blank#amalgm-bsid-s1' };
|
|
218
|
+
const navigated = { tabId: 't3', type: 'page', url: 'https://example.com/' };
|
|
219
|
+
const twin = { tabId: 't5', type: 'page', url: 'https://example.com/' };
|
|
220
|
+
const other = { tabId: 't4', type: 'page', url: 'https://other.test/' };
|
|
185
221
|
|
|
186
222
|
// Fresh surface: the marker URL identifies it exactly.
|
|
187
223
|
assert.deepEqual(attach.identityCandidates([other, fresh], 's1', null), [fresh]);
|
|
@@ -198,11 +234,11 @@ test('surface routing: marker first, then the embedder DOM src as candidates', (
|
|
|
198
234
|
);
|
|
199
235
|
});
|
|
200
236
|
|
|
201
|
-
test('surface routing: an unidentified lone
|
|
237
|
+
test('surface routing: an unidentified lone page is never adopted', () => {
|
|
202
238
|
const attach = require('../browser/attach');
|
|
203
|
-
// One
|
|
239
|
+
// One page left and it is not provably ours — that is the cross-chat
|
|
204
240
|
// leak shape. Fail closed; ensureReady reopens a marked surface instead.
|
|
205
|
-
const stranger = { tabId: 't9', type: '
|
|
241
|
+
const stranger = { tabId: 't9', type: 'page', url: 'https://another-chats-page.test/' };
|
|
206
242
|
assert.deepEqual(attach.identityCandidates([stranger], 'displaced-session', null), []);
|
|
207
243
|
assert.deepEqual(attach.identityCandidates([], 'displaced-session', null), []);
|
|
208
244
|
});
|
|
@@ -148,7 +148,7 @@ console.log(JSON.stringify({ success: true, data: cmds.map((c) => ({ command: c,
|
|
|
148
148
|
process.env.AMALGM_BROWSER_CDP_URL = '9242';
|
|
149
149
|
// A live surface record makes ensureReady a no-op and turns the guard on.
|
|
150
150
|
surfaces.set('guard-test', { tabId: 't7', wsUrl: 'ws://x/guest', client: { close() {} }, external: false });
|
|
151
|
-
knownSessions.set('guard-test', {
|
|
151
|
+
knownSessions.set('guard-test', { surfaceTabId: 't7' });
|
|
152
152
|
try {
|
|
153
153
|
const single = await attach.run('guard-test', ['get', 'url'], { timeoutMs: 5000 });
|
|
154
154
|
assert.deepEqual(single.data, { echoed: ['get', 'url'] }, 'single result keeps its unbatched shape');
|
|
@@ -169,6 +169,18 @@ test('checkpoint captures are local cache; their operations travel on the one wa
|
|
|
169
169
|
assert.match(first, /repo-checkpoints-v7/);
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
+
test('a materialized peer card is immediately a valid checkpoint base', () => {
|
|
173
|
+
const oid = 'a'.repeat(40);
|
|
174
|
+
assert.equal(_test.checkpointBaseReady({
|
|
175
|
+
head: { branch: 'main', oid },
|
|
176
|
+
}, {
|
|
177
|
+
branch: 'main',
|
|
178
|
+
baseOid: oid,
|
|
179
|
+
indexDiff: '',
|
|
180
|
+
worktreeDiff: '',
|
|
181
|
+
}), true);
|
|
182
|
+
});
|
|
183
|
+
|
|
172
184
|
test('an uploading checkpoint payload is not published as a transition', () => {
|
|
173
185
|
const pending = {
|
|
174
186
|
revision: 'a'.repeat(64),
|
|
@@ -178,6 +178,16 @@ function readCheckpointValue(baseOid, value) {
|
|
|
178
178
|
return { state: 'ready', indexDiff, worktreeDiff };
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
function checkpointBaseReady(card, reduced) {
|
|
182
|
+
return Boolean(
|
|
183
|
+
reduced
|
|
184
|
+
&& reduced.baseOid === card.head?.oid
|
|
185
|
+
&& reduced.branch === card.head?.branch
|
|
186
|
+
&& typeof reduced.indexDiff === 'string'
|
|
187
|
+
&& typeof reduced.worktreeDiff === 'string'
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
181
191
|
async function publishPass(replicaId = null) {
|
|
182
192
|
const machine = machineId();
|
|
183
193
|
const run = { published: 0, skips: 0, waiting: 0, failures: 0 };
|
|
@@ -187,16 +197,8 @@ async function publishPass(replicaId = null) {
|
|
|
187
197
|
const repoKey = repoKeyOfCard(card);
|
|
188
198
|
if (!repoKey || !card.head?.oid) continue;
|
|
189
199
|
try {
|
|
190
|
-
const publishedCard = require('./wall').publishedCardFor(card);
|
|
191
200
|
const reduced = require('./reducer').readRepoState(repoKey);
|
|
192
|
-
if (
|
|
193
|
-
!publishedCard
|
|
194
|
-
|| !reduced
|
|
195
|
-
|| reduced.baseOid !== card.head.oid
|
|
196
|
-
|| reduced.branch !== card.head.branch
|
|
197
|
-
|| typeof reduced.indexDiff !== 'string'
|
|
198
|
-
|| typeof reduced.worktreeDiff !== 'string'
|
|
199
|
-
) {
|
|
201
|
+
if (!checkpointBaseReady(card, reduced)) {
|
|
200
202
|
run.waiting += 1;
|
|
201
203
|
continue;
|
|
202
204
|
}
|
|
@@ -372,6 +374,7 @@ function checkpointStats() {
|
|
|
372
374
|
|
|
373
375
|
module.exports = {
|
|
374
376
|
_test: {
|
|
377
|
+
checkpointBaseReady,
|
|
375
378
|
checkpointForSharedState,
|
|
376
379
|
},
|
|
377
380
|
CHECKPOINT_CONTRACT,
|