diff options
| author | Tianren Dong <tianrendong.dtr@gmail.com> | 2026-09-26 22:42:19 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-26 22:42:19 -0400 |
| commit | 4d93d850d2b1bb0c1967e87e9a09d841f8c5020c (patch) | |
| tree | 91e9464aa10dfb9cf0e146db5ceeb8738ea1bf86 | |
| parent | b8dd042d899df98898a299cdb1e030f19983b835 (diff) | |
| parent | f034b8517d0903455e1b9729e876420cbfd002fb (diff) | |
Merge pull request #22 from tianrendong/fix/early-capture-scope
Stop console capture from tripping DevTools detection; scope early capture to Pi tabs
| -rw-r--r-- | CHANGELOG.md | 5 | ||||
| -rw-r--r-- | extensions/chrome-profile-bridge/browser-extension/service_worker.js | 124 | ||||
| -rw-r--r-- | extensions/chrome-profile-bridge/browser-extension/snapshot_injected.js | 10 | ||||
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | test-suite/unit/console-capture.test.mjs | 122 |
5 files changed, 227 insertions, 36 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index d0f7ae3..081c2d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable user-facing changes to `pi-chrome`. +## Unreleased + +- **Console capture no longer breaks sites with DevTools detection (#9).** Some sites (BOSS Zhipin) log an object with a getter or custom `toString` and close the tab if anything reads it, since only open DevTools would. The console capture serialized every logged argument immediately, so it tripped the check. Captured arguments are now stored as-is, and `chrome_list_console_messages` serializes them without running page code: data properties only, accessors shown as `[getter]`, and DOM nodes, regexes, dates and errors described through Chrome's native getters. +- **Early capture only in Pi's tabs.** The document-start console/network capture used to be injected into every page you opened. It now runs only in Pi's automation tab and in tabs tracked for a Pi session. Other tabs are not touched until a Pi tool acts on them. + ## 0.15.54 — 2026-09-26 - **Hidden tabs report instead of silently dropping input.** Chrome ignores trusted input to hidden pages. A page is hidden when it is an inactive tab, its window is minimized, its window is fully covered by another window, or (on macOS) its window became a hidden window tab behind a full-screen window. In a hidden page, mouse presses and keys were dropped while the tool reported success, and mouse moves could hang until a timeout (the source of `Detached while handling command` in background runs). Input tools now check page visibility first. `chrome_type`, `chrome_key`, `chrome_hover`, `chrome_scroll`, `chrome_tap` and `chrome_drag` fail fast with the reason and the options (`background:false`, or `/chrome background off`). `chrome_click` and `chrome_fill` go straight to their existing DOM fallback and report the reason, or reject with `domFallback:false`. Hard background mode is unchanged: nothing is activated or focused. diff --git a/extensions/chrome-profile-bridge/browser-extension/service_worker.js b/extensions/chrome-profile-bridge/browser-extension/service_worker.js index ead8ae0..24caa58 100644 --- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js +++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js @@ -2293,14 +2293,22 @@ async function unregisterInitScript(tabId) { await cdp(tabId, "Page.removeScriptToEvaluateOnNewDocument", { identifier }).catch(() => undefined); } -// Always inject early console/network capture at document_start on every navigation. -// Catches console messages, errors, and network requests that fire during page load, -// before chrome_snapshot or chrome_evaluate install the instrumentation normally. -// The function installEarlyCapture sets __piChromeWrapped flags so the post-hoc -// installPiChromeInstrumentation() call is idempotent. +// Inject early console/network capture at document_start, but only into tabs Pi owns or has used +// (its automation tab and tabs tracked for a session). Your other tabs are never patched. Catches +// console messages, errors, and network requests that fire during page load, before chrome_snapshot +// or chrome_evaluate install the instrumentation normally. installEarlyCapture sets __piChromeWrapped +// flags so the post-hoc installPiChromeInstrumentation() call is idempotent. +async function isPiTrackedTab(tabId) { + await Promise.all([hydrateAutomationTargets(), hydrateSessionTabs()]); + if (isPiChromeOwnedTarget(tabId)) return true; + for (const tabs of sessionTabs.values()) if (tabs.has(tabId)) return true; + return false; +} + if (chrome.webNavigation && chrome.webNavigation.onCommitted) { - chrome.webNavigation.onCommitted.addListener((details) => { + chrome.webNavigation.onCommitted.addListener(async (details) => { if (details.frameId !== 0) return; + if (!(await isPiTrackedTab(details.tabId).catch(() => false))) return; chrome.scripting.executeScript({ target: { tabId: details.tabId, frameIds: [0] }, world: "MAIN", @@ -2592,15 +2600,9 @@ function installPiChromeInstrumentation() { level, timestamp: Date.now(), url: location.href, - args: Array.from(args).map((arg) => { - try { - if (typeof arg === "string") return arg; - if (arg instanceof Error) return { name: arg.name, message: arg.message, stack: arg.stack }; - return JSON.parse(JSON.stringify(arg)); - } catch { - return String(arg); - } - }), + // Keep the raw values and serialize only when Pi lists messages. Serializing here reads every + // logged object (getters, toString), which is exactly what anti-DevTools checks watch for. + args: Array.from(args), }); if (state.console.length > 500) state.console.splice(0, state.console.length - 500); }; @@ -2715,15 +2717,7 @@ function installEarlyCapture() { level: level, timestamp: Date.now(), url: location.href, - args: Array.from(args).map(function(arg) { - try { - if (typeof arg === "string") return arg; - if (arg instanceof Error) return { name: arg.name, message: arg.message, stack: arg.stack }; - return JSON.parse(JSON.stringify(arg)); - } catch (e) { - return String(arg); - } - }), + args: Array.from(args), // raw; serialized on read (see listConsoleMessages) }); if (state.console.length > 500) state.console.splice(0, state.console.length - 500); } @@ -2917,7 +2911,7 @@ async function clickPage(selector, uid, x, y) { // Inspect recent console errors for activation-gate rejections. const recent = (state.console || []).slice(-8); const hit = recent.find((e) => /NotAllowedError|Document is not focused|requires transient activation|gesture is required/.test( - (e.args || []).map((a) => typeof a === "string" ? a : (a && a.message) || JSON.stringify(a)).join(" ") + (e.args || []).map((a) => typeof a === "string" ? a : a instanceof Error ? a.message : "").join(" ") )); if (hit) { suggestChromeInput = true; suggestReason = "recent console error indicates user-activation gate"; } } @@ -3285,7 +3279,85 @@ async function pressKeyInPage(key) { function listConsoleMessages(clear) { installPiChromeInstrumentation(); const state = getPiChromeState(); - const messages = state.console.slice(); + // Serialize without running page code. Anti-DevTools checks log objects with getters or a custom + // toString and treat any call as "DevTools is open" (issue #9). Copy data properties only, report + // accessors as "[getter]" without calling them, and describe built-ins through native getters. + const nativeSource = Function.prototype.toString; + const isNative = (fn) => { try { return typeof fn === "function" && nativeSource.call(fn).includes("[native code]"); } catch { return false; } }; + const protoGetter = (proto, key) => { try { return Object.getOwnPropertyDescriptor(proto, key)?.get; } catch { return undefined; } }; + const callGetter = (proto, key, value) => { const get = protoGetter(proto, key); return get ? get.call(value) : undefined; }; + const dataProp = (value, key) => { + for (let obj = value, hops = 0; obj && hops < 20; obj = Object.getPrototypeOf(obj), hops++) { + const desc = Object.getOwnPropertyDescriptor(obj, key); + if (!desc) continue; + if ("value" in desc) return { ok: true, value: desc.value }; + return isNative(desc.get) ? { ok: true, value: desc.get.call(value) } : { ok: false }; + } + return { ok: true, value: undefined }; + }; + const serialize = (value, depth = 0, seen = new Set()) => { + try { + const type = typeof value; + if (value === null || type === "string" || type === "boolean") return value; + if (type === "number") return Number.isFinite(value) ? value : String(value); + if (type === "undefined") return "undefined"; + if (type === "bigint") return `${BigInt.prototype.toString.call(value)}n`; + if (type === "symbol") return Symbol.prototype.toString.call(value); + if (type === "function") { + const name = dataProp(value, "name"); + return `[function ${name.ok && typeof name.value === "string" && name.value ? name.value : "anonymous"}]`; + } + if (seen.has(value)) return "[circular]"; + if (value instanceof Error) { + const out = {}; + for (const key of ["name", "message"]) { + const prop = dataProp(value, key); + out[key] = prop.ok ? (typeof prop.value === "string" ? prop.value : serialize(prop.value, depth + 1, seen)) : "[getter]"; + } + // V8 formats `stack` lazily from name/message, so only read it when both are plain data. + const stack = out.name !== "[getter]" && out.message !== "[getter]" ? dataProp(value, "stack") : { ok: false }; + out.stack = stack.ok ? (typeof stack.value === "string" ? stack.value : undefined) : "[getter]"; + return out; + } + if (value instanceof Date) return Date.prototype.toISOString.call(value); + if (value instanceof RegExp) return `/${callGetter(RegExp.prototype, "source", value)}/${callGetter(RegExp.prototype, "flags", value)}`; + if (typeof Node !== "undefined" && value instanceof Node) { + const name = String(callGetter(Node.prototype, "nodeName", value) || "node").toLowerCase(); + if (typeof Element === "undefined" || !(value instanceof Element)) return `<${name}>`; + const id = callGetter(Element.prototype, "id", value); + const cls = String(callGetter(Element.prototype, "className", value) || "").trim().split(/\s+/).filter(Boolean).slice(0, 3); + return `<${name}${id ? `#${id}` : ""}${cls.map((c) => `.${c}`).join("")}>`; + } + if (value instanceof Map) return `[Map(${callGetter(Map.prototype, "size", value)})]`; + if (value instanceof Set) return `[Set(${callGetter(Set.prototype, "size", value)})]`; + if (depth >= 4) return Array.isArray(value) ? "[array]" : "[object]"; + seen.add(value); + if (Array.isArray(value)) { + const length = dataProp(value, "length"); + const n = length.ok ? Math.min(Number(length.value) || 0, 50) : 0; + const out = []; + for (let i = 0; i < n; i++) { + const item = dataProp(value, String(i)); + out.push(item.ok ? serialize(item.value, depth + 1, seen) : "[getter]"); + } + if (length.ok && length.value > 50) out.push(`[${length.value - 50} more]`); + seen.delete(value); + return out; + } + const out = {}; + const keys = Object.keys(value); + for (const key of keys.slice(0, 50)) { + const desc = Object.getOwnPropertyDescriptor(value, key); + out[key] = desc && "value" in desc ? serialize(desc.value, depth + 1, seen) : "[getter]"; + } + if (keys.length > 50) out["…"] = `${keys.length - 50} more keys`; + seen.delete(value); + return out; + } catch { + return "[unserializable]"; + } + }; + const messages = state.console.map((entry) => ({ ...entry, args: Array.from(entry.args || [], (arg) => serialize(arg)) })); if (clear) state.console = []; return { messages, count: messages.length }; } diff --git a/extensions/chrome-profile-bridge/browser-extension/snapshot_injected.js b/extensions/chrome-profile-bridge/browser-extension/snapshot_injected.js index ceee8ce..fd549a0 100644 --- a/extensions/chrome-profile-bridge/browser-extension/snapshot_injected.js +++ b/extensions/chrome-profile-bridge/browser-extension/snapshot_injected.js @@ -125,15 +125,7 @@ level, timestamp: Date.now(), url: location.href, - args: Array.from(args).map((arg) => { - try { - if (typeof arg === "string") return arg; - if (arg instanceof Error) return { name: arg.name, message: arg.message, stack: arg.stack }; - return JSON.parse(JSON.stringify(arg)); - } catch { - return String(arg); - } - }), + args: Array.from(args), // raw; serialized on read by listConsoleMessages }); if (state.console.length > 500) state.console.splice(0, state.console.length - 500); }; diff --git a/package.json b/package.json index 6767806..3904c9f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "pi-chrome", "version": "0.15.54", "scripts": { - "test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs && node test-suite/unit/session-cleanup.test.mjs && node test-suite/unit/background-policy.test.mjs && node test-suite/unit/input-reliability.test.mjs && node test-suite/unit/chrome-command.test.mjs && node test-suite/unit/bridge-resilience.test.mjs && node test-suite/unit/cdp-passthrough.test.mjs && node test-suite/unit/type-evidence.test.mjs && node test-suite/unit/hidden-input.test.mjs", + "test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs && node test-suite/unit/session-cleanup.test.mjs && node test-suite/unit/background-policy.test.mjs && node test-suite/unit/input-reliability.test.mjs && node test-suite/unit/chrome-command.test.mjs && node test-suite/unit/bridge-resilience.test.mjs && node test-suite/unit/cdp-passthrough.test.mjs && node test-suite/unit/type-evidence.test.mjs && node test-suite/unit/hidden-input.test.mjs && node test-suite/unit/console-capture.test.mjs", "version": "node scripts/sync-manifest-version.js", "prepublishOnly": "node scripts/sync-manifest-version.js" }, diff --git a/test-suite/unit/console-capture.test.mjs b/test-suite/unit/console-capture.test.mjs new file mode 100644 index 0000000..ba0410b --- /dev/null +++ b/test-suite/unit/console-capture.test.mjs @@ -0,0 +1,122 @@ +// Console capture must not trip anti-DevTools checks (issue #9) and must only touch Pi's tabs. +// Sites detect DevTools by logging an object whose getter/toString fires when something inspects +// it. Closed DevTools never does; our capture used to serialize every argument immediately. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import vm from "node:vm"; +import { test } from "node:test"; + +const workerSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/browser-extension/service_worker.js", import.meta.url), "utf8"); +const snapshotSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/browser-extension/snapshot_injected.js", import.meta.url), "utf8"); + +function loadWorker() { + const navListeners = []; + const injected = []; + const noop = { addListener() {}, removeListener() {} }; + const chrome = { + runtime: { id: "test", getManifest: () => ({ version: "0.0.0" }), onInstalled: noop, onStartup: noop }, + alarms: { create() {}, onAlarm: noop }, action: { onClicked: noop }, + webNavigation: { onCommitted: { addListener: (fn) => navListeners.push(fn) } }, + debugger: { onDetach: noop, onEvent: noop }, + storage: { session: { get: async () => ({}), set: async () => {} } }, + scripting: { executeScript: async (options) => { injected.push(options); return []; } }, + tabs: { get: async () => null }, + }; + const w = { chrome, console, setTimeout, clearTimeout, setInterval: () => 0, navigator: { userAgent: "unit-test" }, fetch: async () => { throw new Error("offline"); } }; + w.self = w; + vm.runInNewContext(workerSource, w); + const commit = async (tabId, frameId = 0) => { + for (const fn of navListeners) await fn({ tabId, frameId, url: "https://site.test/" }); + }; + return { w, injected, commit }; +} + +// A page world whose console records native calls and a DevTools trap object. +function pageWorld() { + const nativeCalls = []; + const trap = { tripped: 0 }; + const page = vm.createContext({ + console: Object.fromEntries(["debug", "log", "info", "warn", "error"].map((l) => [l, (...a) => nativeCalls.push([l, ...a])])), + location: { href: "https://site.test/" }, + addEventListener() {}, + fetch: undefined, XMLHttpRequest: undefined, + }); + page.window = page; + page.trip = () => { trap.tripped++; }; + vm.runInContext(` + var bait = { name: "bait" }; + Object.defineProperty(bait, "id", { enumerable: true, get() { trip(); return "x"; } }); + bait.toString = function toString() { trip(); return "bait"; }; + var trapError = new Error("trap"); + Object.defineProperty(trapError, "message", { get() { trip(); return "m"; } }); + var trapRegex = /a+/g; + trapRegex.toString = function () { trip(); return "re"; }; + Object.defineProperty(trapRegex, "source", { get() { trip(); return "x"; } }); + var trapDate = new Date(0); + trapDate.toString = function () { trip(); return "d"; }; + `, page); + return { page, trap, nativeCalls }; +} + +const fnSource = (name) => { + const start = workerSource.indexOf(`function ${name}(`); + let depth = 0; + for (let i = workerSource.indexOf("{", start); i < workerSource.length; i++) { + if (workerSource[i] === "{") depth++; + else if (workerSource[i] === "}" && --depth === 0) return workerSource.slice(start, i + 1); + } + throw new Error(`missing ${name}`); +}; + +for (const [label, install] of [ + ["early capture", (page) => vm.runInContext(`(${fnSource("installEarlyCapture")})()`, page)], + ["post-hoc instrumentation", (page) => vm.runInContext(`${fnSource("getPiChromeState")}\n(${fnSource("installPiChromeInstrumentation")})()`, page)], + ["snapshot_injected instrumentation", (page) => { vm.runInContext(snapshotSource, page); }], +]) { + test(`${label}: logging an object does not read it until Pi lists messages`, () => { + const { page, trap, nativeCalls } = pageWorld(); + install(page); + if (label.startsWith("snapshot")) { + // snapshot_injected installs lazily through its snapshot entry point; force the install. + assert.equal(typeof page.__piChromeSnapshotPage, "function"); + try { page.__piChromeSnapshotPage({}); } catch {} + } + vm.runInContext("console.log('hello', bait); console.error(new Error('boom')); console.log(trapError, trapRegex, trapDate)", page); + assert.equal(trap.tripped, 0, "no getter/toString fired while the page runs"); + assert.equal(nativeCalls.length, 3, "native console still receives the calls"); + const listed = vm.runInContext(`${fnSource("getPiChromeState")}\n${fnSource("installPiChromeInstrumentation")}\n(${fnSource("listConsoleMessages")})(false)`, page); + assert.equal(trap.tripped, 0, "listing does not fire page getters or toString either"); + assert.equal(listed.count, 3); + assert.equal(listed.messages[0].args[0], "hello"); + assert.deepEqual(JSON.parse(JSON.stringify(listed.messages[0].args[1])), { name: "bait", id: "[getter]", toString: "[function toString]" }); + assert.equal(listed.messages[1].args[0].message, "boom"); + assert.match(listed.messages[1].args[0].stack, /boom/); + const [err, re, date] = listed.messages[2].args; + assert.equal(err.message, "[getter]"); + assert.equal(re, "/a+/g", "regex described through native getters"); + assert.equal(date, "1970-01-01T00:00:00.000Z"); + }); +} + +test("listing survives values that cannot be serialized", () => { + const { page } = pageWorld(); + vm.runInContext(`(${fnSource("installEarlyCapture")})()`, page); + vm.runInContext("const loop = { a: [1, 2n, undefined, Symbol('s')] }; loop.self = loop; console.log(loop); console.log(new Proxy({}, { ownKeys() { throw new Error('no'); } }), new Map([[1, 2]]), function named() {})", page); + const listed = vm.runInContext(`${fnSource("getPiChromeState")}\n${fnSource("installPiChromeInstrumentation")}\n(${fnSource("listConsoleMessages")})(true)`, page); + assert.equal(listed.count, 2); + assert.deepEqual(JSON.parse(JSON.stringify(listed.messages[0].args[0])), { a: [1, "2n", "undefined", "Symbol(s)"], self: "[circular]" }); + assert.deepEqual(JSON.parse(JSON.stringify(listed.messages[1].args)), ["[unserializable]", "[Map(1)]", "[function named]"]); + assert.equal(vm.runInContext("window.__PI_CHROME_STATE__.console.length", page), 0, "clear still empties the buffer"); +}); + +test("early capture is injected only into tabs Pi owns or tracks", async () => { + const h = loadWorker(); + await h.commit(10); + assert.equal(h.injected.length, 0, "a user tab is never patched"); + vm.runInContext("automationTargets.set('s', { tabId: 11 }); sessionTabs.set('s', new Map([[12, { created: true }]]));", h.w); + await h.commit(11); + await h.commit(12); + await h.commit(11, 3); // subframe + assert.deepEqual(h.injected.map((o) => o.target.tabId), [11, 12]); + assert.ok(h.injected.every((o) => o.func.name === "installEarlyCapture" && o.injectImmediately === true)); +}); |
