@jackwener/opencli 1.5.7 → 1.5.8
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 +8 -0
- package/dist/extension-manifest-regression.test.js +1 -0
- package/extension/dist/background.js +736 -778
- package/extension/manifest.json +2 -1
- package/extension/src/background.ts +3 -2
- package/extension/src/cdp.test.ts +75 -0
- package/extension/src/cdp.ts +77 -3
- package/package.json +1 -1
- package/src/extension-manifest-regression.test.ts +1 -0
package/extension/manifest.json
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
"description": "Browser automation bridge for the OpenCLI CLI tool. Executes commands in isolated Chrome windows via a local daemon.",
|
|
6
6
|
"permissions": [
|
|
7
7
|
"debugger",
|
|
8
|
+
"scripting",
|
|
8
9
|
"tabs",
|
|
9
10
|
"cookies",
|
|
10
11
|
"activeTab",
|
|
@@ -35,4 +36,4 @@
|
|
|
35
36
|
"extension_pages": "script-src 'self'; object-src 'self'"
|
|
36
37
|
},
|
|
37
38
|
"homepage_url": "https://github.com/jackwener/opencli"
|
|
38
|
-
}
|
|
39
|
+
}
|
|
@@ -428,9 +428,10 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi
|
|
|
428
428
|
if (adoptedTabId !== null) return adoptedTabId;
|
|
429
429
|
|
|
430
430
|
const existingSession = automationSessions.get(workspace);
|
|
431
|
-
if (existingSession
|
|
431
|
+
if (existingSession && existingSession.preferredTabId !== null) {
|
|
432
432
|
try {
|
|
433
|
-
const
|
|
433
|
+
const preferredTabId = existingSession.preferredTabId;
|
|
434
|
+
const preferredTab = await chrome.tabs.get(preferredTabId);
|
|
434
435
|
if (isDebuggableUrl(preferredTab.url)) return preferredTab.id!;
|
|
435
436
|
} catch {
|
|
436
437
|
automationSessions.delete(workspace);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
function createChromeMock() {
|
|
4
|
+
const tabs = {
|
|
5
|
+
get: vi.fn(async (_tabId: number) => ({
|
|
6
|
+
id: 1,
|
|
7
|
+
windowId: 1,
|
|
8
|
+
url: 'https://x.com/home',
|
|
9
|
+
})),
|
|
10
|
+
onRemoved: { addListener: vi.fn() },
|
|
11
|
+
onUpdated: { addListener: vi.fn() },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const debuggerApi = {
|
|
15
|
+
attach: vi.fn(async () => {}),
|
|
16
|
+
detach: vi.fn(async () => {}),
|
|
17
|
+
sendCommand: vi.fn(async (_target: unknown, method: string) => {
|
|
18
|
+
if (method === 'Runtime.evaluate') return { result: { value: 'ok' } };
|
|
19
|
+
return {};
|
|
20
|
+
}),
|
|
21
|
+
onDetach: { addListener: vi.fn() },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const scripting = {
|
|
25
|
+
executeScript: vi.fn(async () => [{ result: { removed: 1 } }]),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
chrome: {
|
|
30
|
+
tabs,
|
|
31
|
+
debugger: debuggerApi,
|
|
32
|
+
scripting,
|
|
33
|
+
runtime: { id: 'opencli-test' },
|
|
34
|
+
},
|
|
35
|
+
debuggerApi,
|
|
36
|
+
scripting,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('cdp attach recovery', () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
vi.resetModules();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
vi.unstubAllGlobals();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('does not mutate the DOM before a successful attach', async () => {
|
|
50
|
+
const { chrome, debuggerApi, scripting } = createChromeMock();
|
|
51
|
+
vi.stubGlobal('chrome', chrome);
|
|
52
|
+
|
|
53
|
+
const mod = await import('./cdp');
|
|
54
|
+
const result = await mod.evaluate(1, '1');
|
|
55
|
+
|
|
56
|
+
expect(result).toBe('ok');
|
|
57
|
+
expect(debuggerApi.attach).toHaveBeenCalledTimes(1);
|
|
58
|
+
expect(scripting.executeScript).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('retries after cleanup when attach fails with a foreign extension error', async () => {
|
|
62
|
+
const { chrome, debuggerApi, scripting } = createChromeMock();
|
|
63
|
+
debuggerApi.attach
|
|
64
|
+
.mockRejectedValueOnce(new Error('Cannot access a chrome-extension:// URL of different extension'))
|
|
65
|
+
.mockResolvedValueOnce(undefined);
|
|
66
|
+
vi.stubGlobal('chrome', chrome);
|
|
67
|
+
|
|
68
|
+
const mod = await import('./cdp');
|
|
69
|
+
const result = await mod.evaluate(1, '1');
|
|
70
|
+
|
|
71
|
+
expect(result).toBe('ok');
|
|
72
|
+
expect(scripting.executeScript).toHaveBeenCalledTimes(1);
|
|
73
|
+
expect(debuggerApi.attach).toHaveBeenCalledTimes(2);
|
|
74
|
+
});
|
|
75
|
+
});
|
package/extension/src/cdp.ts
CHANGED
|
@@ -10,6 +10,8 @@ const attached = new Set<number>();
|
|
|
10
10
|
|
|
11
11
|
/** Internal blank page used when no user URL is provided. */
|
|
12
12
|
const BLANK_PAGE = 'data:text/html,<html></html>';
|
|
13
|
+
const FOREIGN_EXTENSION_URL_PREFIX = 'chrome-extension://';
|
|
14
|
+
const ATTACH_RECOVERY_DELAY_MS = 120;
|
|
13
15
|
|
|
14
16
|
/** Check if a URL can be attached via CDP — only allow http(s) and our internal blank page. */
|
|
15
17
|
function isDebuggableUrl(url?: string): boolean {
|
|
@@ -17,6 +19,67 @@ function isDebuggableUrl(url?: string): boolean {
|
|
|
17
19
|
return url.startsWith('http://') || url.startsWith('https://') || url === BLANK_PAGE;
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
type CleanupResult = { removed: number };
|
|
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
|
+
|
|
20
83
|
async function ensureAttached(tabId: number): Promise<void> {
|
|
21
84
|
// Verify the tab URL is debuggable before attempting attach
|
|
22
85
|
try {
|
|
@@ -47,16 +110,27 @@ async function ensureAttached(tabId: number): Promise<void> {
|
|
|
47
110
|
}
|
|
48
111
|
|
|
49
112
|
try {
|
|
50
|
-
await
|
|
113
|
+
await tryAttach(tabId);
|
|
51
114
|
} catch (e: unknown) {
|
|
52
115
|
const msg = e instanceof Error ? e.message : String(e);
|
|
53
116
|
const hint = msg.includes('chrome-extension://')
|
|
54
117
|
? '. Tip: another Chrome extension may be interfering — try disabling other extensions'
|
|
55
118
|
: '';
|
|
56
|
-
if (msg.includes('
|
|
119
|
+
if (msg.includes('chrome-extension://')) {
|
|
120
|
+
const recoveryCleanup = await removeForeignExtensionEmbeds(tabId);
|
|
121
|
+
if (recoveryCleanup.removed > 0) {
|
|
122
|
+
console.warn(`[opencli] Removed ${recoveryCleanup.removed} foreign extension frame(s) after attach failure on tab ${tabId}`);
|
|
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')) {
|
|
57
131
|
try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
|
|
58
132
|
try {
|
|
59
|
-
await
|
|
133
|
+
await tryAttach(tabId);
|
|
60
134
|
} catch {
|
|
61
135
|
throw new Error(`attach failed: ${msg}${hint}`);
|
|
62
136
|
}
|
package/package.json
CHANGED