From d326f18ca3809db21d0e5b17bedc77139a8f3c1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20R=C3=A5dahl=20Benz?= <3107277+alexanderradahl@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:02:08 -0700 Subject: [PATCH] fix: bound background Chrome fill settling --- CHANGELOG.md | 1 + chrome-extension/manifest.json | 2 +- chrome-extension/service-worker.js | 21 +++++++- package.json | 2 +- tests/chrome-background.mjs | 5 +- tests/chrome-fill-background.mjs | 77 ++++++++++++++++++++++++++++++ 6 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 tests/chrome-fill-background.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f82481..233e5f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Background Chrome without focus stealing +- Fixed `chrome_fill` hanging until its 45-second transport timeout in inactive MDB tabs. Framework-settle verification still uses two animation frames when Chrome schedules them, but now falls back to a bounded timer because background tabs may suspend `requestAnimationFrame` indefinitely. - Added the experimental `chatgpt_conversation_start` MCP tool and static-bearer, direct-loopback `/experimental/chatgpt/conversation` wrapper. The default `runtime` transport leases an inactive signed-in ChatGPT tab and invokes the first-party mounted `submitComposer` store action as a `text_action`, without typing/clicking the UI or extracting credentials/proof material. After the bounded stream/rendered handoff, MDB reloads the exact returned conversation and reads the exact persisted assistant message before returning text; ambiguous handoff fails without a second submission. The former direct private request remains available only as explicit `transport: "raw"` diagnostics. Prompt plaintext is redacted from every audit mode. - Added a separate static-bearer, loopback `/v1/responses` adapter and OpenCodex model registration for `chatgpt-runtime/chatgpt-browser`. It converts text-only stateless Responses turns plus supported function/custom tool definitions into a fixed ChatGPT-runtime JSON decision protocol, then emits canonical message or tool-call Responses output so Codex retains local tool ownership. It is deliberately excluded from defaults, combos, subagent models, and automatic fallback chains. - Added the separately selectable `chatgpt-runtime/chatgpt-sol` model backed by the mounted `gpt-5-6-thinking` runtime. Codex reasoning levels map monotonically onto ChatGPT's observed low/standard/high/max tiers, and both browser-runtime models can be bound to one mode-0600 configured ChatGPT Project with route plus mounted-composer validation before submission. diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index 599e26b..81f3413 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Mac Developer Bridge Background Browser", - "version": "0.2.7", + "version": "0.2.8", "description": "Lets Mac Developer Bridge operate approved Chrome tabs without bringing Chrome to the foreground.", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq9QjCNUI8xWx8wNSCITv4TFRy2pYhd77cDUTmYFJqduaYwhplrkvbo1BB9wTn7VBKLe8ohNp4fnZz9T03il69oRPsYGRb5aHAdSVaYdCrOiGs7GN00wvwUf6d2lQeuslDd53SpTnXZdtGRMVRBeK4X4NyEksQHzxKpaeaRlT0R9qREp+TdiLfEh5Z6UImZ0ZeBVn7efazrNBYMARBjas3A/AbXUyH654TVMOAqePUpgj129g8ZGQjcPqOFKGqhAyyCVhpbJBU+1Kt7sX6wbIIREtjmryW7sqMba7TnubqCv2qTPLZNeHQVQaQpl75cYdL26lh8PXwSTHeQKX+3KwHwIDAQAB", "permissions": [ diff --git a/chrome-extension/service-worker.js b/chrome-extension/service-worker.js index cdf2a7f..c634e71 100644 --- a/chrome-extension/service-worker.js +++ b/chrome-extension/service-worker.js @@ -1,5 +1,5 @@ const NATIVE_HOST = "io.github.alexanderradahl.mac_developer_bridge"; -const VERSION = "0.2.7"; +const VERSION = "0.2.8"; const WORKSPACE_KEY = "macDeveloperBridgeWorkspace"; const WORKSPACE_GROUP_TITLE = "MDB"; const WORKSPACE_GROUP_COLOR = "blue"; @@ -2916,8 +2916,25 @@ async function pageFill(selector, value, submit) { element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value })); element.dispatchEvent(new Event("change", { bubbles: true })); }; + const FRAMEWORK_COMMIT_FALLBACK_MS = 250; const waitForFrameworkCommit = () => new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(resolve, 0))); + let settled = false; + let fallbackTimer = null; + const finish = () => { + if (settled) return; + settled = true; + if (fallbackTimer !== null) clearTimeout(fallbackTimer); + resolve(); + }; + // Inactive/background Chrome tabs may suspend requestAnimationFrame + // indefinitely. Retain the two-frame framework settle path when it is + // available, but never let a fill request wait on animation frames alone. + fallbackTimer = setTimeout(finish, FRAMEWORK_COMMIT_FALLBACK_MS); + try { + requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(finish, 0))); + } catch { + finish(); + } }); let inserted = false; try { inserted = Boolean(document.execCommand("insertText", false, value)); } catch {} diff --git a/package.json b/package.json index a40c514..54de9b0 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "start": "node bridge.mjs", "start:http": "node mcp-http.mjs", "check": "node --check bridge.mjs && node --check mcp-http.mjs && node --check lib/chatgpt-responses-adapter.mjs && node --check lib/chrome-extension-client.mjs && node --check scripts/chrome-native-host.mjs && node --check chrome-extension/service-worker.js", - "test": "node tests/smoke.mjs && node tests/integration.mjs && node tests/adversarial.mjs && node tests/chatgpt-responses.mjs && node tests/http.mjs && node tests/oauth.mjs && node tests/pty.mjs && node tests/federation.mjs && node tests/chatgpt-conversation.mjs && node tests/chatgpt-runtime.mjs && node tests/chrome-background.mjs && bash tests/installer-mock.sh" + "test": "node tests/smoke.mjs && node tests/integration.mjs && node tests/adversarial.mjs && node tests/chatgpt-responses.mjs && node tests/http.mjs && node tests/oauth.mjs && node tests/pty.mjs && node tests/federation.mjs && node tests/chatgpt-conversation.mjs && node tests/chatgpt-runtime.mjs && node tests/chrome-fill-background.mjs && node tests/chrome-background.mjs && bash tests/installer-mock.sh" }, "engines": { "node": ">=18" diff --git a/tests/chrome-background.mjs b/tests/chrome-background.mjs index 2dda60b..e3bf4a4 100644 --- a/tests/chrome-background.mjs +++ b/tests/chrome-background.mjs @@ -203,13 +203,14 @@ try { assert.ok(manifest.permissions.includes("tabGroups")); assert.ok(manifest.permissions.includes("storage")); assert.ok(manifest.icons?.["16"] && manifest.icons?.["128"]); - assert.equal(manifest.version, "0.2.7"); + assert.equal(manifest.version, "0.2.8"); assert.equal(manifest.permissions.includes("debugger"), false, "realistic click support must not require Chrome debugger permission"); await Promise.all([16, 32, 48, 128].map(async (size) => { const stat = await fs.stat(path.join(root, "chrome-extension", "icons", `icon-${size}.png`)); assert.ok(stat.size > 0, `expected non-empty ${size}px extension icon`); })); const workerSource = await fs.readFile(path.join(root, "chrome-extension", "service-worker.js"), "utf8"); + assert.match(workerSource, /const VERSION = "0\.2\.8"/); assert.match(workerSource, /WORKSPACE_GROUP_TITLE = "MDB"/); assert.match(workerSource, /chrome\.tabs\.group/); assert.match(workerSource, /chrome\.tabGroups\.query/); @@ -264,6 +265,8 @@ try { assert.match(workerSource, /chrome\.runtime\.reload\(\)/); assert.match(workerSource, /async function pageFill\(/); assert.match(workerSource, /CHROME_FILL_NOT_STICKY/); + assert.match(workerSource, /FRAMEWORK_COMMIT_FALLBACK_MS = 250/); + assert.match(workerSource, /fallbackTimer = setTimeout\(finish, FRAMEWORK_COMMIT_FALLBACK_MS\)/); assert.match(workerSource, /executeInTab\(tab\.id, pageFill, \[String\(args\.selector \|\| ""\), String\(args\.value \?\? ""\), Boolean\(args\.submit\)\], "MAIN"\)/); assert.equal((workerSource.match(/async function executeInTab\(/g) || []).length, 1, "executeInTab should have one definition"); const publicKey = Buffer.from(manifest.key, "base64"); diff --git a/tests/chrome-fill-background.mjs b/tests/chrome-fill-background.mjs new file mode 100644 index 0000000..959a5c5 --- /dev/null +++ b/tests/chrome-fill-background.mjs @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import vm from "node:vm"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const workerSource = await fs.readFile(path.join(root, "chrome-extension", "service-worker.js"), "utf8"); +const match = workerSource.match(/async function pageFill\([\s\S]*?\n}\n\nasync function executeInTab/); +assert.ok(match, "pageFill source should be extractable for the background-frame regression"); +const pageFillSource = match[0].replace(/\n\nasync function executeInTab$/, ""); + +class FakeInputElement { + constructor() { + this._value = ""; + this.type = "text"; + this.events = []; + this.isContentEditable = false; + } + get value() { return this._value; } + set value(next) { this._value = String(next); } + focus() {} + select() {} + dispatchEvent(event) { this.events.push(event.type); return true; } + closest() { return null; } +} +class FakeTextAreaElement extends FakeInputElement {} +class FakeSelectElement extends FakeInputElement { + constructor() { + super(); + this.options = []; + } +} +class FakeEvent { + constructor(type, init = {}) { + this.type = type; + Object.assign(this, init); + } +} + +const element = new FakeTextAreaElement(); +const context = vm.createContext({ + document: { + title: "Background Reddit composer", + querySelector: () => element, + execCommand: () => false, + }, + location: { href: "https://old.reddit.com/r/test/comments/example/" }, + HTMLInputElement: FakeInputElement, + HTMLTextAreaElement: FakeTextAreaElement, + HTMLSelectElement: FakeSelectElement, + InputEvent: FakeEvent, + Event: FakeEvent, + KeyboardEvent: FakeEvent, + // Deliberately never invoke the callback: Chrome can suspend rAF in an + // inactive background tab, which previously left chrome_fill unresolved. + requestAnimationFrame: () => 1, + setTimeout, + clearTimeout, +}); +const pageFill = vm.runInContext(`(${pageFillSource})`, context); + +const startedAt = Date.now(); +const result = await Promise.race([ + pageFill('textarea[name="text"]', "background fill succeeds", false), + new Promise((_, reject) => setTimeout(() => reject(new Error("pageFill hung on suspended requestAnimationFrame")), 1_500)), +]); +const elapsedMs = Date.now() - startedAt; + +assert.equal(result.filled, true); +assert.equal(result.submitted, false); +assert.equal(element.value, "background fill succeeds"); +assert.deepEqual(element.events, ["input", "change"]); +assert.ok(elapsedMs >= 200, `expected the bounded fallback to be exercised, got ${elapsedMs}ms`); +assert.ok(elapsedMs < 1_250, `background fill should settle well before the transport timeout, got ${elapsedMs}ms`); + +console.log(JSON.stringify({ passed: true, elapsedMs, value: element.value }));