@phnx-labs/agents-cli 1.22.94 → 1.22.95

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.
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Read-only Firefox profile discovery from `profiles.ini` (PHNX-4043).
3
+ *
4
+ * One agents-cli profile per `[ProfileN]` entry, named `firefox-<name-slug>`
5
+ * and pinned to that entry's directory. The profile already carries its
6
+ * cookies and logins, so it IS the browser profile agents pick with
7
+ * `--profile` — the same shape Arc Space discovery uses (`arc-discovery.ts`).
8
+ */
9
+ import * as fs from 'node:fs';
10
+ import * as os from 'node:os';
11
+ import * as path from 'node:path';
12
+ /**
13
+ * Every directory a `profiles.ini` may live in on this machine. Ubuntu's snap
14
+ * Firefox keeps its whole `.mozilla` under `~/snap/firefox/common/`, a deb or
15
+ * tarball Firefox uses `~/.mozilla/firefox/` — a box can carry both, so both
16
+ * are read. `AGENTS_FIREFOX_DIRS` (path-separator list) points tests and
17
+ * non-default installs elsewhere.
18
+ */
19
+ export function firefoxProfileRoots() {
20
+ const override = process.env.AGENTS_FIREFOX_DIRS;
21
+ if (override)
22
+ return override.split(path.delimiter).filter(Boolean).map((p) => path.resolve(p));
23
+ const home = os.homedir();
24
+ switch (process.platform) {
25
+ case 'darwin':
26
+ return [path.join(home, 'Library', 'Application Support', 'Firefox')];
27
+ case 'win32':
28
+ return [path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Mozilla', 'Firefox')];
29
+ default:
30
+ return [
31
+ path.join(home, '.mozilla', 'firefox'),
32
+ path.join(home, 'snap', 'firefox', 'common', '.mozilla', 'firefox'),
33
+ ];
34
+ }
35
+ }
36
+ /**
37
+ * Parse one `profiles.ini`. Only `[Profile<n>]` sections are profiles;
38
+ * `[General]` and `[Install<hash>]` (which record the per-install default) are
39
+ * skipped. A `[Profile]` section that lacks `Name` or `Path` is a malformed
40
+ * file, not a profile to guess at.
41
+ */
42
+ export function parseProfilesIni(iniPath) {
43
+ let text;
44
+ try {
45
+ text = fs.readFileSync(iniPath, 'utf8');
46
+ }
47
+ catch (error) {
48
+ return { error: `Cannot read ${iniPath}: ${error instanceof Error ? error.message : String(error)}` };
49
+ }
50
+ const root = path.dirname(iniPath);
51
+ const sections = [];
52
+ for (const raw of text.split(/\r?\n/)) {
53
+ const line = raw.trim();
54
+ if (!line || line.startsWith(';') || line.startsWith('#'))
55
+ continue;
56
+ const header = line.match(/^\[(.+)\]$/);
57
+ if (header) {
58
+ sections.push({ header: header[1], values: {} });
59
+ continue;
60
+ }
61
+ const eq = line.indexOf('=');
62
+ if (eq === -1 || sections.length === 0) {
63
+ return { error: `${iniPath} has a malformed line: ${JSON.stringify(raw)}` };
64
+ }
65
+ sections[sections.length - 1].values[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
66
+ }
67
+ const profiles = [];
68
+ for (const section of sections) {
69
+ if (!/^Profile\d+$/.test(section.header))
70
+ continue;
71
+ const name = section.values.Name;
72
+ const rel = section.values.Path;
73
+ if (!name || !rel) {
74
+ return { error: `${iniPath} [${section.header}] has no Name or Path` };
75
+ }
76
+ const isRelative = section.values.IsRelative !== '0';
77
+ const dir = isRelative ? path.resolve(root, rel) : path.resolve(rel);
78
+ profiles.push({ name, dir, isDefault: section.values.Default === '1', iniPath });
79
+ }
80
+ return profiles;
81
+ }
82
+ export function discoverFirefoxProfilesAt(roots) {
83
+ const iniPaths = roots.map((root) => path.join(root, 'profiles.ini')).filter((p) => fs.existsSync(p));
84
+ if (iniPaths.length === 0) {
85
+ return {
86
+ ok: false,
87
+ kind: 'not-installed',
88
+ reason: `No Firefox profiles.ini under ${roots.join(', ')}`,
89
+ };
90
+ }
91
+ const profiles = [];
92
+ for (const iniPath of iniPaths) {
93
+ const parsed = parseProfilesIni(iniPath);
94
+ if ('error' in parsed)
95
+ return { ok: false, kind: 'invalid', reason: parsed.error };
96
+ profiles.push(...parsed);
97
+ }
98
+ if (profiles.length === 0) {
99
+ return { ok: false, kind: 'invalid', reason: `No [Profile] entries in ${iniPaths.join(', ')}` };
100
+ }
101
+ return { ok: true, profiles, iniPaths };
102
+ }
103
+ export function discoverFirefoxProfiles() {
104
+ return discoverFirefoxProfilesAt(firefoxProfileRoots());
105
+ }
106
+ function slugify(value) {
107
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
108
+ }
109
+ /** FNV-1a over the path, folded into the range. Stable across runs and daemons. */
110
+ function stableHash(value) {
111
+ let hash = 0x811c9dc5;
112
+ for (let i = 0; i < value.length; i++) {
113
+ hash ^= value.charCodeAt(i);
114
+ hash = Math.imul(hash, 0x01000193) >>> 0;
115
+ }
116
+ return hash;
117
+ }
118
+ /** Firefox BiDi ports live in their own range so they never collide with the 9222-9399 CDP profiles. */
119
+ export const FIREFOX_PORT_RANGE = Object.freeze({ base: 9600, size: 100 });
120
+ /**
121
+ * The port a discovered Firefox profile listens on: derived from its directory,
122
+ * so the relaunch hint in an attach error is the same string every time and a
123
+ * daemon restart reattaches to the Firefox it launched earlier. Collisions
124
+ * between two discovered profiles resolve by linear probing within the range.
125
+ */
126
+ export function firefoxProfilePort(profileDir, taken = new Set()) {
127
+ const { base, size } = FIREFOX_PORT_RANGE;
128
+ let port = base + (stableHash(profileDir) % size);
129
+ for (let i = 0; i < size && taken.has(port); i++) {
130
+ port = base + ((port - base + 1) % size);
131
+ }
132
+ return port;
133
+ }
134
+ /**
135
+ * Flatten discovery into one agents-cli profile per `profiles.ini` entry, named
136
+ * `firefox-<name>` (`firefox-default`, `firefox-default-release`, …). Two
137
+ * entries with the same name (one per ini, say) get the first six hex
138
+ * characters of their directory hash appended so the names stay distinct.
139
+ */
140
+ export function firefoxDiscoveredProfiles(result) {
141
+ if (!result.ok)
142
+ return [];
143
+ const taken = new Set();
144
+ const flat = result.profiles.map((profile) => {
145
+ const port = firefoxProfilePort(profile.dir, taken);
146
+ taken.add(port);
147
+ return {
148
+ name: `firefox-${slugify(profile.name) || 'profile'}`,
149
+ profileName: profile.name,
150
+ profileDir: profile.dir,
151
+ iniPath: profile.iniPath,
152
+ isDefault: profile.isDefault,
153
+ port,
154
+ };
155
+ });
156
+ const counts = new Map();
157
+ for (const entry of flat)
158
+ counts.set(entry.name, (counts.get(entry.name) ?? 0) + 1);
159
+ return flat.map((entry) => (counts.get(entry.name) ?? 0) > 1
160
+ ? { ...entry, name: `${entry.name}-${stableHash(entry.profileDir).toString(16).padStart(8, '0').slice(0, 6)}` }
161
+ : entry);
162
+ }
@@ -85,12 +85,46 @@ export declare function getProfileDownloadsDir(name: string): string;
85
85
  * one-shot migration in migrate.ts folds old captures into here.
86
86
  */
87
87
  export declare function getProfileSessionsDir(name: string, task: string): string;
88
+ /**
89
+ * Every browser profile this Mac already has, as agents-cli profiles:
90
+ *
91
+ * - each Arc Space (PHNX-2399): a Space carries its Arc profile's cookies and
92
+ * logins, so it IS the browser profile agents pick with `--profile`;
93
+ * - each Comet profile from Comet's own `Local State` (PHNX-4042): pinned to
94
+ * Comet's user-data dir and profile directory, so the window agents open is
95
+ * the owner's window and a sign-in done once serves both.
96
+ *
97
+ * No second concept: agents see one `--profile` list. Discovery reads files
98
+ * the browsers rewrite on every change, so a torn read is an ordinary event,
99
+ * not corruption. It is reported as `errors` rather than thrown: the profile
100
+ * store serves every browser on this machine, and a momentary Arc read must
101
+ * never take the Comet or Chrome rows down with it. An error surfaces where a
102
+ * profile of that browser is actually asked for by name.
103
+ */
104
+ export interface DiscoveredProfiles {
105
+ profiles: BrowserProfileWithDeclarations[];
106
+ /** Per-browser discovery failures, keyed by the profile-name prefix (`arc`, `comet`). */
107
+ errors: Record<string, string>;
108
+ }
109
+ export declare function discoverNativeProfiles(): DiscoveredProfiles;
110
+ /**
111
+ * Publish every discovered native profile that this device has not declared
112
+ * yet into its device declaration (`~/.agents/devices/<device>/agents.yaml`),
113
+ * which the fleet shared-store sync carries to every other box (PHNX-4042).
114
+ * That is what lets `agents browser profiles list` on a worker show this
115
+ * Mac's `arc-*` and `comet-*` rows with WHERE=<this device>, and a start from
116
+ * there route here. Idempotent; a discovery error skips that browser.
117
+ */
118
+ export declare function publishDiscoveredProfiles(): Promise<{
119
+ published: string[];
120
+ errors: Record<string, string>;
121
+ }>;
88
122
  /** Whether this name has persisted agents-cli metadata on the current device. */
89
123
  export declare function isProfileDeclaredHere(name: string): boolean;
90
124
  export declare function listProfiles(): Promise<BrowserProfileWithDeclarations[]>;
91
125
  export declare function getProfile(name: string): Promise<BrowserProfileWithDeclarations | null>;
92
- /** Persist only the agents-cli alias for an auto-discovered Arc profile. */
93
- export declare function persistDiscoveredArcProfile(name: string): Promise<void>;
126
+ /** Persist only the agents-cli alias for an auto-discovered native profile; never the browser's own data. */
127
+ export declare function persistDiscoveredProfile(name: string): Promise<void>;
94
128
  /**
95
129
  * True when `profile` can actually launch or attach on THIS machine.
96
130
  *
@@ -6,6 +6,8 @@ import { machineId } from '../machine-id.js';
6
6
  import { declaringDevices, profileRegistry, } from './registry.js';
7
7
  import { findBrowserPath, isPortInUse } from './chrome.js';
8
8
  import { arcSpaceProfiles, discoverArcProfiles } from './arc-discovery.js';
9
+ import { discoverChromiumProfiles, discoverableChromiumBrowsers } from './chromium-discovery.js';
10
+ import { firefoxDiscoveredProfiles, discoverFirefoxProfiles } from './firefox-discovery.js';
9
11
  export { declaringDevices, profileKind, profileRegistry, } from './registry.js';
10
12
  /**
11
13
  * Name of the profile the setup wizards pin as this machine's default browser
@@ -125,12 +127,14 @@ function configToProfile(name, config, devices = []) {
125
127
  defaultEndpoint: config.defaultEndpoint,
126
128
  launchPolicy: config.launchPolicy,
127
129
  userDataDir: config.userDataDir,
130
+ profileDirectory: config.profileDirectory,
128
131
  chrome: config.chrome,
129
132
  secrets: config.secrets,
130
133
  viewport: config.viewport,
131
134
  logDir: config.logDir,
132
135
  logHost: config.logHost,
133
136
  arc: config.arc,
137
+ firefox: config.firefox,
134
138
  devices,
135
139
  };
136
140
  }
@@ -154,6 +158,8 @@ function profileToConfig(profile) {
154
158
  config.launchPolicy = profile.launchPolicy;
155
159
  if (profile.userDataDir)
156
160
  config.userDataDir = profile.userDataDir;
161
+ if (profile.profileDirectory)
162
+ config.profileDirectory = profile.profileDirectory;
157
163
  if (profile.chrome)
158
164
  config.chrome = profile.chrome;
159
165
  if (profile.secrets)
@@ -166,57 +172,170 @@ function profileToConfig(profile) {
166
172
  config.logHost = profile.logHost;
167
173
  if (profile.arc)
168
174
  config.arc = profile.arc;
175
+ if (profile.firefox)
176
+ config.firefox = profile.firefox;
169
177
  return config;
170
178
  }
179
+ export function discoverNativeProfiles() {
180
+ const profiles = [];
181
+ const errors = {};
182
+ const arc = discoverArcProfiles();
183
+ if (arc.ok) {
184
+ for (const space of arcSpaceProfiles(arc)) {
185
+ profiles.push({
186
+ name: space.name,
187
+ description: `Arc Space "${space.spaceTitle}" (${space.profileName})`,
188
+ browser: 'arc',
189
+ endpoints: { native: { target: 'arc-native://local' } },
190
+ defaultEndpoint: 'native',
191
+ launchPolicy: 'attach-only',
192
+ arc: {
193
+ profileId: space.profileId,
194
+ profileName: space.profileName,
195
+ spaceId: space.spaceId,
196
+ spaceTitle: space.spaceTitle,
197
+ },
198
+ devices: [machineId()],
199
+ });
200
+ }
201
+ }
202
+ else if (arc.kind === 'invalid') {
203
+ errors.arc = `Cannot discover Arc Spaces: ${arc.reason}`;
204
+ }
205
+ // Ports handed out earlier in this pass: two unpublished profiles must never
206
+ // derive the same port before either is declared.
207
+ const reserved = new Set();
208
+ for (const browser of discoverableChromiumBrowsers()) {
209
+ const found = discoverChromiumProfiles(browser);
210
+ if (!found.ok) {
211
+ if (found.kind === 'invalid')
212
+ errors[browser] = `Cannot discover ${browser} profiles: ${found.reason}`;
213
+ continue;
214
+ }
215
+ for (const native of found.profiles) {
216
+ const port = nativeChromiumPort(native.name, reserved);
217
+ reserved.add(port);
218
+ profiles.push({
219
+ name: native.name,
220
+ description: `${browser} profile "${native.displayName}" (${native.profileDirectory})`,
221
+ browser,
222
+ endpoints: [`cdp://127.0.0.1:${port}`],
223
+ userDataDir: native.userDataDir,
224
+ profileDirectory: native.profileDirectory,
225
+ devices: [machineId()],
226
+ });
227
+ }
228
+ }
229
+ // Firefox (PHNX-4043): one profiles.ini entry is one `firefox-<slug>` profile,
230
+ // pinned to that directory and a stable WebDriver BiDi port. Firefox dropped
231
+ // CDP in 129, so these use a `firefox-bidi:` endpoint, not `cdp://`.
232
+ const firefox = discoverFirefoxProfiles();
233
+ if (firefox.ok) {
234
+ for (const entry of firefoxDiscoveredProfiles(firefox)) {
235
+ profiles.push({
236
+ name: entry.name,
237
+ description: `Firefox profile "${entry.profileName}"${entry.isDefault ? ' (default)' : ''}`,
238
+ browser: 'firefox',
239
+ endpoints: { bidi: { target: `firefox-bidi://127.0.0.1:${entry.port}` } },
240
+ defaultEndpoint: 'bidi',
241
+ userDataDir: entry.profileDir,
242
+ firefox: {
243
+ profileName: entry.profileName,
244
+ iniPath: entry.iniPath,
245
+ isDefault: entry.isDefault,
246
+ },
247
+ devices: [machineId()],
248
+ });
249
+ }
250
+ }
251
+ else if (firefox.kind === 'invalid') {
252
+ errors.firefox = `Cannot discover Firefox profiles: ${firefox.reason}`;
253
+ }
254
+ return { profiles, errors };
255
+ }
171
256
  /**
172
- * Every Arc Space on this Mac as an agents-cli profile (PHNX-2399). A Space
173
- * already carries its Arc profile's cookies and logins, so it IS the browser
174
- * profile agents pick with `--profile` — no second concept.
175
- *
176
- * Discovery reads files Arc rewrites on every Space or tab change, so a torn
177
- * read is an ordinary event, not corruption. It is reported as `error` rather
178
- * than thrown: the profile store serves every browser on this machine, and a
179
- * momentary Arc read must never take the Comet or Chrome rows down with it.
180
- * The error surfaces where an Arc profile is actually asked for by name.
181
- */
182
- function discoverArcSpaceProfiles() {
183
- const discovered = discoverArcProfiles();
184
- if (!discovered.ok) {
185
- if (discovered.kind === 'unsupported' || discovered.kind === 'not-installed')
186
- return { profiles: [] };
187
- return { profiles: [], error: `Cannot discover Arc Spaces: ${discovered.reason}` };
257
+ * The CDP port a discovered Chromium profile attaches on. Once the profile is
258
+ * declared its port lives in the declaration; before that, the port is the
259
+ * first free one from the canonical Comet port (9333, PHNX-3967), stepping
260
+ * past ports other local declarations hold and past `reserved`, the ports
261
+ * already given to sibling profiles in the same discovery pass.
262
+ */
263
+ function nativeChromiumPort(name, reserved) {
264
+ const declared = localDeclaration(name)?.config;
265
+ const declaredPort = declared ? parseEndpointPort(declared) : undefined;
266
+ if (declaredPort)
267
+ return declaredPort;
268
+ const taken = new Set(reserved);
269
+ for (const [other, declarations] of profileRegistry()) {
270
+ if (other === name)
271
+ continue;
272
+ for (const declaration of declarations) {
273
+ if (declaration.device !== machineId())
274
+ continue;
275
+ const port = parseEndpointPort(declaration.config);
276
+ if (port)
277
+ taken.add(port);
278
+ }
188
279
  }
189
- return {
190
- profiles: arcSpaceProfiles(discovered).map((space) => ({
191
- name: space.name,
192
- description: `Arc Space "${space.spaceTitle}" (${space.profileName})`,
193
- browser: 'arc',
194
- endpoints: { native: { target: 'arc-native://local' } },
195
- defaultEndpoint: 'native',
196
- launchPolicy: 'attach-only',
197
- arc: {
198
- profileId: space.profileId,
199
- profileName: space.profileName,
200
- spaceId: space.spaceId,
201
- spaceTitle: space.spaceTitle,
202
- },
203
- devices: [machineId()],
204
- })),
205
- };
280
+ let port = 9333;
281
+ while (taken.has(port))
282
+ port += 1;
283
+ return port;
284
+ }
285
+ function parseEndpointPort(config) {
286
+ const endpoints = config.endpoints;
287
+ const first = Array.isArray(endpoints)
288
+ ? endpoints[0]
289
+ : endpoints && typeof endpoints === 'object'
290
+ ? Object.values(endpoints)[0]?.target
291
+ : undefined;
292
+ if (typeof first !== 'string')
293
+ return undefined;
294
+ const match = /^cdp:\/\/[^:/?]+(?::(\d+)|\?port=(\d+))/.exec(first);
295
+ const raw = match?.[1] ?? match?.[2];
296
+ return raw ? Number(raw) : undefined;
206
297
  }
207
298
  /**
208
- * Bring a persisted Arc alias up to date with the live Space (title renames).
209
- * A Space that is no longer discoverable leaves the alias as stored: listing
210
- * still works, and using the profile fails loud in `resolveArcSpace` with the
211
- * Space that is missing from Arc's visible windows.
299
+ * Publish every discovered native profile that this device has not declared
300
+ * yet into its device declaration (`~/.agents/devices/<device>/agents.yaml`),
301
+ * which the fleet shared-store sync carries to every other box (PHNX-4042).
302
+ * That is what lets `agents browser profiles list` on a worker show this
303
+ * Mac's `arc-*` and `comet-*` rows with WHERE=<this device>, and a start from
304
+ * there route here. Idempotent; a discovery error skips that browser.
212
305
  */
213
- function refreshLocalArcProfile(profile, live) {
214
- if (!profile.arc || !profile.devices.includes(machineId()))
215
- return profile;
216
- const current = live.find((candidate) => candidate.arc?.spaceId === profile.arc?.spaceId);
217
- if (!current)
306
+ export async function publishDiscoveredProfiles() {
307
+ const discovered = discoverNativeProfiles();
308
+ const published = [];
309
+ for (const profile of discovered.profiles) {
310
+ if (localDeclaration(profile.name))
311
+ continue;
312
+ await createProfile(profile);
313
+ published.push(profile.name);
314
+ }
315
+ return { published, errors: discovered.errors };
316
+ }
317
+ /**
318
+ * Bring a persisted discovered profile up to date with its live source (an Arc
319
+ * Space title rename, a Comet profile rename). A source that is no longer
320
+ * discoverable leaves the declaration as stored: listing still works, and using
321
+ * the profile fails loud where the browser is actually reached.
322
+ */
323
+ function refreshDiscoveredProfile(profile, live) {
324
+ if (!profile.devices.includes(machineId()))
218
325
  return profile;
219
- return { ...profile, arc: current.arc, description: profile.description ?? current.description };
326
+ if (profile.arc) {
327
+ const current = live.find((candidate) => candidate.arc?.spaceId === profile.arc?.spaceId);
328
+ if (!current)
329
+ return profile;
330
+ return { ...profile, arc: current.arc, description: profile.description ?? current.description };
331
+ }
332
+ if (profile.userDataDir && profile.profileDirectory) {
333
+ const current = live.find((candidate) => candidate.userDataDir === profile.userDataDir && candidate.profileDirectory === profile.profileDirectory);
334
+ if (!current)
335
+ return profile;
336
+ return { ...profile, description: profile.description ?? current.description };
337
+ }
338
+ return profile;
220
339
  }
221
340
  function selectedDeclaration(declarations) {
222
341
  return declarations.find((declaration) => declaration.device === machineId()) ?? declarations[0];
@@ -229,32 +348,33 @@ export function isProfileDeclaredHere(name) {
229
348
  return localDeclaration(name) !== undefined;
230
349
  }
231
350
  export async function listProfiles() {
232
- const native = discoverArcSpaceProfiles();
351
+ const native = discoverNativeProfiles();
233
352
  const configured = [...profileRegistry()].map(([name, declarations]) => {
234
353
  const selected = selectedDeclaration(declarations);
235
- return refreshLocalArcProfile(configToProfile(name, selected.config, declarations.map((declaration) => declaration.device)), native.profiles);
354
+ return refreshDiscoveredProfile(configToProfile(name, selected.config, declarations.map((declaration) => declaration.device)), native.profiles);
236
355
  });
237
356
  const names = new Set(configured.map((profile) => profile.name));
238
357
  return [...configured, ...native.profiles.filter((profile) => !names.has(profile.name))];
239
358
  }
240
359
  export async function getProfile(name) {
241
- const native = discoverArcSpaceProfiles();
360
+ const native = discoverNativeProfiles();
242
361
  const declarations = profileRegistry().get(name);
243
362
  if (declarations?.length) {
244
363
  const selected = selectedDeclaration(declarations);
245
- return refreshLocalArcProfile(configToProfile(name, selected.config, declarations.map((declaration) => declaration.device)), native.profiles);
364
+ return refreshDiscoveredProfile(configToProfile(name, selected.config, declarations.map((declaration) => declaration.device)), native.profiles);
246
365
  }
247
366
  const discovered = native.profiles.find((profile) => profile.name === name) ?? null;
248
- // Only an Arc profile asked for by name pays for a failed Arc read.
249
- if (!discovered && native.error && name.startsWith('arc-'))
250
- throw new Error(native.error);
367
+ // Only a profile of that browser asked for by name pays for its failed read.
368
+ const prefix = name.split('-')[0];
369
+ if (!discovered && native.errors[prefix])
370
+ throw new Error(native.errors[prefix]);
251
371
  return discovered;
252
372
  }
253
- /** Persist only the agents-cli alias for an auto-discovered Arc profile. */
254
- export async function persistDiscoveredArcProfile(name) {
373
+ /** Persist only the agents-cli alias for an auto-discovered native profile; never the browser's own data. */
374
+ export async function persistDiscoveredProfile(name) {
255
375
  if (localDeclaration(name))
256
376
  return;
257
- const profile = discoverArcSpaceProfiles().profiles.find((candidate) => candidate.name === name);
377
+ const profile = discoverNativeProfiles().profiles.find((candidate) => candidate.name === name);
258
378
  if (!profile)
259
379
  return;
260
380
  await createProfile(profile);
@@ -272,7 +392,22 @@ export async function persistDiscoveredArcProfile(name) {
272
392
  export function isProfileLaunchableHere(profile) {
273
393
  const nativeArc = Object.values(getEndpointPresets(profile)).some((preset) => preset.target.startsWith('arc-native:'));
274
394
  if (nativeArc) {
275
- return !!profile.arc && discoverArcSpaceProfiles().profiles.some((candidate) => candidate.arc?.spaceId === profile.arc?.spaceId);
395
+ return !!profile.arc && discoverNativeProfiles().profiles.some((candidate) => candidate.arc?.spaceId === profile.arc?.spaceId);
396
+ }
397
+ const bidi = Object.values(getEndpointPresets(profile)).some((preset) => preset.target.startsWith('firefox-bidi:'));
398
+ if (bidi) {
399
+ // Launchable here only when Firefox is installed AND this box holds the
400
+ // pinned profile directory (a synced-in firefox- profile from another box
401
+ // has that box's path, so it command-dispatches instead).
402
+ if (!profile.userDataDir || !fs.existsSync(profile.userDataDir))
403
+ return false;
404
+ try {
405
+ findBrowserPath('firefox', profile.binary);
406
+ return true;
407
+ }
408
+ catch {
409
+ return false;
410
+ }
276
411
  }
277
412
  const remote = Object.values(getEndpointPresets(profile)).some((preset) => preset.target.startsWith('ssh://'));
278
413
  if (remote)
@@ -604,7 +739,17 @@ export async function createProfile(profile) {
604
739
  if (nativeArc && (!profile.arc || profile.browser !== 'arc')) {
605
740
  throw new Error('An arc-native profile requires stable Arc Space metadata. Pick a discovered one from `agents browser profiles list`.');
606
741
  }
607
- if (!hasSshEndpoint(profile.endpoints) && !nativeArc) {
742
+ const firefoxBidi = Object.values(getEndpointPresets(profile)).some((preset) => preset.target.startsWith('firefox-bidi:'));
743
+ if (firefoxBidi && (!profile.firefox || profile.browser !== 'firefox')) {
744
+ throw new Error('A firefox-bidi profile requires a discovered Firefox profile. Pick one from `agents browser profiles list`.');
745
+ }
746
+ // A discovered profile is pinned to a browser store on this disk
747
+ // (`userDataDir` + `profileDirectory`) and is published unattended by the
748
+ // daemon tick. Its binary is resolved where it is launched (`launchBrowser`),
749
+ // so an app that was removed after its store fails loud at `start` instead of
750
+ // blocking every other discovered row from publishing.
751
+ const storePinned = !!profile.userDataDir && !!profile.profileDirectory;
752
+ if (!hasSshEndpoint(profile.endpoints) && !nativeArc && !storePinned) {
608
753
  findBrowserPath(profile.browser, profile.binary);
609
754
  }
610
755
  const config = profileToConfig(profile);
@@ -43,6 +43,7 @@ export function profileFromDeclaration(name, declaration) {
43
43
  logDir: config.logDir,
44
44
  logHost: config.logHost,
45
45
  arc: config.arc,
46
+ firefox: config.firefox,
46
47
  };
47
48
  }
48
49
  /** Runtime key: profile name plus the resolved device. Drops `@endpoint-N`. */
@@ -132,6 +133,11 @@ export function sshEndpointForDeclaration(device, config, endpointName, os) {
132
133
  // (PHNX-2399). They dispatch the whole command to the owner device instead.
133
134
  if (resolved.target.startsWith('arc-native:'))
134
135
  return resolved.target;
136
+ // Firefox BiDi endpoints (firefox-bidi:) are a local WebSocket bound to a
137
+ // specific profile directory on the owner box; they dispatch the whole
138
+ // command to that box rather than tunnelling (PHNX-4043).
139
+ if (resolved.target.startsWith('firefox-bidi:'))
140
+ return resolved.target;
135
141
  const parsed = parseEndpointUrl(resolved.target);
136
142
  const port = parsed?.port ?? 9222;
137
143
  const osQuery = isWindowsOs(os) ? '&os=windows' : '';
@@ -209,7 +215,7 @@ export function resolveBrowserTarget(name, opts = {}) {
209
215
  targetFilter: resolved.targetFilter,
210
216
  },
211
217
  picked,
212
- commandDispatch: resolved.target.startsWith('arc-native:'),
218
+ commandDispatch: resolved.target.startsWith('arc-native:') || resolved.target.startsWith('firefox-bidi:'),
213
219
  };
214
220
  }
215
221
  throw unreachableDeclaringDevicesError(name, devices, failures);
@@ -4,7 +4,8 @@ import { type ResolvedDomainSkill } from './domain-skills.js';
4
4
  import { type Task, type TabInfo, type ProfileStatus, type BrowserType, type HistoricalTask, type ReapResult, type ProfileName, type ConnectionKey } from './types.js';
5
5
  import { type ReapOptions } from './hygiene.js';
6
6
  import { type RefOpts, type RefNode } from './refs.js';
7
- import type { TargetFilter, PageOpenResult, ArcNativeProfileIdentity } from './types.js';
7
+ import type { TargetFilter, PageOpenResult, ArcNativeProfileIdentity, FirefoxProfileIdentity } from './types.js';
8
+ import { FirefoxBiDiClient } from './drivers/firefox.js';
8
9
  export type UploadMode = 'auto' | 'input' | 'drop' | 'chooser';
9
10
  export declare function resolveScreenshotOutputPath(outputPath: string | undefined, automaticPath: string): string;
10
11
  /**
@@ -95,7 +96,22 @@ interface ArcProfileConnection extends BaseProfileConnection {
95
96
  browserType: 'arc';
96
97
  arcProfile: ArcNativeProfileIdentity;
97
98
  }
98
- type ProfileConnection = CdpProfileConnection | ArcProfileConnection;
99
+ /**
100
+ * A Firefox connection driven over WebDriver BiDi (PHNX-4043). No CDP socket:
101
+ * `bidi` is the live BiDi client, and `firefoxProfile` is the discovered
102
+ * profile identity. Task tab ids map to BiDi browsing-context ids.
103
+ */
104
+ interface FirefoxProfileConnection extends BaseProfileConnection {
105
+ backend: 'bidi';
106
+ browserType: 'firefox';
107
+ bidi: FirefoxBiDiClient;
108
+ firefoxProfile: FirefoxProfileIdentity;
109
+ /** The BiDi session id from `session.new`. */
110
+ sessionId: string;
111
+ /** Absolute Firefox profile directory this connection is bound to. */
112
+ profileDir: string;
113
+ }
114
+ type ProfileConnection = CdpProfileConnection | ArcProfileConnection | FirefoxProfileConnection;
99
115
  /** Join error lines so callers get a next command, not a dead-end message. */
100
116
  export declare function actionable(...lines: string[]): string;
101
117
  /**
@@ -714,6 +730,39 @@ export declare class BrowserService {
714
730
  * the original window and Space.
715
731
  */
716
732
  private closeArcNativeTabs;
733
+ /** The BiDi browsing-context id a task owns for `shortId`. */
734
+ private firefoxContext;
735
+ /** The context ids currently live in this Firefox (a killed tab drops out). */
736
+ private firefoxLiveContexts;
737
+ /**
738
+ * Same-task reopen for Firefox (mirrors {@link reopenOwnedTab}): the URL is
739
+ * already live in one of this task's own tabs → reload THAT tab in place and
740
+ * keep its id, rather than opening a duplicate. Borrowed tabs are excluded —
741
+ * Firefox never borrows, but the guard keeps parity. Returns the retained
742
+ * short id, or undefined when nothing matches.
743
+ */
744
+ private reopenOwnedFirefoxTab;
745
+ private navigateFirefox;
746
+ private evaluateFirefox;
747
+ private listFirefoxTaskTabs;
748
+ /**
749
+ * Close every non-borrowed tab a Firefox task owns. Never kills the Firefox
750
+ * process (that is `stopProfile`'s job) — a killed browser would take the
751
+ * user's other tabs with it. A tab already gone is skipped.
752
+ */
753
+ private closeFirefoxTabs;
754
+ /**
755
+ * Viewport-center coordinates of a selector-addressed element in a Firefox
756
+ * tab, scrolled into view first — the (x, y) `input.performActions` clicks.
757
+ * Throws when the element is gone or has zero size (off-screen / display:none).
758
+ */
759
+ private firefoxRefCoords;
760
+ /**
761
+ * Screenshot a Firefox tab over BiDi. Same output contract as the CDP path:
762
+ * `raw` is a pixel-faithful PNG; the default is JPEG iteratively downscaled
763
+ * under 100 KB so chat-injected screenshots stay token-cheap.
764
+ */
765
+ private screenshotFirefox;
717
766
  /** Connect a profile at `target`, register it under `key`, and arm downloads. */
718
767
  private openConnection;
719
768
  /**