@phnx-labs/agents-cli 1.22.93 → 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.
Files changed (33) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/commands/browser.d.ts +1 -0
  3. package/dist/commands/browser.js +156 -11
  4. package/dist/lib/browser/arc-discovery.d.ts +60 -0
  5. package/dist/lib/browser/arc-discovery.js +187 -0
  6. package/dist/lib/browser/arc-dom.d.ts +14 -0
  7. package/dist/lib/browser/arc-dom.js +121 -0
  8. package/dist/lib/browser/chrome.d.ts +17 -0
  9. package/dist/lib/browser/chrome.js +121 -16
  10. package/dist/lib/browser/chromium-discovery.d.ts +27 -0
  11. package/dist/lib/browser/chromium-discovery.js +96 -0
  12. package/dist/lib/browser/drivers/arc.d.ts +57 -0
  13. package/dist/lib/browser/drivers/arc.js +281 -0
  14. package/dist/lib/browser/drivers/firefox.d.ts +103 -0
  15. package/dist/lib/browser/drivers/firefox.js +377 -0
  16. package/dist/lib/browser/drivers/local.d.ts +8 -0
  17. package/dist/lib/browser/drivers/local.js +38 -3
  18. package/dist/lib/browser/firefox-discovery.d.ts +68 -0
  19. package/dist/lib/browser/firefox-discovery.js +162 -0
  20. package/dist/lib/browser/profiles.d.ts +39 -1
  21. package/dist/lib/browser/profiles.js +239 -9
  22. package/dist/lib/browser/refs.d.ts +2 -0
  23. package/dist/lib/browser/refs.js +2 -1
  24. package/dist/lib/browser/resolve-target.d.ts +2 -0
  25. package/dist/lib/browser/resolve-target.js +14 -0
  26. package/dist/lib/browser/runtime-state.js +9 -1
  27. package/dist/lib/browser/service.d.ts +86 -3
  28. package/dist/lib/browser/service.js +1045 -42
  29. package/dist/lib/browser/types.d.ts +85 -2
  30. package/dist/lib/daemon/usage-sync-service.js +12 -0
  31. package/dist/lib/open-url.js +6 -0
  32. package/dist/lib/types.d.ts +20 -1
  33. 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,8 +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
+ }>;
122
+ /** Whether this name has persisted agents-cli metadata on the current device. */
123
+ export declare function isProfileDeclaredHere(name: string): boolean;
88
124
  export declare function listProfiles(): Promise<BrowserProfileWithDeclarations[]>;
89
125
  export declare function getProfile(name: string): Promise<BrowserProfileWithDeclarations | null>;
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>;
90
128
  /**
91
129
  * True when `profile` can actually launch or attach on THIS machine.
92
130
  *
@@ -245,7 +283,7 @@ export declare function updateProfile(profile: BrowserProfile): Promise<void>;
245
283
  * ({@link getProfileRuntimeDir}) and any live `<name>@<endpoint>` connection, so
246
284
  * changing either orphans the cached browser data. Delete and recreate instead.
247
285
  */
248
- export type EditableProfileFields = Partial<Pick<BrowserProfile, 'description' | 'binary' | 'electron' | 'targetFilter' | 'endpoints' | 'launchPolicy' | 'userDataDir' | 'chrome' | 'secrets' | 'viewport'>>;
286
+ export type EditableProfileFields = Partial<Pick<BrowserProfile, 'description' | 'binary' | 'electron' | 'targetFilter' | 'endpoints' | 'launchPolicy' | 'userDataDir' | 'chrome' | 'secrets' | 'viewport' | 'arc'>>;
249
287
  export interface EditProfileResult {
250
288
  profile: BrowserProfile;
251
289
  devices: string[];
@@ -5,6 +5,9 @@ import { getConfigValue } from '../device-config.js';
5
5
  import { machineId } from '../machine-id.js';
6
6
  import { declaringDevices, profileRegistry, } from './registry.js';
7
7
  import { findBrowserPath, isPortInUse } from './chrome.js';
8
+ import { arcSpaceProfiles, discoverArcProfiles } from './arc-discovery.js';
9
+ import { discoverChromiumProfiles, discoverableChromiumBrowsers } from './chromium-discovery.js';
10
+ import { firefoxDiscoveredProfiles, discoverFirefoxProfiles } from './firefox-discovery.js';
8
11
  export { declaringDevices, profileKind, profileRegistry, } from './registry.js';
9
12
  /**
10
13
  * Name of the profile the setup wizards pin as this machine's default browser
@@ -124,11 +127,14 @@ function configToProfile(name, config, devices = []) {
124
127
  defaultEndpoint: config.defaultEndpoint,
125
128
  launchPolicy: config.launchPolicy,
126
129
  userDataDir: config.userDataDir,
130
+ profileDirectory: config.profileDirectory,
127
131
  chrome: config.chrome,
128
132
  secrets: config.secrets,
129
133
  viewport: config.viewport,
130
134
  logDir: config.logDir,
131
135
  logHost: config.logHost,
136
+ arc: config.arc,
137
+ firefox: config.firefox,
132
138
  devices,
133
139
  };
134
140
  }
@@ -152,6 +158,8 @@ function profileToConfig(profile) {
152
158
  config.launchPolicy = profile.launchPolicy;
153
159
  if (profile.userDataDir)
154
160
  config.userDataDir = profile.userDataDir;
161
+ if (profile.profileDirectory)
162
+ config.profileDirectory = profile.profileDirectory;
155
163
  if (profile.chrome)
156
164
  config.chrome = profile.chrome;
157
165
  if (profile.secrets)
@@ -162,26 +170,214 @@ function profileToConfig(profile) {
162
170
  config.logDir = profile.logDir;
163
171
  if (profile.logHost)
164
172
  config.logHost = profile.logHost;
173
+ if (profile.arc)
174
+ config.arc = profile.arc;
175
+ if (profile.firefox)
176
+ config.firefox = profile.firefox;
165
177
  return config;
166
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
+ }
256
+ /**
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
+ }
279
+ }
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;
297
+ }
298
+ /**
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.
305
+ */
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()))
325
+ return profile;
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;
339
+ }
167
340
  function selectedDeclaration(declarations) {
168
341
  return declarations.find((declaration) => declaration.device === machineId()) ?? declarations[0];
169
342
  }
170
343
  function localDeclaration(name) {
171
344
  return profileRegistry().get(name)?.find((declaration) => declaration.device === machineId());
172
345
  }
346
+ /** Whether this name has persisted agents-cli metadata on the current device. */
347
+ export function isProfileDeclaredHere(name) {
348
+ return localDeclaration(name) !== undefined;
349
+ }
173
350
  export async function listProfiles() {
174
- return [...profileRegistry()].map(([name, declarations]) => {
351
+ const native = discoverNativeProfiles();
352
+ const configured = [...profileRegistry()].map(([name, declarations]) => {
175
353
  const selected = selectedDeclaration(declarations);
176
- return configToProfile(name, selected.config, declarations.map((declaration) => declaration.device));
354
+ return refreshDiscoveredProfile(configToProfile(name, selected.config, declarations.map((declaration) => declaration.device)), native.profiles);
177
355
  });
356
+ const names = new Set(configured.map((profile) => profile.name));
357
+ return [...configured, ...native.profiles.filter((profile) => !names.has(profile.name))];
178
358
  }
179
359
  export async function getProfile(name) {
360
+ const native = discoverNativeProfiles();
180
361
  const declarations = profileRegistry().get(name);
181
- if (!declarations?.length)
182
- return null;
183
- const selected = selectedDeclaration(declarations);
184
- return configToProfile(name, selected.config, declarations.map((declaration) => declaration.device));
362
+ if (declarations?.length) {
363
+ const selected = selectedDeclaration(declarations);
364
+ return refreshDiscoveredProfile(configToProfile(name, selected.config, declarations.map((declaration) => declaration.device)), native.profiles);
365
+ }
366
+ const discovered = native.profiles.find((profile) => profile.name === name) ?? null;
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]);
371
+ return discovered;
372
+ }
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) {
375
+ if (localDeclaration(name))
376
+ return;
377
+ const profile = discoverNativeProfiles().profiles.find((candidate) => candidate.name === name);
378
+ if (!profile)
379
+ return;
380
+ await createProfile(profile);
185
381
  }
186
382
  /**
187
383
  * True when `profile` can actually launch or attach on THIS machine.
@@ -194,6 +390,25 @@ export async function getProfile(name) {
194
390
  * for the current machine.
195
391
  */
196
392
  export function isProfileLaunchableHere(profile) {
393
+ const nativeArc = Object.values(getEndpointPresets(profile)).some((preset) => preset.target.startsWith('arc-native:'));
394
+ if (nativeArc) {
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
+ }
411
+ }
197
412
  const remote = Object.values(getEndpointPresets(profile)).some((preset) => preset.target.startsWith('ssh://'));
198
413
  if (remote)
199
414
  return true;
@@ -334,7 +549,7 @@ export async function ensureDefaultBrowserProfile() {
334
549
  const configured = getConfiguredDefaultProfileName();
335
550
  if (configured && configured !== DEFAULT_PROFILE_ALIAS) {
336
551
  const chosen = await getProfile(configured);
337
- if (chosen && isProfileLaunchableHere(chosen))
552
+ if (chosen && (!chosen.devices.includes(machineId()) || isProfileLaunchableHere(chosen)))
338
553
  return chosen;
339
554
  if (!chosen) {
340
555
  const central = readMeta().browser ?? {};
@@ -520,7 +735,21 @@ export async function createProfile(profile) {
520
735
  // Skip for SSH profiles: the browser binary lives on the remote host, so a
521
736
  // local lookup would validate the wrong machine. The remote launcher resolves
522
737
  // it at connect time.
523
- if (!hasSshEndpoint(profile.endpoints)) {
738
+ const nativeArc = Object.values(getEndpointPresets(profile)).some((preset) => preset.target.startsWith('arc-native:'));
739
+ if (nativeArc && (!profile.arc || profile.browser !== 'arc')) {
740
+ throw new Error('An arc-native profile requires stable Arc Space metadata. Pick a discovered one from `agents browser profiles list`.');
741
+ }
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) {
524
753
  findBrowserPath(profile.browser, profile.binary);
525
754
  }
526
755
  const config = profileToConfig(profile);
@@ -575,7 +804,8 @@ export async function editProfile(name, patch) {
575
804
  assertLocalPortFree(merged, { ignore: name });
576
805
  // Same rule as create: the binary lives on the remote for an SSH profile, so a
577
806
  // local lookup would validate the wrong machine.
578
- if (!hasSshEndpoint(merged.endpoints)) {
807
+ const nativeArc = Object.values(getEndpointPresets(merged)).some((preset) => preset.target.startsWith('arc-native:'));
808
+ if (!hasSshEndpoint(merged.endpoints) && !nativeArc) {
579
809
  findBrowserPath(merged.browser, merged.binary);
580
810
  }
581
811
  await updateProfile(merged);
@@ -10,6 +10,8 @@ export interface RefNode {
10
10
  name: string;
11
11
  attrs: string[];
12
12
  backendNodeId?: number;
13
+ /** Stable selector used by non-CDP DOM backends such as native Arc. */
14
+ selector?: string;
13
15
  editor?: string;
14
16
  }
15
17
  /**
@@ -6,6 +6,7 @@ export function describeRefs(nodeMap) {
6
6
  role: n.role,
7
7
  name: n.name,
8
8
  attrs: n.attrs.slice(),
9
+ selector: n.selector,
9
10
  }));
10
11
  }
11
12
  /**
@@ -57,7 +58,7 @@ export function healRef(descriptor, freshNodeMap) {
57
58
  // Lexicographic key, all "higher is better":
58
59
  // [ DOM-backed, attribute similarity, -distance-to-original-ref ].
59
60
  const key = [
60
- node.backendNodeId !== undefined ? 1 : 0,
61
+ node.backendNodeId !== undefined || node.selector !== undefined ? 1 : 0,
61
62
  attrScore(node),
62
63
  -Math.abs(node.ref - ref),
63
64
  ];
@@ -24,6 +24,8 @@ export interface ResolvedBrowserTarget {
24
24
  * surfaces this so the caller knows WHERE the browser actually lives.
25
25
  */
26
26
  picked?: string;
27
+ /** Native endpoints cannot be tunnelled; the CLI must re-exec on this device. */
28
+ commandDispatch?: boolean;
27
29
  }
28
30
  /** Fork only fungible Electron profiles; identity-bearing ones share one connection. */
29
31
  export declare function shouldForkProfile(kind: 'identity' | 'fungible', conn: {
@@ -35,11 +35,15 @@ export function profileFromDeclaration(name, declaration) {
35
35
  targetFilter: config.targetFilter,
36
36
  endpoints: config.endpoints,
37
37
  defaultEndpoint: config.defaultEndpoint,
38
+ launchPolicy: config.launchPolicy,
39
+ userDataDir: config.userDataDir,
38
40
  chrome: config.chrome,
39
41
  secrets: config.secrets,
40
42
  viewport: config.viewport,
41
43
  logDir: config.logDir,
42
44
  logHost: config.logHost,
45
+ arc: config.arc,
46
+ firefox: config.firefox,
43
47
  };
44
48
  }
45
49
  /** Runtime key: profile name plus the resolved device. Drops `@endpoint-N`. */
@@ -125,6 +129,15 @@ export function sshEndpointForDeclaration(device, config, endpointName, os) {
125
129
  const resolved = resolveEndpoint(profile, endpointName);
126
130
  if (resolved.target.startsWith('ssh:'))
127
131
  return resolved.target;
132
+ // Native Arc endpoints (arc-native:) must NOT be rewritten into SSH tunnels
133
+ // (PHNX-2399). They dispatch the whole command to the owner device instead.
134
+ if (resolved.target.startsWith('arc-native:'))
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;
128
141
  const parsed = parseEndpointUrl(resolved.target);
129
142
  const port = parsed?.port ?? 9222;
130
143
  const osQuery = isWindowsOs(os) ? '&os=windows' : '';
@@ -202,6 +215,7 @@ export function resolveBrowserTarget(name, opts = {}) {
202
215
  targetFilter: resolved.targetFilter,
203
216
  },
204
217
  picked,
218
+ commandDispatch: resolved.target.startsWith('arc-native:') || resolved.target.startsWith('firefox-bidi:'),
205
219
  };
206
220
  }
207
221
  throw unreachableDeclaringDevicesError(name, devices, failures);
@@ -1,7 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { execFileSync } from 'child_process';
4
- import { getProfileRuntimeDir, getBrowserRuntimeDir, getEndpointPresets, listProfiles, getConfiguredDefaultProfileName, isProfileLaunchableHere, deleteProfile, DEFAULT_BROWSER_PROFILE_NAME, LEGACY_DEFAULT_BROWSER_PROFILE_NAME, } from './profiles.js';
4
+ import { getProfileRuntimeDir, getBrowserRuntimeDir, getEndpointPresets, listProfiles, getConfiguredDefaultProfileName, isProfileLaunchableHere, isProfileDeclaredHere, deleteProfile, DEFAULT_BROWSER_PROFILE_NAME, LEGACY_DEFAULT_BROWSER_PROFILE_NAME, } from './profiles.js';
5
5
  import { keyBelongsToProfile } from './types.js';
6
6
  import { declaringDevices, profileKind } from './registry.js';
7
7
  import { machineId } from '../machine-id.js';
@@ -405,6 +405,14 @@ export async function buildProfilePrunePlan() {
405
405
  for (const profile of profiles) {
406
406
  const scope = profile.devices.length > 1 ? 'fungible' : 'identity';
407
407
  const misfiled = identityLoopbackMismatch(profile);
408
+ if (profile.arc && !isProfileDeclaredHere(profile.name)) {
409
+ peerKept.push({
410
+ name: profile.name,
411
+ scope,
412
+ why: 'read-only native Arc discovery; no agents-cli alias to prune',
413
+ });
414
+ continue;
415
+ }
408
416
  if (profile.devices.includes(current)) {
409
417
  declaredHere.push({
410
418
  name: profile.name,