building vodou.

Another extension's iframe can lock your chrome.debugger agent out of a tab

Chrome refuses a debugger attach to another extension's chrome-extension:// frame. If your CDP agent auto-attaches to every iframe, skip those targets first.

Chad Priest / / 6 min read

If your agent is a Chrome extension that drives tabs through chrome.debugger, it shares every tab with every other extension the person has installed. Password managers and shopping add-ons put their own chrome-extension:// frames into ordinary web pages, and Chrome will not let one extension debug another extension’s frame. When one of those frames is in the tab, your attach gets refused.

That’s the scope: extensions that use chrome.debugger. I first wrote that this also hits Playwright and Puppeteer over --remote-debugging-port. I never ran that case. The check compares the frame’s extension against the extension that’s calling, and a client on a debugging port isn’t an extension, so it has nothing to compare. I can’t show it failing there, so I’m not claiming it.

On 2026-09-29 my agent was checking an Amazon order status in my own Chrome, and it died at the first step with this:

Cannot access a chrome-extension:// URL of different extension (ATTACH)

(ATTACH) is my own label. My bridge tags any error from chrome.debugger.attach with it. The rest is Chrome’s message. So Chrome refused the attach call itself. My agent never got as far as clicking anything.

The two-minute check: in your extension, find the chrome.debugger.attach callback and the Target.attachedToTarget handler. If the callback doesn’t recognize this error string, and the handler never looks at targetInfo.url, you’re exposed in both places.

The error said “extension,” and I read “Amazon

I blamed Amazon first. I was wrong, and the error had already told me so. It doesn’t say the page is restricted. It says different extension. Something on that tab was a chrome-extension:// URL belonging to an extension that wasn’t mine. Amazon pages don’t serve those. Other extensions inject them.

The next step should have been to log which frame it was. I didn’t. The comment I left next to the fix lists 1Password, Honey and Amazon Assistant, which tells you I was guessing from what I had installed. The repro below does the step I skipped: it prints every child target’s type and url as it arrives, and a chrome-extension://<id>/ URL gives you the ID to match against chrome://extensions.

This is the general class: auto-attach hands you targets you don’t own, and Chrome holds you responsible for every one of them.

Two failures from Target.setAutoAttach, and a fix for each

My agent attaches with Target.setAutoAttach({ autoAttach: true, waitForDebuggerOnStart: false, flatten: true }) so cross-origin iframes, like a booking dialog, show up as child sessions it can work inside. That gives you two ways to run into someone else’s frame.

The frame is already there when you attach. That’s the failure I actually saw. No event-handler filter helps, because Chrome refuses before any event fires. All you can do is recognize the error and tell the person what to do:

if (/chrome-extension:\/\/ URL of different extension/i.test(msg)) {
  reject(err('OTHER_EXT', 'another Chrome extension is on this page (password manager or shopping add-on). Open the site in a normal https tab, or turn that extension off for this window, then retry'));
}

The frame shows up after you attach. An inline password menu appears when someone focuses a username field, and that’s exactly when an agent is typing into a login form. In flatten mode, Chrome has already attached to the child by the time Target.attachedToTarget fires. So returning early doesn’t undo the attach. It stops what my handler did next, which was send Target.setAutoAttach and Accessibility.enable into the child’s sessionId. I can’t tell you which of those two Chrome objected to, because both ended in .catch(() => {}). I threw away the evidence. Here’s the handler now, with the predicate:

function isExtUrl(u) { return /^chrome(-extension)?:/i.test(String(u || '')); }

if (method === 'Target.attachedToTarget' && params?.targetInfo?.type === 'iframe') {
  if (isExtUrl(params.targetInfo.url)) return;   // send nothing into it
  // ...register the child session, enable domains
}

That predicate is stricter than it needs to be. It skips every chrome:// and chrome-extension:// frame, mine included. That’s fine for me because my bridge never puts its own frames into pages. If yours does, exempt your own ID, which is chrome.runtime.id:

const OWN = `chrome-extension://${chrome.runtime.id}/`;
const isForeignExt = (u = '') => u.startsWith('chrome-extension://') && !u.startsWith(OWN);

The bare return is only correct because I use waitForDebuggerOnStart: false. With true, Chrome pauses every new child until a debugger lets it run. If you ignore a child, it stays paused, and the person’s password menu freezes. In that mode, send Target.detachFromTarget({ sessionId }) on the parent session instead of returning.

Where the UiPath answer and the Chromanche patch stop

The UiPath forum thread gets the cause right (a Kaspersky iframe, and a hard check in Chromium), then tells you to disable the extension. An agent running on someone else’s machine can’t do that. Chromanche’s commit falls back to coordinate clicks when the focus step hits the error. That’s a sensible patch, but it handles the symptom at one call site. The frame still gets followed, and the next command can fail the same way.

Every extension the user installed shares your tab

An agent that auto-attaches to child targets must not send anything into a target whose URL is chrome-extension:// and not its own, and must treat a refused attach as “someone else is here,” not as a crash. You can check that rule against any codebase. It either holds or it doesn’t.

To see it on your own Chrome, use a scratch profile so you don’t touch your real one. Since Chrome 136 you couldn’t point --remote-debugging-port at your default profile anyway. Launch Chrome with --user-data-dir=/tmp/attach-repro, install a password manager from the Web Store, and sign in to it so it has a saved login. Then save these two files in a folder and load it with Load unpacked at chrome://extensions (turn on Developer mode first).

manifest.json:

{
  "manifest_version": 3,
  "name": "attach-repro",
  "version": "1",
  "permissions": ["debugger"],
  "background": { "service_worker": "background.js" },
  "action": {}
}

background.js:

const OWN = `chrome-extension://${chrome.runtime.id}/`;
const isForeignExt = (u = '') => u.startsWith('chrome-extension://') && !u.startsWith(OWN);
const SKIP = false; // flip to true to test the fix

chrome.action.onClicked.addListener((tab) => {
  const t = { tabId: tab.id };
  chrome.debugger.attach(t, '1.3', () => {
    if (chrome.runtime.lastError) return console.log('attach failed:', chrome.runtime.lastError.message);
    console.log('attached', tab.url);
    chrome.debugger.sendCommand(t, 'Target.setAutoAttach',
      { autoAttach: true, waitForDebuggerOnStart: true, flatten: true });
  });
});

chrome.debugger.onEvent.addListener((src, method, p) => {
  if (method !== 'Target.attachedToTarget') return;
  console.log('child', p.targetInfo.type, p.targetInfo.url);
  if (SKIP && isForeignExt(p.targetInfo.url)) {
    console.log('skipped', p.targetInfo.url);
    return chrome.debugger.sendCommand(src, 'Target.detachFromTarget', { sessionId: p.sessionId });
  }
  const child = { tabId: src.tabId, sessionId: p.sessionId };
  chrome.debugger.sendCommand(child, 'Runtime.enable', {}, () =>
    console.log('Runtime.enable', chrome.runtime.lastError?.message || 'ok'));
  chrome.debugger.sendCommand(child, 'Runtime.runIfWaitingForDebugger');
});

chrome.debugger.onDetach.addListener((src, reason) => console.log('detached', reason));

Open the repro’s service worker console from its card on chrome://extensions. Then run it twice on a sign-in page for a site the password manager has a login saved for.

  1. Frame already present. Click into the username field and wait until the password manager’s inline menu is visible. Then click the repro’s toolbar icon. attach failed: Cannot access a chrome-extension:// URL of different extension means you’ve reproduced what I hit. attached <url> means your build allowed it.
  2. Frame arrives later. Reload the page, click the toolbar icon first, then click into the username field and wait for the menu. Look for a child iframe chrome-extension://<id>/... line. What comes after it is your answer: a Runtime.enable line with an error, or a detached <reason> line, is the failure. Note the reason string, because that’s the one fact I didn’t keep. With SKIP = true, a pass is a skipped chrome-extension://... line and no detached line after it.

If neither run prints any chrome-extension:// line, the menu never injected. That isn’t a pass. Focus the field again, wait for the menu to appear, and rerun.