diff --git a/extension/dist/background.js b/extension/dist/background.js index 210f3a759..26b42174a 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -183,22 +183,83 @@ async function screenshot(tabId, options = {}) { } } } +const FILE_CHOOSER_TIMEOUT_MS = 1e4; +const pendingFileChooserTabs = /* @__PURE__ */ new Set(); +const FILE_UPLOAD_OBJECT_GROUP = "opencli-file-upload"; async function setFileInputFiles(tabId, files, selector) { - await ensureAttached(tabId); - await sendDebuggerCommand({ tabId }, "DOM.enable"); - const doc = await sendDebuggerCommand({ tabId }, "DOM.getDocument"); - const query = selector || 'input[type="file"]'; - const result = await sendDebuggerCommand({ tabId }, "DOM.querySelector", { - nodeId: doc.root.nodeId, - selector: query - }); - if (!result.nodeId) { - throw new Error(`No element found matching selector: ${query}`); + if (pendingFileChooserTabs.has(tabId)) { + throw new Error(`Another file upload is already in progress on tab ${tabId}.`); + } + pendingFileChooserTabs.add(tabId); + try { + await ensureAttached(tabId); + await sendDebuggerCommand({ tabId }, "DOM.enable"); + await sendDebuggerCommand({ tabId }, "Page.enable"); + const doc = await sendDebuggerCommand({ tabId }, "DOM.getDocument"); + const query = selector || 'input[type="file"]'; + const result = await sendDebuggerCommand({ tabId }, "DOM.querySelector", { + nodeId: doc.root.nodeId, + selector: query + }); + if (!result.nodeId) { + throw new Error(`No element found matching selector: ${query}`); + } + const resolved = await sendDebuggerCommand({ tabId }, "DOM.resolveNode", { + nodeId: result.nodeId, + objectGroup: FILE_UPLOAD_OBJECT_GROUP + }); + const objectId = resolved?.object?.objectId; + if (!objectId) { + throw new Error(`Could not resolve file input element for selector: ${query}`); + } + let onChooser; + const chooserOpened = new Promise((resolve) => { + onChooser = (source, method, params) => { + if (source.tabId !== tabId || method !== "Page.fileChooserOpened") return; + resolve({ backendNodeId: params?.backendNodeId, mode: params?.mode }); + }; + chrome.debugger.onEvent.addListener(onChooser); + }); + try { + await sendDebuggerCommand({ tabId }, "Page.setInterceptFileChooserDialog", { enabled: false }).catch(() => { + }); + await sendDebuggerCommand({ tabId }, "Page.setInterceptFileChooserDialog", { enabled: true }); + await sendDebuggerCommand({ tabId }, "Runtime.callFunctionOn", { + objectId, + functionDeclaration: "function() { this.click(); }", + userGesture: true + }); + let timer; + const chooser = await Promise.race([ + chooserOpened, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error( + `Timed out waiting for Page.fileChooserOpened after clicking the file input (${Math.round(FILE_CHOOSER_TIMEOUT_MS / 1e3)}s). The input may be disabled or the page may have prevented the click.` + )), FILE_CHOOSER_TIMEOUT_MS); + }) + ]).finally(() => { + if (timer !== void 0) clearTimeout(timer); + }); + if (chooser.mode === "selectSingle" && files.length > 1) { + throw new Error(`File input only accepts a single file, got ${files.length}.`); + } + if (typeof chooser.backendNodeId !== "number") { + throw new Error("File chooser opened without a backing file input (no backendNodeId in Page.fileChooserOpened)."); + } + await sendDebuggerCommand({ tabId }, "DOM.setFileInputFiles", { + files, + backendNodeId: chooser.backendNodeId + }); + } finally { + if (onChooser) chrome.debugger.onEvent.removeListener(onChooser); + await sendDebuggerCommand({ tabId }, "Page.setInterceptFileChooserDialog", { enabled: false }).catch(() => { + }); + await sendDebuggerCommand({ tabId }, "Runtime.releaseObjectGroup", { objectGroup: FILE_UPLOAD_OBJECT_GROUP }).catch(() => { + }); + } + } finally { + pendingFileChooserTabs.delete(tabId); } - await sendDebuggerCommand({ tabId }, "DOM.setFileInputFiles", { - files, - nodeId: result.nodeId - }); } function matchesDownloadPattern(item, pattern) { if (!pattern) return true; diff --git a/extension/manifest.json b/extension/manifest.json index 02db293cc..e77bede93 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "OpenCLI", - "version": "1.0.22", + "version": "1.0.23", "description": "Browser automation bridge for the OpenCLI CLI tool. Executes commands in Chrome tab leases via a local daemon.", "permissions": [ "debugger", diff --git a/extension/package.json b/extension/package.json index 491a0ee65..60a366737 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,6 +1,6 @@ { "name": "opencli-extension", - "version": "1.0.22", + "version": "1.0.23", "private": true, "opencli": { "compatRange": ">=1.7.0" diff --git a/extension/src/cdp.test.ts b/extension/src/cdp.test.ts index 3d96fd7ad..0b97eb9b8 100644 --- a/extension/src/cdp.test.ts +++ b/extension/src/cdp.test.ts @@ -654,3 +654,136 @@ describe('cdp command deadline', () => { await assertion; }); }); + +describe('cdp setFileInputFiles (file-chooser interception)', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + // chrome.debugger clients may not call DOM.setFileInputFiles directly + // (Chromium #928255) — the flow must intercept the file chooser and answer + // the Page.fileChooserOpened event's backendNodeId. + function chromeMockForFileUpload(chooserEvent: { backendNodeId?: number; mode?: string } | null = { backendNodeId: 42, mode: 'selectMultiple' }) { + const calls: Array<{ method: string; params?: any }> = []; + const eventListeners: Array<(source: { tabId?: number }, method: string, params: any) => void> = []; + const debuggerApi = { + attach: vi.fn(async () => {}), + detach: vi.fn(async () => {}), + sendCommand: vi.fn(async (_target: unknown, method: string, params?: any) => { + calls.push({ method, params }); + if (method === 'DOM.getDocument') return { root: { nodeId: 1 } }; + if (method === 'DOM.querySelector') return { nodeId: 7 }; + if (method === 'DOM.resolveNode') return { object: { objectId: 'obj-7' } }; + if (method === 'Runtime.callFunctionOn' && chooserEvent) { + queueMicrotask(() => { + for (const fn of [...eventListeners]) fn({ tabId: 1 }, 'Page.fileChooserOpened', chooserEvent); + }); + } + return {}; + }), + onDetach: { addListener: vi.fn() }, + onEvent: { + addListener: vi.fn((fn: (source: { tabId?: number }, method: string, params: any) => void) => { eventListeners.push(fn); }), + removeListener: vi.fn((fn: (source: { tabId?: number }, method: string, params: any) => void) => { + const i = eventListeners.indexOf(fn); + if (i >= 0) eventListeners.splice(i, 1); + }), + }, + }; + const tabs = { + get: vi.fn(async () => ({ id: 1, windowId: 1, url: 'https://example.com' })), + onRemoved: { addListener: vi.fn() }, + onUpdated: { addListener: vi.fn() }, + }; + return { + chrome: { tabs, debugger: debuggerApi, scripting: {}, runtime: { id: 'opencli-test' } }, + debuggerApi, + calls, + }; + } + + it('answers the intercepted chooser with the event backendNodeId instead of calling DOM.setFileInputFiles directly', async () => { + const { chrome, calls, debuggerApi } = chromeMockForFileUpload(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + await mod.setFileInputFiles(1, ['/tmp/a.txt'], 'input[type=file]'); + + const methods = calls.map((c) => c.method); + const interceptOn = calls.findIndex((c) => c.method === 'Page.setInterceptFileChooserDialog' && c.params?.enabled === true); + const click = calls.findIndex((c) => c.method === 'Runtime.callFunctionOn'); + const setFiles = calls.findIndex((c) => c.method === 'DOM.setFileInputFiles'); + expect(interceptOn).toBeGreaterThan(-1); + expect(click).toBeGreaterThan(interceptOn); + expect(setFiles).toBeGreaterThan(click); + expect(calls[click].params).toMatchObject({ objectId: 'obj-7', userGesture: true }); + expect(calls[setFiles].params).toEqual({ files: ['/tmp/a.txt'], backendNodeId: 42 }); + expect(methods).toContain('Page.enable'); + // Interception is switched back off after the upload. + expect(calls.filter((c) => c.method === 'Page.setInterceptFileChooserDialog').at(-1)?.params).toEqual({ enabled: false }); + expect(debuggerApi.onEvent.removeListener).toHaveBeenCalled(); + }); + + it('rejects multiple files when the chooser reports selectSingle', async () => { + const { chrome, calls } = chromeMockForFileUpload({ backendNodeId: 42, mode: 'selectSingle' }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + await expect(mod.setFileInputFiles(1, ['/tmp/a.txt', '/tmp/b.txt'])) + .rejects.toThrow(/single file/); + expect(calls.some((c) => c.method === 'DOM.setFileInputFiles')).toBe(false); + expect(calls.filter((c) => c.method === 'Page.setInterceptFileChooserDialog').at(-1)?.params).toEqual({ enabled: false }); + }); + + it('rejects when the chooser event carries no backendNodeId', async () => { + const { chrome, calls, debuggerApi } = chromeMockForFileUpload({ mode: 'selectSingle' }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + await expect(mod.setFileInputFiles(1, ['/tmp/a.txt'])) + .rejects.toThrow(/no backendNodeId/); + expect(calls.some((c) => c.method === 'DOM.setFileInputFiles')).toBe(false); + expect(debuggerApi.onEvent.removeListener).toHaveBeenCalled(); + }); + + it('times out with a clear error when Page.fileChooserOpened never arrives, and still disables interception', async () => { + vi.useFakeTimers(); + const { chrome, calls, debuggerApi } = chromeMockForFileUpload(null); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + const pending = mod.setFileInputFiles(1, ['/tmp/a.txt']); + const assertion = expect(pending).rejects.toThrow(/Timed out waiting for Page.fileChooserOpened/); + await vi.advanceTimersByTimeAsync(10_000); + await assertion; + + expect(calls.some((c) => c.method === 'DOM.setFileInputFiles')).toBe(false); + expect(calls.filter((c) => c.method === 'Page.setInterceptFileChooserDialog').at(-1)?.params).toEqual({ enabled: false }); + expect(debuggerApi.onEvent.removeListener).toHaveBeenCalled(); + }); + + it('rejects an overlapping upload on the same tab while one is in flight', async () => { + vi.useFakeTimers(); + const { chrome } = chromeMockForFileUpload(null); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + const first = mod.setFileInputFiles(1, ['/tmp/a.txt']); + const firstAssertion = expect(first).rejects.toThrow(/Timed out/); + await expect(mod.setFileInputFiles(1, ['/tmp/b.txt'])) + .rejects.toThrow(/already in progress/); + await vi.advanceTimersByTimeAsync(10_000); + await firstAssertion; + + // The guard is released after the first call settles. + const third = mod.setFileInputFiles(1, ['/tmp/c.txt']); + const thirdAssertion = expect(third).rejects.toThrow(/Timed out/); + await vi.advanceTimersByTimeAsync(10_000); + await thirdAssertion; + }); +}); diff --git a/extension/src/cdp.ts b/extension/src/cdp.ts index a32b10cb7..d42ac429b 100644 --- a/extension/src/cdp.ts +++ b/extension/src/cdp.ts @@ -332,10 +332,27 @@ export async function screenshot( } } +/** Time allowed between clicking the file input and Page.fileChooserOpened arriving. */ +const FILE_CHOOSER_TIMEOUT_MS = 10_000; + +/** + * Tabs with a file-chooser interception in flight. Interception state is + * per-tab, so two overlapping calls would steal each other's + * Page.fileChooserOpened event — serialize by rejecting the second call. + */ +const pendingFileChooserTabs = new Set(); + +const FILE_UPLOAD_OBJECT_GROUP = 'opencli-file-upload'; + /** - * Set local file paths on a file input element via CDP DOM.setFileInputFiles. - * This bypasses the need to send large base64 payloads through the message channel — - * Chrome reads the files directly from the local filesystem. + * Set local file paths on a file input element. + * + * Chrome rejects a direct DOM.setFileInputFiles from a chrome.debugger client + * with -32000 "Not allowed" (Chromium #928255, since Chrome 72) — the call is + * only permitted as an answer to a file chooser Chrome itself opened. So: + * intercept the chooser dialog, click the input (user gesture), and feed the + * files to the backendNodeId carried by the resulting Page.fileChooserOpened + * event. Chrome then reads the files directly from the local filesystem. * * @param tabId - Target tab ID * @param files - Array of absolute local file paths @@ -346,32 +363,97 @@ export async function setFileInputFiles( files: string[], selector?: string, ): Promise { - await ensureAttached(tabId); + if (pendingFileChooserTabs.has(tabId)) { + throw new Error(`Another file upload is already in progress on tab ${tabId}.`); + } + pendingFileChooserTabs.add(tabId); + try { + await ensureAttached(tabId); - // Enable DOM domain (required for DOM.querySelector and DOM.setFileInputFiles) - await sendDebuggerCommand({ tabId }, 'DOM.enable'); + // DOM domain for querySelector/setFileInputFiles, Page domain for + // fileChooserOpened notifications. + await sendDebuggerCommand({ tabId }, 'DOM.enable'); + await sendDebuggerCommand({ tabId }, 'Page.enable'); - // Get the document root - const doc = await sendDebuggerCommand({ tabId }, 'DOM.getDocument') as { - root: { nodeId: number }; - }; + // Get the document root + const doc = await sendDebuggerCommand({ tabId }, 'DOM.getDocument') as { + root: { nodeId: number }; + }; - // Find the file input element - const query = selector || 'input[type="file"]'; - const result = await sendDebuggerCommand({ tabId }, 'DOM.querySelector', { - nodeId: doc.root.nodeId, - selector: query, - }) as { nodeId: number }; + // Find the file input element + const query = selector || 'input[type="file"]'; + const result = await sendDebuggerCommand({ tabId }, 'DOM.querySelector', { + nodeId: doc.root.nodeId, + selector: query, + }) as { nodeId: number }; - if (!result.nodeId) { - throw new Error(`No element found matching selector: ${query}`); - } + if (!result.nodeId) { + throw new Error(`No element found matching selector: ${query}`); + } - // Set files directly via CDP — Chrome reads from local filesystem - await sendDebuggerCommand({ tabId }, 'DOM.setFileInputFiles', { - files, - nodeId: result.nodeId, - }); + const resolved = await sendDebuggerCommand({ tabId }, 'DOM.resolveNode', { + nodeId: result.nodeId, + objectGroup: FILE_UPLOAD_OBJECT_GROUP, + }) as { object?: { objectId?: string } }; + const objectId = resolved?.object?.objectId; + if (!objectId) { + throw new Error(`Could not resolve file input element for selector: ${query}`); + } + + // Register before enabling interception so the event cannot slip past. + let onChooser: ((source: { tabId?: number }, method: string, params: any) => void) | undefined; + const chooserOpened = new Promise<{ backendNodeId?: number; mode?: string }>((resolve) => { + onChooser = (source, method, params: any) => { + if (source.tabId !== tabId || method !== 'Page.fileChooserOpened') return; + resolve({ backendNodeId: params?.backendNodeId, mode: params?.mode }); + }; + chrome.debugger.onEvent.addListener(onChooser); + }); + + try { + // Clear interception a dead service worker may have left enabled. + await sendDebuggerCommand({ tabId }, 'Page.setInterceptFileChooserDialog', { enabled: false }).catch(() => {}); + await sendDebuggerCommand({ tabId }, 'Page.setInterceptFileChooserDialog', { enabled: true }); + + // userGesture: file inputs only open their chooser from user activation. + await sendDebuggerCommand({ tabId }, 'Runtime.callFunctionOn', { + objectId, + functionDeclaration: 'function() { this.click(); }', + userGesture: true, + }); + + let timer: ReturnType | undefined; + const chooser = await Promise.race([ + chooserOpened, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error( + `Timed out waiting for Page.fileChooserOpened after clicking the file input (${Math.round(FILE_CHOOSER_TIMEOUT_MS / 1000)}s). The input may be disabled or the page may have prevented the click.`, + )), FILE_CHOOSER_TIMEOUT_MS); + }), + ]).finally(() => { + if (timer !== undefined) clearTimeout(timer); + }); + + if (chooser.mode === 'selectSingle' && files.length > 1) { + throw new Error(`File input only accepts a single file, got ${files.length}.`); + } + if (typeof chooser.backendNodeId !== 'number') { + throw new Error('File chooser opened without a backing file input (no backendNodeId in Page.fileChooserOpened).'); + } + + // Answer the intercepted chooser — the only form Chrome permits here. + await sendDebuggerCommand({ tabId }, 'DOM.setFileInputFiles', { + files, + backendNodeId: chooser.backendNodeId, + }); + } finally { + if (onChooser) chrome.debugger.onEvent.removeListener(onChooser); + await sendDebuggerCommand({ tabId }, 'Page.setInterceptFileChooserDialog', { enabled: false }).catch(() => {}); + await sendDebuggerCommand({ tabId }, 'Runtime.releaseObjectGroup', { objectGroup: FILE_UPLOAD_OBJECT_GROUP }).catch(() => {}); + } + } finally { + pendingFileChooserTabs.delete(tabId); + } } function matchesDownloadPattern(item: chrome.downloads.DownloadItem, pattern: string): boolean {