Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion chrome-extension/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Mac Developer Bridge Background Browser",
"version": "0.2.7",
"version": "0.2.8",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Synchronize the runtime version with the manifest

Bumping only the manifest leaves VERSION in service-worker.js at 0.2.7. After installing this 0.2.8 extension, the native-host ready message and the status and extension.reload responses therefore still identify it as 0.2.7, preventing callers and diagnostics from confirming that the updated worker is actually running. Update the runtime constant as part of this version bump, or derive it from chrome.runtime.getManifest().version.

Useful? React with 👍 / 👎.

"description": "Lets Mac Developer Bridge operate approved Chrome tabs without bringing Chrome to the foreground.",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq9QjCNUI8xWx8wNSCITv4TFRy2pYhd77cDUTmYFJqduaYwhplrkvbo1BB9wTn7VBKLe8ohNp4fnZz9T03il69oRPsYGRb5aHAdSVaYdCrOiGs7GN00wvwUf6d2lQeuslDd53SpTnXZdtGRMVRBeK4X4NyEksQHzxKpaeaRlT0R9qREp+TdiLfEh5Z6UImZ0ZeBVn7efazrNBYMARBjas3A/AbXUyH654TVMOAqePUpgj129g8ZGQjcPqOFKGqhAyyCVhpbJBU+1Kt7sX6wbIIREtjmryW7sqMba7TnubqCv2qTPLZNeHQVQaQpl75cYdL26lh8PXwSTHeQKX+3KwHwIDAQAB",
"permissions": [
Expand Down
21 changes: 19 additions & 2 deletions chrome-extension/service-worker.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 4 additions & 1 deletion tests/chrome-background.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand Down Expand Up @@ -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");
Expand Down
77 changes: 77 additions & 0 deletions tests/chrome-fill-background.mjs
Original file line number Diff line number Diff line change
@@ -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 }));