diff --git a/CHANGELOG.md b/CHANGELOG.md index 77a4140..cb4f423 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Background Chrome without focus stealing +- Added persistent dynamic MDB pool provisioning: eight tabs remain the default, operators can request a growth-only target up to 32 with `chrome_workspace_setup`, and capacity pressure auto-targets four more tabs without repeatedly ratcheting while expansion is pending. Provisioning is recorded even when Chrome is in the background; actual tab creation still occurs only when the existing MDB window is naturally focused. Workspace status and exhaustion errors now expose current/target/max/pending capacity and safe lease-pressure details. - Made `chrome_fill` resolve non-unique selectors to the first visible, enabled, fillable match instead of blindly using a hidden template. Snapshot selectors now return an id or attribute selector only when it is unique, and form submission passes the visible submit control to `requestSubmit` while reporting the selected match and submit strategy for native readback. - Fixed `chrome_fill` hanging until its 45-second transport timeout in inactive MDB tabs. Framework-settle verification still uses two animation frames when Chrome schedules them, but now falls back to a bounded timer because background tabs may suspend `requestAnimationFrame` indefinitely. - Added the experimental `chatgpt_conversation_start` MCP tool and static-bearer, direct-loopback `/experimental/chatgpt/conversation` wrapper. The default `runtime` transport leases an inactive signed-in ChatGPT tab and invokes the first-party mounted `submitComposer` store action as a `text_action`, without typing/clicking the UI or extracting credentials/proof material. After the bounded stream/rendered handoff, MDB reloads the exact returned conversation and reads the exact persisted assistant message before returning text; ambiguous handoff fails without a second submission. The former direct private request remains available only as explicit `transport: "raw"` diagnostics. Prompt plaintext is redacted from every audit mode. @@ -28,9 +29,9 @@ - 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 (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. +- Added a Chrome-native **`MDB`** tab group with a reusable background-tab pool (eight tabs by default, later extended to a persistent growth-only target up to 32), 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. +- Keep actual workspace tab creation/expansion gated on Chrome already being naturally focused. Provisioning the desired capacity may happen in the background, but measured on Chrome 151/macOS, even `tabs.create({active:false})` can bring Chrome to the foreground, so pending tabs wait for the next natural focus rather than activating Chrome. - 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. - Added a friendly cat/headphones/laptop extension icon and Chrome action icon at 16/32/48/128px. - Deliberately did **not** expose arbitrary page JavaScript, network-header capture, or file uploads. Snapshot password values are redacted, and browser/OS security UI that requires trusted foreground interaction is reported rather than silently activated. diff --git a/README.md b/README.md index 11f2714..aa55243 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Git, package managers, Vercel CLI, database CLIs, AppleScript, browser CLIs, bui | `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 | | `chatgpt_conversation_start` | Experimentally start or continue one exact ChatGPT conversation through the signed-in page's first-party runtime action; no UI typing/clicking or credential export | -| `chrome_workspace_setup` | Create or expand the `MDB` pool while Chrome is already foreground; default target is eight reusable tabs | +| `chrome_workspace_setup` | Provision a growth-only `MDB` pool target from 1 to 32 tabs; default is eight, with creation deferred until Chrome is naturally focused | | `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 | @@ -129,11 +129,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 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 extension keeps a Chrome-native tab group named **`MDB`**. By default it targets eight extension-owned idle tabs, with a hard maximum of 32. They are created only while the existing MDB Chrome window is already naturally foreground, then leased and reused for routine work. The group is collapsed when idle and expands while one or more tabs are leased. This preserves the no-focus-steal boundary around 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 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). +The pool self-heals and has a persistent growth-only capacity target. `chrome_workspace_setup(pool_size=16)` records a 16-tab target immediately. If the MDB Chrome window is already focused, the missing tabs are created and grouped at once; otherwise status reports the pending count and the extension expands on the next **natural Chrome focus**. A lower later request never closes existing tabs. When every current tab is leased and no expansion is already pending, pressure raises the target by four, up to 32, and attempts immediate creation only when Chrome is already focused. MDB never activates Chrome to satisfy either manual or automatic provisioning. -`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. +`chrome_workspace_status` is grantless because it only reads extension-owned local workspace state. It reports current and target pool sizes, the 32-tab maximum, four-tab automatic growth step, pending capacity, lease age/idle metadata, the 10-minute idle-reclaim timeout, and the 20-second lease-wait budget. `chrome_workspace_setup` is also grantless: provisioning is always accepted locally, while actual creation remains deferred when Chrome is not focused. 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` first provisions or creates capacity where safe, then waits briefly for a release; 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, including localhost and non-default ports, 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. diff --git a/bridge.mjs b/bridge.mjs index 14a5101..0d14a2e 100755 --- a/bridge.mjs +++ b/bridge.mjs @@ -763,16 +763,16 @@ const TOOLS = [ { name: "chrome_workspace_setup", title: "Set up MDB Chrome workspace", - description: "Create or expand the extension-owned MDB Chrome tab group and its reusable background-tab pool. This is a one-time/local setup action and does not access authenticated websites. On macOS, setup refuses to create tabs unless a normal Chrome window is already focused, because Chrome may otherwise steal focus even for active:false tab creation.", + description: "Provision the desired capacity for the extension-owned MDB Chrome tab group and reusable background-tab pool. The target is persisted even when Chrome is not focused. Missing tabs are created immediately only while the existing MDB Chrome window is already naturally focused; otherwise expansion remains pending until the next natural focus, so MDB never steals focus.", inputSchema: { type: "object", properties: { pool_size: { type: "integer", minimum: 1, - maximum: 8, + maximum: 32, 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.", + description: "Growth-only desired capacity for the reusable extension-owned MDB pool. Eight is the default and 32 is the hard maximum. Lower requests never close existing tabs.", }, }, additionalProperties: false, @@ -1546,7 +1546,7 @@ const BACKGROUND_CHROME_GRANT_DIR = process.env.MAC_DEV_BRIDGE_BACKGROUND_CHROME 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 BACKGROUND_CHROME_MAX_POOL_SIZE = 32; const CHATGPT_CHROME_EXTENSION_ID = "hehggadaopoacecdllhhajmbjkdcmajg"; const CHATGPT_NATIVE_HOST_NAME = "com.openai.codexextension"; diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index a8df64b..0735881 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.9", + "version": "0.2.10", "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 06a923b..eafca9a 100644 --- a/chrome-extension/service-worker.js +++ b/chrome-extension/service-worker.js @@ -1,6 +1,7 @@ const NATIVE_HOST = "io.github.alexanderradahl.mac_developer_bridge"; -const VERSION = "0.2.9"; +const VERSION = "0.2.10"; const WORKSPACE_KEY = "macDeveloperBridgeWorkspace"; +const WORKSPACE_TARGET_KEY = "macDeveloperBridgeWorkspaceTarget"; const WORKSPACE_GROUP_TITLE = "MDB"; const WORKSPACE_GROUP_COLOR = "blue"; const WORKSPACE_LEASE_IDLE_TIMEOUT_MS = 10 * 60 * 1000; @@ -8,7 +9,8 @@ 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 = 8; -const MAX_WORKSPACE_POOL_SIZE = 8; +const MAX_WORKSPACE_POOL_SIZE = 32; +const WORKSPACE_AUTO_GROW_STEP = 4; const CHATGPT_EXTENSION_ID = "hehggadaopoacecdllhhajmbjkdcmajg"; const CHATGPT_STATUS_REQUEST_EVENT = "chatgpt-extension-request-status"; const CHATGPT_STATUS_RESPONSE_EVENT = "chatgpt-extension-status"; @@ -18,13 +20,82 @@ let port = null; let reconnectTimer = null; let workspaceMutationQueue = Promise.resolve(); +function normalizeWorkspacePoolSize(value, fallback = DEFAULT_WORKSPACE_POOL_SIZE) { + const safeFallback = Number.isInteger(Number(fallback)) + && Number(fallback) >= 1 + && Number(fallback) <= MAX_WORKSPACE_POOL_SIZE + ? Number(fallback) + : DEFAULT_WORKSPACE_POOL_SIZE; + const numeric = Number(value); + if (!Number.isInteger(numeric) || numeric < 1) return safeFallback; + return Math.min(MAX_WORKSPACE_POOL_SIZE, numeric); +} + +function normalizeCurrentWorkspacePoolSize(value) { + const numeric = Number(value); + if (!Number.isInteger(numeric) || numeric < 0) return 0; + return Math.min(MAX_WORKSPACE_POOL_SIZE, numeric); +} + +function effectiveWorkspaceTargetSize(requested, storedTarget, currentPoolSize = 0) { + const current = normalizeCurrentWorkspacePoolSize(currentPoolSize); + const stored = normalizeWorkspacePoolSize(storedTarget, DEFAULT_WORKSPACE_POOL_SIZE); + const requestedTarget = requested === undefined || requested === null + ? stored + : normalizeWorkspacePoolSize(requested, stored); + return Math.max(current, stored, requestedTarget); +} + +function nextWorkspaceAutoGrowTarget(currentPoolSize, targetPoolSize) { + const current = normalizeCurrentWorkspacePoolSize(currentPoolSize); + const target = Math.max(current, normalizeWorkspacePoolSize(targetPoolSize, DEFAULT_WORKSPACE_POOL_SIZE)); + if (target > current || current >= MAX_WORKSPACE_POOL_SIZE) return target; + return Math.min(MAX_WORKSPACE_POOL_SIZE, current + WORKSPACE_AUTO_GROW_STEP); +} + +function workspaceCapacityStatus(currentPoolSize, targetPoolSize) { + const poolSize = normalizeCurrentWorkspacePoolSize(currentPoolSize); + const targetPoolSizeSafe = Math.max( + poolSize, + normalizeWorkspacePoolSize(targetPoolSize, DEFAULT_WORKSPACE_POOL_SIZE), + ); + const pendingTabCount = Math.max(0, targetPoolSizeSafe - poolSize); + return { + poolSize, + targetPoolSize: targetPoolSizeSafe, + maxPoolSize: MAX_WORKSPACE_POOL_SIZE, + autoGrowStep: WORKSPACE_AUTO_GROW_STEP, + pendingTabCount, + provisioningPending: pendingTabCount > 0, + canGrow: targetPoolSizeSafe < MAX_WORKSPACE_POOL_SIZE || poolSize < MAX_WORKSPACE_POOL_SIZE, + }; +} + function errorPayload(error, code = "CHROME_EXTENSION_ERROR") { const sourceDetails = error?.details && typeof error.details === "object" && !Array.isArray(error.details) ? error.details : null; const details = sourceDetails ? Object.fromEntries(Object.entries(sourceDetails).filter(([key, value]) => - ["status", "complete", "conversation_id", "event_count", "parse_failure_count", "action_error_name", "action_error_message", "action_error_stack"].includes(key) + [ + "status", + "complete", + "conversation_id", + "event_count", + "parse_failure_count", + "action_error_name", + "action_error_message", + "action_error_stack", + "poolSize", + "leased", + "waitTimeoutMs", + "targetPoolSize", + "pendingTabCount", + "maxPoolSize", + "autoGrowStep", + "provisioningPending", + "canGrow", + ].includes(key) && (["string", "number", "boolean"].includes(typeof value) || value === null))) : null; return { @@ -100,6 +171,39 @@ async function clearWorkspaceState() { await chrome.storage.local.remove(WORKSPACE_KEY); } +async function loadWorkspaceTargetSize() { + try { + const stored = (await chrome.storage.local.get(WORKSPACE_TARGET_KEY))?.[WORKSPACE_TARGET_KEY]; + const raw = stored && typeof stored === "object" && !Array.isArray(stored) + ? stored.targetPoolSize + : stored; + return normalizeWorkspacePoolSize(raw, DEFAULT_WORKSPACE_POOL_SIZE); + } catch { + return DEFAULT_WORKSPACE_POOL_SIZE; + } +} + +async function saveWorkspaceTargetSize(targetPoolSize) { + const normalized = normalizeWorkspacePoolSize(targetPoolSize, DEFAULT_WORKSPACE_POOL_SIZE); + await chrome.storage.local.set({ + [WORKSPACE_TARGET_KEY]: { + targetPoolSize: normalized, + }, + }); + return normalized; +} + +async function provisionWorkspaceTargetSize(requestedPoolSize, currentPoolSize = 0) { + const storedTarget = await loadWorkspaceTargetSize(); + const targetPoolSize = effectiveWorkspaceTargetSize( + requestedPoolSize, + storedTarget, + currentPoolSize, + ); + if (targetPoolSize !== storedTarget) await saveWorkspaceTargetSize(targetPoolSize); + return targetPoolSize; +} + async function readTab(tabId) { try { return await chrome.tabs.get(tabId); } catch { return null; } } @@ -192,22 +296,37 @@ async function setWorkspaceGroupActivity(state) { } catch {} } +function workspaceProvisioningResult(state, targetPoolSize, { + created = false, + deferred = false, +} = {}) { + const capacity = workspaceCapacityStatus(state?.tabIds?.length || 0, targetPoolSize); + return { + initialized: Boolean(state), + provisioned: true, + created, + deferred, + deferredReason: deferred ? "CHROME_WORKSPACE_SETUP_FOREGROUND_REQUIRED" : null, + pendingForegroundExpansion: deferred && capacity.provisioningPending, + groupId: state?.groupId ?? null, + tabIds: state?.tabIds || [], + ...capacity, + title: WORKSPACE_GROUP_TITLE, + color: WORKSPACE_GROUP_COLOR, + foregroundSetupMayBeRequired: capacity.provisioningPending, + }; +} + async function initializeWorkspace(poolSize) { 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) { + const targetPoolSize = await provisionWorkspaceTargetSize( + poolSize, + state?.tabIds?.length || 0, + ); + if (state && state.tabIds.length >= targetPoolSize) { 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, - }; + return workspaceProvisioningResult(state, targetPoolSize); } let windowId = state?.tabs?.[0]?.windowId; @@ -221,22 +340,16 @@ async function initializeWorkspace(poolSize) { } // 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; + // to the foreground. Provisioning the desired capacity is always safe and is + // persisted above, but actual tab creation remains deferred until the target + // Chrome window is already naturally focused. MDB never activates Chrome. + if (!targetWindow || !Number.isInteger(windowId) || targetWindow.focused !== true) { + return workspaceProvisioningResult(state, targetPoolSize, { deferred: true }); } const existingTabIds = state?.tabIds || []; const tabIds = [...existingTabIds]; - while (tabIds.length < desired) { + while (tabIds.length < targetPoolSize) { 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); @@ -257,17 +370,10 @@ async function initializeWorkspace(poolSize) { color: WORKSPACE_GROUP_COLOR, collapsed: Object.keys(next.leases).length === 0, }); - return { - initialized: true, + state = { ...next, tabs: await Promise.all(tabIds.map(readTab)) }; + return workspaceProvisioningResult(state, targetPoolSize, { created: tabIds.length > existingTabIds.length, - groupId, - tabIds, - poolSize: tabIds.length, - targetPoolSize: desired, - title: WORKSPACE_GROUP_TITLE, - color: WORKSPACE_GROUP_COLOR, - foregroundSetupMayBeRequired: true, - }; + }); }); } @@ -313,21 +419,36 @@ async function waitForApprovedNavigation(tabId, compiled, { async function initializeWorkspaceIfChromeFocused() { let state = await reconcileWorkspaceState(); + const targetPoolSize = effectiveWorkspaceTargetSize( + null, + await loadWorkspaceTargetSize(), + state?.tabIds?.length || 0, + ); const windows = await chrome.windows.getAll({ windowTypes: ["normal"] }); const focused = windows.find((win) => win.focused === true); 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; - } + if (!state || state.tabIds.length < targetPoolSize) { + await initializeWorkspace(targetPoolSize); state = await reconcileWorkspaceState(); } return state; } +async function autoProvisionWorkspaceForPressure(state) { + const currentPoolSize = state?.tabIds?.length || 0; + const storedTarget = await loadWorkspaceTargetSize(); + const targetPoolSize = nextWorkspaceAutoGrowTarget(currentPoolSize, storedTarget); + if (targetPoolSize > storedTarget) await saveWorkspaceTargetSize(targetPoolSize); + if (targetPoolSize > currentPoolSize) { + await initializeWorkspaceIfChromeFocused(); + } + const refreshed = await reconcileWorkspaceState(); + return { + state: refreshed, + ...workspaceCapacityStatus(refreshed?.tabIds?.length || currentPoolSize, targetPoolSize), + }; +} + async function touchWorkspaceLease(tabId) { const wanted = numericTabId(tabId); return await mutateWorkspaceState(async () => { @@ -361,28 +482,64 @@ async function reserveIdleWorkspaceTab() { async function leaseWorkspaceTab(url, compiled) { assertUrlAllowed(url, compiled); let state = await reconcileWorkspaceState(); - if (!state || state.tabIds.length < DEFAULT_WORKSPACE_POOL_SIZE) { + const configuredTarget = effectiveWorkspaceTargetSize( + null, + await loadWorkspaceTargetSize(), + state?.tabIds?.length || 0, + ); + if (!state || state.tabIds.length < configuredTarget) { 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."); + const targetPoolSize = await loadWorkspaceTargetSize(); + const capacity = workspaceCapacityStatus(0, targetPoolSize); + const error = new Error(`The Mac Developer Bridge Chrome tab group is missing. MDB has provisioned a ${capacity.targetPoolSize}-tab target and will create it the next time Chrome is naturally foreground; browser work refuses to create a loose fallback tab in the meantime.`); error.code = "CHROME_WORKSPACE_MISSING"; + error.details = capacity; throw error; } const waitStartedAt = Date.now(); const deadline = waitStartedAt + WORKSPACE_LEASE_WAIT_TIMEOUT_MS; let reservation = null; + let pressureProvisioned = false; for (;;) { reservation = await reserveIdleWorkspaceTab(); if (reservation.tab) break; + if (!pressureProvisioned) { + const pressure = await autoProvisionWorkspaceForPressure(reservation.state || state); + pressureProvisioned = true; + state = pressure.state || state; + continue; + } 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.`); + const targetPoolSize = effectiveWorkspaceTargetSize( + null, + await loadWorkspaceTargetSize(), + poolSize, + ); + const capacity = workspaceCapacityStatus(poolSize, targetPoolSize); + const expansion = capacity.provisioningPending + ? ` Another ${capacity.pendingTabCount} tabs are provisioned and will be created the next time the MDB Chrome window is naturally focused.` + : capacity.canGrow + ? ` MDB can provision another ${WORKSPACE_AUTO_GROW_STEP} tabs on the next pressure event, up to ${MAX_WORKSPACE_POOL_SIZE}.` + : " The pool is already at its configured maximum." + 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.${expansion}`); error.code = "CHROME_WORKSPACE_EXHAUSTED"; - error.details = { poolSize, leased, waitTimeoutMs: WORKSPACE_LEASE_WAIT_TIMEOUT_MS, targetPoolSize: DEFAULT_WORKSPACE_POOL_SIZE }; + error.details = { + poolSize, + leased, + waitTimeoutMs: WORKSPACE_LEASE_WAIT_TIMEOUT_MS, + targetPoolSize: capacity.targetPoolSize, + pendingTabCount: capacity.pendingTabCount, + maxPoolSize: capacity.maxPoolSize, + autoGrowStep: capacity.autoGrowStep, + provisioningPending: capacity.provisioningPending, + canGrow: capacity.canGrow, + }; throw error; } await delay(WORKSPACE_LEASE_WAIT_POLL_MS); @@ -396,6 +553,11 @@ async function leaseWorkspaceTab(url, compiled) { await chrome.tabs.update(tab.id, { url, active: false }); const settled = await waitForApprovedNavigation(tab.id, compiled, { previousUrl, requestedUrl: url }); await touchWorkspaceLease(tab.id); + const targetPoolSize = effectiveWorkspaceTargetSize( + null, + await loadWorkspaceTargetSize(), + state.tabIds.length, + ); return { workspace: true, groupId: state.groupId, @@ -404,7 +566,7 @@ async function leaseWorkspaceTab(url, compiled) { active: Boolean(settled.active), title: settled.title || "", url: settled.url || url, - poolSize: state.tabIds.length, + ...workspaceCapacityStatus(state.tabIds.length, targetPoolSize), waitedForSlotMs: Math.max(0, Date.now() - waitStartedAt), }; } catch (error) { @@ -445,12 +607,17 @@ async function releaseWorkspaceTab(tabId, { resetUrl = true } = {}) { async function workspaceStatus() { const state = await reconcileWorkspaceState(); + const targetPoolSize = effectiveWorkspaceTargetSize( + null, + await loadWorkspaceTargetSize(), + state?.tabIds?.length || 0, + ); + const capacity = workspaceCapacityStatus(state?.tabIds?.length || 0, targetPoolSize); if (!state) return { initialized: false, + ...capacity, 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, }; @@ -466,9 +633,7 @@ async function workspaceStatus() { initialized: true, groupId: state.groupId, tabIds: state.tabIds, - poolSize: state.tabIds.length, - targetPoolSize: DEFAULT_WORKSPACE_POOL_SIZE, - maxPoolSize: MAX_WORKSPACE_POOL_SIZE, + ...capacity, leasedTabIds: Object.keys(state.leases).map(Number), idleTabIds: state.tabIds.filter((id) => !state.leases[String(id)]), leaseDetails, @@ -3408,8 +3573,7 @@ async function connect() { return; } try { - let workspace = await reconcileWorkspaceState(); - if (!workspace) workspace = await initializeWorkspaceIfChromeFocused(); + const workspace = await initializeWorkspaceIfChromeFocused(); if (workspace) await setWorkspaceGroupActivity(workspace); } catch {} diff --git a/package.json b/package.json index 54de9b0..21bf9e9 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "start": "node bridge.mjs", "start:http": "node mcp-http.mjs", "check": "node --check bridge.mjs && node --check mcp-http.mjs && node --check lib/chatgpt-responses-adapter.mjs && node --check lib/chrome-extension-client.mjs && node --check scripts/chrome-native-host.mjs && node --check chrome-extension/service-worker.js", - "test": "node tests/smoke.mjs && node tests/integration.mjs && node tests/adversarial.mjs && node tests/chatgpt-responses.mjs && node tests/http.mjs && node tests/oauth.mjs && node tests/pty.mjs && node tests/federation.mjs && node tests/chatgpt-conversation.mjs && node tests/chatgpt-runtime.mjs && node tests/chrome-fill-background.mjs && node tests/chrome-background.mjs && bash tests/installer-mock.sh" + "test": "node tests/smoke.mjs && node tests/integration.mjs && node tests/adversarial.mjs && node tests/chatgpt-responses.mjs && node tests/http.mjs && node tests/oauth.mjs && node tests/pty.mjs && node tests/federation.mjs && node tests/chatgpt-conversation.mjs && node tests/chatgpt-runtime.mjs && node tests/chrome-workspace-capacity.mjs && node tests/chrome-fill-background.mjs && node tests/chrome-background.mjs && bash tests/installer-mock.sh" }, "engines": { "node": ">=18" diff --git a/tests/chrome-background.mjs b/tests/chrome-background.mjs index 8370bcc..5be21aa 100644 --- a/tests/chrome-background.mjs +++ b/tests/chrome-background.mjs @@ -203,14 +203,14 @@ try { assert.ok(manifest.permissions.includes("tabGroups")); assert.ok(manifest.permissions.includes("storage")); assert.ok(manifest.icons?.["16"] && manifest.icons?.["128"]); - assert.equal(manifest.version, "0.2.9"); + assert.equal(manifest.version, "0.2.10"); assert.equal(manifest.permissions.includes("debugger"), false, "realistic click support must not require Chrome debugger permission"); await Promise.all([16, 32, 48, 128].map(async (size) => { const stat = await fs.stat(path.join(root, "chrome-extension", "icons", `icon-${size}.png`)); assert.ok(stat.size > 0, `expected non-empty ${size}px extension icon`); })); const workerSource = await fs.readFile(path.join(root, "chrome-extension", "service-worker.js"), "utf8"); - assert.match(workerSource, /const VERSION = "0\.2\.9"/); + assert.match(workerSource, /const VERSION = "0\.2\.10"/); assert.match(workerSource, /WORKSPACE_GROUP_TITLE = "MDB"/); assert.match(workerSource, /chrome\.tabs\.group/); assert.match(workerSource, /chrome\.tabGroups\.query/); @@ -227,6 +227,14 @@ try { assert.match(workerSource, /waitForApprovedNavigation/); assert.match(workerSource, /CHROME_NAVIGATION_TIMEOUT/); assert.match(workerSource, /DEFAULT_WORKSPACE_POOL_SIZE = 8/); + assert.match(workerSource, /MAX_WORKSPACE_POOL_SIZE = 32/); + assert.match(workerSource, /WORKSPACE_AUTO_GROW_STEP = 4/); + assert.match(workerSource, /WORKSPACE_TARGET_KEY = "macDeveloperBridgeWorkspaceTarget"/); + assert.match(workerSource, /provisionWorkspaceTargetSize/); + assert.match(workerSource, /autoProvisionWorkspaceForPressure/); + assert.match(workerSource, /pendingForegroundExpansion/); + assert.match(workerSource, /targetWindow\.focused !== true/); + assert.match(workerSource, /return workspaceProvisioningResult\(state, targetPoolSize, \{ deferred: true \}\)/); 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/); @@ -432,10 +440,20 @@ try { 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 }); + const bridgeSetup = await bridgeTool(bridge, "chrome_workspace_setup", { pool_size: 16 }); 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); + assert.equal(host.seen.at(-1).args.poolSize, 16); + await fs.stat(approvalFile); + const bridgeSetupMax = await bridgeTool(bridge, "chrome_workspace_setup", { pool_size: 32 }); + assert.equal(bridgeSetupMax.result.isError, false, bridgeSetupMax.result.content[0].text); + assert.equal(host.seen.at(-1).method, "workspace.init"); + assert.equal(host.seen.at(-1).args.poolSize, 32); + const beforeTooLargePool = host.seen.length; + const bridgeSetupTooLarge = await bridgeTool(bridge, "chrome_workspace_setup", { pool_size: 33 }); + assert.equal(bridgeSetupTooLarge.result.isError, true); + assert.match(bridgeSetupTooLarge.result.content[0].text, /between 1 and 32/i); + assert.equal(host.seen.length, beforeTooLargePool, "pool sizes above 32 must fail before extension dispatch"); await fs.stat(approvalFile); // Workspace cleanup is grantless even in Strict mode. Removing the only diff --git a/tests/chrome-workspace-capacity.mjs b/tests/chrome-workspace-capacity.mjs new file mode 100644 index 0000000..8ae35f9 --- /dev/null +++ b/tests/chrome-workspace-capacity.mjs @@ -0,0 +1,259 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import vm from "node:vm"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const workerSource = await fs.readFile(path.join(root, "chrome-extension", "service-worker.js"), "utf8"); +const helperMatch = workerSource.match( + /function normalizeWorkspacePoolSize[\s\S]*?\n}\n\n(?=function errorPayload)/, +); +assert.ok(helperMatch, "workspace capacity helper source should be extractable"); + +const context = vm.createContext({ + DEFAULT_WORKSPACE_POOL_SIZE: 8, + MAX_WORKSPACE_POOL_SIZE: 32, + WORKSPACE_AUTO_GROW_STEP: 4, +}); +vm.runInContext(helperMatch[0], context); + +const normalize = vm.runInContext("normalizeWorkspacePoolSize", context); +const effective = vm.runInContext("effectiveWorkspaceTargetSize", context); +const nextTarget = vm.runInContext("nextWorkspaceAutoGrowTarget", context); +const capacity = vm.runInContext("workspaceCapacityStatus", context); + +assert.equal(normalize(undefined), 8); +assert.equal(normalize(null), 8); +assert.equal(normalize("corrupt"), 8); +assert.equal(normalize(0), 8); +assert.equal(normalize(1), 1); +assert.equal(normalize(16), 16); +assert.equal(normalize(32), 32); +assert.equal(normalize(33), 32); +assert.equal(normalize(7.5), 8); +assert.equal(normalize(undefined, 16), 16); +assert.equal(normalize(undefined, 99), 8); + +// Provisioning is growth-only: neither an explicit lower request nor stale +// persisted state may close already-created workspace tabs. +assert.equal(effective(6, 8, 8), 8); +assert.equal(effective(16, 8, 8), 16); +assert.equal(effective(8, 16, 8), 16); +assert.equal(effective(1, 8, 12), 12); +assert.equal(effective(null, "corrupt", 4), 8); + +// Pressure grows once by four, does not ratchet while the earlier target is +// pending, and caps at the hard maximum. +assert.equal(nextTarget(8, 8), 12); +assert.equal(nextTarget(8, 12), 12); +assert.equal(nextTarget(12, 12), 16); +assert.equal(nextTarget(30, 30), 32); +assert.equal(nextTarget(32, 32), 32); + +assert.deepEqual( + JSON.parse(JSON.stringify(capacity(8, 12))), + { + poolSize: 8, + targetPoolSize: 12, + maxPoolSize: 32, + autoGrowStep: 4, + pendingTabCount: 4, + provisioningPending: true, + canGrow: true, + }, +); +assert.deepEqual( + JSON.parse(JSON.stringify(capacity(32, 32))), + { + poolSize: 32, + targetPoolSize: 32, + maxPoolSize: 32, + autoGrowStep: 4, + pendingTabCount: 0, + provisioningPending: false, + canGrow: false, + }, +); + +// Persisted targets are migration-safe and growth-only. +const targetHelpersMatch = workerSource.match( + /async function loadWorkspaceTargetSize[\s\S]*?\n}\n\n(?=async function readTab)/, +); +assert.ok(targetHelpersMatch, "workspace target storage helpers should be extractable"); +let storedTarget; +const storageWrites = []; +const storageContext = vm.createContext({ + DEFAULT_WORKSPACE_POOL_SIZE: 8, + WORKSPACE_TARGET_KEY: "macDeveloperBridgeWorkspaceTarget", + normalizeWorkspacePoolSize: normalize, + effectiveWorkspaceTargetSize: effective, + chrome: { + storage: { + local: { + async get() { + return storedTarget === undefined + ? {} + : { macDeveloperBridgeWorkspaceTarget: storedTarget }; + }, + async set(value) { + storageWrites.push(value); + storedTarget = value.macDeveloperBridgeWorkspaceTarget; + }, + }, + }, + }, +}); +vm.runInContext(targetHelpersMatch[0], storageContext); +const loadTarget = vm.runInContext("loadWorkspaceTargetSize", storageContext); +const provisionTarget = vm.runInContext("provisionWorkspaceTargetSize", storageContext); +assert.equal(await loadTarget(), 8); +storedTarget = { targetPoolSize: "corrupt" }; +assert.equal(await loadTarget(), 8); +storedTarget = { targetPoolSize: 16 }; +assert.equal(await loadTarget(), 16); +assert.equal(await provisionTarget(6, 8), 16); +assert.equal(storageWrites.length, 0, "a lower request must not shrink or rewrite the target"); +assert.equal(await provisionTarget(20, 8), 20); +assert.equal(storageWrites.at(-1).macDeveloperBridgeWorkspaceTarget.targetPoolSize, 20); + +// Provisioning is accepted while Chrome is unfocused, but actual tab creation +// remains structurally deferred until natural focus. +const provisioningMatch = workerSource.match( + /function workspaceProvisioningResult[\s\S]*?\n}\n\n(?=async function waitForApprovedNavigation)/, +); +assert.ok(provisioningMatch, "workspace provisioning implementation should be extractable"); +let createCalls = 0; +const existingState = { + groupId: 9, + tabIds: Array.from({ length: 8 }, (_, index) => index + 1), + leases: {}, + tabs: [{ id: 1, windowId: 7 }], +}; +const deferredContext = vm.createContext({ + workspaceCapacityStatus: capacity, + WORKSPACE_GROUP_TITLE: "MDB", + WORKSPACE_GROUP_COLOR: "blue", + workspaceIdleUrl: () => "chrome-extension://test/workspace.html", + mutateWorkspaceState: async (operation) => await operation(), + reconcileWorkspaceStateUnlocked: async () => existingState, + provisionWorkspaceTargetSize: async () => 16, + setWorkspaceGroupActivity: async () => {}, + saveWorkspaceState: async () => {}, + readTab: async (id) => ({ id, windowId: 7 }), + chrome: { + windows: { + async get() { return { id: 7, focused: false }; }, + async getAll() { return []; }, + }, + tabs: { + async create() { createCalls += 1; return { id: 100 + createCalls }; }, + async group() { return 9; }, + }, + tabGroups: { async update() {} }, + }, +}); +vm.runInContext(provisioningMatch[0], deferredContext); +const initializeDeferred = vm.runInContext("initializeWorkspace", deferredContext); +const deferred = await initializeDeferred(16); +assert.equal(deferred.provisioned, true); +assert.equal(deferred.deferred, true); +assert.equal(deferred.pendingForegroundExpansion, true); +assert.equal(deferred.deferredReason, "CHROME_WORKSPACE_SETUP_FOREGROUND_REQUIRED"); +assert.equal(deferred.poolSize, 8); +assert.equal(deferred.targetPoolSize, 16); +assert.equal(deferred.pendingTabCount, 8); +assert.equal(createCalls, 0, "unfocused provisioning must not create any Chrome tab"); + +let nextTabId = 100; +const createdTabs = []; +const groupedTabs = []; +let savedState = null; +const focusedContext = vm.createContext({ + workspaceCapacityStatus: capacity, + WORKSPACE_GROUP_TITLE: "MDB", + WORKSPACE_GROUP_COLOR: "blue", + workspaceIdleUrl: () => "chrome-extension://test/workspace.html", + mutateWorkspaceState: async (operation) => await operation(), + reconcileWorkspaceStateUnlocked: async () => existingState, + provisionWorkspaceTargetSize: async () => 12, + setWorkspaceGroupActivity: async () => {}, + saveWorkspaceState: async (value) => { savedState = value; }, + readTab: async (id) => ({ id, windowId: 7 }), + chrome: { + windows: { + async get() { return { id: 7, focused: true }; }, + async getAll() { return []; }, + }, + tabs: { + async create(options) { + createdTabs.push(options); + nextTabId += 1; + return { id: nextTabId, windowId: 7 }; + }, + async group(options) { groupedTabs.push(options); return 9; }, + }, + tabGroups: { async update() {} }, + }, +}); +vm.runInContext(provisioningMatch[0], focusedContext); +const initializeFocused = vm.runInContext("initializeWorkspace", focusedContext); +const expanded = await initializeFocused(12); +assert.equal(expanded.deferred, false); +assert.equal(expanded.created, true); +assert.equal(expanded.poolSize, 12); +assert.equal(expanded.targetPoolSize, 12); +assert.equal(expanded.pendingTabCount, 0); +assert.equal(createdTabs.length, 4); +assert.ok(createdTabs.every((options) => options.active === false)); +assert.equal(groupedTabs.length, 1); +assert.deepEqual(JSON.parse(JSON.stringify(groupedTabs[0].tabIds)), [101, 102, 103, 104]); +assert.equal(savedState.tabIds.length, 12); + +const errorPayloadMatch = workerSource.match( + /function errorPayload[\s\S]*?\n}\n\n(?=function mutateWorkspaceState)/, +); +assert.ok(errorPayloadMatch, "safe error payload helper should be extractable"); +const errorContext = vm.createContext({}); +vm.runInContext(errorPayloadMatch[0], errorContext); +const safeErrorPayload = vm.runInContext("errorPayload", errorContext); +const exhaustionError = new Error("pool exhausted"); +exhaustionError.code = "CHROME_WORKSPACE_EXHAUSTED"; +exhaustionError.details = { + poolSize: 8, + leased: 8, + waitTimeoutMs: 20000, + targetPoolSize: 12, + pendingTabCount: 4, + maxPoolSize: 32, + autoGrowStep: 4, + provisioningPending: true, + canGrow: true, + secret: "must-not-cross-boundary", +}; +assert.deepEqual( + JSON.parse(JSON.stringify(safeErrorPayload(exhaustionError))), + { + code: "CHROME_WORKSPACE_EXHAUSTED", + message: "pool exhausted", + details: { + poolSize: 8, + leased: 8, + waitTimeoutMs: 20000, + targetPoolSize: 12, + pendingTabCount: 4, + maxPoolSize: 32, + autoGrowStep: 4, + provisioningPending: true, + canGrow: true, + }, + }, +); + +console.log(JSON.stringify({ + passed: true, + defaultPoolSize: 8, + maxPoolSize: 32, + autoGrowStep: 4, + pressureSequence: [nextTarget(8, 8), nextTarget(8, 12), nextTarget(12, 12)], +})); diff --git a/tests/smoke.mjs b/tests/smoke.mjs index e4190e4..e79ae9d 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -119,6 +119,7 @@ try { 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_workspace_setup").inputSchema.properties.pool_size.maximum, 32); assert.equal(byName.get("chrome_tabs").annotations.readOnlyHint, true); assert.equal(byName.get("chrome_click").annotations.destructiveHint, true); }