@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.
- package/CHANGELOG.md +20 -0
- package/dist/commands/browser.d.ts +1 -0
- package/dist/commands/browser.js +156 -11
- package/dist/lib/browser/arc-discovery.d.ts +60 -0
- package/dist/lib/browser/arc-discovery.js +187 -0
- package/dist/lib/browser/arc-dom.d.ts +14 -0
- package/dist/lib/browser/arc-dom.js +121 -0
- 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/arc.d.ts +57 -0
- package/dist/lib/browser/drivers/arc.js +281 -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 +39 -1
- package/dist/lib/browser/profiles.js +239 -9
- package/dist/lib/browser/refs.d.ts +2 -0
- package/dist/lib/browser/refs.js +2 -1
- package/dist/lib/browser/resolve-target.d.ts +2 -0
- package/dist/lib/browser/resolve-target.js +14 -0
- package/dist/lib/browser/runtime-state.js +9 -1
- package/dist/lib/browser/service.d.ts +86 -3
- package/dist/lib/browser/service.js +1045 -42
- package/dist/lib/browser/types.d.ts +85 -2
- package/dist/lib/daemon/usage-sync-service.js +12 -0
- package/dist/lib/open-url.js +6 -0
- package/dist/lib/types.d.ts +20 -1
- package/package.json +1 -1
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { truncate } from '../format.js';
|
|
2
|
+
const INTERACTIVE_SELECTOR = [
|
|
3
|
+
'a[href]', 'button', 'input', 'select', 'textarea', '[contenteditable="true"]',
|
|
4
|
+
'[role="button"]', '[role="link"]', '[role="textbox"]', '[role="checkbox"]',
|
|
5
|
+
'[role="radio"]', '[role="combobox"]', '[role="listbox"]', '[role="option"]',
|
|
6
|
+
'[role="menuitem"]', '[role="tab"]', '[role="slider"]', '[role="spinbutton"]',
|
|
7
|
+
'[role="searchbox"]', '[role="switch"]', '[role="treeitem"]',
|
|
8
|
+
].join(',');
|
|
9
|
+
/** Build one synchronous expression that reads DOM-backed refs from native Arc. */
|
|
10
|
+
export function arcRefsExpression(opts = {}) {
|
|
11
|
+
const interactive = opts.interactive ?? true;
|
|
12
|
+
const limit = opts.limit ?? 500;
|
|
13
|
+
return `(() => {
|
|
14
|
+
const selectorFor = (el) => {
|
|
15
|
+
if (el.id) return '#' + CSS.escape(el.id);
|
|
16
|
+
const parts = [];
|
|
17
|
+
for (let node = el; node && node.nodeType === 1 && node !== document.documentElement; node = node.parentElement) {
|
|
18
|
+
let part = node.localName;
|
|
19
|
+
if (!part) return '';
|
|
20
|
+
const siblings = node.parentElement ? Array.from(node.parentElement.children).filter((s) => s.localName === node.localName) : [];
|
|
21
|
+
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
|
|
22
|
+
parts.unshift(part);
|
|
23
|
+
}
|
|
24
|
+
return parts.join(' > ');
|
|
25
|
+
};
|
|
26
|
+
const roleFor = (el) => {
|
|
27
|
+
const explicit = el.getAttribute('role');
|
|
28
|
+
if (explicit) return explicit.toLowerCase();
|
|
29
|
+
if (el.matches('button')) return 'button';
|
|
30
|
+
if (el.matches('a[href]')) return 'link';
|
|
31
|
+
if (el.matches('textarea,[contenteditable="true"]')) return 'textbox';
|
|
32
|
+
if (el.matches('select')) return 'combobox';
|
|
33
|
+
if (el.matches('input')) {
|
|
34
|
+
const type = (el.getAttribute('type') || 'text').toLowerCase();
|
|
35
|
+
if (type === 'checkbox') return 'checkbox';
|
|
36
|
+
if (type === 'radio') return 'radio';
|
|
37
|
+
if (type === 'range') return 'slider';
|
|
38
|
+
if (type === 'number') return 'spinbutton';
|
|
39
|
+
return 'textbox';
|
|
40
|
+
}
|
|
41
|
+
return el.localName || 'generic';
|
|
42
|
+
};
|
|
43
|
+
const nameFor = (el) => el.getAttribute('aria-label') || el.getAttribute('title') ||
|
|
44
|
+
(el.labels && el.labels[0] && el.labels[0].innerText) || el.innerText || el.value || el.getAttribute('placeholder') || '';
|
|
45
|
+
const attrsFor = (el) => ['disabled','checked','selected','expanded','required','readonly','invalid']
|
|
46
|
+
.filter((name) => el[name] === true || el.getAttribute('aria-' + name) === 'true');
|
|
47
|
+
const editorFor = (el) => {
|
|
48
|
+
for (let node = el, i = 0; node && i < 6; node = node.parentElement, i++) {
|
|
49
|
+
if (node.hasAttribute('data-lexical-editor')) return 'lexical';
|
|
50
|
+
if (node.classList.contains('ProseMirror')) return 'prosemirror';
|
|
51
|
+
if (node.hasAttribute('data-slate-editor')) return 'slate';
|
|
52
|
+
if (Array.from(node.classList).some((c) => /^DraftEditor-/.test(c))) return 'draft';
|
|
53
|
+
if (node.classList.contains('ql-editor')) return 'quill';
|
|
54
|
+
if (node.classList.contains('ck-editor__editable')) return 'ckeditor5';
|
|
55
|
+
if (node.tagName === 'TRIX-EDITOR') return 'trix';
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
};
|
|
59
|
+
const source = ${interactive ? `document.querySelectorAll(${JSON.stringify(INTERACTIVE_SELECTOR)})` : `document.querySelectorAll('body *')`};
|
|
60
|
+
const rows = [];
|
|
61
|
+
for (const el of source) {
|
|
62
|
+
if (rows.length >= ${JSON.stringify(limit)}) break;
|
|
63
|
+
const selector = selectorFor(el);
|
|
64
|
+
if (!selector) continue;
|
|
65
|
+
const row = { role: roleFor(el), name: String(nameFor(el)).trim(), attrs: attrsFor(el), selector };
|
|
66
|
+
const editor = editorFor(el);
|
|
67
|
+
if (editor) row.editor = editor;
|
|
68
|
+
rows.push(row);
|
|
69
|
+
}
|
|
70
|
+
return JSON.stringify(rows);
|
|
71
|
+
})()`;
|
|
72
|
+
}
|
|
73
|
+
export function parseArcRefsResult(value, opts = {}) {
|
|
74
|
+
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
|
75
|
+
if (!Array.isArray(parsed))
|
|
76
|
+
throw new Error('Arc returned an invalid DOM ref listing.');
|
|
77
|
+
const nodeMap = new Map();
|
|
78
|
+
const lines = [];
|
|
79
|
+
const compact = opts.compact ?? false;
|
|
80
|
+
parsed.forEach((raw, index) => {
|
|
81
|
+
const row = raw;
|
|
82
|
+
if (typeof row.role !== 'string' || typeof row.name !== 'string' ||
|
|
83
|
+
typeof row.selector !== 'string' || !Array.isArray(row.attrs) ||
|
|
84
|
+
!row.attrs.every((attr) => typeof attr === 'string')) {
|
|
85
|
+
throw new Error('Arc returned an invalid DOM ref entry.');
|
|
86
|
+
}
|
|
87
|
+
const ref = index + 1;
|
|
88
|
+
const node = { ref, role: row.role, name: row.name, attrs: row.attrs, selector: row.selector };
|
|
89
|
+
if (typeof row.editor === 'string')
|
|
90
|
+
node.editor = row.editor;
|
|
91
|
+
nodeMap.set(ref, node);
|
|
92
|
+
const name = row.name ? ` "${truncate(row.name, 50)}"` : '';
|
|
93
|
+
const attrs = row.attrs.length ? ` [${row.attrs.join('] [')}]` : '';
|
|
94
|
+
const editor = row.editor ? ` [editor=${row.editor}]` : '';
|
|
95
|
+
lines.push(`${compact ? '' : '- '}${row.role}${name} [ref=${ref}]${attrs}${editor}`);
|
|
96
|
+
});
|
|
97
|
+
return { refs: lines.join('\n'), nodeMap, opts: { interactive: opts.interactive ?? true, limit: opts.limit ?? 500 } };
|
|
98
|
+
}
|
|
99
|
+
export function arcClickExpression(selector) {
|
|
100
|
+
return `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) throw new Error('DOM ref is missing'); el.click(); return true; })()`;
|
|
101
|
+
}
|
|
102
|
+
export function arcFillExpression(selector, text, clear = true) {
|
|
103
|
+
return `(() => {
|
|
104
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
105
|
+
if (!el) throw new Error('DOM ref is missing');
|
|
106
|
+
const next = ${JSON.stringify(text)};
|
|
107
|
+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
|
108
|
+
const proto = el instanceof HTMLInputElement ? HTMLInputElement.prototype : HTMLTextAreaElement.prototype;
|
|
109
|
+
const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
|
|
110
|
+
setter.call(el, ${clear ? 'next' : 'el.value + next'});
|
|
111
|
+
} else if (el.isContentEditable) {
|
|
112
|
+
el.textContent = ${clear ? 'next' : '(el.textContent || "") + next'};
|
|
113
|
+
} else throw new Error('DOM ref is not editable');
|
|
114
|
+
el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: next }));
|
|
115
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
116
|
+
return true;
|
|
117
|
+
})()`;
|
|
118
|
+
}
|
|
119
|
+
export function arcScrollExpression(deltaX, deltaY) {
|
|
120
|
+
return `(() => { window.scrollBy(${JSON.stringify(deltaX)}, ${JSON.stringify(deltaY)}); return true; })()`;
|
|
121
|
+
}
|
|
@@ -38,6 +38,23 @@ export interface LaunchResult {
|
|
|
38
38
|
port: number;
|
|
39
39
|
wsUrl: string;
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* The process that holds a Chromium user-data dir, read from the `SingletonLock`
|
|
43
|
+
* symlink Chromium keeps inside the dir on macOS and Linux (`<host>-<pid>`,
|
|
44
|
+
* PHNX-4042). A live pid means a browser already owns that store: a launch on it
|
|
45
|
+
* would only hand its arguments to the running instance and the requested debug
|
|
46
|
+
* port would never bind. Windows keeps no lock file, so this reports null there
|
|
47
|
+
* and `launchBrowser` catches the hand-off by the child's early exit instead.
|
|
48
|
+
*/
|
|
49
|
+
export declare function storeOccupant(userDataDir: string): {
|
|
50
|
+
pid: number;
|
|
51
|
+
} | null;
|
|
52
|
+
/**
|
|
53
|
+
* The relaunch that makes a browser holding `userDataDir` attachable: the
|
|
54
|
+
* ownership guard reads `--user-data-dir` off the running process, so the
|
|
55
|
+
* owner's normal launch (no flag) can never be verified.
|
|
56
|
+
*/
|
|
57
|
+
export declare function storeRelaunchCommand(browserType: BrowserType, port: number, userDataDir: string, profileDirectory?: string): string;
|
|
41
58
|
/**
|
|
42
59
|
* Resolve a browser-profile secrets bundle into an env map for the child, or an
|
|
43
60
|
* EMPTY map when the bundle is absent, locked, or otherwise unreadable — never a
|
|
@@ -24,6 +24,10 @@ const BROWSER_PATHS = {
|
|
|
24
24
|
brave: ['/Applications/Brave Browser.app/Contents/MacOS/Brave Browser'],
|
|
25
25
|
edge: ['/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],
|
|
26
26
|
arc: ['/Applications/Arc.app/Contents/MacOS/Arc'],
|
|
27
|
+
firefox: [
|
|
28
|
+
'/Applications/Firefox.app/Contents/MacOS/firefox',
|
|
29
|
+
'/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox',
|
|
30
|
+
],
|
|
27
31
|
custom: [],
|
|
28
32
|
},
|
|
29
33
|
linux: {
|
|
@@ -34,6 +38,10 @@ const BROWSER_PATHS = {
|
|
|
34
38
|
edge: ['/usr/bin/microsoft-edge'],
|
|
35
39
|
// Arc has no Linux build (macOS + Windows only).
|
|
36
40
|
arc: [],
|
|
41
|
+
// `/usr/bin/firefox` on Ubuntu is the snap wrapper script: it execs the snap
|
|
42
|
+
// in place, so the pid survives and `--remote-debugging-port` works through
|
|
43
|
+
// it. The driver reads the real pid from the BiDi handshake anyway.
|
|
44
|
+
firefox: ['/usr/bin/firefox', '/snap/bin/firefox', '/usr/lib/firefox/firefox', '/opt/firefox/firefox'],
|
|
37
45
|
custom: [],
|
|
38
46
|
},
|
|
39
47
|
win32: {
|
|
@@ -63,6 +71,11 @@ const BROWSER_PATHS = {
|
|
|
63
71
|
// Arc ships a Windows build, but its install path is not yet verified here;
|
|
64
72
|
// leave unlisted (undetected) rather than guess a path that false-positives.
|
|
65
73
|
arc: [],
|
|
74
|
+
firefox: [
|
|
75
|
+
`${WIN_PROGRAMFILES}\\Mozilla Firefox\\firefox.exe`,
|
|
76
|
+
`${WIN_PROGRAMFILES_X86}\\Mozilla Firefox\\firefox.exe`,
|
|
77
|
+
`${WIN_LOCALAPPDATA}\\Mozilla Firefox\\firefox.exe`,
|
|
78
|
+
],
|
|
66
79
|
custom: [],
|
|
67
80
|
},
|
|
68
81
|
};
|
|
@@ -236,6 +249,43 @@ export function listInstalledBrowsers(platform = os.platform()) {
|
|
|
236
249
|
}
|
|
237
250
|
return found;
|
|
238
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* The process that holds a Chromium user-data dir, read from the `SingletonLock`
|
|
254
|
+
* symlink Chromium keeps inside the dir on macOS and Linux (`<host>-<pid>`,
|
|
255
|
+
* PHNX-4042). A live pid means a browser already owns that store: a launch on it
|
|
256
|
+
* would only hand its arguments to the running instance and the requested debug
|
|
257
|
+
* port would never bind. Windows keeps no lock file, so this reports null there
|
|
258
|
+
* and `launchBrowser` catches the hand-off by the child's early exit instead.
|
|
259
|
+
*/
|
|
260
|
+
export function storeOccupant(userDataDir) {
|
|
261
|
+
let target;
|
|
262
|
+
try {
|
|
263
|
+
target = fs.readlinkSync(path.join(userDataDir, 'SingletonLock'));
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
const pid = Number(/-(\d+)$/.exec(target)?.[1]);
|
|
269
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
270
|
+
return null;
|
|
271
|
+
try {
|
|
272
|
+
process.kill(pid, 0);
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
return error.code === 'EPERM' ? { pid } : null;
|
|
276
|
+
}
|
|
277
|
+
return { pid };
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* The relaunch that makes a browser holding `userDataDir` attachable: the
|
|
281
|
+
* ownership guard reads `--user-data-dir` off the running process, so the
|
|
282
|
+
* owner's normal launch (no flag) can never be verified.
|
|
283
|
+
*/
|
|
284
|
+
export function storeRelaunchCommand(browserType, port, userDataDir, profileDirectory) {
|
|
285
|
+
const app = browserType === 'comet' ? 'Comet' : browserType;
|
|
286
|
+
const profileFlag = profileDirectory ? ` --profile-directory=${profileDirectory}` : '';
|
|
287
|
+
return `open -a ${app} --args --remote-debugging-port=${port} --user-data-dir=${userDataDir}${profileFlag}`;
|
|
288
|
+
}
|
|
239
289
|
/**
|
|
240
290
|
* Resolve a browser-profile secrets bundle into an env map for the child, or an
|
|
241
291
|
* EMPTY map when the bundle is absent, locked, or otherwise unreadable — never a
|
|
@@ -265,14 +315,25 @@ export async function launchBrowser(profileName, browserType, port, options = {}
|
|
|
265
315
|
// orphan reaper and `agents browser status` can label processes.
|
|
266
316
|
isElectron = false) {
|
|
267
317
|
const browserPath = findBrowserPath(browserType, customBinary);
|
|
318
|
+
// A profile discovered from the browser's OWN store (PHNX-4042) launches on
|
|
319
|
+
// that store — the owner's real user-data dir and profile directory — so the
|
|
320
|
+
// window agents open is the window the owner uses. Its Preferences are the
|
|
321
|
+
// owner's and are never rewritten. Anything else runs under a managed dir.
|
|
322
|
+
const ownerStore = options.userDataDir !== undefined;
|
|
268
323
|
const runtimeDir = getProfileRuntimeDir(profileName);
|
|
269
|
-
const userDataDir = path.join(runtimeDir, 'chrome-data');
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
324
|
+
const userDataDir = options.userDataDir ?? path.join(runtimeDir, 'chrome-data');
|
|
325
|
+
if (!ownerStore) {
|
|
326
|
+
fs.mkdirSync(userDataDir, { recursive: true });
|
|
327
|
+
// Pre-launch Preferences pass: first-launch profile-name stamp, plus (for
|
|
328
|
+
// real browsers, not Electron apps) the session-cookie persistence pin.
|
|
329
|
+
// Electron apps manage their own storage and don't read Chromium's
|
|
330
|
+
// `session.*` prefs, so they get the name stamp only.
|
|
331
|
+
ensureProfilePreferences(userDataDir, profileName, !isElectron);
|
|
332
|
+
}
|
|
333
|
+
// The owner's store is attached over a TCP port rather than the daemon's
|
|
334
|
+
// private pipe: the instance outlives any one daemon and must stay reachable
|
|
335
|
+
// (and verifiable by the ownership guard) after a daemon restart.
|
|
336
|
+
const transport = ownerStore ? 'port' : 'pipe';
|
|
276
337
|
// Chromium on macOS coordinates instances via the SingletonLock file
|
|
277
338
|
// *inside* each user-data-dir. Direct binary spawn with a fresh
|
|
278
339
|
// --user-data-dir creates a fully independent process — the user's
|
|
@@ -288,8 +349,9 @@ isElectron = false) {
|
|
|
288
349
|
// profile never reaches this launcher (see connectLocal / isAttachOnlyProfile).
|
|
289
350
|
const viewport = options.viewport ?? { width: 1512, height: 982 };
|
|
290
351
|
const args = [
|
|
291
|
-
'--remote-debugging-pipe'
|
|
352
|
+
transport === 'pipe' ? '--remote-debugging-pipe' : `--remote-debugging-port=${port}`,
|
|
292
353
|
`--user-data-dir=${userDataDir}`,
|
|
354
|
+
...(options.profileDirectory ? [`--profile-directory=${options.profileDirectory}`] : []),
|
|
293
355
|
'--disable-background-timer-throttling',
|
|
294
356
|
'--disable-backgrounding-occluded-windows',
|
|
295
357
|
'--disable-renderer-backgrounding',
|
|
@@ -311,7 +373,7 @@ isElectron = false) {
|
|
|
311
373
|
// cookies survive, no ghost tabs — and the task flow creates its own tab
|
|
312
374
|
// over CDP anyway. Electron apps need their window to appear (the CDP
|
|
313
375
|
// driver binds to it), so they skip the flag.
|
|
314
|
-
...(isElectron ? [] : ['--no-startup-window']),
|
|
376
|
+
...(isElectron || ownerStore ? [] : ['--no-startup-window']),
|
|
315
377
|
...(options.headless ? ['--headless=new'] : []),
|
|
316
378
|
`--window-size=${viewport.width},${viewport.height}`,
|
|
317
379
|
...(viewport.x !== undefined && viewport.y !== undefined
|
|
@@ -324,26 +386,69 @@ isElectron = false) {
|
|
|
324
386
|
const env = { ...process.env, ...(await resolveProfileSecretsEnv(secrets)) };
|
|
325
387
|
const child = spawn(browserPath, args, {
|
|
326
388
|
detached: true,
|
|
327
|
-
stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'],
|
|
389
|
+
stdio: transport === 'pipe' ? ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'],
|
|
328
390
|
env,
|
|
329
391
|
});
|
|
330
392
|
child.unref();
|
|
331
393
|
child.stdout?.resume();
|
|
332
394
|
child.stderr?.resume();
|
|
333
395
|
const pid = child.pid;
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
396
|
+
let wsUrl;
|
|
397
|
+
if (transport === 'pipe') {
|
|
398
|
+
const writePipe = child.stdio[3];
|
|
399
|
+
const readPipe = child.stdio[4];
|
|
400
|
+
if (!writePipe || !readPipe) {
|
|
401
|
+
throw new Error('Chrome failed to expose CDP pipe file descriptors');
|
|
402
|
+
}
|
|
403
|
+
wsUrl = registerPipeTransport({ read: readPipe, write: writePipe });
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
wsUrl = (await waitForDevToolsPort(port, profileName, child, { browserType, userDataDir, profileDirectory: options.profileDirectory })).wsUrl;
|
|
338
407
|
}
|
|
339
|
-
const wsUrl = registerPipeTransport({ read: readPipe, write: writePipe });
|
|
340
408
|
writeProfileRuntime(profileName, {
|
|
341
409
|
pid,
|
|
342
410
|
command: path.basename(browserPath),
|
|
343
411
|
userDataDir,
|
|
344
412
|
kind: isElectron ? 'electron' : 'browser',
|
|
345
413
|
});
|
|
346
|
-
return { pid, port: 0, wsUrl };
|
|
414
|
+
return { pid, port: transport === 'pipe' ? 0 : port, wsUrl };
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Poll the DevTools endpoint of a browser just launched with
|
|
418
|
+
* `--remote-debugging-port` until it answers, or fail loud naming the pid.
|
|
419
|
+
* Chromium binds the port only after its profile has loaded, which on a real
|
|
420
|
+
* signed-in store takes a few seconds. A child that exits first never bound
|
|
421
|
+
* it: on an owner's store that is the singleton hand-off to a browser already
|
|
422
|
+
* open there (PHNX-4042), so the failure names that instance's relaunch
|
|
423
|
+
* instead of waiting out the deadline.
|
|
424
|
+
*/
|
|
425
|
+
async function waitForDevToolsPort(port, profileName, child, store, timeoutMs = 20_000) {
|
|
426
|
+
const pid = child.pid;
|
|
427
|
+
let exited;
|
|
428
|
+
child.once('exit', (code, signal) => {
|
|
429
|
+
exited = code !== null ? `code ${code}` : `signal ${signal}`;
|
|
430
|
+
});
|
|
431
|
+
const deadline = Date.now() + timeoutMs;
|
|
432
|
+
let lastError = '';
|
|
433
|
+
while (Date.now() < deadline) {
|
|
434
|
+
if (exited) {
|
|
435
|
+
const app = store.browserType === 'comet' ? 'Comet' : store.browserType;
|
|
436
|
+
throw new Error(`${app} (pid ${pid}) exited (${exited}) before serving the DevTools protocol on port ${port} ` +
|
|
437
|
+
`for profile "${profileName}". A ${app} already open on ${store.userDataDir} takes a new launch ` +
|
|
438
|
+
`as an argument hand-off and never binds the port; quit it, then relaunch it with remote debugging:\n` +
|
|
439
|
+
` ${storeRelaunchCommand(store.browserType, port, store.userDataDir, store.profileDirectory)}\n` +
|
|
440
|
+
`and retry. If none is open, the browser crashed on start.`);
|
|
441
|
+
}
|
|
442
|
+
try {
|
|
443
|
+
return await discoverBrowserWsUrl(port, 'localhost', profileName);
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
447
|
+
}
|
|
448
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
449
|
+
}
|
|
450
|
+
throw new Error(`Browser for profile "${profileName}" (pid ${pid}) did not serve the DevTools protocol on ` +
|
|
451
|
+
`port ${port} within ${Math.round(timeoutMs / 1000)}s: ${lastError}`);
|
|
347
452
|
}
|
|
348
453
|
export async function attachToChrome(port) {
|
|
349
454
|
const { wsUrl } = await discoverBrowserWsUrl(port);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { BrowserType } from './types.js';
|
|
2
|
+
export interface ChromiumNativeProfile {
|
|
3
|
+
browser: BrowserType;
|
|
4
|
+
/** The agents-cli profile name: `<browser>-<display-name-slug>`. */
|
|
5
|
+
name: string;
|
|
6
|
+
/** The browser's user-data dir (its own, never a cache dir). */
|
|
7
|
+
userDataDir: string;
|
|
8
|
+
/** Profile directory basename inside the user-data dir. Authoritative id. */
|
|
9
|
+
profileDirectory: string;
|
|
10
|
+
/** Display-only name from Local State. */
|
|
11
|
+
displayName: string;
|
|
12
|
+
}
|
|
13
|
+
export type ChromiumDiscoveryResult = {
|
|
14
|
+
ok: true;
|
|
15
|
+
profiles: ChromiumNativeProfile[];
|
|
16
|
+
userDataDir: string;
|
|
17
|
+
} | {
|
|
18
|
+
ok: false;
|
|
19
|
+
kind: 'unsupported' | 'not-installed' | 'invalid';
|
|
20
|
+
reason: string;
|
|
21
|
+
};
|
|
22
|
+
/** The browser's own user-data dir on this platform, or undefined where it does not ship. */
|
|
23
|
+
export declare function chromiumUserDataDir(browser: BrowserType): string | undefined;
|
|
24
|
+
export declare function discoverChromiumProfilesAt(browser: BrowserType, userDataDir: string): ChromiumDiscoveryResult;
|
|
25
|
+
export declare function discoverChromiumProfiles(browser: BrowserType): ChromiumDiscoveryResult;
|
|
26
|
+
/** Every browser whose native profiles agents-cli discovers on this platform. */
|
|
27
|
+
export declare function discoverableChromiumBrowsers(): BrowserType[];
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only discovery of a Chromium-family browser's OWN profiles (PHNX-4042).
|
|
3
|
+
*
|
|
4
|
+
* Chromium keeps its profiles under one user-data dir: `Local State` lists them
|
|
5
|
+
* in `profile.info_cache` keyed by directory (`Default`, `Profile 1`, ...) with
|
|
6
|
+
* the display name the user sees in the profile menu. Each entry becomes one
|
|
7
|
+
* agents-cli profile (`comet-work`, ...) pinned to that dir and directory, so
|
|
8
|
+
* the owner and agents share ONE window per profile instead of agents spawning
|
|
9
|
+
* a rival instance under a cache dir. Nothing here writes to the browser's
|
|
10
|
+
* files.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from 'node:fs';
|
|
13
|
+
import * as os from 'node:os';
|
|
14
|
+
import * as path from 'node:path';
|
|
15
|
+
/** Browsers whose native profiles are discovered, and the env var that points tests at another user-data dir. */
|
|
16
|
+
const NATIVE_CHROMIUM = {
|
|
17
|
+
comet: { dirName: 'Comet', envOverride: 'AGENTS_COMET_DIR' },
|
|
18
|
+
};
|
|
19
|
+
/** The browser's own user-data dir on this platform, or undefined where it does not ship. */
|
|
20
|
+
export function chromiumUserDataDir(browser) {
|
|
21
|
+
const entry = NATIVE_CHROMIUM[browser];
|
|
22
|
+
if (!entry)
|
|
23
|
+
return undefined;
|
|
24
|
+
const override = process.env[entry.envOverride];
|
|
25
|
+
if (override)
|
|
26
|
+
return path.resolve(override);
|
|
27
|
+
if (process.platform === 'darwin') {
|
|
28
|
+
return path.join(os.homedir(), 'Library', 'Application Support', entry.dirName);
|
|
29
|
+
}
|
|
30
|
+
if (process.platform === 'win32') {
|
|
31
|
+
const local = process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local');
|
|
32
|
+
return path.join(local, entry.dirName, 'User Data');
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
function isRecord(value) {
|
|
37
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
38
|
+
}
|
|
39
|
+
function slugify(value) {
|
|
40
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
41
|
+
}
|
|
42
|
+
export function discoverChromiumProfilesAt(browser, userDataDir) {
|
|
43
|
+
const localState = path.join(userDataDir, 'Local State');
|
|
44
|
+
if (!fs.existsSync(localState)) {
|
|
45
|
+
return { ok: false, kind: 'not-installed', reason: `${browser} Local State not found: ${localState}` };
|
|
46
|
+
}
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(fs.readFileSync(localState, 'utf8'));
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
kind: 'invalid',
|
|
55
|
+
reason: error instanceof SyntaxError
|
|
56
|
+
? `${browser} Local State is not valid JSON`
|
|
57
|
+
: `Cannot read ${browser} Local State: ${error instanceof Error ? error.message : String(error)}`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (!isRecord(parsed) || !isRecord(parsed.profile) || !isRecord(parsed.profile.info_cache)) {
|
|
61
|
+
return { ok: false, kind: 'invalid', reason: `${browser} Local State has no profile.info_cache map` };
|
|
62
|
+
}
|
|
63
|
+
const rows = [];
|
|
64
|
+
for (const [profileDirectory, value] of Object.entries(parsed.profile.info_cache)) {
|
|
65
|
+
if (!isRecord(value) || typeof value.name !== 'string' || value.name.trim() === '') {
|
|
66
|
+
return { ok: false, kind: 'invalid', reason: `${browser} Local State profile ${JSON.stringify(profileDirectory)} has no non-empty name` };
|
|
67
|
+
}
|
|
68
|
+
rows.push({
|
|
69
|
+
browser,
|
|
70
|
+
name: `${browser}-${slugify(value.name) || 'profile'}`,
|
|
71
|
+
userDataDir,
|
|
72
|
+
profileDirectory,
|
|
73
|
+
displayName: value.name,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
// Two profiles with the same display name stay distinct by their directory.
|
|
77
|
+
const counts = new Map();
|
|
78
|
+
for (const row of rows)
|
|
79
|
+
counts.set(row.name, (counts.get(row.name) ?? 0) + 1);
|
|
80
|
+
return {
|
|
81
|
+
ok: true,
|
|
82
|
+
userDataDir,
|
|
83
|
+
profiles: rows.map((row) => (counts.get(row.name) ?? 0) > 1 ? { ...row, name: `${row.name}-${slugify(row.profileDirectory)}` } : row),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export function discoverChromiumProfiles(browser) {
|
|
87
|
+
const userDataDir = chromiumUserDataDir(browser);
|
|
88
|
+
if (!userDataDir) {
|
|
89
|
+
return { ok: false, kind: 'unsupported', reason: `${browser} profiles are not discovered on this platform` };
|
|
90
|
+
}
|
|
91
|
+
return discoverChromiumProfilesAt(browser, userDataDir);
|
|
92
|
+
}
|
|
93
|
+
/** Every browser whose native profiles agents-cli discovers on this platform. */
|
|
94
|
+
export function discoverableChromiumBrowsers() {
|
|
95
|
+
return Object.keys(NATIVE_CHROMIUM).filter((browser) => chromiumUserDataDir(browser) !== undefined);
|
|
96
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export interface ArcNativeTabRef {
|
|
2
|
+
windowId: string;
|
|
3
|
+
spaceId: string;
|
|
4
|
+
tabId: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ArcNativeTab extends ArcNativeTabRef {
|
|
7
|
+
url: string;
|
|
8
|
+
title: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ArcEnumeratedSpace {
|
|
11
|
+
windowId: string;
|
|
12
|
+
spaceId: string;
|
|
13
|
+
spaceTitle: string;
|
|
14
|
+
activeTabId?: string;
|
|
15
|
+
tabs: ArcNativeTab[];
|
|
16
|
+
}
|
|
17
|
+
export declare const ARC_NATIVE_CAPABILITIES: Readonly<{
|
|
18
|
+
readonly createTab: true;
|
|
19
|
+
readonly navigate: true;
|
|
20
|
+
readonly evaluateSync: true;
|
|
21
|
+
readonly closeTab: true;
|
|
22
|
+
readonly enumerate: true;
|
|
23
|
+
readonly screenshot: false;
|
|
24
|
+
readonly asyncEvaluate: false;
|
|
25
|
+
readonly networkCapture: false;
|
|
26
|
+
readonly consoleCapture: false;
|
|
27
|
+
readonly upload: false;
|
|
28
|
+
readonly pdf: false;
|
|
29
|
+
readonly background: false;
|
|
30
|
+
}>;
|
|
31
|
+
export declare class ArcNativeCapabilityError extends Error {
|
|
32
|
+
readonly capability: string;
|
|
33
|
+
constructor(capability: string, message?: string);
|
|
34
|
+
}
|
|
35
|
+
/** AppleScript strings do not implement JSON's \uXXXX escape syntax. */
|
|
36
|
+
export declare function escapeAppleScriptString(value: string): string;
|
|
37
|
+
/** No shell, bounded output and lifetime, and no blocking of the shared daemon. */
|
|
38
|
+
export declare function execAppleScript(source: string, timeoutMs?: number): Promise<string>;
|
|
39
|
+
export declare function isArcRunning(): Promise<boolean>;
|
|
40
|
+
export declare function enumerateArcSpaces(): Promise<ArcEnumeratedSpace[]>;
|
|
41
|
+
/** Caller durably records marker intent before calling; never use a real page URL as ownership. */
|
|
42
|
+
export declare function createArcTab(target: Pick<ArcNativeTabRef, 'windowId' | 'spaceId'>, markerUrl: string): Promise<ArcNativeTab>;
|
|
43
|
+
export declare function resolveArcTab(ref: ArcNativeTabRef): Promise<ArcNativeTab | null>;
|
|
44
|
+
export declare function navigateArcTab(ref: ArcNativeTabRef, url: string): Promise<void>;
|
|
45
|
+
/** Arc serializes a returned object once. JSON.stringify at the top level would double-encode. */
|
|
46
|
+
export declare function executeJavaScript(ref: ArcNativeTabRef, expression: string): Promise<unknown>;
|
|
47
|
+
export declare function closeArcTab(ref: ArcNativeTabRef): Promise<'closed' | 'missing'>;
|
|
48
|
+
/** Explicit user-facing focus only. Evaluation and cleanup never invoke this. */
|
|
49
|
+
export declare function selectArcTab(ref: ArcNativeTabRef): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* Select `tabId` wherever it lives in window `windowId` (any Space). Used to put
|
|
52
|
+
* the owner back on their tab after a creation that failed mid-way, when there is
|
|
53
|
+
* no owned tab to check against. Returns false when the tab is gone.
|
|
54
|
+
*/
|
|
55
|
+
export declare function selectWindowTab(windowId: string, tabId: string): Promise<boolean>;
|
|
56
|
+
/** Restore only while the task's new tab is still selected; preserve a later human choice. */
|
|
57
|
+
export declare function restoreArcSelection(owned: ArcNativeTabRef, previousTabId: string): Promise<boolean>;
|