@phnx-labs/agents-cli 1.22.94 → 1.22.96
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/CHANGELOG.md +22 -2
- package/dist/commands/browser.js +32 -9
- package/dist/lib/browser/chrome.d.ts +17 -0
- package/dist/lib/browser/chrome.js +121 -16
- package/dist/lib/browser/chromium-discovery.d.ts +27 -0
- package/dist/lib/browser/chromium-discovery.js +96 -0
- package/dist/lib/browser/drivers/firefox.d.ts +103 -0
- package/dist/lib/browser/drivers/firefox.js +377 -0
- package/dist/lib/browser/drivers/local.d.ts +8 -0
- package/dist/lib/browser/drivers/local.js +38 -3
- package/dist/lib/browser/firefox-discovery.d.ts +68 -0
- package/dist/lib/browser/firefox-discovery.js +162 -0
- package/dist/lib/browser/ipc.js +4 -0
- package/dist/lib/browser/profiles.d.ts +36 -2
- package/dist/lib/browser/profiles.js +200 -55
- package/dist/lib/browser/resolve-target.js +7 -1
- package/dist/lib/browser/service.d.ts +60 -3
- package/dist/lib/browser/service.js +545 -14
- package/dist/lib/browser/types.d.ts +51 -4
- package/dist/lib/daemon/usage-sync-service.js +12 -0
- package/dist/lib/open-url.js +6 -0
- package/dist/lib/types.d.ts +13 -1
- package/package.json +1 -1
|
@@ -23,6 +23,7 @@ import { resolveActor } from '../actor.js';
|
|
|
23
23
|
import { recordBrowserSession } from '../session/db.js';
|
|
24
24
|
import { sshExecAsync } from '../ssh-exec.js';
|
|
25
25
|
import { resolveFfmpeg } from './ffmpeg.js';
|
|
26
|
+
import { FirefoxCapabilityError, connectFirefox, bidiTopLevelContexts, bidiCreateTab, bidiNavigate, bidiReload, bidiCloseTab, bidiActivate, bidiEvaluate, bidiScreenshot, bidiClickAt, } from './drivers/firefox.js';
|
|
26
27
|
import { ArcNativeCapabilityError, executeJavaScript, navigateArcTab, closeArcTab, createArcTab, enumerateArcSpaces, isArcRunning, resolveArcTab, restoreArcSelection, selectWindowTab, selectArcTab, } from './drivers/arc.js';
|
|
27
28
|
/**
|
|
28
29
|
* Canonical form for comparing a requested URL against what CDP reports for a
|
|
@@ -235,6 +236,11 @@ async function isConnHealthy(conn, timeoutMs = 1000) {
|
|
|
235
236
|
if (conn.backend === 'arc-native') {
|
|
236
237
|
return isArcRunning();
|
|
237
238
|
}
|
|
239
|
+
// Firefox BiDi connections: healthy while the WebSocket is open (a killed
|
|
240
|
+
// Firefox closes it). No lightweight round-trip verb is needed.
|
|
241
|
+
if (conn.backend === 'bidi') {
|
|
242
|
+
return conn.bidi.isOpen;
|
|
243
|
+
}
|
|
238
244
|
if (!conn.cdp.isOpen)
|
|
239
245
|
return false;
|
|
240
246
|
try {
|
|
@@ -248,9 +254,18 @@ async function isConnHealthy(conn, timeoutMs = 1000) {
|
|
|
248
254
|
return false;
|
|
249
255
|
}
|
|
250
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* Narrow `conn` to the CDP backend, or throw the backend's capability error.
|
|
259
|
+
* Because the union now carries `arc-native` AND `bidi`, every action that
|
|
260
|
+
* reaches `conn.cdp` without a preceding backend branch fails to type-check —
|
|
261
|
+
* the compiler is the checklist that no verb silently calls CDP on a
|
|
262
|
+
* non-CDP connection.
|
|
263
|
+
*/
|
|
251
264
|
function requireCdp(conn, capability) {
|
|
252
265
|
if (conn.backend === 'arc-native')
|
|
253
266
|
throw new ArcNativeCapabilityError(capability);
|
|
267
|
+
if (conn.backend === 'bidi')
|
|
268
|
+
throw new FirefoxCapabilityError(capability);
|
|
254
269
|
}
|
|
255
270
|
/** Join error lines so callers get a next command, not a dead-end message. */
|
|
256
271
|
export function actionable(...lines) {
|
|
@@ -536,7 +551,7 @@ export class BrowserService {
|
|
|
536
551
|
// pile-up in RUSH-2622. Tabs the daemon did NOT open are still left alone —
|
|
537
552
|
// the branch only fires when the profile has no page target at all.
|
|
538
553
|
let startupBlankTargetId;
|
|
539
|
-
if (!opts.url && !conn.electron && conn.backend !== 'arc-native') {
|
|
554
|
+
if (!opts.url && !conn.electron && conn.backend !== 'arc-native' && conn.backend !== 'bidi') {
|
|
540
555
|
const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
|
|
541
556
|
if (!targetInfos.some((t) => t.type === 'page')) {
|
|
542
557
|
const created = await this.createPageTarget(conn, {
|
|
@@ -581,7 +596,7 @@ export class BrowserService {
|
|
|
581
596
|
task.currentTabId = shortId;
|
|
582
597
|
}
|
|
583
598
|
// For Electron, get the existing window as the tab
|
|
584
|
-
if (conn.electron && conn.backend !== 'arc-native') {
|
|
599
|
+
if (conn.electron && conn.backend !== 'arc-native' && conn.backend !== 'bidi') {
|
|
585
600
|
const windowId = await this.getOrCreateWindow(conn);
|
|
586
601
|
if (windowId) {
|
|
587
602
|
const shortId = generateShortId();
|
|
@@ -633,7 +648,14 @@ export class BrowserService {
|
|
|
633
648
|
// opening a fresh target is created:true.
|
|
634
649
|
let tabId;
|
|
635
650
|
let firstOpen;
|
|
636
|
-
if (opts.url && conn.backend
|
|
651
|
+
if (opts.url && conn.backend === 'bidi') {
|
|
652
|
+
// Firefox: the implicit first navigate opens a fresh tab (no adopt path —
|
|
653
|
+
// Firefox tabs aren't shared across abandoned tasks the way CDP ones are).
|
|
654
|
+
const result = await this.navigate(taskName, opts.url, effectiveKey);
|
|
655
|
+
tabId = result.tabId;
|
|
656
|
+
firstOpen = { tabId: result.tabId, created: result.created, refreshed: result.refreshed, message: result.message };
|
|
657
|
+
}
|
|
658
|
+
else if (opts.url && conn.backend !== 'arc-native' && conn.backend !== 'bidi' && !conn.electron && conn.browserType !== 'arc') {
|
|
637
659
|
const adopted = opts.fresh ? undefined : await this.adoptTabShowing(conn, opts.url);
|
|
638
660
|
const targetId = adopted ?? (await this.createPageTarget(conn, { url: opts.url })).targetId;
|
|
639
661
|
const shortId = generateShortId();
|
|
@@ -884,6 +906,21 @@ export class BrowserService {
|
|
|
884
906
|
await this.saveToHistory(task, Array.from(domains));
|
|
885
907
|
await this.closeArcNativeTabs(conn, task);
|
|
886
908
|
}
|
|
909
|
+
else if (conn.backend === 'bidi') {
|
|
910
|
+
try {
|
|
911
|
+
for (const tab of await this.listFirefoxTaskTabs(conn, task)) {
|
|
912
|
+
try {
|
|
913
|
+
const domain = new URL(tab.url).hostname.replace(/^www\./, '');
|
|
914
|
+
if (domain && domain !== 'blank')
|
|
915
|
+
domains.add(domain);
|
|
916
|
+
}
|
|
917
|
+
catch { /* invalid URL */ }
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
catch { /* Firefox not responding */ }
|
|
921
|
+
await this.saveToHistory(task, Array.from(domains));
|
|
922
|
+
await this.closeFirefoxTabs(conn, task);
|
|
923
|
+
}
|
|
887
924
|
else {
|
|
888
925
|
try {
|
|
889
926
|
const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
|
|
@@ -931,7 +968,12 @@ export class BrowserService {
|
|
|
931
968
|
});
|
|
932
969
|
}).catch(() => { });
|
|
933
970
|
if (conn.forkedFrom && conn.tasks.size === 0) {
|
|
934
|
-
if (conn.backend
|
|
971
|
+
if (conn.backend === 'bidi') {
|
|
972
|
+
conn.bidi.close();
|
|
973
|
+
if (conn.pid)
|
|
974
|
+
killChrome(conn.pid);
|
|
975
|
+
}
|
|
976
|
+
else if (conn.backend !== 'arc-native') {
|
|
935
977
|
conn.cdp.close();
|
|
936
978
|
killChrome(conn.pid);
|
|
937
979
|
}
|
|
@@ -976,6 +1018,18 @@ export class BrowserService {
|
|
|
976
1018
|
clearProfileRuntime(key);
|
|
977
1019
|
continue;
|
|
978
1020
|
}
|
|
1021
|
+
// Firefox: close the BiDi socket and kill the Firefox WE launched (pid !=
|
|
1022
|
+
// 0). An attached Firefox the user started (pid 0) is left running — the
|
|
1023
|
+
// CLI does not own that process, same as Arc.
|
|
1024
|
+
if (conn.backend === 'bidi') {
|
|
1025
|
+
conn.bidi.close();
|
|
1026
|
+
if (conn.pid)
|
|
1027
|
+
killChrome(conn.pid);
|
|
1028
|
+
conn.cleanup?.();
|
|
1029
|
+
this.connections.delete(key);
|
|
1030
|
+
clearProfileRuntime(key);
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
979
1033
|
conn.cdp.close();
|
|
980
1034
|
killChrome(conn.pid);
|
|
981
1035
|
conn.cleanup?.();
|
|
@@ -1006,6 +1060,10 @@ export class BrowserService {
|
|
|
1006
1060
|
if (conn.backend === 'arc-native') {
|
|
1007
1061
|
return this.navigateArcNative(conn, task, url);
|
|
1008
1062
|
}
|
|
1063
|
+
// Firefox BiDi backend (PHNX-4043).
|
|
1064
|
+
if (conn.backend === 'bidi') {
|
|
1065
|
+
return this.navigateFirefox(conn, task, url);
|
|
1066
|
+
}
|
|
1009
1067
|
requireCdp(conn, 'navigate');
|
|
1010
1068
|
// Same-task reopen (PHNX-2399): the URL is already live in a tab this task
|
|
1011
1069
|
// owns → reload THAT tab in place and keep its id, before the create/reuse
|
|
@@ -1111,6 +1169,9 @@ export class BrowserService {
|
|
|
1111
1169
|
if (conn.backend === 'arc-native') {
|
|
1112
1170
|
return this.navigateArcNative(conn, task, url, true);
|
|
1113
1171
|
}
|
|
1172
|
+
if (conn.backend === 'bidi') {
|
|
1173
|
+
return this.navigateFirefox(conn, task, url, true);
|
|
1174
|
+
}
|
|
1114
1175
|
requireCdp(conn, 'createTab');
|
|
1115
1176
|
if (conn.electron) {
|
|
1116
1177
|
throw new Error('Electron apps do not support opening additional tabs');
|
|
@@ -1145,6 +1206,12 @@ export class BrowserService {
|
|
|
1145
1206
|
});
|
|
1146
1207
|
return { tabId: resolvedTabId };
|
|
1147
1208
|
}
|
|
1209
|
+
if (conn.backend === 'bidi') {
|
|
1210
|
+
await bidiActivate(conn.bidi, this.firefoxContext(task, resolvedTabId));
|
|
1211
|
+
task.currentTabId = resolvedTabId;
|
|
1212
|
+
await this.saveTaskState(task.profile, conn.tasks);
|
|
1213
|
+
return { tabId: resolvedTabId };
|
|
1214
|
+
}
|
|
1148
1215
|
task.currentTabId = resolvedTabId;
|
|
1149
1216
|
await this.saveTaskState(task.profile, conn.tasks);
|
|
1150
1217
|
return { tabId: resolvedTabId };
|
|
@@ -1154,6 +1221,11 @@ export class BrowserService {
|
|
|
1154
1221
|
if (conn.backend === 'arc-native') {
|
|
1155
1222
|
return (await this.listArcTaskTabs(task)).map((tab) => ({ ...tab, current: tab.current === true }));
|
|
1156
1223
|
}
|
|
1224
|
+
if (conn.backend === 'bidi') {
|
|
1225
|
+
return (await this.listFirefoxTaskTabs(conn, task)).map((tab) => ({
|
|
1226
|
+
id: tab.id, url: tab.url, title: tab.title, current: tab.current === true,
|
|
1227
|
+
}));
|
|
1228
|
+
}
|
|
1157
1229
|
requireCdp(conn, 'enumerate');
|
|
1158
1230
|
const targets = (await conn.cdp.send('Target.getTargets'));
|
|
1159
1231
|
const tabs = [];
|
|
@@ -1170,6 +1242,64 @@ export class BrowserService {
|
|
|
1170
1242
|
}
|
|
1171
1243
|
return tabs;
|
|
1172
1244
|
}
|
|
1245
|
+
/**
|
|
1246
|
+
* Every top-level tab open in the task's profile browser, the owner's tabs
|
|
1247
|
+
* included (`agents browser tabs --all`). Read-only: nothing is focused,
|
|
1248
|
+
* navigated, or closed. A tab an agents-cli task owns carries that task's
|
|
1249
|
+
* name and short id (so it can be addressed); any other tab carries the
|
|
1250
|
+
* browser's own id and no task. Same rows on every backend.
|
|
1251
|
+
*/
|
|
1252
|
+
async profileTabs(taskId) {
|
|
1253
|
+
const { conn, task } = await this.findTask(taskId);
|
|
1254
|
+
const owners = new Map();
|
|
1255
|
+
for (const [, owner] of conn.tasks) {
|
|
1256
|
+
const entries = conn.backend === 'arc-native'
|
|
1257
|
+
? Object.entries(owner.arcNative?.tabs ?? {}).map(([shortId, ref]) => [shortId, ref.tabId])
|
|
1258
|
+
: Object.entries(owner.tabs);
|
|
1259
|
+
for (const [shortId, nativeId] of entries)
|
|
1260
|
+
owners.set(nativeId, { owner, shortId });
|
|
1261
|
+
}
|
|
1262
|
+
const row = (nativeId, url, title) => {
|
|
1263
|
+
const owned = owners.get(nativeId);
|
|
1264
|
+
if (!owned)
|
|
1265
|
+
return { id: nativeId, url, title };
|
|
1266
|
+
return {
|
|
1267
|
+
id: owned.shortId,
|
|
1268
|
+
url,
|
|
1269
|
+
title,
|
|
1270
|
+
task: owned.owner.name,
|
|
1271
|
+
current: owned.owner === task && owned.shortId === task.currentTabId,
|
|
1272
|
+
};
|
|
1273
|
+
};
|
|
1274
|
+
if (conn.backend === 'arc-native') {
|
|
1275
|
+
const native = this.requireArcTask(task);
|
|
1276
|
+
const space = (await enumerateArcSpaces()).find((candidate) => candidate.windowId === native.windowId && candidate.spaceId === native.spaceId);
|
|
1277
|
+
if (!space) {
|
|
1278
|
+
throw new Error(`Arc Space "${native.spaceTitle}" is not open in its original window (${native.windowId}); ` +
|
|
1279
|
+
'open it in Arc and retry.');
|
|
1280
|
+
}
|
|
1281
|
+
return space.tabs.map((tab) => row(tab.tabId, tab.url, tab.title));
|
|
1282
|
+
}
|
|
1283
|
+
if (conn.backend === 'bidi') {
|
|
1284
|
+
const rows = [];
|
|
1285
|
+
for (const context of await bidiTopLevelContexts(conn.bidi)) {
|
|
1286
|
+
let title = '';
|
|
1287
|
+
try {
|
|
1288
|
+
title = String(await bidiEvaluate(conn.bidi, context.context, 'document.title'));
|
|
1289
|
+
}
|
|
1290
|
+
catch {
|
|
1291
|
+
/* about: pages or a mid-navigation context — url is enough */
|
|
1292
|
+
}
|
|
1293
|
+
rows.push(row(context.context, context.url, title));
|
|
1294
|
+
}
|
|
1295
|
+
return rows;
|
|
1296
|
+
}
|
|
1297
|
+
requireCdp(conn, 'enumerate');
|
|
1298
|
+
const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
|
|
1299
|
+
return targetInfos
|
|
1300
|
+
.filter((target) => target.type === 'page')
|
|
1301
|
+
.map((target) => row(target.targetId, target.url, target.title));
|
|
1302
|
+
}
|
|
1173
1303
|
async resolveTabHint(conn, task, hint) {
|
|
1174
1304
|
// Exact match
|
|
1175
1305
|
if (task.tabs[hint])
|
|
@@ -1185,6 +1315,9 @@ export class BrowserService {
|
|
|
1185
1315
|
// and title are display data and must never become fallback identities.
|
|
1186
1316
|
if (conn.backend === 'arc-native')
|
|
1187
1317
|
throw new Error(`Tab "${hint}" not found`);
|
|
1318
|
+
// Firefox tabs are addressed by the task's stable short id too.
|
|
1319
|
+
if (conn.backend === 'bidi')
|
|
1320
|
+
throw new Error(`Tab "${hint}" not found`);
|
|
1188
1321
|
// URL substring match
|
|
1189
1322
|
const targets = (await conn.cdp.send('Target.getTargets'));
|
|
1190
1323
|
const matches = [];
|
|
@@ -1219,6 +1352,8 @@ export class BrowserService {
|
|
|
1219
1352
|
const { conn, task } = await this.findTask(taskId, profileRef);
|
|
1220
1353
|
if (conn.backend === 'arc-native')
|
|
1221
1354
|
return this.listArcTaskTabs(task);
|
|
1355
|
+
if (conn.backend === 'bidi')
|
|
1356
|
+
return this.listFirefoxTaskTabs(conn, task);
|
|
1222
1357
|
return this.getTabsForTask(conn.cdp, task);
|
|
1223
1358
|
}
|
|
1224
1359
|
const allTabs = [];
|
|
@@ -1226,7 +1361,9 @@ export class BrowserService {
|
|
|
1226
1361
|
for (const [, task] of conn.tasks) {
|
|
1227
1362
|
const tabs = conn.backend === 'arc-native'
|
|
1228
1363
|
? await this.listArcTaskTabs(task)
|
|
1229
|
-
:
|
|
1364
|
+
: conn.backend === 'bidi'
|
|
1365
|
+
? await this.listFirefoxTaskTabs(conn, task)
|
|
1366
|
+
: await this.getTabsForTask(conn.cdp, task);
|
|
1230
1367
|
allTabs.push(...tabs);
|
|
1231
1368
|
}
|
|
1232
1369
|
}
|
|
@@ -1257,6 +1394,30 @@ export class BrowserService {
|
|
|
1257
1394
|
});
|
|
1258
1395
|
return;
|
|
1259
1396
|
}
|
|
1397
|
+
if (conn.backend === 'bidi') {
|
|
1398
|
+
const borrowed = new Set(task.borrowedTabs ?? []);
|
|
1399
|
+
const ids = tabHint === undefined
|
|
1400
|
+
? Object.keys(task.tabs)
|
|
1401
|
+
: [await this.resolveTabHint(conn, task, tabHint)];
|
|
1402
|
+
for (const shortId of ids) {
|
|
1403
|
+
const context = task.tabs[shortId];
|
|
1404
|
+
// A borrowed tab is released, not closed — it outlives this task.
|
|
1405
|
+
if (context && !borrowed.has(shortId)) {
|
|
1406
|
+
try {
|
|
1407
|
+
await bidiCloseTab(conn.bidi, context);
|
|
1408
|
+
}
|
|
1409
|
+
catch { /* already closed */ }
|
|
1410
|
+
}
|
|
1411
|
+
delete task.tabs[shortId];
|
|
1412
|
+
delete task.refDescriptors?.[shortId];
|
|
1413
|
+
task.borrowedTabs = (task.borrowedTabs ?? []).filter((id) => id !== shortId);
|
|
1414
|
+
}
|
|
1415
|
+
if (!task.currentTabId || ids.includes(task.currentTabId)) {
|
|
1416
|
+
task.currentTabId = Object.keys(task.tabs).at(-1);
|
|
1417
|
+
}
|
|
1418
|
+
await this.saveTaskState(task.profile, conn.tasks);
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1260
1421
|
requireCdp(conn, 'closeTab');
|
|
1261
1422
|
if (tabHint !== undefined) {
|
|
1262
1423
|
const shortId = await this.resolveTabHint(conn, task, tabHint);
|
|
@@ -1298,6 +1459,10 @@ export class BrowserService {
|
|
|
1298
1459
|
if (conn.backend === 'arc-native') {
|
|
1299
1460
|
return this.evaluateArcNative(conn, task, shortId, expression);
|
|
1300
1461
|
}
|
|
1462
|
+
// Firefox BiDi backend (PHNX-4043): async-capable, isolated realm.
|
|
1463
|
+
if (conn.backend === 'bidi') {
|
|
1464
|
+
return this.evaluateFirefox(conn, task, shortId, expression);
|
|
1465
|
+
}
|
|
1301
1466
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1302
1467
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
1303
1468
|
if (!target) {
|
|
@@ -1334,6 +1499,10 @@ export class BrowserService {
|
|
|
1334
1499
|
'This task still belongs to the Arc profile. ' +
|
|
1335
1500
|
'Use a Chromium-family browser for screenshot capability.');
|
|
1336
1501
|
}
|
|
1502
|
+
// Firefox BiDi backend (PHNX-4043): captureScreenshot on the context.
|
|
1503
|
+
if (conn.backend === 'bidi') {
|
|
1504
|
+
return this.screenshotFirefox(conn, task, runtimeKey, tabHint, outputPath, quality);
|
|
1505
|
+
}
|
|
1337
1506
|
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
1338
1507
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1339
1508
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -1731,6 +1900,14 @@ export class BrowserService {
|
|
|
1731
1900
|
await this.saveTaskState(task.profile, conn.tasks);
|
|
1732
1901
|
return result;
|
|
1733
1902
|
}
|
|
1903
|
+
// Firefox BiDi (PHNX-4043): the same DOM-derived accessibility listing Arc
|
|
1904
|
+
// uses, evaluated over BiDi. selector-based refs, no CDP accessibility tree.
|
|
1905
|
+
if (conn.backend === 'bidi') {
|
|
1906
|
+
const result = parseArcRefsResult(await this.evaluateFirefox(conn, task, shortId, arcRefsExpression(opts)), opts);
|
|
1907
|
+
this.cacheRefDescriptors(task, shortId, result.nodeMap, result.opts);
|
|
1908
|
+
await this.saveTaskState(task.profile, conn.tasks);
|
|
1909
|
+
return result;
|
|
1910
|
+
}
|
|
1734
1911
|
requireCdp(conn, 'enumerate');
|
|
1735
1912
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1736
1913
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -1797,6 +1974,19 @@ export class BrowserService {
|
|
|
1797
1974
|
await this.evaluateArcNative(conn, task, shortId, arcClickExpression(selector));
|
|
1798
1975
|
return healed ? { healed } : {};
|
|
1799
1976
|
}
|
|
1977
|
+
if (conn.backend === 'bidi') {
|
|
1978
|
+
const snapshot = task.refDescriptors?.[shortId];
|
|
1979
|
+
const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
|
|
1980
|
+
const { nodeMap } = parseArcRefsResult(await this.evaluateFirefox(conn, task, shortId, arcRefsExpression(buildOpts)), buildOpts);
|
|
1981
|
+
const { targetRef, healed } = this.resolveHealedRef(snapshot, nodeMap, ref);
|
|
1982
|
+
const selector = nodeMap.get(targetRef)?.selector;
|
|
1983
|
+
if (!selector)
|
|
1984
|
+
throw new Error(`Ref ${ref} has no DOM selector`);
|
|
1985
|
+
const coords = await this.firefoxRefCoords(conn, task, shortId, selector);
|
|
1986
|
+
const context = this.firefoxContext(task, shortId);
|
|
1987
|
+
await bidiClickAt(conn.bidi, context, coords.x, coords.y);
|
|
1988
|
+
return healed ? { healed } : {};
|
|
1989
|
+
}
|
|
1800
1990
|
requireCdp(conn, 'click');
|
|
1801
1991
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1802
1992
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -1852,6 +2042,17 @@ export class BrowserService {
|
|
|
1852
2042
|
await this.evaluateArcNative(conn, task, shortId, arcFillExpression(selector, text, clear ?? false));
|
|
1853
2043
|
return;
|
|
1854
2044
|
}
|
|
2045
|
+
if (conn.backend === 'bidi') {
|
|
2046
|
+
const snapshot = task.refDescriptors?.[shortId];
|
|
2047
|
+
const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
|
|
2048
|
+
const { nodeMap } = parseArcRefsResult(await this.evaluateFirefox(conn, task, shortId, arcRefsExpression(buildOpts)), buildOpts);
|
|
2049
|
+
const { targetRef } = this.resolveHealedRef(snapshot, nodeMap, ref);
|
|
2050
|
+
const selector = nodeMap.get(targetRef)?.selector;
|
|
2051
|
+
if (!selector)
|
|
2052
|
+
throw new Error(`Ref ${ref} has no DOM selector`);
|
|
2053
|
+
await this.evaluateFirefox(conn, task, shortId, arcFillExpression(selector, text, clear ?? false));
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
1855
2056
|
requireCdp(conn, 'type');
|
|
1856
2057
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1857
2058
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -1911,6 +2112,10 @@ export class BrowserService {
|
|
|
1911
2112
|
await this.evaluateArcNative(conn, task, shortId, arcScrollExpression(deltaX, deltaY));
|
|
1912
2113
|
return;
|
|
1913
2114
|
}
|
|
2115
|
+
if (conn.backend === 'bidi') {
|
|
2116
|
+
await this.evaluateFirefox(conn, task, shortId, arcScrollExpression(deltaX, deltaY));
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
1914
2119
|
requireCdp(conn, 'scroll');
|
|
1915
2120
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1916
2121
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -2482,7 +2687,9 @@ export class BrowserService {
|
|
|
2482
2687
|
this.recordings.delete(taskId);
|
|
2483
2688
|
}
|
|
2484
2689
|
for (const [, conn] of this.connections) {
|
|
2485
|
-
if (conn.backend
|
|
2690
|
+
if (conn.backend === 'bidi')
|
|
2691
|
+
conn.bidi.close();
|
|
2692
|
+
else if (conn.backend !== 'arc-native')
|
|
2486
2693
|
conn.cdp.close();
|
|
2487
2694
|
conn.cleanup?.();
|
|
2488
2695
|
}
|
|
@@ -2538,6 +2745,11 @@ export class BrowserService {
|
|
|
2538
2745
|
async connectProfile(effectiveProfile, target, key, opts = {}) {
|
|
2539
2746
|
const existingInfo = getRunningChromeInfo(key);
|
|
2540
2747
|
const tunnelled = opts.persistRemote || target.startsWith('ssh:');
|
|
2748
|
+
// Native Arc and Firefox BiDi speak their own transport, not CDP — never
|
|
2749
|
+
// route them through the CDP reuse branch (which would probe /json/version
|
|
2750
|
+
// and wipe the runtime meta the Firefox driver wrote). connectEndpoint owns
|
|
2751
|
+
// their attach-or-launch decision.
|
|
2752
|
+
const nativeTransport = target.startsWith('arc-native:') || target.startsWith('firefox-bidi:');
|
|
2541
2753
|
if (tunnelled) {
|
|
2542
2754
|
// A leftover local chrome-data / pid file under this key is the
|
|
2543
2755
|
// pre-T2 logged-out browser on THIS box. Attaching localhost CDP
|
|
@@ -2548,7 +2760,7 @@ export class BrowserService {
|
|
|
2548
2760
|
clearProfileRuntime(key);
|
|
2549
2761
|
}
|
|
2550
2762
|
}
|
|
2551
|
-
else if (existingInfo) {
|
|
2763
|
+
else if (existingInfo && !nativeTransport) {
|
|
2552
2764
|
try {
|
|
2553
2765
|
const { wsUrl, browser } = await discoverBrowserWsUrl(existingInfo.port, 'localhost', effectiveProfile.name);
|
|
2554
2766
|
verifyBrowserIdentity(browser, effectiveProfile.browser, existingInfo.port);
|
|
@@ -2660,6 +2872,39 @@ export class BrowserService {
|
|
|
2660
2872
|
sessionCache: new Map(),
|
|
2661
2873
|
};
|
|
2662
2874
|
}
|
|
2875
|
+
// Firefox BiDi backend (PHNX-4043): `firefox-bidi://127.0.0.1:<port>` connects
|
|
2876
|
+
// over a WebDriver BiDi WebSocket. The driver attaches to a Firefox already
|
|
2877
|
+
// serving the port, else launches one headless bound to the pinned profile
|
|
2878
|
+
// directory, else fails loud (Firefox is single-instance per profile).
|
|
2879
|
+
if (url.protocol === 'firefox-bidi:') {
|
|
2880
|
+
if (!profile.firefox) {
|
|
2881
|
+
throw new Error(`Firefox profile "${profile.name}" has no discovered profile metadata. ` +
|
|
2882
|
+
`Re-select it with: agents browser use ${profile.name}`);
|
|
2883
|
+
}
|
|
2884
|
+
const profileDir = profile.userDataDir;
|
|
2885
|
+
if (!profileDir) {
|
|
2886
|
+
throw new Error(`Firefox profile "${profile.name}" has no profile directory.`);
|
|
2887
|
+
}
|
|
2888
|
+
const port = parseInt(url.port || '9600', 10);
|
|
2889
|
+
const conn = await connectFirefox(profile, key, port, {
|
|
2890
|
+
profileDir,
|
|
2891
|
+
headless: profile.chrome?.headless,
|
|
2892
|
+
});
|
|
2893
|
+
return {
|
|
2894
|
+
backend: 'bidi',
|
|
2895
|
+
browserType: 'firefox',
|
|
2896
|
+
bidi: conn.bidi,
|
|
2897
|
+
firefoxProfile: profile.firefox,
|
|
2898
|
+
sessionId: conn.sessionId,
|
|
2899
|
+
profileDir,
|
|
2900
|
+
port: conn.port,
|
|
2901
|
+
pid: conn.pid,
|
|
2902
|
+
electron: false,
|
|
2903
|
+
targetFilter: profile.targetFilter,
|
|
2904
|
+
tasks: this.loadTaskState(key),
|
|
2905
|
+
sessionCache: new Map(),
|
|
2906
|
+
};
|
|
2907
|
+
}
|
|
2663
2908
|
// Native Arc backend (PHNX-2399): `arc-native:` protocol connects through
|
|
2664
2909
|
// Apple Events instead of CDP. No debugging port, no browser launch.
|
|
2665
2910
|
if (url.protocol === 'arc-native:') {
|
|
@@ -2702,6 +2947,8 @@ export class BrowserService {
|
|
|
2702
2947
|
if (conn.browserType === 'arc') {
|
|
2703
2948
|
throw arcNotDrivableError(conn.profile);
|
|
2704
2949
|
}
|
|
2950
|
+
// Firefox opens tabs through the BiDi driver, never Target.createTarget.
|
|
2951
|
+
requireCdp(conn, 'createTab');
|
|
2705
2952
|
return (await conn.cdp.send('Target.createTarget', params));
|
|
2706
2953
|
}
|
|
2707
2954
|
async getOrCreateWindow(conn) {
|
|
@@ -3136,6 +3383,44 @@ export class BrowserService {
|
|
|
3136
3383
|
arcProfile: profile.arc,
|
|
3137
3384
|
};
|
|
3138
3385
|
}
|
|
3386
|
+
// Firefox BiDi endpoints (PHNX-4043): a daemon restart dropped the BiDi
|
|
3387
|
+
// socket, but the Firefox we launched is still serving the port — reattach
|
|
3388
|
+
// by opening a fresh session, then merge the disk tasks. connectFirefox
|
|
3389
|
+
// attaches (never launches) when the port is already served. A failure
|
|
3390
|
+
// returns null like the CDP path, so the caller falls through to disk
|
|
3391
|
+
// reconcile instead of crashing.
|
|
3392
|
+
if (resolved.target.startsWith('firefox-bidi:')) {
|
|
3393
|
+
if (!profile.firefox || !profile.userDataDir)
|
|
3394
|
+
return null;
|
|
3395
|
+
const port = parseEndpointUrl(resolved.target)?.port ?? 9600;
|
|
3396
|
+
try {
|
|
3397
|
+
const ff = await connectFirefox(profile, key, port, { profileDir: profile.userDataDir });
|
|
3398
|
+
const tasks = this.loadTaskState(key);
|
|
3399
|
+
for (const [k, t] of diskTasks) {
|
|
3400
|
+
if (!tasks.has(k))
|
|
3401
|
+
tasks.set(k, t);
|
|
3402
|
+
}
|
|
3403
|
+
return {
|
|
3404
|
+
backend: 'bidi',
|
|
3405
|
+
browserType: 'firefox',
|
|
3406
|
+
bidi: ff.bidi,
|
|
3407
|
+
firefoxProfile: profile.firefox,
|
|
3408
|
+
sessionId: ff.sessionId,
|
|
3409
|
+
profileDir: profile.userDataDir,
|
|
3410
|
+
port: ff.port,
|
|
3411
|
+
pid: ff.pid,
|
|
3412
|
+
electron: false,
|
|
3413
|
+
targetFilter: profile.targetFilter,
|
|
3414
|
+
key,
|
|
3415
|
+
profile: bare,
|
|
3416
|
+
tasks,
|
|
3417
|
+
sessionCache: new Map(),
|
|
3418
|
+
};
|
|
3419
|
+
}
|
|
3420
|
+
catch {
|
|
3421
|
+
return null;
|
|
3422
|
+
}
|
|
3423
|
+
}
|
|
3139
3424
|
const existingInfo = getRunningChromeInfo(key);
|
|
3140
3425
|
const parsed = parseEndpointUrl(resolved.target);
|
|
3141
3426
|
const port = existingInfo?.port ?? parsed?.port;
|
|
@@ -3251,8 +3536,14 @@ export class BrowserService {
|
|
|
3251
3536
|
}
|
|
3252
3537
|
else {
|
|
3253
3538
|
// Shared/borrowed tunnel (or a local connection with no tunnel): close
|
|
3254
|
-
// only our own
|
|
3255
|
-
if (conn.backend
|
|
3539
|
+
// only our own client; killing the tunnel would break the winner.
|
|
3540
|
+
if (conn.backend === 'bidi') {
|
|
3541
|
+
try {
|
|
3542
|
+
conn.bidi.close();
|
|
3543
|
+
}
|
|
3544
|
+
catch { /* best effort */ }
|
|
3545
|
+
}
|
|
3546
|
+
else if (conn.backend !== 'arc-native') {
|
|
3256
3547
|
try {
|
|
3257
3548
|
conn.cdp.close();
|
|
3258
3549
|
}
|
|
@@ -3298,7 +3589,7 @@ export class BrowserService {
|
|
|
3298
3589
|
if (registered !== conn)
|
|
3299
3590
|
continue;
|
|
3300
3591
|
try {
|
|
3301
|
-
if (conn.backend !== 'arc-native')
|
|
3592
|
+
if (conn.backend !== 'arc-native' && conn.backend !== 'bidi')
|
|
3302
3593
|
await this.applyDefaultDownloadBehavior(conn, key);
|
|
3303
3594
|
}
|
|
3304
3595
|
catch {
|
|
@@ -3360,7 +3651,7 @@ export class BrowserService {
|
|
|
3360
3651
|
conn = this.registerRehydratedConnection(key, attached);
|
|
3361
3652
|
if (conn === attached) {
|
|
3362
3653
|
try {
|
|
3363
|
-
if (conn.backend !== 'arc-native')
|
|
3654
|
+
if (conn.backend !== 'arc-native' && conn.backend !== 'bidi')
|
|
3364
3655
|
await this.applyDefaultDownloadBehavior(conn, key);
|
|
3365
3656
|
}
|
|
3366
3657
|
catch {
|
|
@@ -3433,6 +3724,45 @@ export class BrowserService {
|
|
|
3433
3724
|
tasks,
|
|
3434
3725
|
};
|
|
3435
3726
|
}
|
|
3727
|
+
if (conn.backend === 'bidi') {
|
|
3728
|
+
const tasks = [];
|
|
3729
|
+
for (const task of conn.tasks.values()) {
|
|
3730
|
+
let tabs = [];
|
|
3731
|
+
try {
|
|
3732
|
+
tabs = await this.listFirefoxTaskTabs(conn, task);
|
|
3733
|
+
}
|
|
3734
|
+
catch { /* Firefox down */ }
|
|
3735
|
+
const domains = tabs.flatMap((tab) => {
|
|
3736
|
+
try {
|
|
3737
|
+
const domain = new URL(tab.url).hostname.replace(/^www\./, '');
|
|
3738
|
+
return domain && domain !== 'blank' ? [domain] : [];
|
|
3739
|
+
}
|
|
3740
|
+
catch {
|
|
3741
|
+
return [];
|
|
3742
|
+
}
|
|
3743
|
+
});
|
|
3744
|
+
tasks.push({
|
|
3745
|
+
id: task.id,
|
|
3746
|
+
name: task.name,
|
|
3747
|
+
label: task.label ?? task.name,
|
|
3748
|
+
tabCount: Object.keys(task.tabs).length,
|
|
3749
|
+
currentTabId: task.currentTabId,
|
|
3750
|
+
createdAt: task.createdAt,
|
|
3751
|
+
tabs: tabs.length ? tabs.map((t) => ({ id: t.id, url: t.url, title: t.title, current: t.current })) : undefined,
|
|
3752
|
+
domains: domains.length ? [...new Set(domains)] : undefined,
|
|
3753
|
+
});
|
|
3754
|
+
}
|
|
3755
|
+
const parsed = parseConnectionKey(key);
|
|
3756
|
+
return {
|
|
3757
|
+
name: conn.profile ?? parsed.profile,
|
|
3758
|
+
endpoint: parsed.endpoint,
|
|
3759
|
+
key,
|
|
3760
|
+
running: conn.bidi.isOpen,
|
|
3761
|
+
port: conn.port,
|
|
3762
|
+
pid: conn.pid,
|
|
3763
|
+
tasks,
|
|
3764
|
+
};
|
|
3765
|
+
}
|
|
3436
3766
|
// Fetch all targets once for efficiency
|
|
3437
3767
|
let targets = [];
|
|
3438
3768
|
try {
|
|
@@ -3655,7 +3985,13 @@ export class BrowserService {
|
|
|
3655
3985
|
return undefined;
|
|
3656
3986
|
if (await isConnHealthy(conn))
|
|
3657
3987
|
return conn;
|
|
3658
|
-
if (conn.backend
|
|
3988
|
+
if (conn.backend === 'bidi') {
|
|
3989
|
+
try {
|
|
3990
|
+
conn.bidi.close();
|
|
3991
|
+
}
|
|
3992
|
+
catch { /* already closed */ }
|
|
3993
|
+
}
|
|
3994
|
+
else if (conn.backend !== 'arc-native') {
|
|
3659
3995
|
try {
|
|
3660
3996
|
conn.cdp.close();
|
|
3661
3997
|
}
|
|
@@ -3894,14 +4230,205 @@ export class BrowserService {
|
|
|
3894
4230
|
task.currentTabId = undefined;
|
|
3895
4231
|
});
|
|
3896
4232
|
}
|
|
4233
|
+
// ---------------------------------------------------------------------------
|
|
4234
|
+
// Firefox WebDriver BiDi backend helpers (PHNX-4043)
|
|
4235
|
+
// ---------------------------------------------------------------------------
|
|
4236
|
+
/** The BiDi browsing-context id a task owns for `shortId`. */
|
|
4237
|
+
firefoxContext(task, shortId) {
|
|
4238
|
+
const context = task.tabs[shortId];
|
|
4239
|
+
if (!context)
|
|
4240
|
+
throw new Error(`Tab ${shortId} not found`);
|
|
4241
|
+
return context;
|
|
4242
|
+
}
|
|
4243
|
+
/** The context ids currently live in this Firefox (a killed tab drops out). */
|
|
4244
|
+
async firefoxLiveContexts(conn) {
|
|
4245
|
+
const contexts = await bidiTopLevelContexts(conn.bidi);
|
|
4246
|
+
return new Set(contexts.map((c) => c.context));
|
|
4247
|
+
}
|
|
4248
|
+
/**
|
|
4249
|
+
* Same-task reopen for Firefox (mirrors {@link reopenOwnedTab}): the URL is
|
|
4250
|
+
* already live in one of this task's own tabs → reload THAT tab in place and
|
|
4251
|
+
* keep its id, rather than opening a duplicate. Borrowed tabs are excluded —
|
|
4252
|
+
* Firefox never borrows, but the guard keeps parity. Returns the retained
|
|
4253
|
+
* short id, or undefined when nothing matches.
|
|
4254
|
+
*/
|
|
4255
|
+
async reopenOwnedFirefoxTab(conn, task, url) {
|
|
4256
|
+
const borrowed = new Set(task.borrowedTabs ?? []);
|
|
4257
|
+
// Read each live tab's URL from browsingContext.getTree metadata rather than
|
|
4258
|
+
// evaluating location.href per tab: the same source the CDP path reads
|
|
4259
|
+
// (Target.getTargets), so a mid-navigation or just-closed sibling tab can
|
|
4260
|
+
// never throw a BiDi "no such frame" and fail an unrelated navigate. A tab
|
|
4261
|
+
// absent from the tree is simply not a reopen candidate.
|
|
4262
|
+
const liveUrlByContext = new Map((await bidiTopLevelContexts(conn.bidi)).map((c) => [c.context, c.url]));
|
|
4263
|
+
const wanted = canonicalTabUrl(url);
|
|
4264
|
+
for (const [shortId, context] of Object.entries(task.tabs)) {
|
|
4265
|
+
if (borrowed.has(shortId))
|
|
4266
|
+
continue;
|
|
4267
|
+
const liveUrl = liveUrlByContext.get(context);
|
|
4268
|
+
if (liveUrl === undefined)
|
|
4269
|
+
continue; // registered but gone from the browser
|
|
4270
|
+
if (canonicalTabUrl(liveUrl) === wanted) {
|
|
4271
|
+
await bidiReload(conn.bidi, context);
|
|
4272
|
+
task.currentTabId = shortId;
|
|
4273
|
+
await this.saveTaskState(task.profile, conn.tasks);
|
|
4274
|
+
return { tabId: shortId };
|
|
4275
|
+
}
|
|
4276
|
+
}
|
|
4277
|
+
return undefined;
|
|
4278
|
+
}
|
|
4279
|
+
async navigateFirefox(conn, task, url, addTab = false) {
|
|
4280
|
+
if (!addTab) {
|
|
4281
|
+
const reopened = await this.reopenOwnedFirefoxTab(conn, task, url);
|
|
4282
|
+
if (reopened) {
|
|
4283
|
+
emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: reopened.tabId, created: false });
|
|
4284
|
+
return { tabId: reopened.tabId, url, created: false, refreshed: true, message: 'Tab already open—refreshed' };
|
|
4285
|
+
}
|
|
4286
|
+
// Reuse the current tab when there is one — navigate() semantics.
|
|
4287
|
+
const currentShortId = task.currentTabId;
|
|
4288
|
+
if (currentShortId && task.tabs[currentShortId]) {
|
|
4289
|
+
const context = task.tabs[currentShortId];
|
|
4290
|
+
const live = await this.firefoxLiveContexts(conn);
|
|
4291
|
+
if (live.has(context)) {
|
|
4292
|
+
await bidiNavigate(conn.bidi, context, url);
|
|
4293
|
+
await this.saveTaskState(task.profile, conn.tasks);
|
|
4294
|
+
emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: currentShortId, created: false });
|
|
4295
|
+
return { tabId: currentShortId, url, created: false, refreshed: false };
|
|
4296
|
+
}
|
|
4297
|
+
}
|
|
4298
|
+
}
|
|
4299
|
+
else {
|
|
4300
|
+
const reopened = await this.reopenOwnedFirefoxTab(conn, task, url);
|
|
4301
|
+
if (reopened) {
|
|
4302
|
+
return { tabId: reopened.tabId, url, created: false, refreshed: true, message: 'Tab already open—refreshed' };
|
|
4303
|
+
}
|
|
4304
|
+
}
|
|
4305
|
+
// Open a fresh tab.
|
|
4306
|
+
const context = await bidiCreateTab(conn.bidi);
|
|
4307
|
+
await bidiNavigate(conn.bidi, context, url);
|
|
4308
|
+
const shortId = generateShortId();
|
|
4309
|
+
task.tabs[shortId] = context;
|
|
4310
|
+
task.currentTabId = shortId;
|
|
4311
|
+
await this.saveTaskState(task.profile, conn.tasks);
|
|
4312
|
+
emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: shortId, created: true });
|
|
4313
|
+
return { tabId: shortId, url, created: true, refreshed: false };
|
|
4314
|
+
}
|
|
4315
|
+
async evaluateFirefox(conn, task, shortId, expression) {
|
|
4316
|
+
const context = this.firefoxContext(task, shortId);
|
|
4317
|
+
const live = await this.firefoxLiveContexts(conn);
|
|
4318
|
+
if (!live.has(context))
|
|
4319
|
+
throw new Error(`Tab ${shortId} is no longer open in Firefox.`);
|
|
4320
|
+
return bidiEvaluate(conn.bidi, context, expression);
|
|
4321
|
+
}
|
|
4322
|
+
async listFirefoxTaskTabs(conn, task) {
|
|
4323
|
+
const live = await bidiTopLevelContexts(conn.bidi);
|
|
4324
|
+
const byId = new Map(live.map((c) => [c.context, c]));
|
|
4325
|
+
const tabs = [];
|
|
4326
|
+
for (const [shortId, context] of Object.entries(task.tabs)) {
|
|
4327
|
+
const info = byId.get(context);
|
|
4328
|
+
if (!info)
|
|
4329
|
+
continue; // tab closed out from under the task — drop it silently.
|
|
4330
|
+
let title = '';
|
|
4331
|
+
try {
|
|
4332
|
+
title = String(await bidiEvaluate(conn.bidi, context, 'document.title'));
|
|
4333
|
+
}
|
|
4334
|
+
catch {
|
|
4335
|
+
/* about: pages or a mid-navigation context — url is enough */
|
|
4336
|
+
}
|
|
4337
|
+
tabs.push({ id: shortId, url: info.url, title, task: task.name, current: shortId === task.currentTabId });
|
|
4338
|
+
}
|
|
4339
|
+
return tabs;
|
|
4340
|
+
}
|
|
4341
|
+
/**
|
|
4342
|
+
* Close every non-borrowed tab a Firefox task owns. Never kills the Firefox
|
|
4343
|
+
* process (that is `stopProfile`'s job) — a killed browser would take the
|
|
4344
|
+
* user's other tabs with it. A tab already gone is skipped.
|
|
4345
|
+
*/
|
|
4346
|
+
async closeFirefoxTabs(conn, task) {
|
|
4347
|
+
const borrowed = new Set(task.borrowedTabs ?? []);
|
|
4348
|
+
const live = await this.firefoxLiveContexts(conn);
|
|
4349
|
+
for (const [shortId, context] of Object.entries(task.tabs)) {
|
|
4350
|
+
if (borrowed.has(shortId))
|
|
4351
|
+
continue;
|
|
4352
|
+
if (live.has(context)) {
|
|
4353
|
+
try {
|
|
4354
|
+
await bidiCloseTab(conn.bidi, context);
|
|
4355
|
+
}
|
|
4356
|
+
catch { /* already closed */ }
|
|
4357
|
+
}
|
|
4358
|
+
}
|
|
4359
|
+
}
|
|
4360
|
+
/**
|
|
4361
|
+
* Viewport-center coordinates of a selector-addressed element in a Firefox
|
|
4362
|
+
* tab, scrolled into view first — the (x, y) `input.performActions` clicks.
|
|
4363
|
+
* Throws when the element is gone or has zero size (off-screen / display:none).
|
|
4364
|
+
*/
|
|
4365
|
+
async firefoxRefCoords(conn, task, shortId, selector) {
|
|
4366
|
+
const expr = `(() => {
|
|
4367
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
4368
|
+
if (!el) throw new Error('DOM ref is missing');
|
|
4369
|
+
el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
4370
|
+
const r = el.getBoundingClientRect();
|
|
4371
|
+
if (r.width === 0 && r.height === 0) throw new Error('DOM ref has no layout box');
|
|
4372
|
+
return JSON.stringify({ x: r.left + r.width / 2, y: r.top + r.height / 2 });
|
|
4373
|
+
})()`;
|
|
4374
|
+
const raw = await this.evaluateFirefox(conn, task, shortId, expr);
|
|
4375
|
+
const parsed = JSON.parse(String(raw));
|
|
4376
|
+
return parsed;
|
|
4377
|
+
}
|
|
4378
|
+
/**
|
|
4379
|
+
* Screenshot a Firefox tab over BiDi. Same output contract as the CDP path:
|
|
4380
|
+
* `raw` is a pixel-faithful PNG; the default is JPEG iteratively downscaled
|
|
4381
|
+
* under 100 KB so chat-injected screenshots stay token-cheap.
|
|
4382
|
+
*/
|
|
4383
|
+
async screenshotFirefox(conn, task, runtimeKey, tabHint, outputPath, quality) {
|
|
4384
|
+
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
4385
|
+
const context = this.firefoxContext(task, shortId);
|
|
4386
|
+
const live = await this.firefoxLiveContexts(conn);
|
|
4387
|
+
if (!live.has(context))
|
|
4388
|
+
throw new Error(`Tab ${shortId} is no longer open in Firefox.`);
|
|
4389
|
+
let buffer;
|
|
4390
|
+
let extension;
|
|
4391
|
+
if (quality === 'raw') {
|
|
4392
|
+
buffer = await bidiScreenshot(conn.bidi, context, { type: 'image/png' });
|
|
4393
|
+
extension = 'png';
|
|
4394
|
+
}
|
|
4395
|
+
else {
|
|
4396
|
+
buffer = await bidiScreenshot(conn.bidi, context, { type: 'image/jpeg', quality: 0.7 });
|
|
4397
|
+
const MAX_SIZE = 100 * 1024;
|
|
4398
|
+
let q = 0.5;
|
|
4399
|
+
while (buffer.length > MAX_SIZE && q > 0.1) {
|
|
4400
|
+
buffer = await bidiScreenshot(conn.bidi, context, { type: 'image/jpeg', quality: q });
|
|
4401
|
+
q -= 0.1;
|
|
4402
|
+
}
|
|
4403
|
+
extension = 'jpg';
|
|
4404
|
+
}
|
|
4405
|
+
const sessionsDir = getProfileSessionsDir(runtimeKey, task.name);
|
|
4406
|
+
const automaticPath = path.join(sessionsDir, `${Date.now()}.${extension}`);
|
|
4407
|
+
const finalPath = resolveScreenshotOutputPath(outputPath, automaticPath);
|
|
4408
|
+
await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
|
|
4409
|
+
await fs.promises.writeFile(finalPath, buffer);
|
|
4410
|
+
const dims = (extension === 'png' ? readPngDimensions(buffer) : readJpegDimensions(buffer)) ??
|
|
4411
|
+
{ width: 0, height: 0 };
|
|
4412
|
+
emit('browser.screenshot', {
|
|
4413
|
+
profile: parseConnectionKey(runtimeKey).profile,
|
|
4414
|
+
task: task.name,
|
|
4415
|
+
tabId: shortId,
|
|
4416
|
+
path: finalPath,
|
|
4417
|
+
bytes: buffer.length,
|
|
4418
|
+
width: dims.width,
|
|
4419
|
+
height: dims.height,
|
|
4420
|
+
quality,
|
|
4421
|
+
});
|
|
4422
|
+
return { path: finalPath, bytes: buffer.length, width: dims.width, height: dims.height };
|
|
4423
|
+
}
|
|
3897
4424
|
/** Connect a profile at `target`, register it under `key`, and arm downloads. */
|
|
3898
4425
|
async openConnection(profile, target, key, profileName, opts = {}) {
|
|
3899
4426
|
const conn = await this.connectProfile(profile, target, key, opts);
|
|
3900
4427
|
conn.key = key;
|
|
3901
4428
|
conn.profile = profileName;
|
|
3902
4429
|
this.connections.set(key, conn);
|
|
3903
|
-
// Download behavior configuration is CDP-specific; skip for native Arc.
|
|
3904
|
-
if (conn.backend !== 'arc-native') {
|
|
4430
|
+
// Download behavior configuration is CDP-specific; skip for native Arc + Firefox.
|
|
4431
|
+
if (conn.backend !== 'arc-native' && conn.backend !== 'bidi') {
|
|
3905
4432
|
await this.applyDefaultDownloadBehavior(conn, key);
|
|
3906
4433
|
}
|
|
3907
4434
|
return conn;
|
|
@@ -3941,6 +4468,10 @@ export class BrowserService {
|
|
|
3941
4468
|
if (conn.backend === 'arc-native') {
|
|
3942
4469
|
throw new ArcNativeCapabilityError('show', 'Task-less viewer tabs are unavailable for native Arc because creation intent cannot be bound to an owning task.');
|
|
3943
4470
|
}
|
|
4471
|
+
if (conn.backend === 'bidi') {
|
|
4472
|
+
throw new FirefoxCapabilityError('show', 'Task-less viewer tabs are unavailable for a headless-automation Firefox profile. ' +
|
|
4473
|
+
'Use a Chromium-family profile, or open the URL in your OS browser.');
|
|
4474
|
+
}
|
|
3944
4475
|
// createPageTarget refuses Arc with an actionable error rather than crashing it.
|
|
3945
4476
|
const created = await this.createPageTarget(conn, { url });
|
|
3946
4477
|
this.invalidateTargetCache(conn);
|