codex-control-chrome-mcp 1.3.3 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,18 @@ This project follows semantic versioning.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.4.0] - 2026-07-04
10
+
11
+ ### Changed
12
+
13
+ - The debugger attach lifecycle now mirrors the official Codex `browser-client.mjs` discipline instead of trusting the extension's idempotent `attach`. A new `ensureAttached` attaches and then issues `Target.setAutoAttach` (`autoAttach`/`flatten`), which keeps child frames/OOPIFs in the session and doubles as a real liveness probe; `withSession` wraps every CDP tool (`chrome_navigate`/`chrome_evaluate`/`chrome_screenshot`/`chrome_cdp_send`) so a stale or lost attachment — whether it surfaces at the probe or mid-command — is healed once via `detach` → re-attach → retry. `chrome_attach_tab` now verifies the attachment with a probe and reports the real result instead of a hardcoded `{ok:true}`. This supersedes the narrower per-tool self-heal added in 1.3.1/1.3.3.
14
+ - `turn_id` is now stable for the lifetime of the MCP process instead of freshly minted on every request. The extension scopes attach/lease state by `session_id` + `turn_id` (`resolveSession(t).attach(requireTurnId(t))`), so a churning turn re-keyed leases and reset session state on every call. One controller uses one stable turn, matching the official client's turn model.
15
+
16
+ ### Added
17
+
18
+ - `scripts/concurrency-check.mjs`: a concurrency soak harness that runs N real client sessions against the live bridge and reports unrecovered "not attached" failures (the regression signal) and how many self-heals engaged.
19
+ - [`docs/troubleshooting.md`](docs/troubleshooting.md): a debugging playbook and the reference points (official `browser-client.mjs`, the extension service worker's `se`/attach internals) for future debugger/attach investigations. `docs/architecture.md` gained a "Debugger Attach Lifecycle" section.
20
+
9
21
  ## [1.3.3] - 2026-07-04
10
22
 
11
23
  ### Fixed
package/README.md CHANGED
@@ -120,6 +120,7 @@ Because the control entrypoint is inside the normal Chrome profile, existing coo
120
120
  Read more:
121
121
 
122
122
  - [Architecture](./docs/architecture.md)
123
+ - [Troubleshooting & Debugging Playbook](./docs/troubleshooting.md)
123
124
  - [Install And Uninstall](./docs/install.md)
124
125
  - [Official Socket Probe Notes](./docs/socket-probe.md)
125
126
  - [Release Process](./docs/release.md)
@@ -166,6 +167,16 @@ npx -y codex-control-chrome-mcp@latest uninstall-native-host
166
167
 
167
168
  If tab or CDP tools fail, use the bundled skill workflow: list tabs again, claim only current tab IDs, attach before raw CDP calls, and read CDP events after enabling the relevant domain.
168
169
 
170
+ ### "Debugger is not attached" / flaky CDP under concurrency
171
+
172
+ CDP tools self-heal a stale debugger attachment since 1.4.0 (attach is verified,
173
+ and a lost attachment is recovered with `detach` → re-attach → retry). If you
174
+ still see attach flakiness — most likely when several browser-control stacks
175
+ share one Chrome and contend for the single debugger per tab — see the
176
+ [Troubleshooting & Debugging Playbook](./docs/troubleshooting.md) for the root
177
+ cause, reference points (official client, extension service worker), and the
178
+ `scripts/concurrency-check.mjs` soak test.
179
+
169
180
  See [Install And Uninstall](./docs/install.md#troubleshooting) for the full per-browser checklist (the `registered` flag and host `classification`).
170
181
 
171
182
  ## License
@@ -170,6 +170,33 @@ CDP events come back as notifications:
170
170
  }
171
171
  ```
172
172
 
173
+ ## Debugger Attach Lifecycle
174
+
175
+ The extension's `attach` is idempotent on an in-memory set (`background.js`:
176
+ `if(!se.has(t)) chrome.debugger.attach(...)`). If a tab's real debugger is torn
177
+ down out-of-band (a competing debugger client, DevTools, a renderer/process
178
+ swap) and that flag goes stale, `attach` reports success without re-attaching and
179
+ the next CDP command fails with `Debugger is not attached`.
180
+
181
+ Since 1.4.0 the client mirrors the official `browser-client.mjs` discipline
182
+ instead of trusting that `attach`:
183
+
184
+ - `ensureAttached(tabId)` — `attach`, then `Target.setAutoAttach`
185
+ (`autoAttach`/`flatten`), which keeps child frames/OOPIFs in the session and
186
+ doubles as a real liveness probe that surfaces a stale attach.
187
+ - `withSession(tabId, run)` — `ensureAttached` → run the CDP sequence → on
188
+ "not attached" (from the probe or mid-command) `detach` (clears the stale
189
+ flag) → re-`ensureAttached` → retry once. Every CDP tool
190
+ (`chrome_navigate`/`evaluate`/`screenshot`/`cdp_send`) goes through it, and
191
+ `chrome_attach_tab` verifies with a probe instead of returning a hardcoded ok.
192
+ - A **stable per-process `turn_id`**. The extension scopes attach/lease state by
193
+ `session_id` + `turn_id` (`resolveSession(t).attach(requireTurnId(t))`);
194
+ minting a fresh turn per request churned that state.
195
+
196
+ When a debugger/attach issue recurs, start from
197
+ [`docs/troubleshooting.md`](./troubleshooting.md) — it has the reference points
198
+ (official client, extension service worker) and the debugging playbook.
199
+
173
200
  ## Reusing Codex Logic
174
201
 
175
202
  The preferred long-term reuse point is Codex's `browser-client.mjs`, not the official `extension-host` socket.
@@ -187,7 +214,17 @@ globalThis.nodeRepl.setResponseMeta()
187
214
 
188
215
  This project can shim those APIs and point `nativePipe.createConnection()` at its own bridge socket. That lets the vendored `browser-client.mjs` reuse its existing high-level browser logic while avoiding Codex App as a dependency.
189
216
 
190
- The initial implementation exposes direct MCP tools first. Vendoring `browser-client.mjs` should be a later phase after the native host bridge is stable.
217
+ The initial implementation exposes direct MCP tools first.
218
+
219
+ As of 1.4.0 we chose to **align the lifecycle discipline in our own small client**
220
+ (see "Debugger Attach Lifecycle" above) rather than vendor `browser-client.mjs`.
221
+ Vendoring the 983 KB minified bundle into this MIT-licensed, published package
222
+ would redistribute OpenAI's proprietary code (a licensing question), couple us to
223
+ a specific Codex build, and add an opaque, unreviewable dependency. Re-implementing
224
+ the key mechanisms (`ensureAttached`, `Target.setAutoAttach`, stable turn,
225
+ verified attach, self-heal) gets the same behavior while staying MIT-clean and
226
+ reviewable. Full vendoring remains an option only if the redistribution question
227
+ is resolved.
191
228
 
192
229
  ## Runtime And Launch Model
193
230
 
@@ -0,0 +1,109 @@
1
+ # Troubleshooting & Debugging Playbook
2
+
3
+ Where to look when something breaks and the fix isn't obvious — especially
4
+ around the Chrome debugger attach lifecycle, which is the trickiest part of this
5
+ project because we drive `chrome.debugger` **through** the Codex extension rather
6
+ than owning it.
7
+
8
+ ## Reference points (read these first)
9
+
10
+ When a debugger/CDP behavior is confusing, the ground truth lives in three
11
+ places, none of which are this repo's tool layer:
12
+
13
+ 1. **The official Codex browser client** —
14
+ `~/.codex/plugins/cache/openai-bundled/chrome/latest/scripts/browser-client.mjs`
15
+ (minified). This is how Codex App itself drives the browser. It is the
16
+ reference implementation we align to. Grep it for the lifecycle discipline:
17
+ `ensureAttached`, `Target.setAutoAttach`, `runAttachedFrameSessionInitialization`,
18
+ `preserveDebuggerOnTimeout`, `Target.closeTarget`. Its only export is
19
+ `setupBrowserRuntime`; it expects a `globalThis.nodeRepl` runtime
20
+ (`nativePipe.createConnection`, `requestMeta["x-codex-turn-metadata"]`, `env`,
21
+ `cwd`, `config`, `emitImage`, `setResponseMeta`).
22
+ 2. **The extension service worker** —
23
+ `~/Library/Application Support/Google/Chrome/Default/Extensions/hehggadaopoacecdllhhajmbjkdcmajg/<version>/background.js`
24
+ (minified). This is what actually calls `chrome.debugger.*`. Key internals:
25
+ - `function cn(t){ ... if(!se.has(t)){ chrome.debugger.attach({tabId:t},"1.3") } }`
26
+ — `attach` is **idempotent on the in-memory `se` map**. If `se` still has a
27
+ tab whose real debugger was torn down out-of-band, `attach` reports success
28
+ without re-attaching. That stale flag is the root of "Debugger is not
29
+ attached".
30
+ - `chrome.debugger.onDetach.addListener(t=>{ se.delete(t.tabId) })` — clears
31
+ `se` on detach, but can miss races / certain eviction types, leaving `se`
32
+ stale.
33
+ - `resolveSession(t).attach(requireTurnId(t), t)` — attach/lease state is
34
+ scoped by `session_id` + `turn_id`; leases are `{sessionId, turnId, instanceId}`.
35
+ This is why a churning `turn_id` destabilizes session state (see below).
36
+ 3. **This repo's design** — [`docs/architecture.md`](./architecture.md), especially
37
+ "Coexistence With Codex App" (the same-name proxy) and "Reusing Codex Logic".
38
+
39
+ ## The "Debugger is not attached" failure (root cause)
40
+
41
+ Three layers stack up:
42
+
43
+ 1. **Trigger (environment):** a tab's `chrome.debugger` is torn down by a
44
+ competing debugger client (another session, DevTools, a renderer/process
45
+ swap). Only one debugger client is allowed per tab. Running multiple
46
+ browser-control stacks (Codex App + several MCP sessions + Claude's extension)
47
+ makes this common.
48
+ 2. **Extension bug:** after that teardown, `se` can stay stale, so `attach`
49
+ lies (`if(!se.has(t))` short-circuit) and the next `sendCommand` throws
50
+ `Debugger is not attached`. A `detach` clears the flag (it may itself throw
51
+ "not attached" — expected), after which `attach` re-attaches for real.
52
+ 3. **Our client:** the pre-1.4.0 client fired `attach` and trusted the `{ok}`,
53
+ with no verification and no recovery, so a recoverable state became a dead
54
+ end.
55
+
56
+ The 1.4.0 client mirrors the official lifecycle discipline: `ensureAttached`
57
+ (attach + `Target.setAutoAttach`, which doubles as a real liveness probe),
58
+ `withSession` (ensure → run → on "not attached" `detach` → re-`ensureAttached` →
59
+ retry once), verified `chrome_attach_tab` (no more hardcoded `{ok:true}`), and a
60
+ **stable per-process `turn_id`** instead of a fresh one per request. See
61
+ `src/mcp-server.js` (`ensureAttached` / `withSession`) and `test/mcp-server.test.js`.
62
+
63
+ ## Debugging playbook
64
+
65
+ Techniques that isolated the bug, roughly in order:
66
+
67
+ - **Is it concurrency?** `chrome_bridge_status` → `clients` is the number of MCP
68
+ sessions sharing the native host. `proxy: true` means Codex App is proxied
69
+ through us too.
70
+ - **Which stacks are running?** `ps aux | grep -iE "codex|native-host"` — look
71
+ for multiple `codex-control-chrome-mcp` instances (global vs `_npx` cache),
72
+ the official `Codex for Chrome` host, and Claude's extension host.
73
+ - **Tab-specific or global?** Open a **fresh** tab and try it. If the new tab
74
+ works but the old one doesn't, the old tab's attach state is corrupted (stale
75
+ `se`), not the whole extension.
76
+ - **Manual recovery / confirm the mechanism:** on a wedged tab, `chrome_detach_tab`
77
+ (it will error with "not attached" — that's expected and clears the stale flag),
78
+ then `chrome_attach_tab`, then retry. If that revives it, it's the stale-attach bug.
79
+ - **What did the page/debugger do?** `chrome_read_events` dumps buffered CDP
80
+ notifications (Page/Runtime lifecycle). Note: events are **broadcast to all MCP
81
+ clients**, so under concurrency you will see other sessions' tabs too. Large
82
+ dumps are written to a file — analyze with `jq '.events[].params.method'`.
83
+ - **Extension-side detach reasons** are NOT on disk: `chrome.debugger.onDetach`
84
+ is a callback, not a CDP event, so `Inspector.detached` won't appear in
85
+ `chrome_read_events`. To see it you'd need the extension service worker console.
86
+ - **Codex App logs:** `~/Library/Logs/com.openai.codex/<date>/codex-desktop-*.log`.
87
+ - **Concurrency soak:** `node scripts/concurrency-check.mjs [sessions] [rounds]`
88
+ runs N real sessions concurrently through the create → navigate → raw
89
+ `cdp_send` → evaluate → finalize flow and reports unrecovered "not attached"
90
+ (must be 0) plus how many self-heals engaged.
91
+
92
+ ## Known limitations
93
+
94
+ - **Session isolation holds, the debugger is shared.** Concurrent sessions can't
95
+ see or close each other's tabs (leases are per `session_id`), but they contend
96
+ for the one debugger-per-tab, so contention raises the eviction rate. The
97
+ self-heal recovers one-shot commands; it does not remove the contention.
98
+ - **Streaming CDP is fragile under contention.** Flows that need the debugger to
99
+ stay attached over time (e.g. `Network.enable` then reading events) can lose
100
+ the attachment mid-stream. Prefer page-side APIs (Resource Timing /
101
+ `PerformanceObserver` via `chrome_evaluate`) for network/DOM observation.
102
+ - **Live tools run the installed package, not this repo.** Editing `src/` does
103
+ nothing until `npm i -g codex-control-chrome-mcp@<version>` **and** the MCP
104
+ client restarts (and the extension reconnects). Beware stale `_npx` copies.
105
+
106
+ ## Runtime/release gotchas
107
+
108
+ See [`docs/release.md`](./release.md). Publishing is `git push` of a `v*` tag →
109
+ `.github/workflows/publish.yml` publishes to npm via OIDC (no local `npm login`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-control-chrome-mcp",
3
- "version": "1.3.3",
3
+ "version": "1.4.0",
4
4
  "description": "MCP server and native host bridge for controlling Chrome through the Codex Chrome extension.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/mcp-server.js CHANGED
@@ -60,9 +60,14 @@ function browserLabel(browser) {
60
60
  return browser === "edge" ? "Edge" : "Chrome";
61
61
  }
62
62
 
63
- class ChromeUseRuntime {
63
+ export class ChromeUseRuntime {
64
64
  constructor() {
65
65
  this.sessionId = `codex-control-chrome-mcp-${crypto.randomUUID()}`;
66
+ // Stable per-process turn id. The Codex extension scopes attach/lease state
67
+ // by (session_id, turn_id); minting a fresh turn per request (as we used to)
68
+ // churned that state and re-keyed tab leases on every call. We are a single
69
+ // controller, so one stable turn mirrors the official client's turn model.
70
+ this.turnId = `turn-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
66
71
  this.bridge = null;
67
72
  }
68
73
 
@@ -76,7 +81,7 @@ class ChromeUseRuntime {
76
81
  sessionParams() {
77
82
  return {
78
83
  session_id: this.sessionId,
79
- turn_id: `turn-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`,
84
+ turn_id: this.turnId,
80
85
  session_context: "codex-control-chrome-mcp",
81
86
  };
82
87
  }
@@ -187,7 +192,16 @@ export function registerTools(server, runtime) {
187
192
  },
188
193
  },
189
194
  async ({ tabId }) => {
190
- await runtime.request("attach", { tabId });
195
+ // Verify the attachment for real instead of trusting the extension's
196
+ // idempotent `attach` (which can report success on a stale flag). The
197
+ // probe runs a trivial evaluate; withSession heals a stale attach first.
198
+ await withSession(runtime, tabId, () =>
199
+ executeCdp(runtime, {
200
+ tabId,
201
+ method: "Runtime.evaluate",
202
+ params: { expression: "1", returnByValue: true },
203
+ }),
204
+ );
191
205
  return toolResult({ ok: true });
192
206
  },
193
207
  );
@@ -218,7 +232,7 @@ export function registerTools(server, runtime) {
218
232
  },
219
233
  },
220
234
  async ({ tabId, method, params, timeoutMs }) =>
221
- toolResult(await withReattach(runtime, tabId, () =>
235
+ toolResult(await withSession(runtime, tabId, () =>
222
236
  executeCdp(runtime, { tabId, method, params, timeoutMs }),
223
237
  )),
224
238
  );
@@ -233,7 +247,7 @@ export function registerTools(server, runtime) {
233
247
  },
234
248
  },
235
249
  async ({ tabId, url }) =>
236
- toolResult(await withReattach(runtime, tabId, async () => {
250
+ toolResult(await withSession(runtime, tabId, async () => {
237
251
  await executeCdp(runtime, { tabId, method: "Page.enable", params: {} });
238
252
  return await executeCdp(runtime, {
239
253
  tabId,
@@ -254,7 +268,7 @@ export function registerTools(server, runtime) {
254
268
  },
255
269
  },
256
270
  async ({ tabId, expression, awaitPromise }) =>
257
- toolResult(await withReattach(runtime, tabId, async () => {
271
+ toolResult(await withSession(runtime, tabId, async () => {
258
272
  await executeCdp(runtime, { tabId, method: "Runtime.enable", params: {} });
259
273
  return await executeCdp(runtime, {
260
274
  tabId,
@@ -278,7 +292,7 @@ export function registerTools(server, runtime) {
278
292
  },
279
293
  },
280
294
  async ({ tabId, format }) =>
281
- toolResult(await withReattach(runtime, tabId, async () => {
295
+ toolResult(await withSession(runtime, tabId, async () => {
282
296
  await executeCdp(runtime, { tabId, method: "Page.enable", params: {} });
283
297
  return await executeCdp(runtime, {
284
298
  tabId,
@@ -323,30 +337,66 @@ export function registerTools(server, runtime) {
323
337
  }
324
338
 
325
339
  /**
326
- * Attach the debugger, run a CDP sequence, and self-heal a stale-attach state.
327
- *
328
- * The Codex Chrome extension tracks debugger attachments in memory. When Chrome
329
- * tears an attachment down out-of-band (a competing debugger client, DevTools, a
330
- * renderer/process swap), that flag can go stale: a fresh `attach` short-circuits
331
- * and reports success without actually re-attaching, so the next CDP command
332
- * fails with "Debugger is not attached". A `detach` clears the stale flag (it may
333
- * itself throw "not attached" — expected, and ignored), after which `attach`
334
- * re-attaches for real. We retry the sequence once on that specific error.
340
+ * Bring `tabId` to a known-attached state, mirroring the official Codex
341
+ * browser-client's discipline instead of trusting the extension's idempotent
342
+ * `attach`. The extension only calls `chrome.debugger.attach` when the tab is
343
+ * absent from its in-memory set (background.js: `if(!se.has(t)) attach()`), so
344
+ * after an out-of-band detach that flag can go stale and `attach` reports
345
+ * success with no live debugger. `Target.setAutoAttach` is a real CDP command:
346
+ * it keeps child frames/OOPIFs in the session (as the official client does) and
347
+ * doubles as a liveness probe that surfaces a stale attach as "not attached".
335
348
  */
336
- export async function withReattach(runtime, tabId, run) {
349
+ async function ensureAttached(runtime, tabId) {
337
350
  await runtime.request("attach", { tabId });
338
351
  try {
352
+ await executeCdp(runtime, {
353
+ tabId,
354
+ method: "Target.setAutoAttach",
355
+ params: { autoAttach: true, flatten: true, waitForDebuggerOnStart: false },
356
+ });
357
+ } catch (error) {
358
+ // A stale attach surfaces here as "not attached"; re-throw so withSession
359
+ // heals it. Any other setAutoAttach failure is best-effort (some targets
360
+ // reject it) and must not block the real command.
361
+ if (isNotAttachedError(error)) throw error;
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Ensure the tab is attached, run a CDP sequence, and self-heal once if the
367
+ * debugger is torn down out-of-band (a competing debugger client, DevTools, or a
368
+ * renderer/process swap) — whether that surfaces during the attach probe or
369
+ * mid-command. Recovery is a `detach`, which clears the extension's stale attach
370
+ * flag (it may itself throw "not attached" — expected, and ignored), then a real
371
+ * re-attach via ensureAttached, then one retry.
372
+ */
373
+ /**
374
+ * Diagnostics for the self-heal path: `heals` counts how often a stale/lost
375
+ * attachment was recovered, `unrecovered` how often recovery still failed.
376
+ * Used by scripts/concurrency-check.mjs to observe behavior under contention.
377
+ */
378
+ export const sessionStats = { heals: 0, unrecovered: 0 };
379
+
380
+ export async function withSession(runtime, tabId, run) {
381
+ try {
382
+ await ensureAttached(runtime, tabId);
339
383
  return await run();
340
384
  } catch (error) {
341
385
  if (!isNotAttachedError(error)) throw error;
386
+ sessionStats.heals += 1;
342
387
  try {
343
388
  await runtime.request("detach", { tabId });
344
389
  } catch {
345
390
  // Detach throws when the debugger is already gone; we only need its side
346
391
  // effect of clearing the extension's stale attach flag.
347
392
  }
348
- await runtime.request("attach", { tabId });
349
- return await run();
393
+ try {
394
+ await ensureAttached(runtime, tabId);
395
+ return await run();
396
+ } catch (retryError) {
397
+ if (isNotAttachedError(retryError)) sessionStats.unrecovered += 1;
398
+ throw retryError;
399
+ }
350
400
  }
351
401
  }
352
402