@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
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebDriver BiDi transport for Firefox (PHNX-4043).
|
|
3
|
+
*
|
|
4
|
+
* Firefox dropped the Chrome DevTools Protocol in 129, so agents drive it over
|
|
5
|
+
* WebDriver BiDi — a JSON-RPC protocol on a WebSocket at
|
|
6
|
+
* `ws://127.0.0.1:<port>/session`. The shape mirrors the CDP driver
|
|
7
|
+
* (`drivers/local.ts`): launch or attach, hand back a live client the service
|
|
8
|
+
* routes every `bidi`-backend action through.
|
|
9
|
+
*
|
|
10
|
+
* Launch: `firefox --remote-debugging-port <port> --profile <dir> --no-remote
|
|
11
|
+
* [--headless]`. `--remote-debugging-port` is what turns BiDi on; `--no-remote`
|
|
12
|
+
* forces a fresh instance bound to that profile rather than handing the URL to
|
|
13
|
+
* an already-running Firefox. When a Firefox already holds the profile without a
|
|
14
|
+
* debug port we cannot launch a rival (Firefox is single-instance per profile),
|
|
15
|
+
* so we fail loud with the exact relaunch — the same contract
|
|
16
|
+
* `attachOnlyRequiredError` states for Chromium.
|
|
17
|
+
*/
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import * as fs from 'node:fs';
|
|
20
|
+
import * as path from 'node:path';
|
|
21
|
+
import WSWebSocket from 'ws';
|
|
22
|
+
import { findBrowserPath } from '../chrome.js';
|
|
23
|
+
import { writeProfileRuntime, isProcessAlive } from '../runtime-state.js';
|
|
24
|
+
/**
|
|
25
|
+
* A verb Firefox-over-BiDi cannot serve (network capture, upload, pdf, trusted
|
|
26
|
+
* key input, …). Mirrors `ArcNativeCapabilityError`: the service throws it in
|
|
27
|
+
* the one place the capability is missing, and the message steers the caller to
|
|
28
|
+
* a Chromium-family profile that has it.
|
|
29
|
+
*/
|
|
30
|
+
export class FirefoxCapabilityError extends Error {
|
|
31
|
+
capability;
|
|
32
|
+
constructor(capability, message) {
|
|
33
|
+
super(message ??
|
|
34
|
+
`Firefox (WebDriver BiDi) does not support ${capability}. ` +
|
|
35
|
+
`Use a Chromium-family profile (chrome/comet/chromium/brave/edge) for that capability.`);
|
|
36
|
+
this.capability = capability;
|
|
37
|
+
this.name = 'FirefoxCapabilityError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** A BiDi error frame carrying the protocol error + human message. */
|
|
41
|
+
export class FirefoxBiDiError extends Error {
|
|
42
|
+
bidiError;
|
|
43
|
+
constructor(bidiError, message) {
|
|
44
|
+
super(`Firefox BiDi ${bidiError}: ${message}`);
|
|
45
|
+
this.bidiError = bidiError;
|
|
46
|
+
this.name = 'FirefoxBiDiError';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Minimal WebDriver BiDi client: request/response keyed by the `id` field, with
|
|
51
|
+
* events drained and ignored. Deliberately shaped like `CDPClient` so the
|
|
52
|
+
* service treats a Firefox connection the same way it treats a CDP one. The
|
|
53
|
+
* large `maxPayload` matches CDPClient's, so a base64 screenshot of a
|
|
54
|
+
* content-rich page never trips the socket's decompressed-size cap.
|
|
55
|
+
*/
|
|
56
|
+
export class FirefoxBiDiClient {
|
|
57
|
+
ws = null;
|
|
58
|
+
nextId = 0;
|
|
59
|
+
pending = new Map();
|
|
60
|
+
closed = false;
|
|
61
|
+
get isOpen() {
|
|
62
|
+
return this.ws !== null && this.ws.readyState === WSWebSocket.OPEN;
|
|
63
|
+
}
|
|
64
|
+
async connect(url) {
|
|
65
|
+
await new Promise((resolve, reject) => {
|
|
66
|
+
this.ws = new WSWebSocket(url, { maxPayload: 256 * 1024 * 1024 });
|
|
67
|
+
this.ws.once('open', () => resolve());
|
|
68
|
+
this.ws.once('error', () => reject(new Error('WebSocket error')));
|
|
69
|
+
});
|
|
70
|
+
this.ws.on('message', (data) => this.handleMessage(String(data)));
|
|
71
|
+
this.ws.on('close', () => this.handleClose());
|
|
72
|
+
}
|
|
73
|
+
handleMessage(raw) {
|
|
74
|
+
let msg;
|
|
75
|
+
try {
|
|
76
|
+
msg = JSON.parse(raw);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// BiDi frames: type 'success' | 'error' carry an `id`; type 'event' does not.
|
|
82
|
+
const id = typeof msg.id === 'number' ? msg.id : undefined;
|
|
83
|
+
if (id === undefined)
|
|
84
|
+
return; // event — nothing subscribes yet.
|
|
85
|
+
const call = this.pending.get(id);
|
|
86
|
+
if (!call)
|
|
87
|
+
return;
|
|
88
|
+
this.pending.delete(id);
|
|
89
|
+
if (msg.type === 'error') {
|
|
90
|
+
call.reject(new FirefoxBiDiError(String(msg.error ?? 'unknown error'), String(msg.message ?? 'no message')));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
call.resolve(msg.result ?? {});
|
|
94
|
+
}
|
|
95
|
+
handleClose() {
|
|
96
|
+
this.closed = true;
|
|
97
|
+
for (const [, call] of this.pending) {
|
|
98
|
+
call.reject(new Error('Firefox BiDi connection closed'));
|
|
99
|
+
}
|
|
100
|
+
this.pending.clear();
|
|
101
|
+
}
|
|
102
|
+
send(method, params = {}) {
|
|
103
|
+
if (!this.isOpen) {
|
|
104
|
+
throw new Error('Firefox BiDi connection not open — the browser was likely closed externally. ' +
|
|
105
|
+
'Run `agents browser stop --profile <name>` (or restart the daemon) and try again.');
|
|
106
|
+
}
|
|
107
|
+
const id = ++this.nextId;
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
this.pending.set(id, { resolve: resolve, reject });
|
|
110
|
+
this.ws.send(JSON.stringify({ id, method, params }));
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
close() {
|
|
114
|
+
if (this.closed)
|
|
115
|
+
return;
|
|
116
|
+
this.closed = true;
|
|
117
|
+
try {
|
|
118
|
+
this.ws?.close();
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
/* already closing */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const BIDI_CONNECT_ATTEMPTS = 120; // 120 × 250ms ≈ 30s — cold snap Firefox first-run is slow.
|
|
126
|
+
const BIDI_CONNECT_INTERVAL_MS = 250;
|
|
127
|
+
function bidiUrl(port) {
|
|
128
|
+
return `ws://127.0.0.1:${port}/session`;
|
|
129
|
+
}
|
|
130
|
+
/** Open a BiDi socket + session against a port, or null when nothing is there yet. */
|
|
131
|
+
async function tryOpenSession(port) {
|
|
132
|
+
const bidi = new FirefoxBiDiClient();
|
|
133
|
+
try {
|
|
134
|
+
await bidi.connect(bidiUrl(port));
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const result = await bidi.send('session.new', { capabilities: {} });
|
|
141
|
+
return { bidi, sessionId: String(result.sessionId ?? '') };
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
bidi.close();
|
|
145
|
+
// A live BiDi endpoint that refuses session.new is a real, surfaced failure
|
|
146
|
+
// (e.g. a session already exists) — never swallow it as "nothing listening".
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The loud error raised when a Firefox already holds this profile but exposes no
|
|
152
|
+
* BiDi port, so agents cannot attach and must not launch a rival (Firefox is
|
|
153
|
+
* single-instance per profile dir). Mirrors `attachOnlyRequiredError` in
|
|
154
|
+
* `drivers/local.ts`: name the exact relaunch that makes the running instance
|
|
155
|
+
* attachable. Exported so the contract is unit-testable without a real Firefox.
|
|
156
|
+
*/
|
|
157
|
+
export function firefoxAttachRequiredError(profile, port, profileDir) {
|
|
158
|
+
return new Error(`Profile "${profile.name}" is a Firefox profile and nothing is serving WebDriver BiDi on ` +
|
|
159
|
+
`ws://127.0.0.1:${port}/session. A Firefox is already running on this profile directory ` +
|
|
160
|
+
`without a debug port, and Firefox is single-instance per profile — agents will not launch ` +
|
|
161
|
+
`a second one. Quit that Firefox, then relaunch it with remote debugging on this port:\n` +
|
|
162
|
+
` firefox --remote-debugging-port ${port} --profile ${profileDir} --no-remote\n` +
|
|
163
|
+
`and retry. Or close it entirely and let agents launch it headless for you.`);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Whether a Firefox is currently holding `profileDir`. Firefox writes a `lock`
|
|
167
|
+
* symlink (POSIX: `<dir>/lock` → `<ip>:+<pid>`; macOS: `.parentlock`; Windows:
|
|
168
|
+
* `parent.lock`) while the profile is open. We only treat it as held when the
|
|
169
|
+
* lock resolves to a live pid, so a stale lock from a crash never wedges launch.
|
|
170
|
+
*/
|
|
171
|
+
function firefoxHoldsProfile(profileDir) {
|
|
172
|
+
const link = path.join(profileDir, 'lock');
|
|
173
|
+
try {
|
|
174
|
+
const target = fs.readlinkSync(link); // e.g. "127.0.0.1:+3685166"
|
|
175
|
+
const pid = Number(target.split('+').pop());
|
|
176
|
+
if (Number.isFinite(pid) && pid > 0)
|
|
177
|
+
return isProcessAlive(pid);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
/* no POSIX lock symlink */
|
|
181
|
+
}
|
|
182
|
+
// macOS/Windows lock files are plain files, not symlinks with a pid. Their mere
|
|
183
|
+
// presence is a weak signal (they can be stale), so we do not block on them —
|
|
184
|
+
// a launch that collides fails loud below via the connect timeout instead.
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Connect a Firefox profile over BiDi: attach if the port is already served,
|
|
189
|
+
* otherwise launch Firefox and wait for it. Fails loud when a Firefox holds the
|
|
190
|
+
* profile without a port.
|
|
191
|
+
*/
|
|
192
|
+
export async function connectFirefox(profile, key, port, opts = { profileDir: '' }) {
|
|
193
|
+
const profileDir = opts.profileDir || profile.userDataDir || '';
|
|
194
|
+
if (!profileDir) {
|
|
195
|
+
throw new Error(`Firefox profile "${profile.name}" has no profile directory to launch with.`);
|
|
196
|
+
}
|
|
197
|
+
// 1. Already serving BiDi on the port → attach (pid 0, we did not launch it).
|
|
198
|
+
const attached = await tryOpenSession(port);
|
|
199
|
+
if (attached) {
|
|
200
|
+
return { bidi: attached.bidi, port, pid: 0, sessionId: attached.sessionId };
|
|
201
|
+
}
|
|
202
|
+
// 2. A Firefox holds the profile but exposes no port → fail loud (never a rival).
|
|
203
|
+
if (firefoxHoldsProfile(profileDir)) {
|
|
204
|
+
throw firefoxAttachRequiredError(profile, port, profileDir);
|
|
205
|
+
}
|
|
206
|
+
// 3. Launch our own headless Firefox bound to this profile + port.
|
|
207
|
+
const binary = findBrowserPath('firefox', profile.binary);
|
|
208
|
+
const headless = opts.headless ?? profile.chrome?.headless ?? true;
|
|
209
|
+
const args = [
|
|
210
|
+
'--remote-debugging-port',
|
|
211
|
+
String(port),
|
|
212
|
+
'--profile',
|
|
213
|
+
profileDir,
|
|
214
|
+
'--no-remote',
|
|
215
|
+
...(headless ? ['--headless'] : []),
|
|
216
|
+
];
|
|
217
|
+
const child = spawn(binary, args, {
|
|
218
|
+
detached: true,
|
|
219
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
220
|
+
env: { ...process.env },
|
|
221
|
+
});
|
|
222
|
+
child.unref();
|
|
223
|
+
const pid = child.pid ?? 0;
|
|
224
|
+
writeProfileRuntime(key, {
|
|
225
|
+
pid,
|
|
226
|
+
port,
|
|
227
|
+
command: path.basename(binary),
|
|
228
|
+
userDataDir: profileDir,
|
|
229
|
+
kind: 'browser',
|
|
230
|
+
});
|
|
231
|
+
for (let i = 0; i < BIDI_CONNECT_ATTEMPTS; i++) {
|
|
232
|
+
// A Firefox handed the URL to an already-running instance exits almost
|
|
233
|
+
// immediately without ever opening the port — detect that and fail loud
|
|
234
|
+
// rather than waiting out the full timeout.
|
|
235
|
+
if (pid && !isProcessAlive(pid) && i > 4) {
|
|
236
|
+
throw firefoxAttachRequiredError(profile, port, profileDir);
|
|
237
|
+
}
|
|
238
|
+
const session = await tryOpenSession(port);
|
|
239
|
+
if (session) {
|
|
240
|
+
return { bidi: session.bidi, port, pid, sessionId: session.sessionId };
|
|
241
|
+
}
|
|
242
|
+
await new Promise((r) => setTimeout(r, BIDI_CONNECT_INTERVAL_MS));
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
if (pid)
|
|
246
|
+
process.kill(pid, 'SIGKILL');
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
/* already gone */
|
|
250
|
+
}
|
|
251
|
+
throw new Error(`Firefox for profile "${profile.name}" never exposed WebDriver BiDi on ` +
|
|
252
|
+
`ws://127.0.0.1:${port}/session within ${(BIDI_CONNECT_ATTEMPTS * BIDI_CONNECT_INTERVAL_MS) / 1000}s. ` +
|
|
253
|
+
`Check that Firefox is installed (\`${binary}\`) and that the profile directory is not locked.`);
|
|
254
|
+
}
|
|
255
|
+
export function deserializeBidi(remote) {
|
|
256
|
+
if (!remote || typeof remote !== 'object')
|
|
257
|
+
return remote;
|
|
258
|
+
const rv = remote;
|
|
259
|
+
switch (rv.type) {
|
|
260
|
+
case 'undefined':
|
|
261
|
+
return undefined;
|
|
262
|
+
case 'null':
|
|
263
|
+
return null;
|
|
264
|
+
case 'string':
|
|
265
|
+
case 'boolean':
|
|
266
|
+
return rv.value;
|
|
267
|
+
case 'number': {
|
|
268
|
+
// BiDi encodes ±Infinity / NaN as strings.
|
|
269
|
+
if (rv.value === 'Infinity')
|
|
270
|
+
return Infinity;
|
|
271
|
+
if (rv.value === '-Infinity')
|
|
272
|
+
return -Infinity;
|
|
273
|
+
if (rv.value === 'NaN')
|
|
274
|
+
return NaN;
|
|
275
|
+
return rv.value;
|
|
276
|
+
}
|
|
277
|
+
case 'bigint':
|
|
278
|
+
return typeof rv.value === 'string' ? BigInt(rv.value) : rv.value;
|
|
279
|
+
case 'date':
|
|
280
|
+
return rv.value;
|
|
281
|
+
case 'array':
|
|
282
|
+
case 'set':
|
|
283
|
+
return Array.isArray(rv.value) ? rv.value.map(deserializeBidi) : [];
|
|
284
|
+
case 'object':
|
|
285
|
+
case 'map': {
|
|
286
|
+
const out = {};
|
|
287
|
+
if (Array.isArray(rv.value)) {
|
|
288
|
+
for (const pair of rv.value) {
|
|
289
|
+
const rawKey = pair[0];
|
|
290
|
+
const key = typeof rawKey === 'string' ? rawKey : String(deserializeBidi(rawKey));
|
|
291
|
+
out[key] = deserializeBidi(pair[1]);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
default:
|
|
297
|
+
// node / regexp / window / … — no JS value to hand back.
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/** Every top-level tab currently open in this Firefox. */
|
|
302
|
+
export async function bidiTopLevelContexts(bidi) {
|
|
303
|
+
const tree = await bidi.send('browsingContext.getTree', {});
|
|
304
|
+
return (tree.contexts ?? []).map((c) => ({ context: c.context, url: c.url }));
|
|
305
|
+
}
|
|
306
|
+
/** Open a fresh tab and return its context id. */
|
|
307
|
+
export async function bidiCreateTab(bidi) {
|
|
308
|
+
const created = await bidi.send('browsingContext.create', { type: 'tab' });
|
|
309
|
+
return created.context;
|
|
310
|
+
}
|
|
311
|
+
/** Navigate a context and wait for the document to finish loading. */
|
|
312
|
+
export async function bidiNavigate(bidi, context, url) {
|
|
313
|
+
await bidi.send('browsingContext.navigate', { context, url, wait: 'complete' });
|
|
314
|
+
}
|
|
315
|
+
/** Reload a context in place, waiting for load. */
|
|
316
|
+
export async function bidiReload(bidi, context) {
|
|
317
|
+
await bidi.send('browsingContext.reload', { context, wait: 'complete' });
|
|
318
|
+
}
|
|
319
|
+
/** Close a tab. */
|
|
320
|
+
export async function bidiCloseTab(bidi, context) {
|
|
321
|
+
await bidi.send('browsingContext.close', { context });
|
|
322
|
+
}
|
|
323
|
+
/** Bring a tab to the foreground (explicit focus only). */
|
|
324
|
+
export async function bidiActivate(bidi, context) {
|
|
325
|
+
await bidi.send('browsingContext.activate', { context });
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Evaluate an expression in a context and return the deserialized value.
|
|
329
|
+
* `awaitPromise` mirrors the CDP evaluate contract; a thrown/rejected value
|
|
330
|
+
* surfaces as an Error rather than a silent undefined.
|
|
331
|
+
*/
|
|
332
|
+
export async function bidiEvaluate(bidi, context, expression) {
|
|
333
|
+
const result = await bidi.send('script.evaluate', {
|
|
334
|
+
expression,
|
|
335
|
+
target: { context },
|
|
336
|
+
awaitPromise: true,
|
|
337
|
+
resultOwnership: 'none',
|
|
338
|
+
});
|
|
339
|
+
if (result.type === 'exception') {
|
|
340
|
+
const ex = result.exceptionDetails;
|
|
341
|
+
const value = ex?.exception ? deserializeBidi(ex.exception) : undefined;
|
|
342
|
+
throw new Error(ex?.text || (typeof value === 'string' ? value : 'evaluate failed'));
|
|
343
|
+
}
|
|
344
|
+
return deserializeBidi(result.result);
|
|
345
|
+
}
|
|
346
|
+
/** Capture a screenshot of the viewport as raw bytes. `quality` is 0–1 for JPEG. */
|
|
347
|
+
export async function bidiScreenshot(bidi, context, format) {
|
|
348
|
+
const shot = await bidi.send('browsingContext.captureScreenshot', {
|
|
349
|
+
context,
|
|
350
|
+
origin: 'viewport',
|
|
351
|
+
format,
|
|
352
|
+
});
|
|
353
|
+
return Buffer.from(shot.data, 'base64');
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* A real, trusted left click at viewport coordinates via `input.performActions`
|
|
357
|
+
* — the pointer path CDP `Input.dispatchMouseEvent` gives Chromium, which Arc
|
|
358
|
+
* (Apple Events) never had. The service resolves a ref to (x, y) first, exactly
|
|
359
|
+
* as the CDP click path does.
|
|
360
|
+
*/
|
|
361
|
+
export async function bidiClickAt(bidi, context, x, y) {
|
|
362
|
+
await bidi.send('input.performActions', {
|
|
363
|
+
context,
|
|
364
|
+
actions: [
|
|
365
|
+
{
|
|
366
|
+
type: 'pointer',
|
|
367
|
+
id: 'mouse',
|
|
368
|
+
parameters: { pointerType: 'mouse' },
|
|
369
|
+
actions: [
|
|
370
|
+
{ type: 'pointerMove', x: Math.round(x), y: Math.round(y) },
|
|
371
|
+
{ type: 'pointerDown', button: 0 },
|
|
372
|
+
{ type: 'pointerUp', button: 0 },
|
|
373
|
+
],
|
|
374
|
+
},
|
|
375
|
+
],
|
|
376
|
+
});
|
|
377
|
+
}
|
|
@@ -37,6 +37,14 @@ export declare function attachOnlyRequiredError(profile: Pick<BrowserProfile, 'n
|
|
|
37
37
|
* fix. Exported for unit testing.
|
|
38
38
|
*/
|
|
39
39
|
export declare function foreignInstanceError(profile: Pick<BrowserProfile, 'name' | 'browser' | 'userDataDir'>, port: number, runningDataDir: string, pid: number): Error;
|
|
40
|
+
/**
|
|
41
|
+
* The loud error a profile pinned to the owner's own store (PHNX-4042) raises
|
|
42
|
+
* when a browser already holds that store without a debug port. Launching on it
|
|
43
|
+
* would not open a second browser: Chromium hands the arguments to the running
|
|
44
|
+
* instance and exits, the port never binds, and the agent would wait out a
|
|
45
|
+
* timeout while the owner sees a stray window. Exported for unit testing.
|
|
46
|
+
*/
|
|
47
|
+
export declare function storeInUseError(profile: Pick<BrowserProfile, 'name' | 'browser' | 'userDataDir' | 'profileDirectory'>, port: number, pid: number): Error;
|
|
40
48
|
export declare function connectLocal(endpoint: string, profile: BrowserProfile,
|
|
41
49
|
/**
|
|
42
50
|
* Runtime key (`<profile>@<endpoint>`) the launched browser's user-data-dir,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as net from 'net';
|
|
2
2
|
import { CDPClient, discoverBrowserWsUrl, verifyBrowserIdentity } from '../cdp.js';
|
|
3
|
-
import { launchBrowser, getPortOccupant, getProcessUserDataDir } from '../chrome.js';
|
|
3
|
+
import { launchBrowser, getPortOccupant, getProcessUserDataDir, storeOccupant, storeRelaunchCommand } from '../chrome.js';
|
|
4
4
|
import { parseEndpointUrl, isAttachOnlyProfile, resolveProfileDataDir, normalizeDataDir } from '../profiles.js';
|
|
5
5
|
/**
|
|
6
6
|
* Cheap TCP-level "is something bound here?" probe. Used as a fallback when
|
|
@@ -94,6 +94,23 @@ export function foreignInstanceError(profile, port, runningDataDir, pid) {
|
|
|
94
94
|
`that instance (\`kill ${pid}\`), then relaunch the canonical ${app}:\n` +
|
|
95
95
|
` open -a ${app} --args --remote-debugging-port=${port} --user-data-dir=${expected}`);
|
|
96
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* The loud error a profile pinned to the owner's own store (PHNX-4042) raises
|
|
99
|
+
* when a browser already holds that store without a debug port. Launching on it
|
|
100
|
+
* would not open a second browser: Chromium hands the arguments to the running
|
|
101
|
+
* instance and exits, the port never binds, and the agent would wait out a
|
|
102
|
+
* timeout while the owner sees a stray window. Exported for unit testing.
|
|
103
|
+
*/
|
|
104
|
+
export function storeInUseError(profile, port, pid) {
|
|
105
|
+
const app = profile.browser === 'comet' ? 'Comet' : profile.browser;
|
|
106
|
+
const dataDir = resolveProfileDataDir(profile);
|
|
107
|
+
return new Error(`Profile "${profile.name}" is pinned to ${dataDir}, and a ${app} (pid ${pid}) already has that ` +
|
|
108
|
+
`store open without remote debugging, so nothing serves the Chrome DevTools Protocol on ` +
|
|
109
|
+
`cdp://127.0.0.1:${port}. agents browser will not launch a second ${app} on the same store. ` +
|
|
110
|
+
`Quit that ${app}, then relaunch it with remote debugging:\n` +
|
|
111
|
+
` ${storeRelaunchCommand(profile.browser, port, dataDir, profile.profileDirectory)}\n` +
|
|
112
|
+
`and retry. Your logins stay: the store is the one you already use.`);
|
|
113
|
+
}
|
|
97
114
|
/**
|
|
98
115
|
* Prefix every ownership-rejection message carries, so `connectLocal`'s catch can
|
|
99
116
|
* re-throw it verbatim instead of misreading it as "attach failed, launch fresh".
|
|
@@ -110,7 +127,9 @@ const OWNERSHIP_REJECTION_PREFIX = 'Attach-only ownership check failed';
|
|
|
110
127
|
* port-squat this guard exists to stop.
|
|
111
128
|
*/
|
|
112
129
|
function verifyEndpointOwnership(profile, port) {
|
|
113
|
-
|
|
130
|
+
// Attach-only profiles and profiles pinned to the owner's own store
|
|
131
|
+
// (PHNX-4042) both name the exact user-data dir a running instance must have.
|
|
132
|
+
if (!isAttachOnlyProfile(profile) && !profile.userDataDir)
|
|
114
133
|
return;
|
|
115
134
|
// Arc is the user's single running instance under its own default data dir; it
|
|
116
135
|
// has no managed durable dir to compare against and no /tmp-squat vector.
|
|
@@ -212,8 +231,24 @@ key) {
|
|
|
212
231
|
`quit it and relaunch with \`--remote-debugging-port=${port}\`. Otherwise, ` +
|
|
213
232
|
`update the profile to a free port (\`agents browser profiles list\`).`);
|
|
214
233
|
}
|
|
234
|
+
// A store-pinned profile launches on the owner's store only while nothing
|
|
235
|
+
// holds it; a browser already open there without a port must be relaunched
|
|
236
|
+
// by the owner, never doubled (PHNX-4042).
|
|
237
|
+
if (profile.userDataDir) {
|
|
238
|
+
const holder = storeOccupant(profile.userDataDir);
|
|
239
|
+
if (holder)
|
|
240
|
+
throw storeInUseError(profile, port, holder.pid);
|
|
241
|
+
}
|
|
215
242
|
const newPort = port;
|
|
216
|
-
const chromeOpts = {
|
|
243
|
+
const chromeOpts = {
|
|
244
|
+
...profile.chrome,
|
|
245
|
+
viewport: profile.viewport,
|
|
246
|
+
// A discovered native profile launches the owner's browser on its own
|
|
247
|
+
// store: one window for the owner and agents, logins intact (PHNX-4042).
|
|
248
|
+
...(profile.userDataDir
|
|
249
|
+
? { userDataDir: profile.userDataDir, profileDirectory: profile.profileDirectory }
|
|
250
|
+
: {}),
|
|
251
|
+
};
|
|
217
252
|
let launched;
|
|
218
253
|
try {
|
|
219
254
|
launched = await launchBrowser(key, profile.browser, newPort, chromeOpts, profile.secrets, profile.binary, profile.electron === true);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export interface FirefoxIniProfile {
|
|
2
|
+
/** `Name=` of the `[ProfileN]` section. */
|
|
3
|
+
name: string;
|
|
4
|
+
/** Absolute profile directory (`Path=` resolved against the ini's dir when `IsRelative=1`). */
|
|
5
|
+
dir: string;
|
|
6
|
+
/** `Default=1` on the entry (a legacy marker; `[Install*]` sections carry the real one). */
|
|
7
|
+
isDefault: boolean;
|
|
8
|
+
/** The `profiles.ini` this entry came from. */
|
|
9
|
+
iniPath: string;
|
|
10
|
+
}
|
|
11
|
+
export type FirefoxDiscoveryResult = {
|
|
12
|
+
ok: true;
|
|
13
|
+
profiles: FirefoxIniProfile[];
|
|
14
|
+
iniPaths: string[];
|
|
15
|
+
} | {
|
|
16
|
+
ok: false;
|
|
17
|
+
kind: 'not-installed' | 'invalid';
|
|
18
|
+
reason: string;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Every directory a `profiles.ini` may live in on this machine. Ubuntu's snap
|
|
22
|
+
* Firefox keeps its whole `.mozilla` under `~/snap/firefox/common/`, a deb or
|
|
23
|
+
* tarball Firefox uses `~/.mozilla/firefox/` — a box can carry both, so both
|
|
24
|
+
* are read. `AGENTS_FIREFOX_DIRS` (path-separator list) points tests and
|
|
25
|
+
* non-default installs elsewhere.
|
|
26
|
+
*/
|
|
27
|
+
export declare function firefoxProfileRoots(): string[];
|
|
28
|
+
/**
|
|
29
|
+
* Parse one `profiles.ini`. Only `[Profile<n>]` sections are profiles;
|
|
30
|
+
* `[General]` and `[Install<hash>]` (which record the per-install default) are
|
|
31
|
+
* skipped. A `[Profile]` section that lacks `Name` or `Path` is a malformed
|
|
32
|
+
* file, not a profile to guess at.
|
|
33
|
+
*/
|
|
34
|
+
export declare function parseProfilesIni(iniPath: string): FirefoxIniProfile[] | {
|
|
35
|
+
error: string;
|
|
36
|
+
};
|
|
37
|
+
export declare function discoverFirefoxProfilesAt(roots: string[]): FirefoxDiscoveryResult;
|
|
38
|
+
export declare function discoverFirefoxProfiles(): FirefoxDiscoveryResult;
|
|
39
|
+
/** One discovered Firefox profile as an agents-cli profile row. */
|
|
40
|
+
export interface FirefoxDiscoveredProfile {
|
|
41
|
+
/** The agents-cli profile name: `firefox-<name-slug>`. */
|
|
42
|
+
name: string;
|
|
43
|
+
profileName: string;
|
|
44
|
+
profileDir: string;
|
|
45
|
+
iniPath: string;
|
|
46
|
+
isDefault: boolean;
|
|
47
|
+
/** The local WebDriver BiDi port this profile is pinned to. */
|
|
48
|
+
port: number;
|
|
49
|
+
}
|
|
50
|
+
/** Firefox BiDi ports live in their own range so they never collide with the 9222-9399 CDP profiles. */
|
|
51
|
+
export declare const FIREFOX_PORT_RANGE: Readonly<{
|
|
52
|
+
base: 9600;
|
|
53
|
+
size: 100;
|
|
54
|
+
}>;
|
|
55
|
+
/**
|
|
56
|
+
* The port a discovered Firefox profile listens on: derived from its directory,
|
|
57
|
+
* so the relaunch hint in an attach error is the same string every time and a
|
|
58
|
+
* daemon restart reattaches to the Firefox it launched earlier. Collisions
|
|
59
|
+
* between two discovered profiles resolve by linear probing within the range.
|
|
60
|
+
*/
|
|
61
|
+
export declare function firefoxProfilePort(profileDir: string, taken?: Set<number>): number;
|
|
62
|
+
/**
|
|
63
|
+
* Flatten discovery into one agents-cli profile per `profiles.ini` entry, named
|
|
64
|
+
* `firefox-<name>` (`firefox-default`, `firefox-default-release`, …). Two
|
|
65
|
+
* entries with the same name (one per ini, say) get the first six hex
|
|
66
|
+
* characters of their directory hash appended so the names stay distinct.
|
|
67
|
+
*/
|
|
68
|
+
export declare function firefoxDiscoveredProfiles(result: FirefoxDiscoveryResult): FirefoxDiscoveredProfile[];
|