@jackwener/opencli 1.5.8 → 1.6.0
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 +42 -0
- package/README.md +35 -1
- package/README.zh-CN.md +17 -1
- package/SKILL.md +31 -851
- package/autoresearch/baseline-browse.txt +1 -0
- package/autoresearch/baseline-skill.txt +1 -0
- package/autoresearch/browse-tasks.json +688 -0
- package/autoresearch/eval-browse.ts +185 -0
- package/autoresearch/eval-skill.ts +248 -0
- package/autoresearch/run-browse.sh +9 -0
- package/autoresearch/run-skill.sh +9 -0
- package/dist/browser/base-page.d.ts +48 -0
- package/dist/browser/base-page.js +160 -0
- package/dist/browser/cdp.js +4 -106
- package/dist/browser/daemon-client.d.ts +20 -7
- package/dist/browser/daemon-client.js +39 -39
- package/dist/browser/daemon-client.test.js +77 -0
- package/dist/browser/discover.d.ts +1 -4
- package/dist/browser/discover.js +9 -23
- package/dist/browser/errors.d.ts +4 -0
- package/dist/browser/errors.js +20 -0
- package/dist/browser/index.d.ts +1 -1
- package/dist/browser/index.js +1 -1
- package/dist/browser/page.d.ts +10 -35
- package/dist/browser/page.js +55 -187
- package/dist/browser/tabs.js +5 -5
- package/dist/browser.test.js +15 -15
- package/dist/cli-manifest.json +294 -22
- package/dist/cli.js +392 -0
- package/dist/clis/amazon/bestsellers.d.ts +21 -0
- package/dist/clis/amazon/bestsellers.js +130 -0
- package/dist/clis/amazon/bestsellers.test.js +20 -0
- package/dist/clis/amazon/discussion.d.ts +20 -0
- package/dist/clis/amazon/discussion.js +91 -0
- package/dist/clis/amazon/discussion.test.d.ts +1 -0
- package/dist/clis/amazon/discussion.test.js +36 -0
- package/dist/clis/amazon/offer.d.ts +23 -0
- package/dist/clis/amazon/offer.js +140 -0
- package/dist/clis/amazon/offer.test.d.ts +1 -0
- package/dist/clis/amazon/offer.test.js +29 -0
- package/dist/clis/amazon/product.d.ts +18 -0
- package/dist/clis/amazon/product.js +92 -0
- package/dist/clis/amazon/product.test.d.ts +1 -0
- package/dist/clis/amazon/product.test.js +24 -0
- package/dist/clis/amazon/search.d.ts +18 -0
- package/dist/clis/amazon/search.js +87 -0
- package/dist/clis/amazon/search.test.d.ts +1 -0
- package/dist/clis/amazon/search.test.js +22 -0
- package/dist/clis/amazon/shared.d.ts +64 -0
- package/dist/clis/amazon/shared.js +255 -0
- package/dist/clis/amazon/shared.test.d.ts +1 -0
- package/dist/clis/amazon/shared.test.js +33 -0
- package/dist/clis/gemini/ask.d.ts +1 -0
- package/dist/clis/gemini/ask.js +40 -0
- package/dist/clis/gemini/image.d.ts +1 -0
- package/dist/clis/gemini/image.js +105 -0
- package/dist/clis/gemini/new.d.ts +1 -0
- package/dist/clis/gemini/new.js +20 -0
- package/dist/clis/gemini/utils.d.ts +34 -0
- package/dist/clis/gemini/utils.js +463 -0
- package/dist/clis/gemini/utils.test.d.ts +1 -0
- package/dist/clis/gemini/utils.test.js +31 -0
- package/dist/clis/notebooklm/compat.test.d.ts +1 -1
- package/dist/clis/notebooklm/compat.test.js +3 -3
- package/dist/clis/notebooklm/current.js +2 -3
- package/dist/clis/notebooklm/get.js +2 -3
- package/dist/clis/notebooklm/history.js +2 -3
- package/dist/clis/notebooklm/note-list.js +2 -3
- package/dist/clis/notebooklm/notes-get.js +2 -3
- package/dist/clis/notebooklm/open.d.ts +1 -0
- package/dist/clis/notebooklm/open.js +41 -0
- package/dist/clis/notebooklm/open.test.d.ts +1 -0
- package/dist/clis/notebooklm/open.test.js +63 -0
- package/dist/clis/notebooklm/source-fulltext.js +2 -3
- package/dist/clis/notebooklm/source-get.js +2 -3
- package/dist/clis/notebooklm/source-guide.js +2 -3
- package/dist/clis/notebooklm/source-list.js +2 -3
- package/dist/clis/notebooklm/status.js +1 -2
- package/dist/clis/notebooklm/summary.js +2 -3
- package/dist/clis/notebooklm/utils.d.ts +2 -1
- package/dist/clis/notebooklm/utils.js +20 -21
- package/dist/clis/twitter/article.js +28 -1
- package/dist/clis/xiaohongshu/creator-note-detail.test.js +11 -11
- package/dist/clis/xiaohongshu/creator-notes-summary.test.js +6 -6
- package/dist/clis/xiaohongshu/creator-notes.test.js +22 -22
- package/dist/clis/xiaohongshu/note.js +11 -0
- package/dist/clis/xiaohongshu/note.test.js +49 -0
- package/dist/commanderAdapter.js +7 -4
- package/dist/commanderAdapter.test.js +76 -0
- package/dist/commands/daemon.js +8 -47
- package/dist/commands/daemon.test.js +45 -70
- package/dist/discovery.js +27 -0
- package/dist/doctor.d.ts +1 -2
- package/dist/doctor.js +7 -8
- package/dist/explore.js +1 -1
- package/dist/output.js +28 -0
- package/dist/output.test.js +15 -0
- package/dist/pipeline/executor.js +2 -7
- package/dist/pipeline/steps/browser.js +1 -1
- package/dist/pipeline/template.js +25 -3
- package/dist/record.d.ts +50 -0
- package/dist/record.js +298 -57
- package/dist/record.test.d.ts +1 -0
- package/dist/record.test.js +293 -0
- package/dist/registry.d.ts +2 -0
- package/dist/registry.js +1 -0
- package/dist/registry.test.js +10 -0
- package/dist/runtime.js +3 -3
- package/dist/snapshotFormatter.d.ts +1 -1
- package/dist/snapshotFormatter.js +4 -4
- package/dist/snapshotFormatter.test.d.ts +1 -1
- package/dist/snapshotFormatter.test.js +2 -2
- package/dist/types.d.ts +11 -1
- package/dist/types.js +1 -1
- package/docs/.vitepress/config.mts +2 -0
- package/docs/adapters/browser/amazon.md +53 -0
- package/docs/adapters/browser/gemini.md +72 -0
- package/docs/adapters/browser/notebooklm.md +5 -5
- package/docs/adapters/index.md +3 -1
- package/docs/guide/getting-started.md +21 -0
- package/docs/superpowers/specs/2026-04-02-browse-skill-testing-design.md +144 -0
- package/docs/zh/guide/getting-started.md +21 -0
- package/extension/package-lock.json +2 -2
- package/extension/src/background.test.ts +7 -163
- package/extension/src/background.ts +58 -161
- package/extension/src/cdp.ts +77 -124
- package/extension/src/protocol.ts +5 -5
- package/package.json +1 -1
- package/skills/opencli-explorer/SKILL.md +853 -0
- package/skills/opencli-oneshot/SKILL.md +222 -0
- package/skills/opencli-operate/SKILL.md +213 -0
- package/skills/opencli-usage/SKILL.md +152 -0
- package/skills/opencli-usage/browser.md +429 -0
- package/skills/opencli-usage/desktop.md +118 -0
- package/skills/opencli-usage/plugins.md +82 -0
- package/skills/opencli-usage/public-api.md +149 -0
- package/src/browser/base-page.ts +197 -0
- package/src/browser/cdp.ts +7 -131
- package/src/browser/daemon-client.test.ts +103 -0
- package/src/browser/daemon-client.ts +55 -43
- package/src/browser/discover.ts +9 -21
- package/src/browser/errors.ts +22 -0
- package/src/browser/index.ts +1 -1
- package/src/browser/page.ts +57 -209
- package/src/browser/tabs.ts +5 -5
- package/src/browser.test.ts +15 -15
- package/src/cli.ts +392 -0
- package/src/clis/amazon/bestsellers.test.ts +22 -0
- package/src/clis/amazon/bestsellers.ts +180 -0
- package/src/clis/amazon/discussion.test.ts +38 -0
- package/src/clis/amazon/discussion.ts +131 -0
- package/src/clis/amazon/offer.test.ts +35 -0
- package/src/clis/amazon/offer.ts +185 -0
- package/src/clis/amazon/product.test.ts +26 -0
- package/src/clis/amazon/product.ts +131 -0
- package/src/clis/amazon/search.test.ts +24 -0
- package/src/clis/amazon/search.ts +128 -0
- package/src/clis/amazon/shared.test.ts +37 -0
- package/src/clis/amazon/shared.ts +316 -0
- package/src/clis/gemini/ask.ts +46 -0
- package/src/clis/gemini/image.ts +115 -0
- package/src/clis/gemini/new.ts +22 -0
- package/src/clis/gemini/utils.test.ts +36 -0
- package/src/clis/gemini/utils.ts +523 -0
- package/src/clis/notebooklm/compat.test.ts +3 -3
- package/src/clis/notebooklm/current.ts +2 -3
- package/src/clis/notebooklm/get.ts +1 -3
- package/src/clis/notebooklm/history.ts +1 -3
- package/src/clis/notebooklm/note-list.ts +1 -3
- package/src/clis/notebooklm/notes-get.ts +1 -3
- package/src/clis/notebooklm/open.test.ts +78 -0
- package/src/clis/notebooklm/open.ts +61 -0
- package/src/clis/notebooklm/source-fulltext.ts +1 -3
- package/src/clis/notebooklm/source-get.ts +1 -3
- package/src/clis/notebooklm/source-guide.ts +1 -3
- package/src/clis/notebooklm/source-list.ts +1 -3
- package/src/clis/notebooklm/status.ts +1 -2
- package/src/clis/notebooklm/summary.ts +1 -3
- package/src/clis/notebooklm/utils.ts +29 -20
- package/src/clis/twitter/article.ts +31 -1
- package/src/clis/xiaohongshu/creator-note-detail.test.ts +11 -11
- package/src/clis/xiaohongshu/creator-notes-summary.test.ts +6 -6
- package/src/clis/xiaohongshu/creator-notes.test.ts +22 -22
- package/src/clis/xiaohongshu/note.test.ts +51 -0
- package/src/clis/xiaohongshu/note.ts +18 -0
- package/src/commanderAdapter.test.ts +109 -0
- package/src/commanderAdapter.ts +8 -4
- package/src/commands/daemon.test.ts +50 -84
- package/src/commands/daemon.ts +8 -56
- package/src/discovery.ts +22 -0
- package/src/doctor.ts +8 -9
- package/src/explore.ts +1 -1
- package/src/output.test.ts +17 -0
- package/src/output.ts +27 -0
- package/src/pipeline/executor.ts +2 -7
- package/src/pipeline/steps/browser.ts +1 -1
- package/src/pipeline/template.ts +27 -4
- package/src/record.test.ts +362 -0
- package/src/record.ts +341 -62
- package/src/registry.test.ts +12 -0
- package/src/registry.ts +3 -0
- package/src/runtime.ts +3 -3
- package/src/snapshotFormatter.test.ts +2 -2
- package/src/snapshotFormatter.ts +4 -4
- package/src/types.ts +11 -1
- package/.agents/skills/cross-project-adapter-migration/SKILL.md +0 -249
- package/.agents/workflows/cross-project-adapter-migration.md +0 -54
- package/dist/clis/notebooklm/bind-current.js +0 -29
- package/dist/clis/notebooklm/bind-current.test.d.ts +0 -1
- package/dist/clis/notebooklm/bind-current.test.js +0 -35
- package/dist/clis/notebooklm/binding.test.js +0 -44
- package/extension/dist/background.js +0 -819
- package/src/clis/notebooklm/bind-current.test.ts +0 -43
- package/src/clis/notebooklm/bind-current.ts +0 -36
- package/src/clis/notebooklm/binding.test.ts +0 -53
- /package/dist/browser/{mcp.d.ts → bridge.d.ts} +0 -0
- /package/dist/browser/{mcp.js → bridge.js} +0 -0
- /package/dist/{clis/notebooklm/bind-current.d.ts → browser/daemon-client.test.d.ts} +0 -0
- /package/dist/clis/{notebooklm/binding.test.d.ts → amazon/bestsellers.test.d.ts} +0 -0
- /package/src/browser/{mcp.ts → bridge.ts} +0 -0
|
@@ -117,8 +117,6 @@ type AutomationSession = {
|
|
|
117
117
|
windowId: number;
|
|
118
118
|
idleTimer: ReturnType<typeof setTimeout> | null;
|
|
119
119
|
idleDeadlineAt: number;
|
|
120
|
-
owned: boolean;
|
|
121
|
-
preferredTabId: number | null;
|
|
122
120
|
};
|
|
123
121
|
|
|
124
122
|
const automationSessions = new Map<string, AutomationSession>();
|
|
@@ -136,11 +134,6 @@ function resetWindowIdleTimer(workspace: string): void {
|
|
|
136
134
|
session.idleTimer = setTimeout(async () => {
|
|
137
135
|
const current = automationSessions.get(workspace);
|
|
138
136
|
if (!current) return;
|
|
139
|
-
if (!current.owned) {
|
|
140
|
-
console.log(`[opencli] Borrowed workspace ${workspace} detached from window ${current.windowId} (idle timeout)`);
|
|
141
|
-
automationSessions.delete(workspace);
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
137
|
try {
|
|
145
138
|
await chrome.windows.remove(current.windowId);
|
|
146
139
|
console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`);
|
|
@@ -180,8 +173,6 @@ async function getAutomationWindow(workspace: string): Promise<number> {
|
|
|
180
173
|
windowId: win.id!,
|
|
181
174
|
idleTimer: null,
|
|
182
175
|
idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT,
|
|
183
|
-
owned: true,
|
|
184
|
-
preferredTabId: null,
|
|
185
176
|
};
|
|
186
177
|
automationSessions.set(workspace, session);
|
|
187
178
|
console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`);
|
|
@@ -259,12 +250,12 @@ async function handleCommand(cmd: Command): Promise<Result> {
|
|
|
259
250
|
return await handleScreenshot(cmd, workspace);
|
|
260
251
|
case 'close-window':
|
|
261
252
|
return await handleCloseWindow(cmd, workspace);
|
|
253
|
+
case 'cdp':
|
|
254
|
+
return await handleCdp(cmd, workspace);
|
|
262
255
|
case 'sessions':
|
|
263
256
|
return await handleSessions(cmd);
|
|
264
257
|
case 'set-file-input':
|
|
265
258
|
return await handleSetFileInput(cmd, workspace);
|
|
266
|
-
case 'bind-current':
|
|
267
|
-
return await handleBindCurrent(cmd, workspace);
|
|
268
259
|
default:
|
|
269
260
|
return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
|
|
270
261
|
}
|
|
@@ -280,12 +271,12 @@ async function handleCommand(cmd: Command): Promise<Result> {
|
|
|
280
271
|
// ─── Action handlers ─────────────────────────────────────────────────
|
|
281
272
|
|
|
282
273
|
/** Internal blank page used when no user URL is provided. */
|
|
283
|
-
const BLANK_PAGE = '
|
|
274
|
+
const BLANK_PAGE = 'about:blank';
|
|
284
275
|
|
|
285
|
-
/** Check if a URL can be attached via CDP — only allow http(s) and
|
|
276
|
+
/** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */
|
|
286
277
|
function isDebuggableUrl(url?: string): boolean {
|
|
287
278
|
if (!url) return true; // empty/undefined = tab still loading, allow it
|
|
288
|
-
return url.startsWith('http://') || url.startsWith('https://') || url ===
|
|
279
|
+
return url.startsWith('http://') || url.startsWith('https://') || url === 'about:blank' || url.startsWith('data:');
|
|
289
280
|
}
|
|
290
281
|
|
|
291
282
|
/** Check if a URL is safe for user-facing navigation (http/https only). */
|
|
@@ -312,57 +303,7 @@ function isTargetUrl(currentUrl: string | undefined, targetUrl: string): boolean
|
|
|
312
303
|
return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl);
|
|
313
304
|
}
|
|
314
305
|
|
|
315
|
-
function
|
|
316
|
-
if (!url) return false;
|
|
317
|
-
try {
|
|
318
|
-
const parsed = new URL(url);
|
|
319
|
-
return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`);
|
|
320
|
-
} catch {
|
|
321
|
-
return false;
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function matchesBindCriteria(tab: chrome.tabs.Tab, cmd: Command): boolean {
|
|
326
|
-
if (!tab.id || !isDebuggableUrl(tab.url)) return false;
|
|
327
|
-
if (cmd.matchDomain && !matchesDomain(tab.url, cmd.matchDomain)) return false;
|
|
328
|
-
if (cmd.matchPathPrefix) {
|
|
329
|
-
try {
|
|
330
|
-
const parsed = new URL(tab.url!);
|
|
331
|
-
if (!parsed.pathname.startsWith(cmd.matchPathPrefix)) return false;
|
|
332
|
-
} catch {
|
|
333
|
-
return false;
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
return true;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
function isNotebooklmWorkspace(workspace: string): boolean {
|
|
340
|
-
return workspace === 'site:notebooklm';
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function classifyNotebooklmUrl(url?: string): 'notebook' | 'home' | 'other' {
|
|
344
|
-
if (!url) return 'other';
|
|
345
|
-
try {
|
|
346
|
-
const parsed = new URL(url);
|
|
347
|
-
if (parsed.hostname !== 'notebooklm.google.com') return 'other';
|
|
348
|
-
return parsed.pathname.startsWith('/notebook/') ? 'notebook' : 'home';
|
|
349
|
-
} catch {
|
|
350
|
-
return 'other';
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
function scoreWorkspaceTab(workspace: string, tab: chrome.tabs.Tab): number {
|
|
355
|
-
if (!tab.id || !isDebuggableUrl(tab.url)) return -1;
|
|
356
|
-
if (isNotebooklmWorkspace(workspace)) {
|
|
357
|
-
const kind = classifyNotebooklmUrl(tab.url);
|
|
358
|
-
if (kind === 'other') return -1;
|
|
359
|
-
if (kind === 'notebook') return tab.active ? 400 : 300;
|
|
360
|
-
return tab.active ? 200 : 100;
|
|
361
|
-
}
|
|
362
|
-
return -1;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function setWorkspaceSession(workspace: string, session: Omit<AutomationSession, 'idleTimer' | 'idleDeadlineAt'>): void {
|
|
306
|
+
function setWorkspaceSession(workspace: string, session: Pick<AutomationSession, 'windowId'>): void {
|
|
366
307
|
const existing = automationSessions.get(workspace);
|
|
367
308
|
if (existing?.idleTimer) clearTimeout(existing.idleTimer);
|
|
368
309
|
automationSessions.set(workspace, {
|
|
@@ -372,29 +313,6 @@ function setWorkspaceSession(workspace: string, session: Omit<AutomationSession,
|
|
|
372
313
|
});
|
|
373
314
|
}
|
|
374
315
|
|
|
375
|
-
async function maybeBindWorkspaceToExistingTab(workspace: string): Promise<number | null> {
|
|
376
|
-
if (!isNotebooklmWorkspace(workspace)) return null;
|
|
377
|
-
const tabs = await chrome.tabs.query({});
|
|
378
|
-
let bestTab: chrome.tabs.Tab | null = null;
|
|
379
|
-
let bestScore = -1;
|
|
380
|
-
for (const tab of tabs) {
|
|
381
|
-
const score = scoreWorkspaceTab(workspace, tab);
|
|
382
|
-
if (score > bestScore) {
|
|
383
|
-
bestScore = score;
|
|
384
|
-
bestTab = tab;
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
if (!bestTab?.id || bestScore < 0) return null;
|
|
388
|
-
setWorkspaceSession(workspace, {
|
|
389
|
-
windowId: bestTab.windowId,
|
|
390
|
-
owned: false,
|
|
391
|
-
preferredTabId: bestTab.id,
|
|
392
|
-
});
|
|
393
|
-
console.log(`[opencli] Workspace ${workspace} bound to existing tab ${bestTab.id} in window ${bestTab.windowId}`);
|
|
394
|
-
resetWindowIdleTimer(workspace);
|
|
395
|
-
return bestTab.id;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
316
|
/**
|
|
399
317
|
* Resolve target tab in the automation window.
|
|
400
318
|
* If explicit tabId is given, use that directly.
|
|
@@ -408,9 +326,7 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi
|
|
|
408
326
|
try {
|
|
409
327
|
const tab = await chrome.tabs.get(tabId);
|
|
410
328
|
const session = automationSessions.get(workspace);
|
|
411
|
-
const matchesSession = session
|
|
412
|
-
? (session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId)
|
|
413
|
-
: false;
|
|
329
|
+
const matchesSession = session ? tab.windowId === session.windowId : false;
|
|
414
330
|
if (isDebuggableUrl(tab.url) && matchesSession) return tabId;
|
|
415
331
|
if (session && !matchesSession) {
|
|
416
332
|
console.warn(`[opencli] Tab ${tabId} is not bound to workspace ${workspace}, re-resolving`);
|
|
@@ -424,20 +340,6 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi
|
|
|
424
340
|
}
|
|
425
341
|
}
|
|
426
342
|
|
|
427
|
-
const adoptedTabId = await maybeBindWorkspaceToExistingTab(workspace);
|
|
428
|
-
if (adoptedTabId !== null) return adoptedTabId;
|
|
429
|
-
|
|
430
|
-
const existingSession = automationSessions.get(workspace);
|
|
431
|
-
if (existingSession && existingSession.preferredTabId !== null) {
|
|
432
|
-
try {
|
|
433
|
-
const preferredTabId = existingSession.preferredTabId;
|
|
434
|
-
const preferredTab = await chrome.tabs.get(preferredTabId);
|
|
435
|
-
if (isDebuggableUrl(preferredTab.url)) return preferredTab.id!;
|
|
436
|
-
} catch {
|
|
437
|
-
automationSessions.delete(workspace);
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
343
|
// Get (or create) the automation window
|
|
442
344
|
const windowId = await getAutomationWindow(workspace);
|
|
443
345
|
|
|
@@ -470,14 +372,6 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi
|
|
|
470
372
|
async function listAutomationTabs(workspace: string): Promise<chrome.tabs.Tab[]> {
|
|
471
373
|
const session = automationSessions.get(workspace);
|
|
472
374
|
if (!session) return [];
|
|
473
|
-
if (session.preferredTabId !== null) {
|
|
474
|
-
try {
|
|
475
|
-
return [await chrome.tabs.get(session.preferredTabId)];
|
|
476
|
-
} catch {
|
|
477
|
-
automationSessions.delete(workspace);
|
|
478
|
-
return [];
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
375
|
try {
|
|
482
376
|
return await chrome.tabs.query({ windowId: session.windowId });
|
|
483
377
|
} catch {
|
|
@@ -495,7 +389,8 @@ async function handleExec(cmd: Command, workspace: string): Promise<Result> {
|
|
|
495
389
|
if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' };
|
|
496
390
|
const tabId = await resolveTabId(cmd.tabId, workspace);
|
|
497
391
|
try {
|
|
498
|
-
const
|
|
392
|
+
const aggressive = workspace.startsWith('operate:');
|
|
393
|
+
const data = await executor.evaluateAsync(tabId, cmd.code, aggressive);
|
|
499
394
|
return { id: cmd.id, ok: true, data };
|
|
500
395
|
} catch (err) {
|
|
501
396
|
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
@@ -686,15 +581,57 @@ async function handleScreenshot(cmd: Command, workspace: string): Promise<Result
|
|
|
686
581
|
}
|
|
687
582
|
}
|
|
688
583
|
|
|
584
|
+
/** CDP methods permitted via the 'cdp' passthrough action. */
|
|
585
|
+
const CDP_ALLOWLIST = new Set([
|
|
586
|
+
// Agent DOM context
|
|
587
|
+
'Accessibility.getFullAXTree',
|
|
588
|
+
'DOM.getDocument',
|
|
589
|
+
'DOM.getBoxModel',
|
|
590
|
+
'DOM.getContentQuads',
|
|
591
|
+
'DOM.querySelectorAll',
|
|
592
|
+
'DOM.scrollIntoViewIfNeeded',
|
|
593
|
+
'DOMSnapshot.captureSnapshot',
|
|
594
|
+
// Native input events
|
|
595
|
+
'Input.dispatchMouseEvent',
|
|
596
|
+
'Input.dispatchKeyEvent',
|
|
597
|
+
'Input.insertText',
|
|
598
|
+
// Page metrics & screenshots
|
|
599
|
+
'Page.getLayoutMetrics',
|
|
600
|
+
'Page.captureScreenshot',
|
|
601
|
+
// Runtime.enable needed for CDP attach setup (Runtime.evaluate goes through 'exec' action)
|
|
602
|
+
'Runtime.enable',
|
|
603
|
+
// Emulation (used by screenshot full-page)
|
|
604
|
+
'Emulation.setDeviceMetricsOverride',
|
|
605
|
+
'Emulation.clearDeviceMetricsOverride',
|
|
606
|
+
]);
|
|
607
|
+
|
|
608
|
+
async function handleCdp(cmd: Command, workspace: string): Promise<Result> {
|
|
609
|
+
if (!cmd.cdpMethod) return { id: cmd.id, ok: false, error: 'Missing cdpMethod' };
|
|
610
|
+
if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) {
|
|
611
|
+
return { id: cmd.id, ok: false, error: `CDP method not permitted: ${cmd.cdpMethod}` };
|
|
612
|
+
}
|
|
613
|
+
const tabId = await resolveTabId(cmd.tabId, workspace);
|
|
614
|
+
try {
|
|
615
|
+
const aggressive = workspace.startsWith('operate:');
|
|
616
|
+
await executor.ensureAttached(tabId, aggressive);
|
|
617
|
+
const data = await chrome.debugger.sendCommand(
|
|
618
|
+
{ tabId },
|
|
619
|
+
cmd.cdpMethod,
|
|
620
|
+
cmd.cdpParams ?? {},
|
|
621
|
+
);
|
|
622
|
+
return { id: cmd.id, ok: true, data };
|
|
623
|
+
} catch (err) {
|
|
624
|
+
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
689
628
|
async function handleCloseWindow(cmd: Command, workspace: string): Promise<Result> {
|
|
690
629
|
const session = automationSessions.get(workspace);
|
|
691
630
|
if (session) {
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
// Window may already be closed
|
|
697
|
-
}
|
|
631
|
+
try {
|
|
632
|
+
await chrome.windows.remove(session.windowId);
|
|
633
|
+
} catch {
|
|
634
|
+
// Window may already be closed
|
|
698
635
|
}
|
|
699
636
|
if (session.idleTimer) clearTimeout(session.idleTimer);
|
|
700
637
|
automationSessions.delete(workspace);
|
|
@@ -726,49 +663,11 @@ async function handleSessions(cmd: Command): Promise<Result> {
|
|
|
726
663
|
return { id: cmd.id, ok: true, data };
|
|
727
664
|
}
|
|
728
665
|
|
|
729
|
-
async function handleBindCurrent(cmd: Command, workspace: string): Promise<Result> {
|
|
730
|
-
const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
|
|
731
|
-
const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true });
|
|
732
|
-
const allTabs = await chrome.tabs.query({});
|
|
733
|
-
const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd))
|
|
734
|
-
?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd))
|
|
735
|
-
?? allTabs.find((tab) => matchesBindCriteria(tab, cmd));
|
|
736
|
-
if (!boundTab?.id) {
|
|
737
|
-
return {
|
|
738
|
-
id: cmd.id,
|
|
739
|
-
ok: false,
|
|
740
|
-
error: cmd.matchDomain || cmd.matchPathPrefix
|
|
741
|
-
? `No visible tab matching ${cmd.matchDomain ?? 'domain'}${cmd.matchPathPrefix ? ` ${cmd.matchPathPrefix}` : ''}`
|
|
742
|
-
: 'No active debuggable tab found',
|
|
743
|
-
};
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
setWorkspaceSession(workspace, {
|
|
747
|
-
windowId: boundTab.windowId,
|
|
748
|
-
owned: false,
|
|
749
|
-
preferredTabId: boundTab.id,
|
|
750
|
-
});
|
|
751
|
-
resetWindowIdleTimer(workspace);
|
|
752
|
-
console.log(`[opencli] Workspace ${workspace} explicitly bound to tab ${boundTab.id} (${boundTab.url})`);
|
|
753
|
-
return {
|
|
754
|
-
id: cmd.id,
|
|
755
|
-
ok: true,
|
|
756
|
-
data: {
|
|
757
|
-
tabId: boundTab.id,
|
|
758
|
-
windowId: boundTab.windowId,
|
|
759
|
-
url: boundTab.url,
|
|
760
|
-
title: boundTab.title,
|
|
761
|
-
workspace,
|
|
762
|
-
},
|
|
763
|
-
};
|
|
764
|
-
}
|
|
765
|
-
|
|
766
666
|
export const __test__ = {
|
|
767
667
|
handleNavigate,
|
|
768
668
|
isTargetUrl,
|
|
769
669
|
handleTabs,
|
|
770
670
|
handleSessions,
|
|
771
|
-
handleBindCurrent,
|
|
772
671
|
resolveTabId,
|
|
773
672
|
resetWindowIdleTimer,
|
|
774
673
|
getSession: (workspace: string = 'default') => automationSessions.get(workspace) ?? null,
|
|
@@ -782,11 +681,9 @@ export const __test__ = {
|
|
|
782
681
|
}
|
|
783
682
|
setWorkspaceSession(workspace, {
|
|
784
683
|
windowId,
|
|
785
|
-
owned: true,
|
|
786
|
-
preferredTabId: null,
|
|
787
684
|
});
|
|
788
685
|
},
|
|
789
|
-
setSession: (workspace: string, session: { windowId: number
|
|
686
|
+
setSession: (workspace: string, session: { windowId: number }) => {
|
|
790
687
|
setWorkspaceSession(workspace, session);
|
|
791
688
|
},
|
|
792
689
|
};
|
package/extension/src/cdp.ts
CHANGED
|
@@ -8,79 +8,13 @@
|
|
|
8
8
|
|
|
9
9
|
const attached = new Set<number>();
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
const BLANK_PAGE = 'data:text/html,<html></html>';
|
|
13
|
-
const FOREIGN_EXTENSION_URL_PREFIX = 'chrome-extension://';
|
|
14
|
-
const ATTACH_RECOVERY_DELAY_MS = 120;
|
|
15
|
-
|
|
16
|
-
/** Check if a URL can be attached via CDP — only allow http(s) and our internal blank page. */
|
|
11
|
+
/** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */
|
|
17
12
|
function isDebuggableUrl(url?: string): boolean {
|
|
18
13
|
if (!url) return true; // empty/undefined = tab still loading, allow it
|
|
19
|
-
return url.startsWith('http://') || url.startsWith('https://') || url ===
|
|
14
|
+
return url.startsWith('http://') || url.startsWith('https://') || url === 'about:blank' || url.startsWith('data:');
|
|
20
15
|
}
|
|
21
16
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
async function removeForeignExtensionEmbeds(tabId: number): Promise<CleanupResult> {
|
|
25
|
-
const tab = await chrome.tabs.get(tabId);
|
|
26
|
-
if (!tab.url || (!tab.url.startsWith('http://') && !tab.url.startsWith('https://'))) {
|
|
27
|
-
return { removed: 0 };
|
|
28
|
-
}
|
|
29
|
-
if (!chrome.scripting?.executeScript) return { removed: 0 };
|
|
30
|
-
|
|
31
|
-
try {
|
|
32
|
-
const [result] = await chrome.scripting.executeScript({
|
|
33
|
-
target: { tabId },
|
|
34
|
-
args: [`${FOREIGN_EXTENSION_URL_PREFIX}${chrome.runtime.id}/`],
|
|
35
|
-
func: (ownExtensionPrefix: string) => {
|
|
36
|
-
const extensionPrefix = 'chrome-extension://';
|
|
37
|
-
const selectors = ['iframe', 'frame', 'embed', 'object'];
|
|
38
|
-
const visitedRoots = new Set<Document | ShadowRoot>();
|
|
39
|
-
const roots: Array<Document | ShadowRoot> = [document];
|
|
40
|
-
let removed = 0;
|
|
41
|
-
|
|
42
|
-
while (roots.length > 0) {
|
|
43
|
-
const root = roots.pop();
|
|
44
|
-
if (!root || visitedRoots.has(root)) continue;
|
|
45
|
-
visitedRoots.add(root);
|
|
46
|
-
|
|
47
|
-
for (const selector of selectors) {
|
|
48
|
-
const nodes = root.querySelectorAll(selector);
|
|
49
|
-
for (const node of nodes) {
|
|
50
|
-
const src = node.getAttribute('src') || node.getAttribute('data') || '';
|
|
51
|
-
if (!src.startsWith(extensionPrefix) || src.startsWith(ownExtensionPrefix)) continue;
|
|
52
|
-
node.remove();
|
|
53
|
-
removed++;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
|
58
|
-
let current = walker.nextNode();
|
|
59
|
-
while (current) {
|
|
60
|
-
const element = current as Element & { shadowRoot?: ShadowRoot | null };
|
|
61
|
-
if (element.shadowRoot) roots.push(element.shadowRoot);
|
|
62
|
-
current = walker.nextNode();
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return { removed };
|
|
67
|
-
},
|
|
68
|
-
});
|
|
69
|
-
return result?.result ?? { removed: 0 };
|
|
70
|
-
} catch {
|
|
71
|
-
return { removed: 0 };
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function delay(ms: number): Promise<void> {
|
|
76
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async function tryAttach(tabId: number): Promise<void> {
|
|
80
|
-
await chrome.debugger.attach({ tabId }, '1.3');
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async function ensureAttached(tabId: number): Promise<void> {
|
|
17
|
+
export async function ensureAttached(tabId: number, aggressiveRetry: boolean = false): Promise<void> {
|
|
84
18
|
// Verify the tab URL is debuggable before attempting attach
|
|
85
19
|
try {
|
|
86
20
|
const tab = await chrome.tabs.get(tabId);
|
|
@@ -109,35 +43,47 @@ async function ensureAttached(tabId: number): Promise<void> {
|
|
|
109
43
|
}
|
|
110
44
|
}
|
|
111
45
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
await delay(ATTACH_RECOVERY_DELAY_MS);
|
|
125
|
-
try {
|
|
126
|
-
await tryAttach(tabId);
|
|
127
|
-
} catch {
|
|
128
|
-
throw new Error(`attach failed: ${msg}${hint}`);
|
|
129
|
-
}
|
|
130
|
-
} else if (msg.includes('Another debugger is already attached')) {
|
|
46
|
+
// Retry attach up to 3 times — other extensions (1Password, Playwright MCP Bridge)
|
|
47
|
+
// can temporarily interfere with chrome.debugger. A short delay usually resolves it.
|
|
48
|
+
// Normal commands: 2 retries, 500ms delay (fast fail for non-operate use)
|
|
49
|
+
// Operate commands: 5 retries, 1500ms delay (aggressive, tolerates extension interference)
|
|
50
|
+
const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2;
|
|
51
|
+
const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500;
|
|
52
|
+
let lastError = '';
|
|
53
|
+
|
|
54
|
+
for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) {
|
|
55
|
+
try {
|
|
56
|
+
// Force detach first to clear any stale state from other extensions
|
|
131
57
|
try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
58
|
+
await chrome.debugger.attach({ tabId }, '1.3');
|
|
59
|
+
lastError = '';
|
|
60
|
+
break; // Success
|
|
61
|
+
} catch (e: unknown) {
|
|
62
|
+
lastError = e instanceof Error ? e.message : String(e);
|
|
63
|
+
if (attempt < MAX_ATTACH_RETRIES) {
|
|
64
|
+
console.warn(`[opencli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`);
|
|
65
|
+
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
|
|
66
|
+
// Re-verify tab URL before retrying (it may have changed)
|
|
67
|
+
try {
|
|
68
|
+
const tab = await chrome.tabs.get(tabId);
|
|
69
|
+
if (!isDebuggableUrl(tab.url)) {
|
|
70
|
+
lastError = `Tab URL changed to ${tab.url} during retry`;
|
|
71
|
+
break; // Don't retry if URL became un-debuggable
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
lastError = `Tab ${tabId} no longer exists`;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
136
77
|
}
|
|
137
|
-
} else {
|
|
138
|
-
throw new Error(`attach failed: ${msg}${hint}`);
|
|
139
78
|
}
|
|
140
79
|
}
|
|
80
|
+
|
|
81
|
+
if (lastError) {
|
|
82
|
+
const hint = lastError.includes('chrome-extension://')
|
|
83
|
+
? '. Tip: another Chrome extension may be interfering — try disabling other extensions'
|
|
84
|
+
: '';
|
|
85
|
+
throw new Error(`attach failed: ${lastError}${hint}`);
|
|
86
|
+
}
|
|
141
87
|
attached.add(tabId);
|
|
142
88
|
|
|
143
89
|
try {
|
|
@@ -145,40 +91,47 @@ async function ensureAttached(tabId: number): Promise<void> {
|
|
|
145
91
|
} catch {
|
|
146
92
|
// Some pages may not need explicit enable
|
|
147
93
|
}
|
|
148
|
-
|
|
149
|
-
// Disable breakpoints so that `debugger;` statements in page code don't
|
|
150
|
-
// pause execution. Anti-bot scripts use `debugger;` traps to detect CDP —
|
|
151
|
-
// they measure the time gap caused by the pause. Deactivating breakpoints
|
|
152
|
-
// makes the engine skip `debugger;` entirely, neutralising the timing
|
|
153
|
-
// side-channel without patching page JS.
|
|
154
|
-
try {
|
|
155
|
-
await chrome.debugger.sendCommand({ tabId }, 'Debugger.enable');
|
|
156
|
-
await chrome.debugger.sendCommand({ tabId }, 'Debugger.setBreakpointsActive', { active: false });
|
|
157
|
-
} catch {
|
|
158
|
-
// Non-fatal: best-effort hardening
|
|
159
|
-
}
|
|
160
94
|
}
|
|
161
95
|
|
|
162
|
-
export async function evaluate(tabId: number, expression: string): Promise<unknown> {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
96
|
+
export async function evaluate(tabId: number, expression: string, aggressiveRetry: boolean = false): Promise<unknown> {
|
|
97
|
+
// Retry the entire evaluate (attach + command).
|
|
98
|
+
// Normal: 2 retries. Operate: 3 retries (tolerates extension interference).
|
|
99
|
+
const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2;
|
|
100
|
+
for (let attempt = 1; attempt <= MAX_EVAL_RETRIES; attempt++) {
|
|
101
|
+
try {
|
|
102
|
+
await ensureAttached(tabId, aggressiveRetry);
|
|
103
|
+
|
|
104
|
+
const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
|
|
105
|
+
expression,
|
|
106
|
+
returnByValue: true,
|
|
107
|
+
awaitPromise: true,
|
|
108
|
+
}) as {
|
|
109
|
+
result?: { type: string; value?: unknown; description?: string; subtype?: string };
|
|
110
|
+
exceptionDetails?: { exception?: { description?: string }; text?: string };
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
if (result.exceptionDetails) {
|
|
114
|
+
const errMsg = result.exceptionDetails.exception?.description
|
|
115
|
+
|| result.exceptionDetails.text
|
|
116
|
+
|| 'Eval error';
|
|
117
|
+
throw new Error(errMsg);
|
|
118
|
+
}
|
|
173
119
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
120
|
+
return result.result?.value;
|
|
121
|
+
} catch (e) {
|
|
122
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
123
|
+
// Only retry on attach/debugger errors, not on JS eval errors
|
|
124
|
+
const isAttachError = msg.includes('attach failed') || msg.includes('Debugger is not attached')
|
|
125
|
+
|| msg.includes('chrome-extension://') || msg.includes('Target closed');
|
|
126
|
+
if (isAttachError && attempt < MAX_EVAL_RETRIES) {
|
|
127
|
+
attached.delete(tabId); // Force re-attach on next attempt
|
|
128
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
throw e;
|
|
132
|
+
}
|
|
179
133
|
}
|
|
180
|
-
|
|
181
|
-
return result.result?.value;
|
|
134
|
+
throw new Error('evaluate: max retries exhausted');
|
|
182
135
|
}
|
|
183
136
|
|
|
184
137
|
export const evaluateAsync = evaluate;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Everything else is just JS code sent via 'exec'.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | '
|
|
8
|
+
export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'cdp';
|
|
9
9
|
|
|
10
10
|
export interface Command {
|
|
11
11
|
/** Unique request ID */
|
|
@@ -26,10 +26,6 @@ export interface Command {
|
|
|
26
26
|
index?: number;
|
|
27
27
|
/** Cookie domain filter */
|
|
28
28
|
domain?: string;
|
|
29
|
-
/** Optional hostname/domain to require for current-tab binding */
|
|
30
|
-
matchDomain?: string;
|
|
31
|
-
/** Optional pathname prefix to require for current-tab binding */
|
|
32
|
-
matchPathPrefix?: string;
|
|
33
29
|
/** Screenshot format: png (default) or jpeg */
|
|
34
30
|
format?: 'png' | 'jpeg';
|
|
35
31
|
/** JPEG quality (0-100), only for jpeg format */
|
|
@@ -40,6 +36,10 @@ export interface Command {
|
|
|
40
36
|
files?: string[];
|
|
41
37
|
/** CSS selector for file input element (set-file-input action) */
|
|
42
38
|
selector?: string;
|
|
39
|
+
/** CDP method name for 'cdp' action (e.g. 'Accessibility.getFullAXTree') */
|
|
40
|
+
cdpMethod?: string;
|
|
41
|
+
/** CDP method params for 'cdp' action */
|
|
42
|
+
cdpParams?: Record<string, unknown>;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export interface Result {
|