From 07d6d3b186f44f4902897cfd4ca51a71b81d616f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20R=C3=A5dahl=20Benz?= <3107277+alexanderradahl@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:41:40 -0700 Subject: [PATCH 1/5] feat: make browser pool resilient and expose ChatGPT status --- CHANGELOG.md | 7 +- README.md | 16 +- SECURITY.md | 11 +- SHA256SUMS | 20 +- bridge.mjs | 50 +++- chrome-extension/manifest.json | 2 +- chrome-extension/service-worker.js | 405 +++++++++++++++++++++-------- lib/chrome-extension-client.mjs | 4 +- scripts/chrome-native-host.mjs | 4 +- tests/chrome-background.mjs | 43 ++- tests/smoke.mjs | 4 +- 11 files changed, 419 insertions(+), 147 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0659db..e439ff8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,16 @@ ### Background Chrome without focus stealing -- Routed the legacy low-level `tabs.open` primitive through `workspace.open` at the client, native-host, and extension layers. Older/stale sessions can no longer create loose Chrome tabs outside `MDB`; if the workspace is missing they fail closed until the group is auto-healed. The extension now recreates the default four-tab workspace whenever Chrome is naturally focused, avoiding manual re-setup after browser/extension restarts. +- Raised the managed `MDB` pool target from four to eight tabs and made contention resilient: opens wait up to 20 seconds for a release, active browser operations renew a lease heartbeat, and leases idle for 10 minutes are reclaimed/reset. Existing smaller pools expand only when Chrome is naturally foreground, preserving the no-focus-steal rule. +- Added read-only `chatgpt_extension_status`, which reports the installed ChatGPT Chrome extension, its local OpenAI native-host registration, and the live `chatgpt.com` page-bridge status when available without patching the OpenAI extension or exposing its private native-host RPC protocol. +- Made workspace release grantless so Strict-mode URL approval expiry cannot strand a finished lease. +- Routed the legacy low-level `tabs.open` primitive through `workspace.open` at the client, native-host, and extension layers. Older/stale sessions can no longer create loose Chrome tabs outside `MDB`; if the workspace is missing they fail closed until the group is auto-healed. The extension now recreates the managed workspace whenever Chrome is naturally focused, avoiding manual re-setup after browser/extension restarts. - Separated approval strictness from Chrome routing. Direct Chrome AppleScript/JXA, direct Chrome executable launches, and shell `open` calls for web URLs (including `open -g`) are always refused with `CHROME_BACKGROUND_REQUIRED` in both Relaxed and Strict modes, forcing browser work through the signed-in `MDB` tab group. This fixes a regression where Relaxed mode let other sessions bypass the extension, create ungrouped tabs, and steal foreground focus. - Fixed test isolation so the federation/installer suites cannot target the live checkout or inherit/remove the running menu-bar bridge unlock file. Running `npm test` no longer causes the live MDB endpoint to fall into 502 afterward. - Changed the product default to **relaxed approvals**: the signed-in MDB Chrome workspace can use normal HTTP/HTTPS sites and native foreground app control can execute without per-site/per-app terminal approval commands. Added a live **Strict approvals** checkbox to the menu-bar app; when enabled it restores the scoped background-Chrome grant pool and one-use foreground-app approvals. - Added an optional Manifest V3 Chrome extension plus native-messaging host so ChatGPT can operate approved pages in the operator's real signed-in Chrome profile without routine focus theft. New built-in tools are `chrome_workspace_status`, `chrome_workspace_setup`, `chrome_tabs`, `chrome_open`, `chrome_navigate`, `chrome_snapshot`, `chrome_click`, `chrome_fill`, and `chrome_close`. - Bound the native host to the selected signed-in Chrome profile/account and fail closed on signed-out or mismatched profiles. -- Added a Chrome-native **`MDB`** tab group with a reusable background-tab pool (four tabs by default, up to eight), per-tab leases, idle collapse, restart reconciliation, and group rediscovery. Routine `chrome_open` leases a pre-created tab rather than creating one; `chrome_close` returns it to the pool. +- Added a Chrome-native **`MDB`** tab group with a reusable background-tab pool (eight tabs by default and maximum), per-tab leases, idle collapse, restart reconciliation, and group rediscovery. Routine `chrome_open` leases a pre-created tab rather than creating one; `chrome_close` returns it to the pool. - Made workspace status/setup local and grantless. Reworked authenticated `chrome-background` approval into a shared additive grant pool: concurrent ChatGPT sessions can approve different domains without replacing one another, URL scopes are unioned while each grant remains unexpired, and active grants survive bridge-child restarts until their original expiry. Legacy fixed-file approvals are imported for backward compatibility. Federated personal-browser providers keep their single-use semantics. - Refuse workspace creation/expansion unless Chrome is already focused. This is deliberate: measured on Chrome 151/macOS, even `tabs.create({active:false})` can bring Chrome to the foreground. Routine work therefore performs no tab creation after the one-time setup. - Added background-first desktop GUI detection for native apps such as Slack. Relaxed mode still permits non-Chrome foreground UI when genuinely required; Strict mode requires a single-use, app-scoped, maximum-five-minute grant. The old model-controlled env bypass is gone, while Chrome is always forced through the separate `MDB` background routing rule above. diff --git a/README.md b/README.md index a1c93bf..2aebe0d 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,9 @@ Git, package managers, Vercel CLI, database CLIs, AppleScript, browser CLIs, bui | Tool | Purpose | |---|---| | `bridge_status` | Runtime identity, paths, permissions context, shell, audit mode, Codex binary, focus policy, and background-Chrome status | -| `chrome_workspace_status` | Inspect the extension-owned `MDB` Chrome group and reusable background-tab pool; no website grant required | -| `chrome_workspace_setup` | Create or expand the `MDB` pool once while Chrome is already foreground | +| `chrome_workspace_status` | Inspect the extension-owned `MDB` Chrome group, lease activity, and reusable background-tab pool; no website grant required | +| `chatgpt_extension_status` | Inspect the installed ChatGPT Chrome extension, OpenAI native-host registration, and live read-only page-bridge status without patching the OpenAI extension | +| `chrome_workspace_setup` | Create or expand the `MDB` pool while Chrome is already foreground; default target is eight reusable tabs | | `chrome_tabs` | List tabs in the real signed-in Chrome profile without activating Chrome; scoped only when Strict approvals is on | | `chrome_open` | Lease an idle tab from the persistent `MDB` group and open a URL without creating a new tab | | `chrome_navigate` | Navigate an approved tab without selecting it | @@ -127,11 +128,11 @@ This is intentionally opt-in because authenticated browser control is powerful. Then in Chrome open `chrome://extensions`, enable **Developer mode**, choose **Load unpacked**, and select this repository's `chrome-extension/` directory. The expected extension id is `pcebfblnmcappinbenkmddjdapaoajgm`. -The extension keeps a Chrome-native tab group named **`MDB`**. By default it contains four extension-owned idle tabs. They are created only while Chrome is already foreground, then leased and reused for routine work. The group is collapsed when idle and expands while one or more tabs are leased. This mirrors the managed-group approach used by browser-agent extensions while avoiding a macOS/Chrome quirk measured on this project: even `chrome.tabs.create({ active:false })` can bring Chrome to the foreground. +The extension keeps a Chrome-native tab group named **`MDB`**. By default it targets eight extension-owned idle tabs. They are created only while Chrome is already foreground, then leased and reused for routine work. The group is collapsed when idle and expands while one or more tabs are leased. This mirrors the managed-group approach used by browser-agent extensions while avoiding a macOS/Chrome quirk measured on this project: even `chrome.tabs.create({ active:false })` can bring Chrome to the foreground. -The pool now self-heals. If Chrome or the extension restarts and the `MDB` group is missing, the extension recreates the default four-tab pool the next time you **naturally focus Chrome**. It never activates Chrome just to repair itself. You can also force setup while Chrome is already foreground by calling `chrome_workspace_setup` (default pool size: 4). +The pool now self-heals and self-expands. If Chrome or the extension restarts, or an older four-tab pool is still present, the extension grows the managed pool to the default eight tabs the next time you **naturally focus Chrome**. It never activates Chrome just to repair or expand itself. You can also force setup while Chrome is already foreground by calling `chrome_workspace_setup` (default pool size: 8). -`chrome_workspace_status` is grantless because it only reads extension-owned local workspace state. `chrome_workspace_setup` is also grantless because it creates only extension-owned idle pages; it refuses to create or expand the pool unless Chrome is already focused rather than stealing focus itself. Legacy/internal `tabs.open` callers are routed to the same `workspace.open` lease path, so they cannot create loose tabs outside `MDB`; if the pool is unavailable while Chrome is background, the open fails closed until the group can be repaired. +`chrome_workspace_status` is grantless because it only reads extension-owned local workspace state. It now includes lease age/idle metadata, the 10-minute idle-reclaim timeout, and the 20-second lease-wait budget. `chrome_workspace_setup` is also grantless because it creates only extension-owned idle pages; it refuses to create or expand the pool unless Chrome is already focused rather than stealing focus itself. Legacy/internal `tabs.open` callers are routed to the same `workspace.open` lease path, so they cannot create loose tabs outside `MDB`. When all tabs are busy, `chrome_open` waits briefly for a release instead of failing immediately; abandoned leases are reclaimed after 10 minutes without browser activity, while every navigate/snapshot/click/fill renews an active lease. **Relaxed access is the default.** Normal HTTP/HTTPS work through the signed-in `MDB` Chrome profile does not require a terminal approval command or per-site allowlist. This is intentional: Mac Developer Bridge already exposes unrestricted shell/file authority as the logged-in macOS user, and the useful default is for browser execution to match that operator-chosen trust level while remaining background-first. @@ -152,10 +153,13 @@ A normal workflow is: 1. `chrome_open` an approved URL into an idle tab leased from the `MDB` group. 2. `chrome_snapshot` to read the page and get stable-enough selectors for visible controls. 3. `chrome_fill` / `chrome_click` / `chrome_navigate` as needed. -4. `chrome_close` to return the workspace tab to its idle extension page and release the lease. +4. `chrome_close` to return the workspace tab to its idle extension page and release the lease. Workspace release is local/grantless cleanup, so Strict-mode URL grants cannot strand a finished lease. Profile binding is always enforced. In relaxed mode the extension permits normal HTTP/HTTPS sites without a per-site grant. In Strict mode, each `chrome-background` approval is stored as its own mode-0600 file under `$DATA_DIR/chrome-background-grants/`, expires after at most 15 minutes, and is merged with other still-live approvals. Expired files are pruned automatically and URL patterns are enforced inside Chrome. Federated personal-browser providers keep their separate single-use behavior. + +`chatgpt_extension_status` is deliberately read-only. It reports the installed ChatGPT Chrome extension version, the local `com.openai.codexextension` native-host registration, and—when a `chatgpt.com` tab is already open—the live status returned by OpenAI's own page bridge. MDB does **not** patch the OpenAI extension, add itself to the OpenAI native-host allowlist, expose arbitrary private OpenAI RPC calls, or programmatically open the ChatGPT side panel. The current ChatGPT extension does not declare `externally_connectable`; its side-panel open path also requires a trusted user gesture. + What background mode does **not** promise: CAPTCHAs, native browser/OS permission dialogs, file pickers, downloads requiring a trusted user gesture, passkeys, and other browser security UI may require a foreground/manual step. The bridge reports that limitation rather than silently activating Chrome. This is also deliberately narrower than arbitrary page JavaScript or network-header capture; see [SECURITY.md](SECURITY.md). To remove the integration: diff --git a/SECURITY.md b/SECURITY.md index 473e60d..da0cccc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -206,13 +206,18 @@ The built-in background surface is intentionally narrower than the federated Chr - no file-upload tool or native file picker; - password input values are returned as `` by `chrome_snapshot`; - authenticated web actions are tab list/open/navigate/snapshot/click/fill/close; -- `chrome_workspace_status` and `chrome_workspace_setup` manage only extension-owned local workspace state and do not consume a website grant; +- `chrome_workspace_status`, `chrome_workspace_setup`, workspace release, and `chatgpt_extension_status` are local/read-only-or-cleanup operations and do not consume a website grant; - routine `chrome_open` **does not create a Chrome tab**. It leases one of the pre-created extension-owned tabs from the `MDB` group; - `chrome_close` releases an `MDB` tab back to its idle extension page instead of destroying the pool tab. -The `MDB` group is a Chrome-native tab group backed by `chrome.storage.local`. The default pool size is four (maximum eight). The group is collapsed when idle and expands while tabs are leased. State is reconciled after extension/service-worker restarts, and the extension can rediscover its own group by title/color plus the presence of an extension-owned workspace page. It deliberately does not adopt an arbitrary user group that merely happens to have the same title. Older/internal `tabs.open` requests are normalized to `workspace.open` in the JS client, native host, and extension so there is no supported loose-tab creation path. +The `MDB` group is a Chrome-native tab group backed by `chrome.storage.local`. The default and maximum managed pool size is eight. The group is collapsed when idle and expands while tabs are leased. State is reconciled after extension/service-worker restarts, and the extension can rediscover its own group by title/color plus the presence of an extension-owned workspace page. It deliberately does not adopt an arbitrary user group that merely happens to have the same title. Older/internal `tabs.open` requests are normalized to `workspace.open` in the JS client, native host, and extension so there is no supported loose-tab creation path. -Pool creation remains a foreground-only boundary. On the measured Chrome/macOS combination, even `chrome.tabs.create({active:false})` can foreground Chrome. `chrome_workspace_setup` therefore refuses to create or expand the pool unless a normal Chrome window is **already focused**, and routine browser work fails closed rather than creating a loose fallback tab. If the pool disappears after a Chrome/extension restart, the extension automatically recreates the default pool the next time Chrome becomes naturally focused; it never activates Chrome on the operator's behalf. Once the pool exists, routine open/navigate/read/click/fill/release operations reuse those tabs and avoid creation-time focus theft. +Pool creation and expansion remain a foreground-only boundary. On the measured Chrome/macOS combination, even `chrome.tabs.create({active:false})` can foreground Chrome. `chrome_workspace_setup` therefore refuses to create or expand the pool unless a normal Chrome window is **already focused**, and routine browser work fails closed rather than creating a loose fallback tab. If the pool disappears after a Chrome/extension restart, or an older pool has fewer than eight tabs, the extension automatically creates/expands the default pool the next time Chrome becomes naturally focused; it never activates Chrome on the operator's behalf. Once the pool exists, routine open/navigate/read/click/fill/release operations reuse those tabs and avoid creation-time focus theft. + + +Lease contention is handled as a scheduling problem rather than an immediate fatal error. An open waits up to 20 seconds for a free slot. A lease records both creation time and last browser activity; navigate/snapshot/click/fill renew that activity timestamp, while a lease idle for 10 minutes is reclaimed and its tab reset to the extension-owned idle page. This is crash/abandonment cleanup, not per-conversation ownership: `mcp-http.mjs` still multiplexes callers into one bridge child and the extension does not know a ChatGPT conversation identity. + +`chatgpt_extension_status` does not create a cross-extension control channel. The installed OpenAI extension currently exposes no `externally_connectable` contract. MDB only reads local installation/native-host metadata and, when a `chatgpt.com` tab exists, asks the page bridge that OpenAI already injects for its read-only status event. MDB does not modify OpenAI's native-host allowlist or invoke arbitrary undocumented `com.openai.codexextension` JSON-RPC methods. By default, background Chrome runs in **relaxed** approval mode: once the extension/profile binding is installed, normal HTTP/HTTPS URLs do not require per-site grant files. This matches the project's intentional unrestricted-shell trust model and removes approval ceremony from ordinary execution. The operator can enable **Strict approvals** in the menu-bar app at any time; the bridge re-reads `$DATA_DIR/settings.json` on each relevant action, so the change is live. In Strict mode, background-Chrome grants are additive mode-0600 files under `$DATA_DIR/chrome-background-grants/`, each with its own nonce, URL patterns, and expiry capped at 15 minutes. The bridge unions all unexpired patterns and Chrome remains the final URL authority. diff --git a/SHA256SUMS b/SHA256SUMS index 5009ba6..d8cef27 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,25 +1,25 @@ 44673237f87de06ed637d4d0391d4dd6c282bc13f368c57039eb8f9660812e24 .github/workflows/ci.yml 9d6687081330fcb210d668192998807cd5ce98bad5d189433a63242274ee603f .gitignore -91d0f6e8d6106539898b54ddf36d851e55505a29a6e88181246c11b90cccf487 CHANGELOG.md +3d1c3b71480de55622952933870945fb614507ed1123e4cf0d9add5331f72d8a CHANGELOG.md 67990f5fd45b6d902f7af87e4fba82386f89e4343683616e28e7b9207ed825db CONTRIBUTING.md b072b4af182ca0631f71db43b3bc1cd952404ddf5a68e18c54f6893ec7c31cb0 DEPLOY.md 39d809ae91249cdbda0284528d11903d13ecd10cae850578a7039c958022549f LICENSE -d59d2529dbf5fb9edc3832965e6c440bf0ac7cc74f5d44b5e30a741c42aca07b README.md -d99180b2ac01103cbb61b9d48a3fc5ef7e71201c19543603b27736061124dfca SECURITY.md -99df746242f2c29a16d27bf19118de84d74b1c266b4fc43e9090fb8872dc5cf8 bridge.mjs +f553c1f567d0e5e2534b30f2644095cc4c52f71a8cef01547a9efa6b108802c9 README.md +3f5942902d8d4cc8976f7d25a43ac73174b33b5efcdb83bc1e442acaec3b3ce3 SECURITY.md +9c8970cdfe214dfc403f70a9711cd95b5cf9de5f1fab1e80f042bbc5e1d93d69 bridge.mjs bfefa7420795df71e1910bb494625479be1729559c4d2c1517bf0ef6ca6958ce chrome-extension/icons/icon-128.png b0915f00aab8a589f924704d52a168c6ff6ba3376f11d6431b08bde36929ad48 chrome-extension/icons/icon-16.png e635470a81fca24f6943269df0d34b11c8fae8e2759842f0bcbcdfa9abd42875 chrome-extension/icons/icon-32.png 53b776f8e8362c9db35f0564746d876bcbc5d21e569bf82cb298227e3b057414 chrome-extension/icons/icon-48.png -50d7a2cd6dfb191718b00e6ba268d5a0e3f150554eacf8cd8345806680c46a9e chrome-extension/manifest.json -bdd33273577364b5bd4670f6e0fce45f28d412e9a6ae9bc8e355e88ad2a2e020 chrome-extension/service-worker.js +c5bc98ece211f6dbc02bcce1ac1c1e47fe047d667419f9083707087fe128f3f2 chrome-extension/manifest.json +cd16d61bd90449f7c1fe80a167de688e26f13f8427994a1c5b2e25ecc1fe3a1e chrome-extension/service-worker.js e63fdde88e17ca59ce59e0c8b711c25b84226d32d46bc7f0177ccb392b9824ee chrome-extension/workspace.html c2cde87780871ee75395ea04a4efe5851bb85c45935bb1506f36b2283ea4ee03 docs/assets/mac-developer-bridge-workflow.png 05eaccdaa5d2958249808b333da4822de9b97070c9a1e54ab2256794a82c9308 examples/README.md cea2a8709b4e3aa387cb4e5224537f632ba25b35444b7fc6ac809743eee73d33 glama.json 6993934223af939d24c63bbaa68e7e17503f8bd309c5371557265cb1f1c2f98b install.sh 4f6a5f274b32a2de78bab652feb25348d0dc764c6567c09555634cf2c1ee92ce launchd/com.openai.mac-developer-bridge-tunnel.plist.template -dbde3a38d3274665246ad3f963ffa462b2f39d89f725813a4b72ff0e5d0bb687 lib/chrome-extension-client.mjs +da9eaebcbf2326bf3f827b56c4318a35ef424625c44137f040b96a121edeec43 lib/chrome-extension-client.mjs e89db110c7b562e835f4cf33448ca211c166391314366ceacb14869f84aaff39 lib/federation.mjs b4f6bae4987bb7a8624a938b854b2d8533c458c7fe3ccc3244edda8851938f4a lib/ptyhelper.pl 392f9fe18af18f455445011bdb47d21dee961412b46fc1ad581581eda74c0fe7 mcp-http.mjs @@ -28,7 +28,7 @@ e43b577965274ec8366514d26eba9d799c808c3968652fbaed22cd773fd38a8f menubar/build. 0c600b94f5e9c2fb1748f52186321ba5630e21e6e0d0f7e532433a7e9a386757 package.json a4269f4463edddecf6e05fe707abf84bb2b3d86ddc7e8e4858cb6d777261d1ce scripts/approve-foreground-gui.sh 5c84901203e64c3b23a8e2840004ff21f375a10832a29197ba2010ebf784252f scripts/approve-personal-browser.sh -ccef6cdb36bab77e8b341610445eac21e3678f104170f30b48fe0b0b7a6a4625 scripts/chrome-native-host.mjs +1d6eff22187fc3052d80257cbfb4fd4692878380ca6edf23d9d5b26a6bd363fb scripts/chrome-native-host.mjs 223682c41ded77766cecbfc6b620c3a79be31f9c3609bbd8b5a63c6a651c17e3 scripts/disable.sh d2ca8d8e60b2442fed487d708d24a50b9dac3d1f26b7b0a344574205b6e75c82 scripts/doctor.sh 434cb03c1e1feaeb1dc1edaeee2cf1477d943800d40afb17a4834295574b15d8 scripts/enable.sh @@ -39,7 +39,7 @@ f3212f5bdf508dd875b07626ddf53c2d503a725cb371576481bddda0730fa3c3 scripts/run-tu 94cd0c7ea3835424f7071206e9aeeda194daa679571646bddd153c6f5a0af027 scripts/tcc-doctor.sh e297e0d764c78d834148ababd2972c58831d4d516c2e6af97ac192be46a61413 scripts/uninstall-background-chrome.sh 365dba0dc7f4a3efb209b31e9b92d1aefb2e39d3351ba01102d66a6097b5a3ab tests/adversarial.mjs -93ed9089990345e24d4e069474a124d653b752d5aadfa5e8b6dd0029f48f412e tests/chrome-background.mjs +1c50fb7b1c224a2e72473f8cbfd38c13ba38aa8eb2f7c117a4ff4706d1f5e861 tests/chrome-background.mjs 427e9d389d2e1b5cf3304bc1002ce0a9e00312bc7c26689c09138e8f4b934d44 tests/federation.mjs f7d86cfe2e99f5261eb8fbe648701b3a0142178514ae7a7eb0fbea04763c6731 tests/fixtures/poisoned-repository.md 808790524799b166abd23acddeff169fa5fd221ec37aeb8ce8ad16aa2a2f0a3b tests/fixtures/poisoned-repository.mjs @@ -49,5 +49,5 @@ ed69edda81ff3fe18d185db8a60242c74f281aba75c232337c213fc32e9cc264 tests/http.mjs 9411325a4961bc11fe8359e2be45ba35ab4015a708251b3b9784439c13b5885d tests/integration.mjs f327668d2a3c603185287b972733f0c3dddceb724f764a0afe9dc491ef8d9724 tests/oauth.mjs 4e97d1f4ea620b6a99e5249a890cef4f8cd03b0052b72d4eec6f922e31870047 tests/pty.mjs -3c0fa33182ad42a1dd52980fffc9eafd03830ac8768a17a8ba4dd5273b4c0dbe tests/smoke.mjs +aa3d6bbaa1eb50d4212dd6829144f786a102e42a60450f98caad07d80cac6dcf tests/smoke.mjs 1cb18d5fa6f1d563f6cd40eecea8e4581fac8642fdabe8c51f9bdf84b8059778 uninstall.sh diff --git a/bridge.mjs b/bridge.mjs index 4ab9115..800af09 100755 --- a/bridge.mjs +++ b/bridge.mjs @@ -723,6 +723,13 @@ const TOOLS = [ inputSchema: { type: "object", additionalProperties: false }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, }, + { + name: "chatgpt_extension_status", + title: "ChatGPT Chrome extension status", + description: "Inspect the installed ChatGPT Chrome extension, its OpenAI native-host registration, and the live read-only chatgpt.com page-bridge status when a ChatGPT tab is open. This does not modify the OpenAI extension or invoke its private native-host RPC protocol.", + inputSchema: { type: "object", additionalProperties: false }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, { name: "chrome_workspace_setup", title: "Set up MDB Chrome workspace", @@ -734,8 +741,8 @@ const TOOLS = [ type: "integer", minimum: 1, maximum: 8, - default: 4, - description: "Number of reusable extension-owned tabs to keep in the MDB group.", + default: 8, + description: "Number of reusable extension-owned tabs to keep in the MDB group. Eight is the default so concurrent automations do not exhaust a four-tab pool.", }, }, additionalProperties: false, @@ -1508,6 +1515,10 @@ const BACKGROUND_CHROME_GRANT_DIR = process.env.MAC_DEV_BRIDGE_BACKGROUND_CHROME || path.join(APP_SUPPORT_DIR, "chrome-background-grants"); const BACKGROUND_CHROME_MAX_TTL_MS = 15 * 60 * 1000; const BACKGROUND_CHROME_MAX_GRANT_FILES = 256; +const BACKGROUND_CHROME_DEFAULT_POOL_SIZE = 8; +const BACKGROUND_CHROME_MAX_POOL_SIZE = 8; +const CHATGPT_CHROME_EXTENSION_ID = "hehggadaopoacecdllhhajmbjkdcmajg"; +const CHATGPT_NATIVE_HOST_NAME = "com.openai.codexextension"; function backgroundChromeApprovalError(reason) { const error = new Error(`Background Chrome is not approved for this website: ${reason}. Run scripts/approve-personal-browser.sh --provider chrome-background with the required URL patterns. Approvals are shared across all ChatGPT sessions on this bridge until their individual expiry times.`); @@ -2178,7 +2189,7 @@ const federationReady = federation.start().then(() => { // one of the two would be unreachable in one direction and unguarded in the other. function advertisedTools() { let base = ptyAvailable ? TOOLS : TOOLS.filter((tool) => !tool.name.startsWith("pty_")); - if (process.platform !== "darwin") base = base.filter((tool) => !tool.name.startsWith("chrome_")); + if (process.platform !== "darwin") base = base.filter((tool) => !tool.name.startsWith("chrome_") && tool.name !== "chatgpt_extension_status"); const federated = federation.listTools(); return federated.length === 0 ? base : base.concat(federated); } @@ -3026,8 +3037,37 @@ async function dispatchTool(name, args) { return await callBackgroundChromeLocal(name, "workspace.status", {}); } + case "chatgpt_extension_status": { + let livePageBridge; + try { + const connection = await backgroundChromeStatus({ dataDir: APP_SUPPORT_DIR, timeoutMs: 1_000 }); + if (!connection?.extensionReady) { + const error = new Error(connection?.profileError?.message || connection?.error?.message || "The background Chrome extension is not connected."); + error.code = connection?.profileError?.code || connection?.error?.code || "CHROME_EXTENSION_OFFLINE"; + throw error; + } + livePageBridge = await backgroundChromeCall("chatgpt.extensionStatus", {}, [], { dataDir: APP_SUPPORT_DIR }); + await audit(name, args, { ok: true, chatgptExtensionStatus: true, localExtensionRead: true }); + } catch (error) { + livePageBridge = { available: false, error: { code: error?.code || "CHROME_EXTENSION_OFFLINE", message: error?.message || String(error) } }; + await audit(name, args, { chatgptExtensionStatus: true, localExtensionRead: true }, error); + } + return { + installation: await chatgptChromeExtensionInstallationStatus(), + nativeHost: await chatgptNativeHostInstallationStatus(), + livePageBridge, + interoperability: { + crossExtensionMessagingExposed: false, + liveStatusViaChatgptPageBridge: true, + programmaticSidePanelOpenExposed: false, + privateNativeHostProtocolInvoked: false, + note: "MDB intentionally reads the supported page bridge and local installation metadata only; it does not patch the OpenAI extension or expose arbitrary private native-host RPC calls.", + }, + }; + } + case "chrome_workspace_setup": { - const poolSize = optionalInteger(args, "pool_size", 4, 1, 8); + const poolSize = optionalInteger(args, "pool_size", BACKGROUND_CHROME_DEFAULT_POOL_SIZE, 1, BACKGROUND_CHROME_MAX_POOL_SIZE); return await callBackgroundChromeLocal(name, "workspace.init", { poolSize }); } @@ -3078,6 +3118,8 @@ async function dispatchTool(name, args) { case "chrome_close": { const tabId = requireInteger(args, "tab_id", 0, 2_147_483_647); const allowActive = optionalBoolean(args, "allow_active", false); + const localRelease = await callBackgroundChromeLocal(name, "workspace.release", { tabId }); + if (localRelease?.released === true) return localRelease; return await callBackgroundChrome(name, "tabs.close", { tabId, allowActive }); } diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index 7dd1aea..f1cc131 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.3", + "version": "0.2.4", "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 8e3734e..7acce0e 100644 --- a/chrome-extension/service-worker.js +++ b/chrome-extension/service-worker.js @@ -1,13 +1,22 @@ const NATIVE_HOST = "io.github.alexanderradahl.mac_developer_bridge"; -const VERSION = "0.2.3"; +const VERSION = "0.2.4"; const WORKSPACE_KEY = "macDeveloperBridgeWorkspace"; const WORKSPACE_GROUP_TITLE = "MDB"; const WORKSPACE_GROUP_COLOR = "blue"; -const WORKSPACE_LEASE_STALE_MS = 30 * 60 * 1000; +const WORKSPACE_LEASE_IDLE_TIMEOUT_MS = 10 * 60 * 1000; +const WORKSPACE_LEASE_WAIT_TIMEOUT_MS = 20_000; +const WORKSPACE_LEASE_WAIT_POLL_MS = 250; const WORKSPACE_NAVIGATION_TIMEOUT_MS = 15_000; -const DEFAULT_WORKSPACE_POOL_SIZE = 4; +const DEFAULT_WORKSPACE_POOL_SIZE = 8; +const MAX_WORKSPACE_POOL_SIZE = 8; +const CHATGPT_EXTENSION_ID = "hehggadaopoacecdllhhajmbjkdcmajg"; +const CHATGPT_STATUS_REQUEST_EVENT = "chatgpt-extension-request-status"; +const CHATGPT_STATUS_RESPONSE_EVENT = "chatgpt-extension-status"; +const CHATGPT_STATUS_ATTRIBUTE = "data-chatgpt-extension-status"; +const CHATGPT_SIDE_PANEL_ATTRIBUTE = "data-chatgpt-extension-side-panel"; let port = null; let reconnectTimer = null; +let workspaceMutationQueue = Promise.resolve(); function errorPayload(error, code = "CHROME_EXTENSION_ERROR") { return { @@ -16,6 +25,16 @@ function errorPayload(error, code = "CHROME_EXTENSION_ERROR") { }; } +function mutateWorkspaceState(operation) { + const run = workspaceMutationQueue.then(operation, operation); + workspaceMutationQueue = run.then(() => {}, () => {}); + return run; +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + function compilePatterns(patterns) { if (!Array.isArray(patterns) || patterns.length === 0) { const error = new Error("No approved URL patterns were supplied by Mac Developer Bridge."); @@ -101,7 +120,7 @@ async function discoverWorkspaceState() { return null; } -async function reconcileWorkspaceState({ releaseStale = true } = {}) { +async function reconcileWorkspaceStateUnlocked({ releaseStale = true } = {}) { let state = await loadWorkspaceState(); if (!state) return await discoverWorkspaceState(); let group = await readGroup(state.groupId); @@ -122,17 +141,35 @@ async function reconcileWorkspaceState({ releaseStale = true } = {}) { const now = Date.now(); const leases = {}; + const staleTabIds = []; for (const tab of tabs) { const lease = state.leases?.[String(tab.id)]; if (!lease || typeof lease !== "object") continue; const leasedAt = Number(lease.leasedAt || 0); - if (releaseStale && (!Number.isFinite(leasedAt) || now - leasedAt > WORKSPACE_LEASE_STALE_MS)) continue; - leases[String(tab.id)] = { leasedAt }; + const hasActivityTimestamp = Number.isFinite(Number(lease.lastActivityAt)) && Number(lease.lastActivityAt) > 0; + // v0.2.3 stored only leasedAt. On first v0.2.4 reconciliation, migrate + // those live leases with a fresh heartbeat instead of interpreting their + // original creation time as 10 minutes of inactivity. + const lastActivityAt = hasActivityTimestamp ? Number(lease.lastActivityAt) : now; + const invalid = !Number.isFinite(leasedAt) || !Number.isFinite(lastActivityAt) || leasedAt <= 0 || lastActivityAt <= 0; + const idleExpired = now - lastActivityAt > WORKSPACE_LEASE_IDLE_TIMEOUT_MS; + if (releaseStale && (invalid || idleExpired)) { + staleTabIds.push(tab.id); + continue; + } + leases[String(tab.id)] = { leasedAt, lastActivityAt }; } const next = { groupId: state.groupId, tabIds: tabs.map((tab) => tab.id), leases }; await saveWorkspaceState(next); - return { ...next, group, tabs }; + for (const tabId of staleTabIds) { + try { await chrome.tabs.update(tabId, { url: workspaceIdleUrl(), active: false }); } catch {} + } + return { ...next, group, tabs, staleReleasedTabIds: staleTabIds }; +} + +async function reconcileWorkspaceState(options = {}) { + return await mutateWorkspaceState(() => reconcileWorkspaceStateUnlocked(options)); } async function setWorkspaceGroupActivity(state) { @@ -147,79 +184,82 @@ async function setWorkspaceGroupActivity(state) { } async function initializeWorkspace(poolSize) { - const desired = Math.max(1, Math.min(8, Number(poolSize || 4))); - let state = await reconcileWorkspaceState(); - if (state && state.tabIds.length >= desired) { - await setWorkspaceGroupActivity(state); - return { - initialized: true, - created: false, - groupId: state.groupId, - tabIds: state.tabIds, - poolSize: state.tabIds.length, - title: WORKSPACE_GROUP_TITLE, - color: WORKSPACE_GROUP_COLOR, - }; - } + return await mutateWorkspaceState(async () => { + const desired = Math.max(1, Math.min(MAX_WORKSPACE_POOL_SIZE, Number(poolSize || DEFAULT_WORKSPACE_POOL_SIZE))); + let state = await reconcileWorkspaceStateUnlocked(); + if (state && state.tabIds.length >= desired) { + await setWorkspaceGroupActivity(state); + return { + initialized: true, + created: false, + groupId: state.groupId, + tabIds: state.tabIds, + poolSize: state.tabIds.length, + targetPoolSize: desired, + title: WORKSPACE_GROUP_TITLE, + color: WORKSPACE_GROUP_COLOR, + }; + } - let windowId = state?.tabs?.[0]?.windowId; - let targetWindow = null; - if (Number.isInteger(windowId)) { - try { targetWindow = await chrome.windows.get(windowId); } catch {} - } else { - const windows = await chrome.windows.getAll({ windowTypes: ["normal"] }); - targetWindow = windows.find((win) => win.focused) || null; - windowId = targetWindow?.id; - } + let windowId = state?.tabs?.[0]?.windowId; + let targetWindow = null; + if (Number.isInteger(windowId)) { + try { targetWindow = await chrome.windows.get(windowId); } catch {} + } else { + const windows = await chrome.windows.getAll({ windowTypes: ["normal"] }); + targetWindow = windows.find((win) => win.focused) || null; + windowId = targetWindow?.id; + } - // Measured on Chrome 151/macOS: even tabs.create({active:false}) can bring - // Chrome to the foreground. Workspace creation/expansion is therefore an - // explicit one-time foreground setup and NEVER performs that focus change on - // the operator's behalf. - if (!targetWindow || !Number.isInteger(windowId)) { - const error = new Error("No focused normal Chrome window is available. Bring Chrome to the front once, then run MDB workspace setup again."); - error.code = "CHROME_WORKSPACE_SETUP_FOREGROUND_REQUIRED"; - throw error; - } - if (targetWindow.focused !== true) { - const error = new Error("MDB workspace setup would need to create background tabs, but Chrome is not currently focused. Bring Chrome to the front once and retry; routine browser work will stay background-only afterwards."); - error.code = "CHROME_WORKSPACE_SETUP_FOREGROUND_REQUIRED"; - throw error; - } + // Measured on Chrome/macOS: even tabs.create({active:false}) can bring Chrome + // to the foreground. Creation/expansion is allowed only while Chrome is + // already naturally focused; MDB never activates Chrome on the user's behalf. + if (!targetWindow || !Number.isInteger(windowId)) { + const error = new Error("No focused normal Chrome window is available. Bring Chrome to the front once, then run MDB workspace setup again."); + error.code = "CHROME_WORKSPACE_SETUP_FOREGROUND_REQUIRED"; + throw error; + } + if (targetWindow.focused !== true) { + const error = new Error("MDB workspace setup would need to create background tabs, but Chrome is not currently focused. Bring Chrome to the front once and retry; routine browser work will stay background-only afterwards."); + error.code = "CHROME_WORKSPACE_SETUP_FOREGROUND_REQUIRED"; + throw error; + } - const existingTabIds = state?.tabIds || []; - const tabIds = [...existingTabIds]; - while (tabIds.length < desired) { - const tab = await chrome.tabs.create({ windowId, url: workspaceIdleUrl(), active: false }); - if (!Number.isInteger(tab.id)) throw new Error("Chrome did not return a tab id during workspace setup."); - tabIds.push(tab.id); - } + const existingTabIds = state?.tabIds || []; + const tabIds = [...existingTabIds]; + while (tabIds.length < desired) { + const tab = await chrome.tabs.create({ windowId, url: workspaceIdleUrl(), active: false }); + if (!Number.isInteger(tab.id)) throw new Error("Chrome did not return a tab id during workspace setup."); + tabIds.push(tab.id); + } - let groupId = state?.groupId; - if (!Number.isInteger(groupId)) { - groupId = await chrome.tabs.group({ tabIds, createProperties: { windowId } }); - } else { - const newlyCreated = tabIds.filter((id) => !existingTabIds.includes(id)); - if (newlyCreated.length) await chrome.tabs.group({ tabIds: newlyCreated, groupId }); - } + let groupId = state?.groupId; + if (!Number.isInteger(groupId)) { + groupId = await chrome.tabs.group({ tabIds, createProperties: { windowId } }); + } else { + const newlyCreated = tabIds.filter((id) => !existingTabIds.includes(id)); + if (newlyCreated.length) await chrome.tabs.group({ tabIds: newlyCreated, groupId }); + } - const next = { groupId, tabIds, leases: state?.leases || {} }; - await saveWorkspaceState(next); - await chrome.tabGroups.update(groupId, { - title: WORKSPACE_GROUP_TITLE, - color: WORKSPACE_GROUP_COLOR, - collapsed: Object.keys(next.leases).length === 0, + const next = { groupId, tabIds, leases: state?.leases || {} }; + await saveWorkspaceState(next); + await chrome.tabGroups.update(groupId, { + title: WORKSPACE_GROUP_TITLE, + color: WORKSPACE_GROUP_COLOR, + collapsed: Object.keys(next.leases).length === 0, + }); + return { + initialized: true, + created: tabIds.length > existingTabIds.length, + groupId, + tabIds, + poolSize: tabIds.length, + targetPoolSize: desired, + title: WORKSPACE_GROUP_TITLE, + color: WORKSPACE_GROUP_COLOR, + foregroundSetupMayBeRequired: true, + }; }); - return { - initialized: true, - created: tabIds.length > existingTabIds.length, - groupId, - tabIds, - poolSize: tabIds.length, - title: WORKSPACE_GROUP_TITLE, - color: WORKSPACE_GROUP_COLOR, - foregroundSetupMayBeRequired: true, - }; } async function waitForApprovedNavigation(tabId, compiled, { @@ -263,45 +303,90 @@ async function waitForApprovedNavigation(tabId, compiled, { } async function initializeWorkspaceIfChromeFocused() { - const state = await reconcileWorkspaceState(); - if (state) return state; + let state = await reconcileWorkspaceState(); const windows = await chrome.windows.getAll({ windowTypes: ["normal"] }); const focused = windows.find((win) => win.focused === true); - if (!focused) return null; - try { - await initializeWorkspace(DEFAULT_WORKSPACE_POOL_SIZE); - } catch (error) { - if (error?.code !== "CHROME_WORKSPACE_SETUP_FOREGROUND_REQUIRED") throw error; - return null; + if (!focused) return state; + if (!state || state.tabIds.length < DEFAULT_WORKSPACE_POOL_SIZE) { + try { + await initializeWorkspace(DEFAULT_WORKSPACE_POOL_SIZE); + } catch (error) { + if (error?.code !== "CHROME_WORKSPACE_SETUP_FOREGROUND_REQUIRED") throw error; + return state; + } + state = await reconcileWorkspaceState(); } - return await reconcileWorkspaceState(); + return state; +} + +async function touchWorkspaceLease(tabId) { + const wanted = numericTabId(tabId); + return await mutateWorkspaceState(async () => { + const state = await reconcileWorkspaceStateUnlocked({ releaseStale: false }); + if (!state) return false; + const lease = state.leases?.[String(wanted)]; + if (!lease) return false; + state.leases[String(wanted)] = { + leasedAt: Number(lease.leasedAt || Date.now()), + lastActivityAt: Date.now(), + }; + await saveWorkspaceState({ groupId: state.groupId, tabIds: state.tabIds, leases: state.leases }); + return true; + }); +} + +async function reserveIdleWorkspaceTab() { + return await mutateWorkspaceState(async () => { + const state = await reconcileWorkspaceStateUnlocked({ releaseStale: true }); + if (!state) return { state: null, tab: null }; + const leasedIds = new Set(Object.keys(state.leases).map(Number)); + const tab = state.tabs.find((candidate) => !leasedIds.has(candidate.id)) || null; + if (!tab) return { state, tab: null }; + const now = Date.now(); + state.leases[String(tab.id)] = { leasedAt: now, lastActivityAt: now }; + await saveWorkspaceState({ groupId: state.groupId, tabIds: state.tabIds, leases: state.leases }); + return { state, tab }; + }); } async function leaseWorkspaceTab(url, compiled) { assertUrlAllowed(url, compiled); let state = await reconcileWorkspaceState(); - if (!state) state = await initializeWorkspaceIfChromeFocused(); + if (!state || state.tabIds.length < DEFAULT_WORKSPACE_POOL_SIZE) { + state = await initializeWorkspaceIfChromeFocused(); + } if (!state) { const error = new Error("The Mac Developer Bridge Chrome tab group is missing. MDB will recreate it automatically the next time Chrome is naturally foreground; browser work refuses to create a loose fallback tab in the meantime."); error.code = "CHROME_WORKSPACE_MISSING"; throw error; } - const leasedIds = new Set(Object.keys(state.leases).map(Number)); - const tab = state.tabs.find((candidate) => !leasedIds.has(candidate.id)); - if (!tab) { - const error = new Error(`All ${state.tabs.length} Mac Developer Bridge background tabs are currently in use. Release one or rerun workspace setup with a larger pool.`); - error.code = "CHROME_WORKSPACE_EXHAUSTED"; - throw error; + const waitStartedAt = Date.now(); + const deadline = waitStartedAt + WORKSPACE_LEASE_WAIT_TIMEOUT_MS; + let reservation = null; + for (;;) { + reservation = await reserveIdleWorkspaceTab(); + if (reservation.tab) break; + if (Date.now() >= deadline) { + const current = reservation.state || await reconcileWorkspaceState(); + const leased = Object.keys(current?.leases || {}).length; + const poolSize = current?.tabIds?.length || 0; + const error = new Error(`All ${poolSize} Mac Developer Bridge background tabs remained in use for ${WORKSPACE_LEASE_WAIT_TIMEOUT_MS}ms. MDB waited for a release instead of failing immediately. The pool will expand to ${DEFAULT_WORKSPACE_POOL_SIZE} the next time Chrome is naturally focused.`); + error.code = "CHROME_WORKSPACE_EXHAUSTED"; + error.details = { poolSize, leased, waitTimeoutMs: WORKSPACE_LEASE_WAIT_TIMEOUT_MS, targetPoolSize: DEFAULT_WORKSPACE_POOL_SIZE }; + throw error; + } + await delay(WORKSPACE_LEASE_WAIT_POLL_MS); } - state.leases[String(tab.id)] = { leasedAt: Date.now() }; - await saveWorkspaceState({ groupId: state.groupId, tabIds: state.tabIds, leases: state.leases }); + state = reservation.state; + const tab = reservation.tab; await setWorkspaceGroupActivity(state); try { const previousUrl = String(tab.url || ""); await chrome.tabs.update(tab.id, { url, active: false }); const settled = await waitForApprovedNavigation(tab.id, compiled, { previousUrl, requestedUrl: url }); + await touchWorkspaceLease(tab.id); return { workspace: true, groupId: state.groupId, @@ -310,49 +395,76 @@ async function leaseWorkspaceTab(url, compiled) { active: Boolean(settled.active), title: settled.title || "", url: settled.url || url, + poolSize: state.tabIds.length, + waitedForSlotMs: Math.max(0, Date.now() - waitStartedAt), }; } catch (error) { - // Failed/blocked navigation must not strand a leased pool slot or leave an - // unapproved destination sitting in the reusable workspace. try { await chrome.tabs.update(tab.id, { url: workspaceIdleUrl(), active: false }); } catch {} - delete state.leases[String(tab.id)]; - await saveWorkspaceState({ groupId: state.groupId, tabIds: state.tabIds, leases: state.leases }); - await setWorkspaceGroupActivity(state); + await releaseWorkspaceTab(tab.id, { resetUrl: false }).catch(() => {}); throw error; } } -async function releaseWorkspaceTab(tabId) { +async function releaseWorkspaceTab(tabId, { resetUrl = true } = {}) { const wanted = numericTabId(tabId); - const state = await reconcileWorkspaceState({ releaseStale: false }); - if (!state || !state.tabIds.includes(wanted)) return null; - delete state.leases[String(wanted)]; - await saveWorkspaceState({ groupId: state.groupId, tabIds: state.tabIds, leases: state.leases }); - let updated = await readTab(wanted); - if (updated) { - try { updated = await chrome.tabs.update(wanted, { url: workspaceIdleUrl(), active: false }); } catch {} - } - await setWorkspaceGroupActivity(state); + const result = await mutateWorkspaceState(async () => { + const state = await reconcileWorkspaceStateUnlocked({ releaseStale: false }); + if (!state || !state.tabIds.includes(wanted)) return null; + const lease = state.leases[String(wanted)] || null; + let updated = await readTab(wanted); + // Reset while the lease is still reserved. Only after chrome.tabs.update + // resolves do we remove the lease, so a waiting opener cannot reserve this + // tab and then have its navigation overwritten by a late cleanup update. + if (resetUrl && updated) { + try { updated = await chrome.tabs.update(wanted, { url: workspaceIdleUrl(), active: false }); } catch {} + } + delete state.leases[String(wanted)]; + await saveWorkspaceState({ groupId: state.groupId, tabIds: state.tabIds, leases: state.leases }); + return { state, lease, updated }; + }); + if (!result) return null; + await setWorkspaceGroupActivity(result.state); return { workspace: true, released: true, closed: false, - groupId: state.groupId, + groupId: result.state.groupId, tabId: wanted, - wasActive: Boolean(updated?.active), + wasActive: Boolean(result.updated?.active), }; } async function workspaceStatus() { const state = await reconcileWorkspaceState(); - if (!state) return { initialized: false, title: WORKSPACE_GROUP_TITLE, color: WORKSPACE_GROUP_COLOR }; + if (!state) return { + initialized: false, + title: WORKSPACE_GROUP_TITLE, + color: WORKSPACE_GROUP_COLOR, + targetPoolSize: DEFAULT_WORKSPACE_POOL_SIZE, + maxPoolSize: MAX_WORKSPACE_POOL_SIZE, + leaseIdleTimeoutMs: WORKSPACE_LEASE_IDLE_TIMEOUT_MS, + leaseWaitTimeoutMs: WORKSPACE_LEASE_WAIT_TIMEOUT_MS, + }; + const now = Date.now(); + const leaseDetails = Object.entries(state.leases).map(([tabId, lease]) => ({ + tabId: Number(tabId), + leasedAt: Number(lease.leasedAt || 0), + lastActivityAt: Number(lease.lastActivityAt || lease.leasedAt || 0), + ageMs: Math.max(0, now - Number(lease.leasedAt || now)), + idleForMs: Math.max(0, now - Number(lease.lastActivityAt || lease.leasedAt || now)), + })); return { initialized: true, groupId: state.groupId, tabIds: state.tabIds, poolSize: state.tabIds.length, + targetPoolSize: DEFAULT_WORKSPACE_POOL_SIZE, + maxPoolSize: MAX_WORKSPACE_POOL_SIZE, leasedTabIds: Object.keys(state.leases).map(Number), idleTabIds: state.tabIds.filter((id) => !state.leases[String(id)]), + leaseDetails, + leaseIdleTimeoutMs: WORKSPACE_LEASE_IDLE_TIMEOUT_MS, + leaseWaitTimeoutMs: WORKSPACE_LEASE_WAIT_TIMEOUT_MS, title: WORKSPACE_GROUP_TITLE, color: WORKSPACE_GROUP_COLOR, collapsed: Boolean(state.group?.collapsed), @@ -370,11 +482,76 @@ function numericTabId(value) { } async function getApprovedTab(tabId, compiled) { - const tab = await chrome.tabs.get(numericTabId(tabId)); + const id = numericTabId(tabId); + const tab = await chrome.tabs.get(id); assertUrlAllowed(tab.url, compiled); + await touchWorkspaceLease(id); return tab; } +async function chatgptExtensionStatus() { + const tabs = await chrome.tabs.query({ url: ["https://chatgpt.com/*"] }); + if (tabs.length === 0) { + return { + available: false, + extensionId: CHATGPT_EXTENSION_ID, + reason: "no-chatgpt-tab", + pageBridgeAvailable: false, + }; + } + for (const tab of tabs) { + if (!Number.isInteger(tab.id)) continue; + try { + const result = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + world: "MAIN", + func: async (requestEvent, responseEvent, statusAttribute, sidePanelAttribute) => { + const root = document.documentElement; + const pageBridgeAvailable = root.getAttribute(sidePanelAttribute) === "available"; + return await new Promise((resolve) => { + let settled = false; + const finish = (payload) => { + if (settled) return; + settled = true; + window.removeEventListener(responseEvent, onResponse); + resolve(payload); + }; + const readState = () => { + const raw = root.getAttribute(statusAttribute); + if (!raw) return null; + try { return JSON.parse(raw); } catch { return { raw }; } + }; + const onResponse = () => finish({ available: true, pageBridgeAvailable, state: readState() }); + window.addEventListener(responseEvent, onResponse, { once: true }); + window.dispatchEvent(new Event(requestEvent)); + setTimeout(() => finish({ available: pageBridgeAvailable, pageBridgeAvailable, state: readState(), timedOut: true }), 1200); + }); + }, + args: [CHATGPT_STATUS_REQUEST_EVENT, CHATGPT_STATUS_RESPONSE_EVENT, CHATGPT_STATUS_ATTRIBUTE, CHATGPT_SIDE_PANEL_ATTRIBUTE], + }); + const value = result?.[0]?.result; + if (value?.available || value?.pageBridgeAvailable) { + return { + ...value, + extensionId: CHATGPT_EXTENSION_ID, + tabId: tab.id, + windowId: tab.windowId, + tabActive: Boolean(tab.active), + tabGroupId: Number.isInteger(tab.groupId) && tab.groupId >= 0 ? tab.groupId : null, + url: tab.url || "", + }; + } + } catch {} + } + return { + available: false, + extensionId: CHATGPT_EXTENSION_ID, + reason: "page-bridge-unavailable", + pageBridgeAvailable: false, + candidateTabs: tabs.length, + }; +} + function pageSnapshot(maxTextChars, maxElements) { function selectorFor(element) { if (!(element instanceof Element)) return null; @@ -516,6 +693,8 @@ async function dispatch(message) { } if (message.method === "workspace.status") return await workspaceStatus(); if (message.method === "workspace.init") return await initializeWorkspace(args.poolSize); + if (message.method === "workspace.release") return await releaseWorkspaceTab(args.tabId) || { released: false, workspace: false, tabId: numericTabId(args.tabId) }; + if (message.method === "chatgpt.extensionStatus") return await chatgptExtensionStatus(); const compiled = compilePatterns(message.allowedUrlPatterns); switch (message.method) { @@ -528,9 +707,6 @@ async function dispatch(message) { return await leaseWorkspaceTab(url, compiled); } - case "workspace.release": - return await releaseWorkspaceTab(args.tabId) || { released: false, workspace: false, tabId: numericTabId(args.tabId) }; - case "tabs.list": { const urlContains = String(args.urlContains || "").toLowerCase(); const titleContains = String(args.titleContains || "").toLowerCase(); @@ -564,6 +740,7 @@ async function dispatch(message) { const previousUrl = String(tab.url || ""); await chrome.tabs.update(tab.id, { url, active: false }); const settled = await waitForApprovedNavigation(tab.id, compiled, { previousUrl, requestedUrl: url }); + await touchWorkspaceLease(tab.id); return { tabId: settled.id, windowId: settled.windowId, active: Boolean(settled.active), url: settled.url || url }; } diff --git a/lib/chrome-extension-client.mjs b/lib/chrome-extension-client.mjs index d032854..9799832 100644 --- a/lib/chrome-extension-client.mjs +++ b/lib/chrome-extension-client.mjs @@ -3,9 +3,9 @@ import net from "node:net"; import os from "node:os"; import path from "node:path"; -const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_TIMEOUT_MS = 45_000; const MAX_RESPONSE_BYTES = 4 * 1024 * 1024; -const GRANTLESS_METHODS = new Set(["status", "workspace.status", "workspace.init"]); +const GRANTLESS_METHODS = new Set(["status", "workspace.status", "workspace.init", "workspace.release", "chatgpt.extensionStatus"]); function normalizeBackgroundChromeMethod(method) { // Compatibility for stale/older callers. There must be exactly one tab-open diff --git a/scripts/chrome-native-host.mjs b/scripts/chrome-native-host.mjs index 4cc84c3..77d76c7 100755 --- a/scripts/chrome-native-host.mjs +++ b/scripts/chrome-native-host.mjs @@ -17,8 +17,8 @@ const PROFILE_BINDING_FILE = process.env.MAC_DEV_BRIDGE_CHROME_PROFILE_BINDING_F || path.join(DATA_DIR, "chrome-background-profile.json"); const MAX_NATIVE_MESSAGE_BYTES = 8 * 1024 * 1024; const MAX_SOCKET_LINE_BYTES = 2 * 1024 * 1024; -const REQUEST_TIMEOUT_MS = 30_000; -const GRANTLESS_EXTENSION_METHODS = new Set(["status", "workspace.status", "workspace.init"]); +const REQUEST_TIMEOUT_MS = 45_000; +const GRANTLESS_EXTENSION_METHODS = new Set(["status", "workspace.status", "workspace.init", "workspace.release", "chatgpt.extensionStatus"]); function normalizeExtensionMethod(method) { // Old/stale clients may still issue tabs.open directly. Never forward that diff --git a/tests/chrome-background.mjs b/tests/chrome-background.mjs index 8af6ad1..abab5d5 100644 --- a/tests/chrome-background.mjs +++ b/tests/chrome-background.mjs @@ -97,7 +97,11 @@ function startFakeExtensionHost() { ok: true, result: message.method === "tabs.list" ? { tabs: [{ tabId: 42, windowId: 7, active: false, title: "Allowed", url: "https://www.producthunt.com/test", status: "complete" }], count: 1 } - : { echoedMethod: message.method, echoedArgs: message.args }, + : message.method === "workspace.release" + ? { released: true, workspace: true, tabId: message.args?.tabId ?? null } + : message.method === "chatgpt.extensionStatus" + ? { available: true, pageBridgeAvailable: true, extensionId: "hehggadaopoacecdllhhajmbjkdcmajg", state: { nativeHostStatus: "connected" } } + : { echoedMethod: message.method, echoedArgs: message.args }, })); } } @@ -196,7 +200,7 @@ try { assert.match(workerSource, /case "tabs\.open"/); assert.match(workerSource, /initializeWorkspaceIfChromeFocused/); assert.match(workerSource, /chrome\.windows\.onFocusChanged/); - const legacyOpenCase = workerSource.match(/case "workspace\.open":[\s\S]*?case "workspace\.release":/)?.[0] || ""; + const legacyOpenCase = workerSource.match(/case "workspace\.open":[\s\S]*?case "tabs\.list":/)?.[0] || ""; assert.match(legacyOpenCase, /case "tabs\.open"/); assert.match(legacyOpenCase, /return await leaseWorkspaceTab/); assert.doesNotMatch(legacyOpenCase, /await chrome\.tabs\.create/); @@ -204,6 +208,13 @@ try { assert.match(workerSource, /targetWindow\.focused !== true/); assert.match(workerSource, /waitForApprovedNavigation/); assert.match(workerSource, /CHROME_NAVIGATION_TIMEOUT/); + assert.match(workerSource, /DEFAULT_WORKSPACE_POOL_SIZE = 8/); + assert.match(workerSource, /WORKSPACE_LEASE_IDLE_TIMEOUT_MS = 10 \* 60 \* 1000/); + assert.match(workerSource, /WORKSPACE_LEASE_WAIT_TIMEOUT_MS = 20_000/); + assert.match(workerSource, /reserveIdleWorkspaceTab/); + assert.match(workerSource, /touchWorkspaceLease/); + assert.match(workerSource, /chatgptExtensionStatus/); + assert.match(workerSource, /chatgpt-extension-request-status/); const publicKey = Buffer.from(manifest.key, "base64"); const digest = crypto.createHash("sha256").update(publicKey).digest().subarray(0, 16); const extensionId = [...digest].flatMap((byte) => [byte >> 4, byte & 0x0f]).map((n) => String.fromCharCode(97 + n)).join(""); @@ -282,15 +293,43 @@ try { const localStatus = await backgroundChromeCall("workspace.status", {}, [], { socketPath }); assert.equal(localStatus.echoedMethod, "workspace.status"); assert.deepEqual(host.seen.at(-1).allowedUrlPatterns, []); + const chatgptStatus = await backgroundChromeCall("chatgpt.extensionStatus", {}, [], { socketPath }); + assert.equal(chatgptStatus.available, true); + assert.equal(host.seen.at(-1).method, "chatgpt.extensionStatus"); + assert.deepEqual(host.seen.at(-1).allowedUrlPatterns, []); + const directRelease = await backgroundChromeCall("workspace.release", { tabId: 42 }, [], { socketPath }); + assert.equal(directRelease.released, true); + assert.equal(host.seen.at(-1).method, "workspace.release"); + assert.deepEqual(host.seen.at(-1).allowedUrlPatterns, []); const bridgeWorkspace = await bridgeTool(bridge, "chrome_workspace_status", {}); assert.equal(bridgeWorkspace.result.isError, false, bridgeWorkspace.result.content[0].text); assert.equal(host.seen.at(-1).method, "workspace.status"); await fs.stat(approvalFile); + const bridgeSetupDefault = await bridgeTool(bridge, "chrome_workspace_setup", {}); + assert.equal(bridgeSetupDefault.result.isError, false, bridgeSetupDefault.result.content[0].text); + assert.equal(host.seen.at(-1).method, "workspace.init"); + assert.equal(host.seen.at(-1).args.poolSize, 8); + await fs.stat(approvalFile); const bridgeSetup = await bridgeTool(bridge, "chrome_workspace_setup", { pool_size: 6 }); assert.equal(bridgeSetup.result.isError, false, bridgeSetup.result.content[0].text); assert.equal(host.seen.at(-1).method, "workspace.init"); assert.equal(host.seen.at(-1).args.poolSize, 6); await fs.stat(approvalFile); + + // Workspace cleanup is grantless even in Strict mode. Removing the only + // unconsumed approval must not prevent chrome_close from releasing MDB state. + await fs.rm(approvalFile, { force: true }); + const bridgeRelease = await bridgeTool(bridge, "chrome_close", { tab_id: 42 }); + assert.equal(bridgeRelease.result.isError, false, bridgeRelease.result.content[0].text); + assert.equal(bridgeRelease.result.structuredContent.released, true); + assert.equal(host.seen.at(-1).method, "workspace.release"); + assert.deepEqual(host.seen.at(-1).allowedUrlPatterns, []); + await fs.writeFile(approvalFile, JSON.stringify({ + nonce: "0123456789abcdef0123456789abcdef", + expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(), + provider: "chrome-background", + allowedUrlPatterns: ["https://www.producthunt.com/*"], + }), { mode: 0o600 }); await assert.rejects( backgroundChromeCall("tabs.list", { maxTabs: 3 }, [], { socketPath }), (error) => error?.code === "CHROME_NO_URL_GRANT", diff --git a/tests/smoke.mjs b/tests/smoke.mjs index fce1d20..e4190e4 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -113,10 +113,12 @@ try { const byName = new Map(tools.result.tools.map((tool) => [tool.name, tool])); assert.ok(byName.has("shell_exec")); if (process.platform === "darwin") { - for (const tool of ["chrome_workspace_status", "chrome_workspace_setup", "chrome_tabs", "chrome_open", "chrome_navigate", "chrome_snapshot", "chrome_click", "chrome_fill", "chrome_close"]) { + for (const tool of ["chrome_workspace_status", "chatgpt_extension_status", "chrome_workspace_setup", "chrome_tabs", "chrome_open", "chrome_navigate", "chrome_snapshot", "chrome_click", "chrome_fill", "chrome_close"]) { assert.ok(byName.has(tool), `expected ${tool} on macOS`); } assert.equal(byName.get("chrome_workspace_status").annotations.readOnlyHint, true); + assert.equal(byName.get("chatgpt_extension_status").annotations.readOnlyHint, true); + assert.equal(byName.get("chrome_workspace_setup").inputSchema.properties.pool_size.default, 8); assert.equal(byName.get("chrome_tabs").annotations.readOnlyHint, true); assert.equal(byName.get("chrome_click").annotations.destructiveHint, true); } From f16f44a1981c7447fa353d01b47c8fe78b437b24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20R=C3=A5dahl=20Benz?= <3107277+alexanderradahl@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:49:43 -0700 Subject: [PATCH 2/5] fix: keep Chrome cleanup compatible during upgrade --- SHA256SUMS | 2 +- bridge.mjs | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/SHA256SUMS b/SHA256SUMS index d8cef27..fe31c8e 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -6,7 +6,7 @@ b072b4af182ca0631f71db43b3bc1cd952404ddf5a68e18c54f6893ec7c31cb0 DEPLOY.md 39d809ae91249cdbda0284528d11903d13ecd10cae850578a7039c958022549f LICENSE f553c1f567d0e5e2534b30f2644095cc4c52f71a8cef01547a9efa6b108802c9 README.md 3f5942902d8d4cc8976f7d25a43ac73174b33b5efcdb83bc1e442acaec3b3ce3 SECURITY.md -9c8970cdfe214dfc403f70a9711cd95b5cf9de5f1fab1e80f042bbc5e1d93d69 bridge.mjs +ca5d10c85c749f9c487299493b65d6029feb0fe0ee60365713b0b82023427323 bridge.mjs bfefa7420795df71e1910bb494625479be1729559c4d2c1517bf0ef6ca6958ce chrome-extension/icons/icon-128.png b0915f00aab8a589f924704d52a168c6ff6ba3376f11d6431b08bde36929ad48 chrome-extension/icons/icon-16.png e635470a81fca24f6943269df0d34b11c8fae8e2759842f0bcbcdfa9abd42875 chrome-extension/icons/icon-32.png diff --git a/bridge.mjs b/bridge.mjs index 800af09..508afc9 100755 --- a/bridge.mjs +++ b/bridge.mjs @@ -3118,8 +3118,16 @@ async function dispatchTool(name, args) { case "chrome_close": { const tabId = requireInteger(args, "tab_id", 0, 2_147_483_647); const allowActive = optionalBoolean(args, "allow_active", false); - const localRelease = await callBackgroundChromeLocal(name, "workspace.release", { tabId }); - if (localRelease?.released === true) return localRelease; + try { + const localRelease = await callBackgroundChromeLocal(name, "workspace.release", { tabId }); + if (localRelease?.released === true) return localRelease; + } catch (error) { + // Rolling-upgrade compatibility: v0.2.3 native hosts did not mark + // workspace.release grantless. During the short bridge-first upgrade + // window, fall back to the old granted tabs.close path rather than + // stranding a lease. Other errors remain real failures. + if (error?.code !== "CHROME_NO_URL_GRANT" && error?.code !== "CHROME_UNKNOWN_METHOD") throw error; + } return await callBackgroundChrome(name, "tabs.close", { tabId, allowActive }); } From 9f18d9c6a9e6354b9b84a96c60be3a70d70a6be3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20R=C3=A5dahl=20Benz?= <3107277+alexanderradahl@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:37:32 -0700 Subject: [PATCH 3/5] fix: make background clicks work with custom controls --- CHANGELOG.md | 1 + SHA256SUMS | 10 ++-- bridge.mjs | 2 +- chrome-extension/manifest.json | 2 +- chrome-extension/service-worker.js | 84 ++++++++++++++++++++++++++++-- tests/chrome-background.mjs | 8 +++ 6 files changed, 97 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e439ff8..7038d71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Background Chrome without focus stealing +- Upgraded `chrome_click` from bare DOM `.click()` to a pointer/mouse event sequence including `pointerdown`, `mousedown`, focus, `pointerup`, and `mouseup` before native click activation. This supports modern custom controls that listen earlier in the interaction cycle while preserving the boundary that synthetic events are not trusted user gestures. - Raised the managed `MDB` pool target from four to eight tabs and made contention resilient: opens wait up to 20 seconds for a release, active browser operations renew a lease heartbeat, and leases idle for 10 minutes are reclaimed/reset. Existing smaller pools expand only when Chrome is naturally foreground, preserving the no-focus-steal rule. - Added read-only `chatgpt_extension_status`, which reports the installed ChatGPT Chrome extension, its local OpenAI native-host registration, and the live `chatgpt.com` page-bridge status when available without patching the OpenAI extension or exposing its private native-host RPC protocol. - Made workspace release grantless so Strict-mode URL approval expiry cannot strand a finished lease. diff --git a/SHA256SUMS b/SHA256SUMS index fe31c8e..2774c2c 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,18 +1,18 @@ 44673237f87de06ed637d4d0391d4dd6c282bc13f368c57039eb8f9660812e24 .github/workflows/ci.yml 9d6687081330fcb210d668192998807cd5ce98bad5d189433a63242274ee603f .gitignore -3d1c3b71480de55622952933870945fb614507ed1123e4cf0d9add5331f72d8a CHANGELOG.md +26265d676be0747639f0b740722218b59a066881f8ec776ddf68b80196d337dd CHANGELOG.md 67990f5fd45b6d902f7af87e4fba82386f89e4343683616e28e7b9207ed825db CONTRIBUTING.md b072b4af182ca0631f71db43b3bc1cd952404ddf5a68e18c54f6893ec7c31cb0 DEPLOY.md 39d809ae91249cdbda0284528d11903d13ecd10cae850578a7039c958022549f LICENSE f553c1f567d0e5e2534b30f2644095cc4c52f71a8cef01547a9efa6b108802c9 README.md 3f5942902d8d4cc8976f7d25a43ac73174b33b5efcdb83bc1e442acaec3b3ce3 SECURITY.md -ca5d10c85c749f9c487299493b65d6029feb0fe0ee60365713b0b82023427323 bridge.mjs +be302b423401236fb1fd238dcd22662e4a5477f0b107fd7a12a9da8891f1be82 bridge.mjs bfefa7420795df71e1910bb494625479be1729559c4d2c1517bf0ef6ca6958ce chrome-extension/icons/icon-128.png b0915f00aab8a589f924704d52a168c6ff6ba3376f11d6431b08bde36929ad48 chrome-extension/icons/icon-16.png e635470a81fca24f6943269df0d34b11c8fae8e2759842f0bcbcdfa9abd42875 chrome-extension/icons/icon-32.png 53b776f8e8362c9db35f0564746d876bcbc5d21e569bf82cb298227e3b057414 chrome-extension/icons/icon-48.png -c5bc98ece211f6dbc02bcce1ac1c1e47fe047d667419f9083707087fe128f3f2 chrome-extension/manifest.json -cd16d61bd90449f7c1fe80a167de688e26f13f8427994a1c5b2e25ecc1fe3a1e chrome-extension/service-worker.js +5c593a44cc152bf3dd58bcb2486012a5c1d55f614432f1de8ea8f9996ddcd2d6 chrome-extension/manifest.json +5d3e3c605bfc1ba5a110fb5186e7c5295fb9f4ba036952f5077b45c96c9ab3f1 chrome-extension/service-worker.js e63fdde88e17ca59ce59e0c8b711c25b84226d32d46bc7f0177ccb392b9824ee chrome-extension/workspace.html c2cde87780871ee75395ea04a4efe5851bb85c45935bb1506f36b2283ea4ee03 docs/assets/mac-developer-bridge-workflow.png 05eaccdaa5d2958249808b333da4822de9b97070c9a1e54ab2256794a82c9308 examples/README.md @@ -39,7 +39,7 @@ f3212f5bdf508dd875b07626ddf53c2d503a725cb371576481bddda0730fa3c3 scripts/run-tu 94cd0c7ea3835424f7071206e9aeeda194daa679571646bddd153c6f5a0af027 scripts/tcc-doctor.sh e297e0d764c78d834148ababd2972c58831d4d516c2e6af97ac192be46a61413 scripts/uninstall-background-chrome.sh 365dba0dc7f4a3efb209b31e9b92d1aefb2e39d3351ba01102d66a6097b5a3ab tests/adversarial.mjs -1c50fb7b1c224a2e72473f8cbfd38c13ba38aa8eb2f7c117a4ff4706d1f5e861 tests/chrome-background.mjs +8c6adf6561131ef52ae9a915368636e7a9728fc2db91510c647abefe2a12f6de tests/chrome-background.mjs 427e9d389d2e1b5cf3304bc1002ce0a9e00312bc7c26689c09138e8f4b934d44 tests/federation.mjs f7d86cfe2e99f5261eb8fbe648701b3a0142178514ae7a7eb0fbea04763c6731 tests/fixtures/poisoned-repository.md 808790524799b166abd23acddeff169fa5fd221ec37aeb8ce8ad16aa2a2f0a3b tests/fixtures/poisoned-repository.mjs diff --git a/bridge.mjs b/bridge.mjs index 508afc9..44e6623 100755 --- a/bridge.mjs +++ b/bridge.mjs @@ -812,7 +812,7 @@ const TOOLS = [ { name: "chrome_click", title: "Click Chrome element in background", - description: "Programmatically click an element in an MDB Chrome tab without activating Chrome. Use selectors returned by chrome_snapshot. Relaxed mode is the default; Strict approvals optionally restricts sites. Trusted-user-gesture flows, CAPTCHAs, native dialogs, and file pickers may still require foreground/manual interaction.", + description: "Click an element in an MDB Chrome tab without activating Chrome. Uses a pointer/mouse event sequence (including pointerdown/mousedown) before native click activation so modern custom controls work more like a real mouse. Events remain synthetic/isTrusted=false, so genuine trusted-user-gesture flows, CAPTCHAs, native dialogs, and file pickers still require foreground/manual interaction.", inputSchema: { type: "object", properties: { diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index f1cc131..5747134 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.4", + "version": "0.2.5", "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 7acce0e..44442d1 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.4"; +const VERSION = "0.2.5"; const WORKSPACE_KEY = "macDeveloperBridgeWorkspace"; const WORKSPACE_GROUP_TITLE = "MDB"; const WORKSPACE_GROUP_COLOR = "blue"; @@ -623,14 +623,92 @@ function pageSnapshot(maxTextChars, maxElements) { function pageClick(selector) { const element = document.querySelector(selector); - if (!element) throw new Error(`No element matches selector: ${selector}`); + if (!(element instanceof Element)) { + const error = new Error(`No element matches selector: ${selector}`); + error.code = "CHROME_ELEMENT_NOT_FOUND"; + throw error; + } if (element instanceof HTMLInputElement && element.type === "file") { const error = new Error("File pickers require foreground/user interaction; background mode will not open one."); error.code = "CHROME_FOREGROUND_REQUIRED"; throw error; } + if (("disabled" in element && Boolean(element.disabled)) || element.getAttribute("aria-disabled") === "true") { + const error = new Error(`Element is disabled: ${selector}`); + error.code = "CHROME_ELEMENT_DISABLED"; + throw error; + } + + element.scrollIntoView({ block: "center", inline: "center", behavior: "instant" }); + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + if (rect.width <= 0 || rect.height <= 0 || style.visibility === "hidden" || style.display === "none") { + const error = new Error(`Element is not visible: ${selector}`); + error.code = "CHROME_ELEMENT_NOT_VISIBLE"; + throw error; + } + + const clientX = Math.max(0, Math.min(Math.max(0, window.innerWidth - 1), rect.left + rect.width / 2)); + const clientY = Math.max(0, Math.min(Math.max(0, window.innerHeight - 1), rect.top + rect.height / 2)); + const common = { + bubbles: true, + cancelable: true, + composed: true, + clientX, + clientY, + screenX: Number(window.screenX || 0) + clientX, + screenY: Number(window.screenY || 0) + clientY, + button: 0, + }; + const events = []; + const dispatchPointer = (type, buttons) => { + if (typeof PointerEvent !== "function") return true; + events.push(type); + return element.dispatchEvent(new PointerEvent(type, { + ...common, + buttons, + pointerId: 1, + pointerType: "mouse", + isPrimary: true, + pressure: buttons ? 0.5 : 0, + })); + }; + const dispatchMouse = (type, buttons) => { + events.push(type); + return element.dispatchEvent(new MouseEvent(type, { ...common, buttons, detail: type === "mousedown" ? 1 : 0 })); + }; + + // Many modern component libraries open menus on pointerdown/mousedown rather + // than click. HTMLElement.click() alone skips those phases entirely. Recreate + // the normal pointer/mouse ordering, then use click() for the element's native + // activation behavior (checkboxes, links, buttons, etc.). These events remain + // synthetic (isTrusted=false); true user-gesture security boundaries are not + // bypassed by this helper. + dispatchPointer("pointerover", 0); + dispatchMouse("mouseover", 0); + dispatchPointer("pointermove", 0); + dispatchMouse("mousemove", 0); + dispatchPointer("pointerdown", 1); + const mouseDownAllowed = dispatchMouse("mousedown", 1); + if (mouseDownAllowed && element instanceof HTMLElement) { + try { element.focus({ preventScroll: true }); } catch {} + } + dispatchPointer("pointerup", 0); + dispatchMouse("mouseup", 0); + events.push("click"); element.click(); - return { clicked: true, selector, title: document.title, url: location.href }; + + return { + clicked: true, + selector, + strategy: "pointer-mouse-sequence", + trusted: false, + clientX, + clientY, + events, + title: document.title, + url: location.href, + }; } function pageFill(selector, value, submit) { diff --git a/tests/chrome-background.mjs b/tests/chrome-background.mjs index abab5d5..7d9bade 100644 --- a/tests/chrome-background.mjs +++ b/tests/chrome-background.mjs @@ -188,6 +188,8 @@ 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.5"); + 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`); @@ -215,6 +217,12 @@ try { assert.match(workerSource, /touchWorkspaceLease/); assert.match(workerSource, /chatgptExtensionStatus/); assert.match(workerSource, /chatgpt-extension-request-status/); + assert.match(workerSource, /dispatchPointer\("pointerdown", 1\)/); + assert.match(workerSource, /dispatchMouse\("mousedown", 1\)/); + assert.match(workerSource, /dispatchMouse\("mouseup", 0\)/); + assert.match(workerSource, /strategy: "pointer-mouse-sequence"/); + assert.match(workerSource, /trusted: false/); + assert.equal((workerSource.match(/async function executeInTab\(/g) || []).length, 1, "executeInTab should have one definition"); const publicKey = Buffer.from(manifest.key, "base64"); const digest = crypto.createHash("sha256").update(publicKey).digest().subarray(0, 16); const extensionId = [...digest].flatMap((byte) => [byte >> 4, byte & 0x0f]).map((n) => String.fromCharCode(97 + n)).join(""); From 1df48c8acfc1476366338ddef29226875b7b0845 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20R=C3=A5dahl=20Benz?= <3107277+alexanderradahl@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:09:57 -0700 Subject: [PATCH 4/5] fix: adapt clicks for mousedown controls --- CHANGELOG.md | 1 + SHA256SUMS | 10 ++--- bridge.mjs | 2 +- chrome-extension/manifest.json | 2 +- chrome-extension/service-worker.js | 61 ++++++++++++++++++++++++------ tests/chrome-background.mjs | 10 ++++- 6 files changed, 66 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7038d71..f115dde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Background Chrome without focus stealing +- Made `chrome_click` adaptive for mousedown-driven combobox/menu controls: if `mousedown` is consumed or changes popup/expanded state, MDB stops after pointer-up/mouse-up instead of issuing a redundant `.click()` that can toggle the control closed. `chrome_snapshot` now exposes `aria-expanded`, `aria-haspopup`, `aria-controls`, and `data-state` for interaction diagnostics. - Upgraded `chrome_click` from bare DOM `.click()` to a pointer/mouse event sequence including `pointerdown`, `mousedown`, focus, `pointerup`, and `mouseup` before native click activation. This supports modern custom controls that listen earlier in the interaction cycle while preserving the boundary that synthetic events are not trusted user gestures. - Raised the managed `MDB` pool target from four to eight tabs and made contention resilient: opens wait up to 20 seconds for a release, active browser operations renew a lease heartbeat, and leases idle for 10 minutes are reclaimed/reset. Existing smaller pools expand only when Chrome is naturally foreground, preserving the no-focus-steal rule. - Added read-only `chatgpt_extension_status`, which reports the installed ChatGPT Chrome extension, its local OpenAI native-host registration, and the live `chatgpt.com` page-bridge status when available without patching the OpenAI extension or exposing its private native-host RPC protocol. diff --git a/SHA256SUMS b/SHA256SUMS index 2774c2c..31515da 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,18 +1,18 @@ 44673237f87de06ed637d4d0391d4dd6c282bc13f368c57039eb8f9660812e24 .github/workflows/ci.yml 9d6687081330fcb210d668192998807cd5ce98bad5d189433a63242274ee603f .gitignore -26265d676be0747639f0b740722218b59a066881f8ec776ddf68b80196d337dd CHANGELOG.md +763192db6f0cf7340474983c19a362ddd134c54dd82bcfe21a024c8aa6917c66 CHANGELOG.md 67990f5fd45b6d902f7af87e4fba82386f89e4343683616e28e7b9207ed825db CONTRIBUTING.md b072b4af182ca0631f71db43b3bc1cd952404ddf5a68e18c54f6893ec7c31cb0 DEPLOY.md 39d809ae91249cdbda0284528d11903d13ecd10cae850578a7039c958022549f LICENSE f553c1f567d0e5e2534b30f2644095cc4c52f71a8cef01547a9efa6b108802c9 README.md 3f5942902d8d4cc8976f7d25a43ac73174b33b5efcdb83bc1e442acaec3b3ce3 SECURITY.md -be302b423401236fb1fd238dcd22662e4a5477f0b107fd7a12a9da8891f1be82 bridge.mjs +3ffa365aae72f04735baad7489bd6ef328741819b0e95ff0a341561ca6f52521 bridge.mjs bfefa7420795df71e1910bb494625479be1729559c4d2c1517bf0ef6ca6958ce chrome-extension/icons/icon-128.png b0915f00aab8a589f924704d52a168c6ff6ba3376f11d6431b08bde36929ad48 chrome-extension/icons/icon-16.png e635470a81fca24f6943269df0d34b11c8fae8e2759842f0bcbcdfa9abd42875 chrome-extension/icons/icon-32.png 53b776f8e8362c9db35f0564746d876bcbc5d21e569bf82cb298227e3b057414 chrome-extension/icons/icon-48.png -5c593a44cc152bf3dd58bcb2486012a5c1d55f614432f1de8ea8f9996ddcd2d6 chrome-extension/manifest.json -5d3e3c605bfc1ba5a110fb5186e7c5295fb9f4ba036952f5077b45c96c9ab3f1 chrome-extension/service-worker.js +ef7520b3759932a68945db445bfbafbc7dd02008bdbd8e013083921c3ccdd66f chrome-extension/manifest.json +e4f3f86a018be56061572a3a1b4e034e11b0799cc55bc309beeed0995ba2c40c chrome-extension/service-worker.js e63fdde88e17ca59ce59e0c8b711c25b84226d32d46bc7f0177ccb392b9824ee chrome-extension/workspace.html c2cde87780871ee75395ea04a4efe5851bb85c45935bb1506f36b2283ea4ee03 docs/assets/mac-developer-bridge-workflow.png 05eaccdaa5d2958249808b333da4822de9b97070c9a1e54ab2256794a82c9308 examples/README.md @@ -39,7 +39,7 @@ f3212f5bdf508dd875b07626ddf53c2d503a725cb371576481bddda0730fa3c3 scripts/run-tu 94cd0c7ea3835424f7071206e9aeeda194daa679571646bddd153c6f5a0af027 scripts/tcc-doctor.sh e297e0d764c78d834148ababd2972c58831d4d516c2e6af97ac192be46a61413 scripts/uninstall-background-chrome.sh 365dba0dc7f4a3efb209b31e9b92d1aefb2e39d3351ba01102d66a6097b5a3ab tests/adversarial.mjs -8c6adf6561131ef52ae9a915368636e7a9728fc2db91510c647abefe2a12f6de tests/chrome-background.mjs +b9b12e53815e648df78cd587db4f83fbb573351609287e4ea352d2f57c7d9225 tests/chrome-background.mjs 427e9d389d2e1b5cf3304bc1002ce0a9e00312bc7c26689c09138e8f4b934d44 tests/federation.mjs f7d86cfe2e99f5261eb8fbe648701b3a0142178514ae7a7eb0fbea04763c6731 tests/fixtures/poisoned-repository.md 808790524799b166abd23acddeff169fa5fd221ec37aeb8ce8ad16aa2a2f0a3b tests/fixtures/poisoned-repository.mjs diff --git a/bridge.mjs b/bridge.mjs index 44e6623..954933d 100755 --- a/bridge.mjs +++ b/bridge.mjs @@ -812,7 +812,7 @@ const TOOLS = [ { name: "chrome_click", title: "Click Chrome element in background", - description: "Click an element in an MDB Chrome tab without activating Chrome. Uses a pointer/mouse event sequence (including pointerdown/mousedown) before native click activation so modern custom controls work more like a real mouse. Events remain synthetic/isTrusted=false, so genuine trusted-user-gesture flows, CAPTCHAs, native dialogs, and file pickers still require foreground/manual interaction.", + description: "Click an element in an MDB Chrome tab without activating Chrome. Uses an adaptive pointer/mouse event sequence; controls that activate on pointerdown/mousedown are not followed by a redundant synthetic click that could toggle them closed. Events remain synthetic/isTrusted=false, so genuine trusted-user-gesture flows, CAPTCHAs, native dialogs, and file pickers still require foreground/manual interaction.", inputSchema: { type: "object", properties: { diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index 5747134..9a6c832 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.5", + "version": "0.2.6", "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 44442d1..15ea219 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.5"; +const VERSION = "0.2.6"; const WORKSPACE_KEY = "macDeveloperBridgeWorkspace"; const WORKSPACE_GROUP_TITLE = "MDB"; const WORKSPACE_GROUP_COLOR = "blue"; @@ -603,6 +603,10 @@ function pageSnapshot(maxTextChars, maxElements) { role: element.getAttribute("role"), text: String(element.innerText || element.textContent || "").trim().slice(0, 500), ariaLabel: element.getAttribute("aria-label"), + ariaExpanded: element.getAttribute("aria-expanded"), + ariaHasPopup: element.getAttribute("aria-haspopup"), + ariaControls: element.getAttribute("aria-controls"), + dataState: element.getAttribute("data-state"), name: element.getAttribute("name"), placeholder: element.getAttribute("placeholder"), href: element instanceof HTMLAnchorElement ? element.href : null, @@ -621,7 +625,7 @@ function pageSnapshot(maxTextChars, maxElements) { }; } -function pageClick(selector) { +async function pageClick(selector) { const element = document.querySelector(selector); if (!(element instanceof Element)) { const error = new Error(`No element matches selector: ${selector}`); @@ -677,13 +681,24 @@ function pageClick(selector) { events.push(type); return element.dispatchEvent(new MouseEvent(type, { ...common, buttons, detail: type === "mousedown" ? 1 : 0 })); }; + const visiblePopupCount = () => [...document.querySelectorAll('[role="listbox"],[role="menu"],[role="dialog"],[data-state="open"]')] + .filter((node) => { + if (!(node instanceof Element)) return false; + const box = node.getBoundingClientRect(); + const computed = getComputedStyle(node); + return box.width > 0 && box.height > 0 && computed.display !== "none" && computed.visibility !== "hidden"; + }).length; + const readActivationState = () => ({ + ariaExpanded: element.getAttribute("aria-expanded"), + ariaHasPopup: element.getAttribute("aria-haspopup"), + dataState: element.getAttribute("data-state"), + visiblePopupCount: visiblePopupCount(), + }); + const stateChanged = (before, after) => before.ariaExpanded !== after.ariaExpanded + || before.dataState !== after.dataState + || before.visiblePopupCount !== after.visiblePopupCount; - // Many modern component libraries open menus on pointerdown/mousedown rather - // than click. HTMLElement.click() alone skips those phases entirely. Recreate - // the normal pointer/mouse ordering, then use click() for the element's native - // activation behavior (checkboxes, links, buttons, etc.). These events remain - // synthetic (isTrusted=false); true user-gesture security boundaries are not - // bypassed by this helper. + const before = readActivationState(); dispatchPointer("pointerover", 0); dispatchMouse("mouseover", 0); dispatchPointer("pointermove", 0); @@ -693,16 +708,40 @@ function pageClick(selector) { if (mouseDownAllowed && element instanceof HTMLElement) { try { element.focus({ preventScroll: true }); } catch {} } + + // React/headless/custom comboboxes often do their real work on mousedown and + // call preventDefault() to manage focus. Give that discrete event one task to + // flush before deciding whether a second synthetic click is appropriate. + await new Promise((resolve) => setTimeout(resolve, 0)); + const afterMouseDown = readActivationState(); + const semanticMouseDownControl = element.matches('[role="combobox"],[aria-haspopup]') + || element.closest('[role="combobox"],[aria-haspopup]') != null; + const activatedOnMouseDown = stateChanged(before, afterMouseDown) + || (mouseDownAllowed === false && semanticMouseDownControl); + dispatchPointer("pointerup", 0); dispatchMouse("mouseup", 0); - events.push("click"); - element.click(); + let activation = "mousedown"; + if (!activatedOnMouseDown) { + events.push("click"); + element.click(); + activation = "click"; + } + await new Promise((resolve) => setTimeout(resolve, 0)); + const after = readActivationState(); return { clicked: true, selector, - strategy: "pointer-mouse-sequence", + strategy: "adaptive-pointer-mouse-sequence", + activation, trusted: false, + mouseDownAllowed, + semanticMouseDownControl, + stateChangedOnMouseDown: stateChanged(before, afterMouseDown), + before, + afterMouseDown, + after, clientX, clientY, events, diff --git a/tests/chrome-background.mjs b/tests/chrome-background.mjs index 7d9bade..e9d085b 100644 --- a/tests/chrome-background.mjs +++ b/tests/chrome-background.mjs @@ -188,7 +188,7 @@ 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.5"); + assert.equal(manifest.version, "0.2.6"); 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`)); @@ -220,8 +220,14 @@ try { assert.match(workerSource, /dispatchPointer\("pointerdown", 1\)/); assert.match(workerSource, /dispatchMouse\("mousedown", 1\)/); assert.match(workerSource, /dispatchMouse\("mouseup", 0\)/); - assert.match(workerSource, /strategy: "pointer-mouse-sequence"/); + assert.match(workerSource, /strategy: "adaptive-pointer-mouse-sequence"/); assert.match(workerSource, /trusted: false/); + assert.match(workerSource, /semanticMouseDownControl/); + assert.match(workerSource, /activatedOnMouseDown/); + assert.match(workerSource, /mouseDownAllowed === false/); + assert.match(workerSource, /ariaExpanded: element\.getAttribute\("aria-expanded"\)/); + assert.match(workerSource, /ariaHasPopup: element\.getAttribute\("aria-haspopup"\)/); + assert.match(workerSource, /dataState: element\.getAttribute\("data-state"\)/); assert.equal((workerSource.match(/async function executeInTab\(/g) || []).length, 1, "executeInTab should have one definition"); const publicKey = Buffer.from(manifest.key, "base64"); const digest = crypto.createHash("sha256").update(publicKey).digest().subarray(0, 16); From d2d19734c955892d0ec80598ca4733a6e60c72c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20R=C3=A5dahl=20Benz?= <3107277+alexanderradahl@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:33:05 -0700 Subject: [PATCH 5/5] fix: execute custom-control clicks in page world --- CHANGELOG.md | 1 + SHA256SUMS | 12 ++++----- chrome-extension/manifest.json | 2 +- chrome-extension/service-worker.js | 39 ++++++++++++++++++++++++++---- lib/chrome-extension-client.mjs | 2 +- scripts/chrome-native-host.mjs | 2 +- tests/chrome-background.mjs | 11 ++++++++- 7 files changed, 54 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f115dde..d3f349a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Background Chrome without focus stealing +- Moved `chrome_click` execution into the page MAIN world so framework handlers receive page-world event objects, added an accessible `ArrowDown` fallback for semantic comboboxes that remain closed after pointer/mouse activation, and added an internal grantless `extension.reload` maintenance hook so future unpacked-extension code updates can reload without another manual `chrome://extensions` click. - Made `chrome_click` adaptive for mousedown-driven combobox/menu controls: if `mousedown` is consumed or changes popup/expanded state, MDB stops after pointer-up/mouse-up instead of issuing a redundant `.click()` that can toggle the control closed. `chrome_snapshot` now exposes `aria-expanded`, `aria-haspopup`, `aria-controls`, and `data-state` for interaction diagnostics. - Upgraded `chrome_click` from bare DOM `.click()` to a pointer/mouse event sequence including `pointerdown`, `mousedown`, focus, `pointerup`, and `mouseup` before native click activation. This supports modern custom controls that listen earlier in the interaction cycle while preserving the boundary that synthetic events are not trusted user gestures. - Raised the managed `MDB` pool target from four to eight tabs and made contention resilient: opens wait up to 20 seconds for a release, active browser operations renew a lease heartbeat, and leases idle for 10 minutes are reclaimed/reset. Existing smaller pools expand only when Chrome is naturally foreground, preserving the no-focus-steal rule. diff --git a/SHA256SUMS b/SHA256SUMS index 31515da..3b427bb 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,6 +1,6 @@ 44673237f87de06ed637d4d0391d4dd6c282bc13f368c57039eb8f9660812e24 .github/workflows/ci.yml 9d6687081330fcb210d668192998807cd5ce98bad5d189433a63242274ee603f .gitignore -763192db6f0cf7340474983c19a362ddd134c54dd82bcfe21a024c8aa6917c66 CHANGELOG.md +a4a0e44ff532c95176b2b12527e15590dd07cf1d00324d53566ca63a998c6066 CHANGELOG.md 67990f5fd45b6d902f7af87e4fba82386f89e4343683616e28e7b9207ed825db CONTRIBUTING.md b072b4af182ca0631f71db43b3bc1cd952404ddf5a68e18c54f6893ec7c31cb0 DEPLOY.md 39d809ae91249cdbda0284528d11903d13ecd10cae850578a7039c958022549f LICENSE @@ -11,15 +11,15 @@ bfefa7420795df71e1910bb494625479be1729559c4d2c1517bf0ef6ca6958ce chrome-extensi b0915f00aab8a589f924704d52a168c6ff6ba3376f11d6431b08bde36929ad48 chrome-extension/icons/icon-16.png e635470a81fca24f6943269df0d34b11c8fae8e2759842f0bcbcdfa9abd42875 chrome-extension/icons/icon-32.png 53b776f8e8362c9db35f0564746d876bcbc5d21e569bf82cb298227e3b057414 chrome-extension/icons/icon-48.png -ef7520b3759932a68945db445bfbafbc7dd02008bdbd8e013083921c3ccdd66f chrome-extension/manifest.json -e4f3f86a018be56061572a3a1b4e034e11b0799cc55bc309beeed0995ba2c40c chrome-extension/service-worker.js +cdbd60aab990ab64f44b536b5adc5cc694df19c82ca63577b9c444d1df27a674 chrome-extension/manifest.json +f0eb3f028cdc64390e9778754a5fe4ca9c4fd06aa413ea6fa68feafa88eab32d chrome-extension/service-worker.js e63fdde88e17ca59ce59e0c8b711c25b84226d32d46bc7f0177ccb392b9824ee chrome-extension/workspace.html c2cde87780871ee75395ea04a4efe5851bb85c45935bb1506f36b2283ea4ee03 docs/assets/mac-developer-bridge-workflow.png 05eaccdaa5d2958249808b333da4822de9b97070c9a1e54ab2256794a82c9308 examples/README.md cea2a8709b4e3aa387cb4e5224537f632ba25b35444b7fc6ac809743eee73d33 glama.json 6993934223af939d24c63bbaa68e7e17503f8bd309c5371557265cb1f1c2f98b install.sh 4f6a5f274b32a2de78bab652feb25348d0dc764c6567c09555634cf2c1ee92ce launchd/com.openai.mac-developer-bridge-tunnel.plist.template -da9eaebcbf2326bf3f827b56c4318a35ef424625c44137f040b96a121edeec43 lib/chrome-extension-client.mjs +504bd385e7d7d2fd255dec19e309b9de4a06736f691ff9fbc23d4cb86f2b93e3 lib/chrome-extension-client.mjs e89db110c7b562e835f4cf33448ca211c166391314366ceacb14869f84aaff39 lib/federation.mjs b4f6bae4987bb7a8624a938b854b2d8533c458c7fe3ccc3244edda8851938f4a lib/ptyhelper.pl 392f9fe18af18f455445011bdb47d21dee961412b46fc1ad581581eda74c0fe7 mcp-http.mjs @@ -28,7 +28,7 @@ e43b577965274ec8366514d26eba9d799c808c3968652fbaed22cd773fd38a8f menubar/build. 0c600b94f5e9c2fb1748f52186321ba5630e21e6e0d0f7e532433a7e9a386757 package.json a4269f4463edddecf6e05fe707abf84bb2b3d86ddc7e8e4858cb6d777261d1ce scripts/approve-foreground-gui.sh 5c84901203e64c3b23a8e2840004ff21f375a10832a29197ba2010ebf784252f scripts/approve-personal-browser.sh -1d6eff22187fc3052d80257cbfb4fd4692878380ca6edf23d9d5b26a6bd363fb scripts/chrome-native-host.mjs +cde725974eb46fa147d103d9728eae00b606d68109f26e3452821153b191b87a scripts/chrome-native-host.mjs 223682c41ded77766cecbfc6b620c3a79be31f9c3609bbd8b5a63c6a651c17e3 scripts/disable.sh d2ca8d8e60b2442fed487d708d24a50b9dac3d1f26b7b0a344574205b6e75c82 scripts/doctor.sh 434cb03c1e1feaeb1dc1edaeee2cf1477d943800d40afb17a4834295574b15d8 scripts/enable.sh @@ -39,7 +39,7 @@ f3212f5bdf508dd875b07626ddf53c2d503a725cb371576481bddda0730fa3c3 scripts/run-tu 94cd0c7ea3835424f7071206e9aeeda194daa679571646bddd153c6f5a0af027 scripts/tcc-doctor.sh e297e0d764c78d834148ababd2972c58831d4d516c2e6af97ac192be46a61413 scripts/uninstall-background-chrome.sh 365dba0dc7f4a3efb209b31e9b92d1aefb2e39d3351ba01102d66a6097b5a3ab tests/adversarial.mjs -b9b12e53815e648df78cd587db4f83fbb573351609287e4ea352d2f57c7d9225 tests/chrome-background.mjs +7a92d4e9ab02caa047d6caac4c45bc73f90a5c0825f5e60525a6c2a0e91a5b91 tests/chrome-background.mjs 427e9d389d2e1b5cf3304bc1002ce0a9e00312bc7c26689c09138e8f4b934d44 tests/federation.mjs f7d86cfe2e99f5261eb8fbe648701b3a0142178514ae7a7eb0fbea04763c6731 tests/fixtures/poisoned-repository.md 808790524799b166abd23acddeff169fa5fd221ec37aeb8ce8ad16aa2a2f0a3b tests/fixtures/poisoned-repository.mjs diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index 9a6c832..599e26b 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.6", + "version": "0.2.7", "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 15ea219..460953d 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.6"; +const VERSION = "0.2.7"; const WORKSPACE_KEY = "macDeveloperBridgeWorkspace"; const WORKSPACE_GROUP_TITLE = "MDB"; const WORKSPACE_GROUP_COLOR = "blue"; @@ -728,7 +728,29 @@ async function pageClick(selector) { activation = "click"; } await new Promise((resolve) => setTimeout(resolve, 0)); - const after = readActivationState(); + let after = readActivationState(); + + // Accessible comboboxes are expected to open on ArrowDown. Some React/headless + // controls ignore synthetic pointer/mouse activation but still honor keyboard + // semantics. Use that as a narrow fallback only when the semantic combobox is + // still closed after the mouse path. + let keyboardFallbackUsed = false; + if (semanticMouseDownControl && after.ariaExpanded !== "true" && after.dataState !== "open" && after.visiblePopupCount === 0) { + if (element instanceof HTMLElement) { + try { element.focus({ preventScroll: true }); } catch {} + } + const keyCommon = { bubbles: true, cancelable: true, composed: true, key: "ArrowDown", code: "ArrowDown" }; + events.push("keydown:ArrowDown"); + element.dispatchEvent(new KeyboardEvent("keydown", keyCommon)); + events.push("keyup:ArrowDown"); + element.dispatchEvent(new KeyboardEvent("keyup", keyCommon)); + keyboardFallbackUsed = true; + await new Promise((resolve) => setTimeout(resolve, 0)); + after = readActivationState(); + if (after.ariaExpanded === "true" || after.dataState === "open" || after.visiblePopupCount > 0) { + activation = "keyboard-arrowdown"; + } + } return { clicked: true, @@ -738,6 +760,7 @@ async function pageClick(selector) { trusted: false, mouseDownAllowed, semanticMouseDownControl, + keyboardFallbackUsed, stateChangedOnMouseDown: stateChanged(before, afterMouseDown), before, afterMouseDown, @@ -790,10 +813,10 @@ function pageFill(selector, value, submit) { return { filled: true, submitted: Boolean(submit), selector, title: document.title, url: location.href }; } -async function executeInTab(tabId, func, args) { +async function executeInTab(tabId, func, args, world = "ISOLATED") { const result = await chrome.scripting.executeScript({ target: { tabId }, - world: "ISOLATED", + world, func, args, }); @@ -808,6 +831,12 @@ async function dispatch(message) { if (message.method === "status") { return { version: VERSION, extensionId: chrome.runtime.id, connected: true }; } + if (message.method === "extension.reload") { + // Internal maintenance hook for this unpacked extension. Respond first so the + // native host/client sees success, then let Chrome restart the service worker. + setTimeout(() => chrome.runtime.reload(), 100); + return { reloading: true, version: VERSION, extensionId: chrome.runtime.id }; + } if (message.method === "workspace.status") return await workspaceStatus(); if (message.method === "workspace.init") return await initializeWorkspace(args.poolSize); if (message.method === "workspace.release") return await releaseWorkspaceTab(args.tabId) || { released: false, workspace: false, tabId: numericTabId(args.tabId) }; @@ -883,7 +912,7 @@ async function dispatch(message) { case "tabs.click": { const tab = await getApprovedTab(args.tabId, compiled); - return await executeInTab(tab.id, pageClick, [String(args.selector || "")]); + return await executeInTab(tab.id, pageClick, [String(args.selector || "")], "MAIN"); } case "tabs.fill": { diff --git a/lib/chrome-extension-client.mjs b/lib/chrome-extension-client.mjs index 9799832..7f73f4d 100644 --- a/lib/chrome-extension-client.mjs +++ b/lib/chrome-extension-client.mjs @@ -5,7 +5,7 @@ import path from "node:path"; const DEFAULT_TIMEOUT_MS = 45_000; const MAX_RESPONSE_BYTES = 4 * 1024 * 1024; -const GRANTLESS_METHODS = new Set(["status", "workspace.status", "workspace.init", "workspace.release", "chatgpt.extensionStatus"]); +const GRANTLESS_METHODS = new Set(["status", "extension.reload", "workspace.status", "workspace.init", "workspace.release", "chatgpt.extensionStatus"]); function normalizeBackgroundChromeMethod(method) { // Compatibility for stale/older callers. There must be exactly one tab-open diff --git a/scripts/chrome-native-host.mjs b/scripts/chrome-native-host.mjs index 77d76c7..0e0fff4 100755 --- a/scripts/chrome-native-host.mjs +++ b/scripts/chrome-native-host.mjs @@ -18,7 +18,7 @@ const PROFILE_BINDING_FILE = process.env.MAC_DEV_BRIDGE_CHROME_PROFILE_BINDING_F const MAX_NATIVE_MESSAGE_BYTES = 8 * 1024 * 1024; const MAX_SOCKET_LINE_BYTES = 2 * 1024 * 1024; const REQUEST_TIMEOUT_MS = 45_000; -const GRANTLESS_EXTENSION_METHODS = new Set(["status", "workspace.status", "workspace.init", "workspace.release", "chatgpt.extensionStatus"]); +const GRANTLESS_EXTENSION_METHODS = new Set(["status", "extension.reload", "workspace.status", "workspace.init", "workspace.release", "chatgpt.extensionStatus"]); function normalizeExtensionMethod(method) { // Old/stale clients may still issue tabs.open directly. Never forward that diff --git a/tests/chrome-background.mjs b/tests/chrome-background.mjs index e9d085b..0b7a8f2 100644 --- a/tests/chrome-background.mjs +++ b/tests/chrome-background.mjs @@ -188,7 +188,7 @@ 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.6"); + assert.equal(manifest.version, "0.2.7"); 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`)); @@ -228,6 +228,12 @@ try { assert.match(workerSource, /ariaExpanded: element\.getAttribute\("aria-expanded"\)/); assert.match(workerSource, /ariaHasPopup: element\.getAttribute\("aria-haspopup"\)/); assert.match(workerSource, /dataState: element\.getAttribute\("data-state"\)/); + assert.match(workerSource, /executeInTab\(tab\.id, pageClick, \[String\(args\.selector \|\| ""\)\], "MAIN"\)/); + assert.match(workerSource, /keyboardFallbackUsed/); + assert.match(workerSource, /keydown:ArrowDown/); + assert.match(workerSource, /activation = "keyboard-arrowdown"/); + assert.match(workerSource, /message\.method === "extension\.reload"/); + assert.match(workerSource, /chrome\.runtime\.reload\(\)/); assert.equal((workerSource.match(/async function executeInTab\(/g) || []).length, 1, "executeInTab should have one definition"); const publicKey = Buffer.from(manifest.key, "base64"); const digest = crypto.createHash("sha256").update(publicKey).digest().subarray(0, 16); @@ -304,6 +310,9 @@ try { // Workspace setup/status are local extension state. They must not require or // consume an authenticated-site URL grant. + const reloadLocal = await backgroundChromeCall("extension.reload", {}, [], { socketPath }); + assert.equal(reloadLocal.echoedMethod, "extension.reload"); + assert.deepEqual(host.seen.at(-1).allowedUrlPatterns, []); const localStatus = await backgroundChromeCall("workspace.status", {}, [], { socketPath }); assert.equal(localStatus.echoedMethod, "workspace.status"); assert.deepEqual(host.seen.at(-1).allowedUrlPatterns, []);