From 3ac55520d6e27ebd2feb53290b36d256d32fe5cb Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 11 Aug 2026 20:22:10 -0700 Subject: [PATCH] [eric] browser: the upload tool also finds inputs inside shadow roots and embedded frames, and a refused path names the exact staging copy that unblocks it (ENG-47) --- .../agents/browser/resolve_upload_path.py | 8 +++-- frontend/src/shared/browserCommandHandler.ts | 35 ++++++++++++++++--- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/backend/apps/agents/browser/resolve_upload_path.py b/backend/apps/agents/browser/resolve_upload_path.py index 240b233c..a5677eae 100644 --- a/backend/apps/agents/browser/resolve_upload_path.py +++ b/backend/apps/agents/browser/resolve_upload_path.py @@ -54,10 +54,12 @@ def resolve_upload_path(path: str) -> str: target = os.path.realpath(os.path.expanduser(raw)) roots = allowed_upload_roots() if not any(target == r or target.startswith(r + os.sep) for r in roots): + # Name the fix, not just the rule. The PARENT agent can read anywhere and is not driven by + # page content, so staging the file is safe there and is the one move that unblocks this. raise UploadPathRefused( - f"Refused: {raw} is outside the folders this agent may upload from. " - "Uploadable files are the ones the user attached to the chat and the ones agents " - "created in their workspace. Copy the file into the workspace first, then upload it." + f"Refused: {raw} is outside the folders a page-driven agent may upload from. " + f"Ask the agent that sent you here to copy the file to {UPLOAD_DIR} first " + f"(e.g. `cp \"{raw}\" {UPLOAD_DIR}/`), then upload it from there." ) if not os.path.isfile(target): raise UploadPathRefused(f"Refused: {raw} is not a file that exists.") diff --git a/frontend/src/shared/browserCommandHandler.ts b/frontend/src/shared/browserCommandHandler.ts index 17a32551..3fdaed97 100644 --- a/frontend/src/shared/browserCommandHandler.ts +++ b/frontend/src/shared/browserCommandHandler.ts @@ -1667,16 +1667,43 @@ async function handleUploadFile(wv: BrowserWebview, params: Record) let found = 0; if (backendNodeId == null) { try { + // Tier 1, cheap: a plain selector over the light DOM, which is where most upload inputs live. const doc = await sendCdp(wv, 'DOM.getDocument', { depth: 0 }); const hits = await sendCdp(wv, 'DOM.querySelectorAll', { nodeId: doc?.root?.nodeId, selector: 'input[type=file]' }); const nodeIds: number[] = hits?.nodeIds || []; found = nodeIds.length; - if (!found) { - return { error: 'No file-upload field on this page. Open the page or dialog that has the upload control first, then retry.' }; + if (found) { + const described = await sendCdp(wv, 'DOM.describeNode', { nodeId: nodeIds[0] }); + backendNodeId = described?.node?.backendNodeId; } - const described = await sendCdp(wv, 'DOM.describeNode', { nodeId: nodeIds[0] }); - backendNodeId = described?.node?.backendNodeId; + } catch { /* tier 2 below is the real answer anyway */ } + } + if (backendNodeId == null) { + // Tier 2: querySelectorAll cannot see into a shadow root or a cross-origin iframe, and design + // systems put upload widgets in both. `pierce` walks through them. It returns the whole tree, so + // it stays behind the cheap tier rather than being the default. + try { + const pierced = await sendCdp(wv, 'DOM.getDocument', { depth: -1, pierce: true }); + const inputs: number[] = []; + const walk = (n: any): void => { + if (!n || inputs.length > 20) return; + if (n.nodeName === 'INPUT') { + const a: string[] = n.attributes || []; + for (let i = 0; i < a.length - 1; i += 2) { + if (a[i] === 'type' && String(a[i + 1]).toLowerCase() === 'file') { inputs.push(n.backendNodeId); break; } + } + } + (n.children || []).forEach(walk); + (n.shadowRoots || []).forEach(walk); + if (n.contentDocument) walk(n.contentDocument); + }; + walk(pierced?.root); + found = inputs.length; + if (!found) { + return { error: 'No file-upload field on this page, including inside its embedded frames. Open the page or dialog that has the upload control first, then retry.' }; + } + backendNodeId = inputs[0]; } catch (err: any) { return { error: `Could not search the page for an upload field (${err?.message || 'DOM query failed'}).` }; }