@different-ai/opencode-browser
Advanced tools
+643
-47
@@ -18,3 +18,5 @@ const NATIVE_HOST_NAME = "com.opencode.browser_automation" | ||
| if (port) { | ||
| try { port.disconnect() } catch {} | ||
| try { | ||
| port.disconnect() | ||
| } catch {} | ||
| port = null | ||
@@ -39,3 +41,2 @@ } | ||
| if (err?.message) { | ||
| // Usually means native host not installed or crashed | ||
| connectionAttempts++ | ||
@@ -109,2 +110,5 @@ if (connectionAttempts === 1) { | ||
| snapshot: toolSnapshot, | ||
| extract: toolExtract, | ||
| query: toolQuery, | ||
| wait_for: toolWaitFor, | ||
| execute_script: toolExecuteScript, | ||
@@ -157,22 +161,126 @@ scroll: toolScroll, | ||
| async function toolClick({ selector, tabId }) { | ||
| function normalizeSelectorList(selector) { | ||
| if (typeof selector !== "string") return [] | ||
| const parts = selector | ||
| .split(",") | ||
| .map((s) => s.trim()) | ||
| .filter(Boolean) | ||
| return parts.length ? parts : [selector.trim()].filter(Boolean) | ||
| } | ||
| async function toolClick({ selector, tabId, index = 0 }) { | ||
| if (!selector) throw new Error("Selector is required") | ||
| const tab = await getTabById(tabId) | ||
| const selectorList = normalizeSelectorList(selector) | ||
| const result = await chrome.scripting.executeScript({ | ||
| target: { tabId: tab.id }, | ||
| func: (sel) => { | ||
| const el = document.querySelector(sel) | ||
| if (!el) return { success: false, error: `Element not found: ${sel}` } | ||
| el.click() | ||
| return { success: true } | ||
| func: (selectors, index) => { | ||
| function safeString(v) { | ||
| return typeof v === "string" ? v : "" | ||
| } | ||
| function isVisible(el) { | ||
| if (!el) return false | ||
| const rect = el.getBoundingClientRect() | ||
| if (rect.width <= 0 || rect.height <= 0) return false | ||
| const style = window.getComputedStyle(el) | ||
| if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false | ||
| return true | ||
| } | ||
| function deepQuerySelectorAll(sel, rootDoc) { | ||
| const out = [] | ||
| const seen = new Set() | ||
| function addAll(nodeList) { | ||
| for (const el of nodeList) { | ||
| if (!el || seen.has(el)) continue | ||
| seen.add(el) | ||
| out.push(el) | ||
| } | ||
| } | ||
| function walkRoot(root, depth) { | ||
| if (!root || depth > 6) return | ||
| try { | ||
| addAll(root.querySelectorAll(sel)) | ||
| } catch { | ||
| // Invalid selector | ||
| return | ||
| } | ||
| const tree = root.querySelectorAll ? root.querySelectorAll("*") : [] | ||
| for (const el of tree) { | ||
| if (el.shadowRoot) { | ||
| walkRoot(el.shadowRoot, depth + 1) | ||
| } | ||
| } | ||
| // Same-origin iframes only | ||
| const frames = root.querySelectorAll ? root.querySelectorAll("iframe") : [] | ||
| for (const frame of frames) { | ||
| try { | ||
| const doc = frame.contentDocument | ||
| if (doc) walkRoot(doc, depth + 1) | ||
| } catch { | ||
| // cross-origin | ||
| } | ||
| } | ||
| } | ||
| walkRoot(rootDoc || document, 0) | ||
| return out | ||
| } | ||
| function tryClick(el) { | ||
| try { | ||
| el.scrollIntoView({ block: "center", inline: "center" }) | ||
| } catch {} | ||
| const rect = el.getBoundingClientRect() | ||
| const x = Math.min(Math.max(rect.left + rect.width / 2, 0), window.innerWidth - 1) | ||
| const y = Math.min(Math.max(rect.top + rect.height / 2, 0), window.innerHeight - 1) | ||
| const opts = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y } | ||
| try { | ||
| el.dispatchEvent(new MouseEvent("mouseover", opts)) | ||
| el.dispatchEvent(new MouseEvent("mousemove", opts)) | ||
| el.dispatchEvent(new MouseEvent("mousedown", opts)) | ||
| el.dispatchEvent(new MouseEvent("mouseup", opts)) | ||
| el.dispatchEvent(new MouseEvent("click", opts)) | ||
| } catch {} | ||
| try { | ||
| el.click() | ||
| } catch {} | ||
| } | ||
| for (const sel of selectors) { | ||
| const s = safeString(sel) | ||
| if (!s) continue | ||
| const matches = deepQuerySelectorAll(s, document) | ||
| const visible = matches.filter(isVisible) | ||
| const chosen = visible[index] || matches[index] | ||
| if (chosen) { | ||
| tryClick(chosen) | ||
| return { success: true, selectorUsed: s } | ||
| } | ||
| } | ||
| return { success: false, error: `Element not found for selectors: ${selectors.join(", ")}` } | ||
| }, | ||
| args: [selector], | ||
| args: [selectorList, index], | ||
| world: "ISOLATED", | ||
| }) | ||
| if (!result[0]?.result?.success) throw new Error(result[0]?.result?.error || "Click failed") | ||
| return { tabId: tab.id, content: `Clicked ${selector}` } | ||
| const used = result[0]?.result?.selectorUsed || selector | ||
| return { tabId: tab.id, content: `Clicked ${used}` } | ||
| } | ||
| async function toolType({ selector, text, tabId, clear = false }) { | ||
| async function toolType({ selector, text, tabId, clear = false, index = 0 }) { | ||
| if (!selector) throw new Error("Selector is required") | ||
@@ -182,24 +290,118 @@ if (text === undefined) throw new Error("Text is required") | ||
| const selectorList = normalizeSelectorList(selector) | ||
| const result = await chrome.scripting.executeScript({ | ||
| target: { tabId: tab.id }, | ||
| func: (sel, txt, shouldClear) => { | ||
| const el = document.querySelector(sel) | ||
| if (!el) return { success: false, error: `Element not found: ${sel}` } | ||
| el.focus() | ||
| if (shouldClear && (el.tagName === "INPUT" || el.tagName === "TEXTAREA")) el.value = "" | ||
| func: (selectors, txt, shouldClear, index) => { | ||
| function isVisible(el) { | ||
| if (!el) return false | ||
| const rect = el.getBoundingClientRect() | ||
| if (rect.width <= 0 || rect.height <= 0) return false | ||
| const style = window.getComputedStyle(el) | ||
| if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false | ||
| return true | ||
| } | ||
| if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") { | ||
| el.value = el.value + txt | ||
| el.dispatchEvent(new Event("input", { bubbles: true })) | ||
| el.dispatchEvent(new Event("change", { bubbles: true })) | ||
| } else if (el.isContentEditable) { | ||
| document.execCommand("insertText", false, txt) | ||
| function deepQuerySelectorAll(sel, rootDoc) { | ||
| const out = [] | ||
| const seen = new Set() | ||
| function addAll(nodeList) { | ||
| for (const el of nodeList) { | ||
| if (!el || seen.has(el)) continue | ||
| seen.add(el) | ||
| out.push(el) | ||
| } | ||
| } | ||
| function walkRoot(root, depth) { | ||
| if (!root || depth > 6) return | ||
| try { | ||
| addAll(root.querySelectorAll(sel)) | ||
| } catch { | ||
| return | ||
| } | ||
| const tree = root.querySelectorAll ? root.querySelectorAll("*") : [] | ||
| for (const el of tree) { | ||
| if (el.shadowRoot) { | ||
| walkRoot(el.shadowRoot, depth + 1) | ||
| } | ||
| } | ||
| const frames = root.querySelectorAll ? root.querySelectorAll("iframe") : [] | ||
| for (const frame of frames) { | ||
| try { | ||
| const doc = frame.contentDocument | ||
| if (doc) walkRoot(doc, depth + 1) | ||
| } catch {} | ||
| } | ||
| } | ||
| walkRoot(rootDoc || document, 0) | ||
| return out | ||
| } | ||
| return { success: true } | ||
| function setNativeValue(el, value) { | ||
| const tag = el.tagName | ||
| if (tag === "INPUT" || tag === "TEXTAREA") { | ||
| const proto = tag === "INPUT" ? window.HTMLInputElement.prototype : window.HTMLTextAreaElement.prototype | ||
| const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set | ||
| if (setter) setter.call(el, value) | ||
| else el.value = value | ||
| return true | ||
| } | ||
| return false | ||
| } | ||
| for (const sel of selectors) { | ||
| if (!sel) continue | ||
| const matches = deepQuerySelectorAll(sel, document) | ||
| const visible = matches.filter(isVisible) | ||
| const el = visible[index] || matches[index] | ||
| if (!el) continue | ||
| try { | ||
| el.scrollIntoView({ block: "center", inline: "center" }) | ||
| } catch {} | ||
| try { | ||
| el.focus() | ||
| } catch {} | ||
| const tag = el.tagName | ||
| const isTextInput = tag === "INPUT" || tag === "TEXTAREA" | ||
| if (isTextInput) { | ||
| if (shouldClear) setNativeValue(el, "") | ||
| setNativeValue(el, (el.value || "") + txt) | ||
| el.dispatchEvent(new Event("input", { bubbles: true })) | ||
| el.dispatchEvent(new Event("change", { bubbles: true })) | ||
| return { success: true, selectorUsed: sel } | ||
| } | ||
| if (el.isContentEditable) { | ||
| if (shouldClear) el.textContent = "" | ||
| try { | ||
| document.execCommand("insertText", false, txt) | ||
| } catch { | ||
| el.textContent = (el.textContent || "") + txt | ||
| } | ||
| el.dispatchEvent(new Event("input", { bubbles: true })) | ||
| return { success: true, selectorUsed: sel } | ||
| } | ||
| return { success: false, error: `Element is not typable: ${sel} (${tag.toLowerCase()})` } | ||
| } | ||
| return { success: false, error: `Element not found for selectors: ${selectors.join(", ")}` } | ||
| }, | ||
| args: [selector, text, clear], | ||
| args: [selectorList, text, !!clear, index], | ||
| world: "ISOLATED", | ||
| }) | ||
| if (!result[0]?.result?.success) throw new Error(result[0]?.result?.error || "Type failed") | ||
| return { tabId: tab.id, content: `Typed "${text}" into ${selector}` } | ||
| const used = result[0]?.result?.selectorUsed || selector | ||
| return { tabId: tab.id, content: `Typed "${text}" into ${used}` } | ||
| } | ||
@@ -219,19 +421,53 @@ | ||
| func: () => { | ||
| function safeText(s) { | ||
| return typeof s === "string" ? s : "" | ||
| } | ||
| function isVisible(el) { | ||
| if (!el) return false | ||
| const rect = el.getBoundingClientRect() | ||
| if (rect.width <= 0 || rect.height <= 0) return false | ||
| const style = window.getComputedStyle(el) | ||
| if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false | ||
| return true | ||
| } | ||
| function pseudoText(el) { | ||
| try { | ||
| const before = window.getComputedStyle(el, "::before").content | ||
| const after = window.getComputedStyle(el, "::after").content | ||
| const norm = (v) => { | ||
| const s = safeText(v) | ||
| if (!s || s === "none") return "" | ||
| return s.replace(/^"|"$/g, "") | ||
| } | ||
| return { before: norm(before), after: norm(after) } | ||
| } catch { | ||
| return { before: "", after: "" } | ||
| } | ||
| } | ||
| function getName(el) { | ||
| return ( | ||
| el.getAttribute("aria-label") || | ||
| el.getAttribute("alt") || | ||
| el.getAttribute("title") || | ||
| el.getAttribute("placeholder") || | ||
| el.innerText?.slice(0, 100) || | ||
| "" | ||
| ) | ||
| const aria = el.getAttribute("aria-label") | ||
| if (aria) return aria | ||
| const alt = el.getAttribute("alt") | ||
| if (alt) return alt | ||
| const title = el.getAttribute("title") | ||
| if (title) return title | ||
| const placeholder = el.getAttribute("placeholder") | ||
| if (placeholder) return placeholder | ||
| const txt = safeText(el.innerText) | ||
| if (txt.trim()) return txt.slice(0, 200) | ||
| const pt = pseudoText(el) | ||
| const combo = `${pt.before} ${pt.after}`.trim() | ||
| if (combo) return combo.slice(0, 200) | ||
| return "" | ||
| } | ||
| function build(el, depth = 0, uid = 0) { | ||
| if (depth > 10) return { nodes: [], nextUid: uid } | ||
| if (!el || depth > 12) return { nodes: [], nextUid: uid } | ||
| const nodes = [] | ||
| const style = window.getComputedStyle(el) | ||
| if (style.display === "none" || style.visibility === "hidden") return { nodes: [], nextUid: uid } | ||
| if (!isVisible(el)) return { nodes: [], nextUid: uid } | ||
| const isInteractive = | ||
@@ -242,16 +478,28 @@ ["A", "BUTTON", "INPUT", "TEXTAREA", "SELECT"].includes(el.tagName) || | ||
| el.isContentEditable | ||
| const rect = el.getBoundingClientRect() | ||
| if (rect.width > 0 && rect.height > 0 && (isInteractive || el.innerText?.trim())) { | ||
| const name = getName(el) | ||
| const pt = pseudoText(el) | ||
| const shouldInclude = isInteractive || name.trim() || pt.before || pt.after | ||
| if (shouldInclude) { | ||
| const node = { | ||
| uid: `e${uid}`, | ||
| role: el.getAttribute("role") || el.tagName.toLowerCase(), | ||
| name: getName(el).slice(0, 200), | ||
| name: name, | ||
| tag: el.tagName.toLowerCase(), | ||
| } | ||
| if (pt.before) node.before = pt.before | ||
| if (pt.after) node.after = pt.after | ||
| if (el.href) node.href = el.href | ||
| if (el.tagName === "INPUT") { | ||
| if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") { | ||
| node.type = el.type | ||
| node.value = el.value | ||
| if (el.readOnly) node.readOnly = true | ||
| if (el.disabled) node.disabled = true | ||
| } | ||
| if (el.id) node.selector = `#${el.id}` | ||
@@ -262,2 +510,3 @@ else if (el.className && typeof el.className === "string") { | ||
| } | ||
| nodes.push(node) | ||
@@ -267,2 +516,7 @@ uid++ | ||
| if (el.shadowRoot) { | ||
| const r = build(el.shadowRoot.host, depth + 1, uid) | ||
| uid = r.nextUid | ||
| } | ||
| for (const child of el.children) { | ||
@@ -273,2 +527,3 @@ const r = build(child, depth + 1, uid) | ||
| } | ||
| return { nodes, nextUid: uid } | ||
@@ -288,12 +543,21 @@ } | ||
| }) | ||
| return links.slice(0, 100) | ||
| return links.slice(0, 200) | ||
| } | ||
| let pageText = "" | ||
| try { | ||
| pageText = safeText(document.body?.innerText || "").slice(0, 20000) | ||
| } catch {} | ||
| const built = build(document.body).nodes.slice(0, 800) | ||
| return { | ||
| url: location.href, | ||
| title: document.title, | ||
| nodes: build(document.body).nodes.slice(0, 500), | ||
| text: pageText, | ||
| nodes: built, | ||
| links: getAllLinks(), | ||
| } | ||
| }, | ||
| world: "ISOLATED", | ||
| }) | ||
@@ -304,2 +568,91 @@ | ||
| async function toolExtract({ tabId, mode = "combined", pattern, flags = "i", limit = 20000 }) { | ||
| const tab = await getTabById(tabId) | ||
| const result = await chrome.scripting.executeScript({ | ||
| target: { tabId: tab.id }, | ||
| func: (mode, pattern, flags, limit) => { | ||
| const cap = (s) => String(s ?? "").slice(0, Math.max(0, limit || 0)) | ||
| const getPseudoText = () => { | ||
| const out = [] | ||
| const pushContent = (content) => { | ||
| if (!content) return | ||
| const c = String(content) | ||
| if (!c || c === "none" || c === "normal") return | ||
| const unquoted = c.replace(/^"|"$/g, "").replace(/^'|'$/g, "") | ||
| if (unquoted && unquoted !== "none" && unquoted !== "normal") out.push(unquoted) | ||
| } | ||
| const elements = Array.from(document.querySelectorAll("*")) | ||
| for (let i = 0; i < elements.length && out.length < 2000; i++) { | ||
| const el = elements[i] | ||
| try { | ||
| const style = window.getComputedStyle(el) | ||
| if (style.display === "none" || style.visibility === "hidden") continue | ||
| const before = window.getComputedStyle(el, "::before").content | ||
| const after = window.getComputedStyle(el, "::after").content | ||
| pushContent(before) | ||
| pushContent(after) | ||
| } catch { | ||
| // ignore | ||
| } | ||
| } | ||
| return out.join("\n") | ||
| } | ||
| const getInputValues = () => { | ||
| const out = [] | ||
| const nodes = document.querySelectorAll("input, textarea") | ||
| nodes.forEach((el) => { | ||
| try { | ||
| const name = el.getAttribute("aria-label") || el.getAttribute("name") || el.id || el.className || el.tagName | ||
| const value = el.value | ||
| if (value != null && String(value).trim()) out.push(`${name}: ${value}`) | ||
| } catch { | ||
| // ignore | ||
| } | ||
| }) | ||
| return out.join("\n") | ||
| } | ||
| const getText = () => { | ||
| try { | ||
| return document.body ? document.body.innerText || "" : "" | ||
| } catch { | ||
| return "" | ||
| } | ||
| } | ||
| const parts = [] | ||
| if (mode === "text" || mode === "combined") parts.push(getText()) | ||
| if (mode === "pseudo" || mode === "combined") parts.push(getPseudoText()) | ||
| if (mode === "inputs" || mode === "combined") parts.push(getInputValues()) | ||
| const text = cap(parts.filter(Boolean).join("\n\n")) | ||
| let matches = [] | ||
| if (pattern) { | ||
| try { | ||
| const re = new RegExp(pattern, flags || "") | ||
| const found = [] | ||
| let m | ||
| while ((m = re.exec(text)) && found.length < 50) { | ||
| found.push(m[0]) | ||
| if (!re.global) break | ||
| } | ||
| matches = found | ||
| } catch (e) { | ||
| matches = [] | ||
| } | ||
| } | ||
| return { url: location.href, title: document.title, mode, text, matches } | ||
| }, | ||
| args: [mode, pattern, flags, limit], | ||
| }) | ||
| return { tabId: tab.id, content: JSON.stringify(result[0]?.result, null, 2) } | ||
| } | ||
| async function toolGetTabs() { | ||
@@ -311,9 +664,254 @@ const tabs = await chrome.tabs.query({}) | ||
| async function toolQuery({ tabId, selector, mode = "text", attribute, property, limit = 50, index = 0 }) { | ||
| if (!selector) throw new Error("selector is required") | ||
| const tab = await getTabById(tabId) | ||
| const selectorList = normalizeSelectorList(selector) | ||
| const result = await chrome.scripting.executeScript({ | ||
| target: { tabId: tab.id }, | ||
| func: (selectors, mode, attribute, property, limit, index) => { | ||
| function isVisible(el) { | ||
| if (!el) return false | ||
| const rect = el.getBoundingClientRect() | ||
| if (rect.width <= 0 || rect.height <= 0) return false | ||
| const style = window.getComputedStyle(el) | ||
| if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false | ||
| return true | ||
| } | ||
| function deepQuerySelectorAll(sel, rootDoc) { | ||
| const out = [] | ||
| const seen = new Set() | ||
| function addAll(nodeList) { | ||
| for (const el of nodeList) { | ||
| if (!el || seen.has(el)) continue | ||
| seen.add(el) | ||
| out.push(el) | ||
| } | ||
| } | ||
| function walkRoot(root, depth) { | ||
| if (!root || depth > 6) return | ||
| try { | ||
| addAll(root.querySelectorAll(sel)) | ||
| } catch { | ||
| return | ||
| } | ||
| const tree = root.querySelectorAll ? root.querySelectorAll("*") : [] | ||
| for (const el of tree) { | ||
| if (el.shadowRoot) { | ||
| walkRoot(el.shadowRoot, depth + 1) | ||
| } | ||
| } | ||
| const frames = root.querySelectorAll ? root.querySelectorAll("iframe") : [] | ||
| for (const frame of frames) { | ||
| try { | ||
| const doc = frame.contentDocument | ||
| if (doc) walkRoot(doc, depth + 1) | ||
| } catch {} | ||
| } | ||
| } | ||
| walkRoot(rootDoc || document, 0) | ||
| return out | ||
| } | ||
| for (const sel of selectors) { | ||
| const matches = deepQuerySelectorAll(sel, document) | ||
| if (!matches.length) continue | ||
| const visible = matches.filter(isVisible) | ||
| const chosen = visible[index] || matches[index] | ||
| if (mode === "exists") { | ||
| return { ok: true, selectorUsed: sel, exists: true, count: matches.length } | ||
| } | ||
| if (!chosen) return { ok: false, error: `No element at index ${index} for ${sel}`, selectorUsed: sel } | ||
| if (mode === "text") { | ||
| const text = (chosen.innerText || chosen.textContent || "").trim() | ||
| return { ok: true, selectorUsed: sel, value: text } | ||
| } | ||
| if (mode === "value") { | ||
| const v = chosen.value | ||
| return { ok: true, selectorUsed: sel, value: typeof v === "string" ? v : String(v ?? "") } | ||
| } | ||
| if (mode === "attribute") { | ||
| const a = attribute ? chosen.getAttribute(attribute) : null | ||
| return { ok: true, selectorUsed: sel, value: a } | ||
| } | ||
| if (mode === "property") { | ||
| if (!property) return { ok: false, error: "property is required", selectorUsed: sel } | ||
| const v = chosen[property] | ||
| return { ok: true, selectorUsed: sel, value: v } | ||
| } | ||
| if (mode === "html") { | ||
| return { ok: true, selectorUsed: sel, value: chosen.outerHTML } | ||
| } | ||
| if (mode === "list") { | ||
| const items = matches | ||
| .slice(0, Math.max(1, Math.min(200, limit))) | ||
| .map((el) => ({ | ||
| text: (el.innerText || el.textContent || "").trim().slice(0, 200), | ||
| tag: (el.tagName || "").toLowerCase(), | ||
| ariaLabel: el.getAttribute ? el.getAttribute("aria-label") : null, | ||
| })) | ||
| return { ok: true, selectorUsed: sel, items, count: matches.length } | ||
| } | ||
| return { ok: false, error: `Unknown mode: ${mode}`, selectorUsed: sel } | ||
| } | ||
| return { ok: false, error: `No matches for selectors: ${selectors.join(", ")}` } | ||
| }, | ||
| args: [selectorList, mode, attribute || null, property || null, limit, index], | ||
| world: "ISOLATED", | ||
| }) | ||
| const r = result[0]?.result | ||
| if (!r?.ok) throw new Error(r?.error || "Query failed") | ||
| // Keep output predictable: JSON for list/property, string otherwise | ||
| if (mode === "list" || mode === "property") { | ||
| return { tabId: tab.id, content: JSON.stringify(r, null, 2) } | ||
| } | ||
| return { tabId: tab.id, content: typeof r.value === "string" ? r.value : JSON.stringify(r.value) } | ||
| } | ||
| async function toolWaitFor({ tabId, selector, timeoutMs = 10000, pollMs = 200 }) { | ||
| if (!selector) throw new Error("selector is required") | ||
| const tab = await getTabById(tabId) | ||
| const selectorList = normalizeSelectorList(selector) | ||
| const result = await chrome.scripting.executeScript({ | ||
| target: { tabId: tab.id }, | ||
| func: async (selectors, timeoutMs, pollMs) => { | ||
| function isVisible(el) { | ||
| if (!el) return false | ||
| const rect = el.getBoundingClientRect() | ||
| if (rect.width <= 0 || rect.height <= 0) return false | ||
| const style = window.getComputedStyle(el) | ||
| if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false | ||
| return true | ||
| } | ||
| function deepQuerySelector(sel, rootDoc) { | ||
| function findInRoot(root, depth) { | ||
| if (!root || depth > 6) return null | ||
| try { | ||
| const found = root.querySelector(sel) | ||
| if (found) return found | ||
| } catch { | ||
| return null | ||
| } | ||
| const tree = root.querySelectorAll ? root.querySelectorAll("*") : [] | ||
| for (const el of tree) { | ||
| if (el.shadowRoot) { | ||
| const f = findInRoot(el.shadowRoot, depth + 1) | ||
| if (f) return f | ||
| } | ||
| } | ||
| const frames = root.querySelectorAll ? root.querySelectorAll("iframe") : [] | ||
| for (const frame of frames) { | ||
| try { | ||
| const doc = frame.contentDocument | ||
| if (doc) { | ||
| const f = findInRoot(doc, depth + 1) | ||
| if (f) return f | ||
| } | ||
| } catch {} | ||
| } | ||
| return null | ||
| } | ||
| return findInRoot(rootDoc || document, 0) | ||
| } | ||
| const start = Date.now() | ||
| while (Date.now() - start < timeoutMs) { | ||
| for (const sel of selectors) { | ||
| if (!sel) continue | ||
| const el = deepQuerySelector(sel, document) | ||
| if (el && isVisible(el)) return { ok: true, selectorUsed: sel } | ||
| } | ||
| await new Promise((r) => setTimeout(r, pollMs)) | ||
| } | ||
| return { ok: false, error: `Timed out waiting for selectors: ${selectors.join(", ")}` } | ||
| }, | ||
| args: [selectorList, timeoutMs, pollMs], | ||
| world: "ISOLATED", | ||
| }) | ||
| const r = result[0]?.result | ||
| if (!r?.ok) throw new Error(r?.error || "wait_for failed") | ||
| return { tabId: tab.id, content: `Found ${r.selectorUsed}` } | ||
| } | ||
| // Legacy tool kept for compatibility. | ||
| // We intentionally do NOT evaluate arbitrary JS strings (unpredictable + CSP/unsafe-eval issues). | ||
| // Instead, accept a JSON payload string describing a query. | ||
| async function toolExecuteScript({ code, tabId }) { | ||
| if (!code) throw new Error("Code is required") | ||
| let command | ||
| try { | ||
| command = JSON.parse(code) | ||
| } catch { | ||
| throw new Error( | ||
| "browser_execute expects JSON (not raw JS) due to MV3 CSP. Try: {\"op\":\"query\",\"selector\":\"...\",\"return\":\"text\" } or use browser_extract." | ||
| ) | ||
| } | ||
| const tab = await getTabById(tabId) | ||
| const result = await chrome.scripting.executeScript({ | ||
| target: { tabId: tab.id }, | ||
| func: new Function(code), | ||
| func: (cmd) => { | ||
| const getBySelector = (selector) => { | ||
| if (!selector) return null | ||
| try { | ||
| return document.querySelector(selector) | ||
| } catch { | ||
| return null | ||
| } | ||
| } | ||
| const op = cmd?.op | ||
| if (op === "query") { | ||
| const el = getBySelector(cmd.selector) | ||
| if (!el) return { ok: false, error: "not_found" } | ||
| const ret = cmd.return || "text" | ||
| if (ret === "text") return { ok: true, value: el.innerText ?? el.textContent ?? "" } | ||
| if (ret === "value") return { ok: true, value: el.value } | ||
| if (ret === "html") return { ok: true, value: el.innerHTML } | ||
| if (ret === "attr") return { ok: true, value: el.getAttribute(cmd.name) } | ||
| if (ret === "href") return { ok: true, value: el.href } | ||
| return { ok: false, error: `unknown_return:${ret}` } | ||
| } | ||
| if (op === "location") { | ||
| return { ok: true, value: { url: location.href, title: document.title } } | ||
| } | ||
| return { ok: false, error: `unknown_op:${String(op)}` } | ||
| }, | ||
| args: [command], | ||
| }) | ||
| return { tabId: tab.id, content: JSON.stringify(result[0]?.result) } | ||
@@ -339,2 +937,3 @@ } | ||
| args: [x, y, sel], | ||
| world: "ISOLATED", | ||
| }) | ||
@@ -346,5 +945,2 @@ | ||
| async function toolWait({ ms = 1000, tabId }) { | ||
| if (typeof tabId === "number") { | ||
| // keep tabId in response for ownership purposes | ||
| } | ||
| await new Promise((resolve) => setTimeout(resolve, ms)) | ||
@@ -366,2 +962,2 @@ return { tabId, content: `Waited ${ms}ms` } | ||
| connect() | ||
| connect() |
| { | ||
| "manifest_version": 3, | ||
| "name": "OpenCode Browser Automation", | ||
| "version": "4.0.0", | ||
| "version": "4.1.0", | ||
| "description": "Browser automation for OpenCode", | ||
@@ -6,0 +6,0 @@ "permissions": [ |
+1
-1
| { | ||
| "name": "@different-ai/opencode-browser", | ||
| "version": "4.0.7", | ||
| "version": "4.1.0", | ||
| "description": "Browser automation plugin for OpenCode (native messaging + per-tab ownership).", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+4
-1
@@ -72,3 +72,6 @@ # OpenCode Browser | ||
| - `browser_wait` | ||
| - `browser_execute` | ||
| - `browser_execute` (deprecated, CSP-limited) | ||
| - `browser_query` | ||
| - `browser_wait_for` | ||
| - `browser_extract` | ||
@@ -75,0 +78,0 @@ ## Troubleshooting |
Sorry, the diff of this file is too big to display
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
AI-detected potential security risk
Supply chain riskAI has determined that this package may contain potential security issues or vulnerabilities.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Uses eval
Supply chain riskPackage uses dynamic code execution (e.g., eval()), which is a dangerous practice. This can prevent the code from running in certain environments and increases the risk that the code may contain exploits or malicious behavior.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
480056
4.72%14215
4.02%94
3.3%5
-28.57%25
4.17%