@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.
- package/CHANGELOG.md +20 -2
- package/dist/commands/browser.js +19 -8
- 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/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 +51 -2
- package/dist/lib/browser/service.js +487 -14
- package/dist/lib/browser/types.d.ts +32 -3
- 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,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
|
|
93
|
-
export declare function
|
|
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
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
if (
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
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
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
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
|
-
|
|
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 =
|
|
351
|
+
const native = discoverNativeProfiles();
|
|
233
352
|
const configured = [...profileRegistry()].map(([name, declarations]) => {
|
|
234
353
|
const selected = selectedDeclaration(declarations);
|
|
235
|
-
return
|
|
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 =
|
|
360
|
+
const native = discoverNativeProfiles();
|
|
242
361
|
const declarations = profileRegistry().get(name);
|
|
243
362
|
if (declarations?.length) {
|
|
244
363
|
const selected = selectedDeclaration(declarations);
|
|
245
|
-
return
|
|
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
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
254
|
-
export async function
|
|
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 =
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|