diff --git a/.planning/SAFARI-PORT-PLAN.md b/.planning/SAFARI-PORT-PLAN.md new file mode 100644 index 000000000..0ebc54ce8 --- /dev/null +++ b/.planning/SAFARI-PORT-PLAN.md @@ -0,0 +1,335 @@ +# Safari Web Extension Port — Research & Plan + +**Branch:** `safari-extension-port` (forked from `origin/main` @ `09d95a61`) +**Date:** 2026-08-21 +**Source:** `extension/` — MV3, 513 files, 30 MB, `background.js` = 16,279 lines +**Local toolchain (verified):** macOS 26.6.1 · Safari 26.6 · Xcode converter present at +`/Applications/Xcode.app/Contents/Developer/usr/bin/safari-web-extension-converter` + +--- + +## 1. Verdict + +**A literal 1:1 port is not achievable.** Three Chrome-proprietary APIs that FSB depends on +have no Safari equivalent and no polyfill: `chrome.debugger` (115 references), +`chrome.sidePanel` (22 references), and `chrome.offscreen` (6 references). These are not +"not yet implemented" — `debugger` and `offscreen` are not part of the WebExtensions +standard at all (MDN's browser-compat-data has no entry for either), and `sidePanel` / +`sidebarAction` are recorded as `safari: NO` on **every single member**. + +**However, a high-fidelity port is very achievable**, because of one architectural fact +discovered in the source: FSB's action layer is already **DOM-first with CDP as an +escalation tier**, not CDP-primary. + +`extension/content/actions.js:135-164`: + +```js +element.dispatchEvent(new MouseEvent('mousedown', mouseEventInit)); +element.dispatchEvent(new MouseEvent('mouseup', mouseEventInit)); +element.dispatchEvent(new MouseEvent('click', mouseEventInit)); +if (typeof element.click === 'function') element.click(); +await waitForStability('click'); +// Check if DOM click had effect; if not, try CDP mouse as final fallback +let clickMethod = 'dom_coordinate'; +const cdpResult = await chrome.runtime.sendMessage({ action: 'cdpMouseClick', ... }); +``` + +`actions.js` is 5,881 lines of synthetic-DOM automation that runs **first** on every click. +CDP is the reliability backstop for the tail of hostile sites. So the Safari build does not +need a new input engine — it needs the escalation tier to degrade cleanly instead of throw. + +**Realistic outcome: ~85% of tool surface at full fidelity, ~10% degraded, ~5% unavailable +on macOS.** Detail in §4. + +--- + +## 2. Compatibility baseline (hard data) + +Sourced from MDN `browser-compat-data/webextensions` (raw JSON) and Apple's +*Assessing your Safari web extension's browser compatibility* (fetched via the +`developer.apple.com/tutorials/data` JSON API — the HTML pages are JS-rendered and return +only titles to a plain fetch). + +### 2.1 Blocked — no Safari support, no polyfill + +| API | Refs | Safari status | +|---|---|---| +| `chrome.debugger` | **115** | Chrome-proprietary. Absent from BCD entirely. | +| `chrome.sidePanel` | **22** | `safari: NO` on all 16 members | +| `sidebar_action` (Firefox alt) | — | `safari: NO` on all 6 members | +| `chrome.offscreen` | **6** | Chrome-proprietary. Absent from BCD. | +| `chrome.system.memory` | 1 | Chrome-proprietary | +| `webRequest` **blocking** | 4 | `webRequest` returned in Safari 18, but `BlockingResponse: NO`, `ResourceType: NO` | +| `runtime.getContexts` | 1 | `safari: NO` (already `?.`-guarded at `background.js:3611`) | +| `runtime.onSuspend` | 2 | `safari: NO` | +| `tabs.discard` | — | `safari: NO` | + +CDP surface actually used — narrower than the 115 refs suggest: + +| CDP command | Count | Purpose | +|---|---|---| +| `Input.dispatchMouseEvent` | 26 | click / click-and-hold / drag | +| `Input.dispatchKeyEvent` | 6 | trusted key events | +| `Input.insertText` | 2 | trusted text insertion | +| `DOM.setFileInputFiles` | 1 | **file upload** | +| `DOM.querySelector` / `getDocument` / `describeNode` | 4 | node resolution for the above | +| `Network.enable` + `requestWillBeSent` / `responseReceived` | — | consent-gated network capture | + +### 2.2 Version-gated — **all satisfied at Safari 26.6** + +| Feature | Min Safari | Used by | +|---|---|---| +| `background.service_worker` | 15.4 | `background.js` | +| `scripting.ExecutionWorld.MAIN` | 15.4 | programmatic MAIN-world injection | +| `web_accessible_resources.matches` / `.resources` | 15.4 | manifest | +| `storage.session` | 16.4 | **127 refs** | +| `scripting.registerContentScripts` | 16.4 | dynamic registration | +| **`content_scripts.world: "MAIN"`** | **18** | `canvas-interceptor.js` @ `document_start` | +| `dom.openOrClosedShadowRoot` | 26 | shadow-DOM traversal | + +→ **Baseline the port at Safari 18.0**, target 26. Do not attempt Safari 15–17. + +### 2.3 Supported but behaviourally different + +- **Content scripts do not run until the user grants per-site permission.** `` + host permission is *not* an auto-grant in Safari — the user must click the extension's + toolbar popover and choose "Always Allow on Every Website". This is the single biggest + *UX* delta and needs first-run onboarding copy. (BCD note on `manifest.content_scripts`.) +- `storage.local`: 5 MB default; `unlimitedStorage` grants genuinely unlimited on Safari 16+. +- `scripting.executeScript`: `injectImmediately` **not supported** (36 refs use `executeScript`). +- `scripting.insertCSS`/`removeCSS`: `origin`, `allFrames`, `frameIds` not supported. +- `runtime.lastError` (**74 refs**): only populated on the **callback** form; promise-form + failures reject instead. FSB mixes both — audit needed. +- `runtime.OnInstalledReason`: only `install` and `update`. +- `webNavigation.onCommitted`: supported; `transitionType` / `transitionQualifiers` **not**. +- `action.getBadgeBackgroundColor` always returns red (setters work — FSB only sets). +- `windows.create`: `popup` and `normal` fine; `panel` / `detached_panel` unsupported. + FSB uses `type: 'popup'` in all 5 call sites → **compatible**. +- `web_accessible_resources`: base URL is *always* dynamic in Safari. +- `update_url` unsupported — updates ship through the App Store. +- Safari ignores `file://` URL schemes in manifest permissions. + +### 2.4 Confirmed working (no action needed) + +All `tabs.*` FSB uses: `get`, `query`, `create`, `update`, `remove`, `reload`, `goBack`, +`goForward`, `sendMessage`, `onUpdated`, `onRemoved`, `onActivated`, `onCreated` — Safari 14+. +`tabs.captureVisibleTab` **is supported** (Safari 14+, defaults to JPEG, doesn't require +``). `alarms` (14+), `storage.local/session/onChanged`, `action.setBadgeText`, +`runtime.sendMessage`/`onMessage`/`connect`/`getURL`/`getManifest`/`openOptionsPage`, +`clipboardWrite`, `webNavigation.onCommitted`. + +**`runtime.connectNative` / `sendNativeMessage`: Safari 14+.** This is the escape hatch that +makes §3 possible. + +--- + +## 3. Replacement designs for the four blockers + +### 3.1 `sidePanel` → the primary UI has no home + +The side panel is FSB's main surface: `sidepanel.js` (3,733 lines) + `sidepanel.html` + +`sidepanel.css` (1,726 lines). Apple treats browser chrome as OS surface; no extension can +dock a panel. Four options, in fidelity order: + +| Option | Persistent | Docked | Effort | Notes | +|---|---|---|---|---| +| **A. Native SwiftUI window hosting `WKWebView`** | ✅ | side-by-side, not docked | High | Loads `sidepanel.html` verbatim; bridges to the extension over native messaging. Highest fidelity, feels like a Mac app. | +| **B. Dedicated extension tab** (`tabs.create('ui/sidepanel.html')`) | ✅ | ❌ | **Low** | Zero UI rewrite. Ship this first. | +| C. In-page content-script overlay | ❌ per-tab | ✅ | Medium | FSB already has overlay infra (`utils/overlay-state.js`, `content/visual-feedback.js`) but it dies on navigation. | +| D. `action.default_popup` | ❌ closes on blur | ✅ | Low | Unusable for a long-running agent loop. | + +**Recommendation: B for MVP, A as the shipped experience.** Both reuse `sidepanel.html` +unmodified; only the *opener* changes. Introduce `openAgentSurface()` and replace the 22 +`chrome.sidePanel.*` call sites with it. + +### 3.2 `debugger` → tiered degradation, not removal + +| Capability | Chrome path | Safari path | Fidelity | +|---|---|---|---| +| `click` | DOM → CDP fallback | **DOM only** | High — DOM path is already primary | +| `type_text` / `insert_text` | `Input.insertText` | DOM + `execCommand('insertText')` (fallback already exists at `background.js:10456-10460`) | High | +| `press_key` | `Input.dispatchKeyEvent` | `KeyboardEvent` dispatch | Medium — untrusted; breaks on handlers checking `isTrusted` | +| `click_and_hold`, `drag`, `drag_variable_speed`, `drop_file` | CDP mouse | Pointer Events sequence | Medium — HTML5 DnD needs trusted events on some sites | +| `upload_file` | `DOM.setFileInputFiles` | **Native messaging** (see below) | High, via new code | +| `network_capture` | `Network.enable` | `webRequest` (Safari 18+, non-blocking) | **Partial — no response bodies** | + +**`upload_file` via native messaging.** In-repo comments call `DOM.setFileInputFiles` "the +only mechanism that" works (`background.js:14796`, `site-guides/utilities/file-upload.js:64`). +That's true *within Chrome's extension sandbox*. Safari has a different, legitimate route: + +1. Extension SW → `runtime.sendNativeMessage({ readFile: path })` +2. `SafariWebExtensionHandler` (Swift, in the container app) reads bytes → base64 +3. SW → content script; content script builds + `const dt = new DataTransfer(); dt.items.add(new File([bytes], name)); input.files = dt.files;` + then dispatches `change`. + +Sites that read `input.files` see a real `File`. Sites gating on `event.isTrusted` will not. +This is a genuine capability, not a stub — and it's *only* possible because of the container +app, which is a Safari advantage worth noting. + +**`network_capture` is the one real loss.** Safari 18's `webRequest` is non-blocking and +exposes no response bodies, so the consent-gated capture in `utils/network-capture.js` +degrades to URL + request-header metadata only. The existing consent gate +(`utils/consent-policy-store.js`) and redactor still apply unchanged. + +### 3.3 `offscreen` → two different fixes + +| Consumer | Chrome reason | Safari replacement | +|---|---|---| +| `offscreen/lattice-host.js` | `WORKERS` — hosts Lattice provider bus, `fetch()` to AI APIs | Run in the SW directly (Safari SWs may `fetch` freely), **or** a hidden extension tab if module-loading forces it. Verify against `tests/lattice-host-step-transition-smoke.test.js`. | +| `offscreen/stt.js` | speech recognition off the SW | FSB **already has** `content/stt-recognition.js`. Route through it; or use native `SFSpeechRecognizer` via the container app for a better result. | + +### 3.4 MCP bridge (`ws://localhost:7225`) → prefer native messaging + +`ws/mcp-bridge-client.js:12` opens a WebSocket to localhost. Two Safari-specific hazards: + +1. **SW lifetime.** Safari terminates idle background service workers aggressively; there + are documented reports of permanent SW kill on iOS 17.4–17.6 after 30–45 s. A dropped SW + drops the socket. +2. **Local Network privacy.** macOS 15+ gates localhost access; the container app needs the + `com.apple.security.network.client` entitlement, and the user sees a prompt. + +**Recommendation:** route the MCP bridge through `connectNative` to the container app, and +let the *app* hold the localhost socket. This sidesteps both hazards and is the idiomatic +Safari design. Keep the existing `ws-client.js` remote-control socket +(`wss://…/ws?key=…&role=extension`) as-is, plus a `getPlatformInfo()`-style keep-alive ping +inside Safari's activity window. + +--- + +## 4. Capability degradation map (user-visible) + +| Tier | Tools | +|---|---| +| **Full fidelity** | `navigate`, `back`/`forward`, `refresh`, `open_tab`, `close_tab`, `switch_tab`, `list_tabs`, `read_page`, `get_page_snapshot`, `get_dom_snapshot`, `get_text`, `get_attribute`, `set_attribute`, `scroll*`, `focus`, `select_option`, `check_box`, `clear_input`, `execute_js`, `capture_screenshot`, `search`, all capability/recipe/trigger/memory/session tooling, cost tracking, telemetry | +| **High (DOM path, untrusted events)** | `click`, `click_at`, `double_click`, `right_click`, `hover`, `type_text`, `insert_text`, `fill_credential`, `press_enter` | +| **Degraded** | `press_key`, `drag`, `drag_drop`, `drag_variable_speed`, `click_and_hold`, `select_text_range` — untrusted; fail on `isTrusted`-gated handlers | +| **Reduced** | `network_capture` — metadata only, no response bodies | +| **Requires new native code** | `upload_file`, `drop_file` | +| **Unavailable** | `get_memory_stats` (`system.memory`) | + +The port should surface this honestly: extend `utils/capability-router.js` / +`capability-catalog.js` so an unsupported tool returns a structured +`capability_unavailable` result with the reason — never a raw throw. + +--- + +## 5. Build & packaging architecture + +**Do not fork `extension/`.** 513 files × 30 MB duplicated would diverge within one release. + +**Single source of truth + build-time transform**, fitting the existing `esbuild.config.js` +pipeline (which already emits to `extension/dist/` and `extension/content/`): + +``` +extension/ ← unchanged source of truth + utils/platform-adapter.js ← NEW: platform detect + capability gates +scripts/build-safari.mjs ← NEW: emits build/safari/ from extension/ + ├─ strips side_panel, offscreen, debugger, system.memory from permissions + ├─ adds nativeMessaging + ├─ rewrites background.service_worker (drops offscreen bootstrap) + └─ copies all assets +safari/ ← NEW: Xcode project (container app + extension target) + FSB/ ← SwiftUI container app (+ agent window, option A) + FSB Extension/ + SafariWebExtensionHandler.swift +``` + +`utils/platform-adapter.js` exposes: + +```js +FSB.platform // 'chrome' | 'safari' +FSB.caps // { trustedInput, networkBodies, sidePanel, offscreen, nativeFS, systemMemory } +FSB.debugger // no-op shim returning { ok:false, reason:'capability_unavailable' } +FSB.openAgentSurface() // sidePanel.open() on Chrome; tab/native window on Safari +``` + +Chrome build is byte-identical to today — the adapter is additive and `FSB.platform` +short-circuits to the existing path. + +Xcode conversion (verified present locally): + +```bash +xcrun safari-web-extension-converter build/safari \ + --project-location safari \ + --app-name "FSB" \ + --bundle-identifier com.fullselfbrowsing.fsb \ + --swift --macos-only --copy-resources --no-open --force +``` + +Run the converter **once** to scaffold; commit the Xcode project; thereafter +`build-safari.mjs` refreshes resources in place. Re-running the converter clobbers Swift edits. + +### ⚠️ Test-suite constraint — read before touching `extension/` + +**15 test files read extension source and assert on its exact contents.** +`tests/capability-fetch.test.js:171` maintains a forbidden-substring list — +`['jmespath','getFSB','require','importScripts','FsbMcpTaskStore','FsbCapabilityInterpreter']` — +that fails if any appears in a serialized function body. Others pin occurrence counts +(`tests/agent-cap-ui.test.js:283`, `tests/cap-counter-live.test.js:137`). The suite is 626 +files; `npm test` chains ~250 of them serially. + +Practical consequence: **even a comment containing the wrong word can break the build.** +Every edit to `extension/` must be followed by `npm run validate:extension && npm test`. +Tests already coupled to the blocked APIs: `network-capture.test.js`, +`network-capture-consent.test.js`, `keyboard-attach-robustness.test.js`, +`_helpers/cdp-event-driver.js`, 3 × `sidepanel-*`, 6 × offscreen/lattice. + +Also note (existing repo constraint): in-SW `chrome.runtime.sendMessage` never loops back — +same-context dispatch must go through `globalThis.fsbDispatchInternalMessage`. The Safari +adapter must preserve this, since more logic moves *into* the SW when offscreen goes away. + +--- + +## 6. Phased plan + +| Phase | Scope | Exit criterion | +|---|---|---| +| **S0 — Scaffold** | `build-safari.mjs`, Safari manifest transform, run converter, Xcode project committed, `Sign to Run Locally` + *Develop ▸ Allow Unsigned Extensions* | Extension loads in Safari 26; popup opens | +| **S1 — Platform adapter** | `utils/platform-adapter.js`; replace 115 `chrome.debugger` + 6 `offscreen` + 1 `system.memory` refs with gated calls; `capability_unavailable` results wired into `capability-router.js` | `npm test` green on Chrome build; Safari build boots with no unhandled rejections | +| **S2 — Agent surface** | `openAgentSurface()`; 22 `sidePanel` refs → tab-based surface (option B) | Full agent loop runs in Safari via extension tab | +| **S3 — Input parity** | DOM-only click/type verified; Pointer-Events path for drag/hold; untrusted-event telemetry | Existing action tests pass against the DOM path | +| **S4 — Native bridge** | `SafariWebExtensionHandler.swift`; `upload_file` via native file read + `DataTransfer`; MCP bridge over `connectNative` | `upload_file` + MCP tool round-trip working in Safari | +| **S5 — Lattice + STT** | Lattice off offscreen; STT via `content/stt-recognition.js` or `SFSpeechRecognizer` | `test:lattice` green; voice input works | +| **S6 — Onboarding & polish** | Per-site permission onboarding ("Always Allow on Every Website"), degradation notices in UI, native agent window (option A) | First-run flow verified on a clean profile | +| **S7 — Distribution** | Apple Developer Program, signing, notarization, App Store or Developer ID | Notarized build installs on a second Mac | + +--- + +## 7. Distribution notes + +- Safari web extensions ship **inside a container app** (macOS / iOS / visionOS / Mac Catalyst). +- Requires **Apple Developer Program** membership for any distribution. +- macOS: App Store, **or** Developer ID + notarization outside the Mac App Store — the latter + fits FSB's current direct-download model better and avoids App Store review friction for an + automation tool with ``. +- Beta: "Copy App" distribution (unsigned) + tester enables *Develop ▸ Allow Unsigned Extensions*. +- `update_url` is unsupported — updates flow through the App Store or your own app updater. +- **Recommend macOS-only initially.** iOS additionally loses `contextMenus`, + `windows.create/remove/update`, and has a harsher SW lifetime. + +--- + +## 8. Open questions + +1. **Agent surface**: ship tab-based (S2) and stop, or invest in the native SwiftUI window? +2. **Trusted input**: is degraded `press_key`/`drag` acceptable, or should S3 explore + a native `CGEvent`-based path from the container app? (Would need Accessibility + permission — powerful, but a significant trust ask and a likely App Store blocker.) +3. **MCP bridge**: native messaging (recommended) vs. keeping the localhost WebSocket? +4. **Distribution channel**: Developer ID + notarization, or App Store? +5. **Version/branding**: separate Safari version line, or lockstep with the Chrome build? + +--- + +## Sources + +- [Assessing your Safari web extension's browser compatibility](https://developer.apple.com/documentation/safariservices/assessing-your-safari-web-extension-s-browser-compatibility) +- [Safari web extensions](https://developer.apple.com/documentation/safariservices/safari-web-extensions) +- [Distributing your Safari web extension](https://developer.apple.com/documentation/safariservices/distributing-your-safari-web-extension) +- [Messaging a Web Extension's Native App](https://developer.apple.com/documentation/SafariServices/messaging-a-web-extension-s-native-app) +- [MDN browser-compat-data — webextensions](https://github.com/mdn/browser-compat-data/tree/main/webextensions) +- [Chrome incompatibilities — MDN](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Chrome_incompatibilities) +- [How to quickly convert Chrome extensions to Safari — Evil Martians](https://evilmartians.com/chronicles/how-to-quickly-and-weightlessly-convert-chrome-extensions-to-safari) +- [Safari Extension Service Worker Permanently Killed on iOS 17.4.x–17.6](https://developer.apple.com/forums/thread/758346) +- [Use WebSockets in service workers — Chrome for Developers](https://developer.chrome.com/docs/extensions/how-to/web-platform/websockets) diff --git a/.planning/quick/261013-gau-multi-browser-mcp-routing-serve-several-/261013-gau-CONTEXT.md b/.planning/quick/261013-gau-multi-browser-mcp-routing-serve-several-/261013-gau-CONTEXT.md new file mode 100644 index 000000000..4867be565 --- /dev/null +++ b/.planning/quick/261013-gau-multi-browser-mcp-routing-serve-several-/261013-gau-CONTEXT.md @@ -0,0 +1,110 @@ +# Quick Task 261013-gau: Multi-browser MCP routing - Context + +**Gathered:** 2026-10-13 +**Status:** Ready for planning + + +## Task Boundary + +Serve several browsers (Chrome, other Chromium-family browsers, Safari) from one +MCP server at the same time, pair each one, and route every MCP call to the +right browser, including Safari pairing. + +Today the MCP bridge hub (`mcp/src/bridge.ts`) holds exactly one extension +socket (`extensionClient`), and `~/.fsb/bridge-auth.json` pins exactly one +`allowedExtensionOrigin`; any other browser Origin gets a 403 at the upgrade, +paired or not. Safari cannot pair at all: its native messages reach the +sandboxed FSB app, which cannot read `~/.fsb`, and Safari regenerates the +extension's `safari-web-extension://` on every browser launch. + + + + +## Implementation Decisions + +### Default browser for a call +- A call that names a tab, an agent/session, or a visual session goes to the + browser that owns it. Ownership always wins. +- A call that names none of those, with several browsers connected, goes to the + **most recently focused** browser: each extension reports when one of its + windows gains focus. With one browser connected, behaviour is unchanged. +- Never broadcast a call to every browser. + +### How tabs are addressed +- `tabId` stays a **number**. `list_tabs` (and any tab listing) adds a + `browser` field, and tab-taking tools accept an optional `browser` argument. +- With one browser connected, nothing changes for clients. +- A numeric tabId that exists in more than one connected browser is + **refused** with a clear "ambiguous tab, pass browser" error unless + `browser` disambiguates. Never guess. + +### Safari pairing-code source +- **One-time folder grant in the FSB app**: the user grants `~/.fsb` once in + the container app (NSOpenPanel with hidden files shown), stored as a + security-scoped bookmark in the App Group, reusing the `GrantedRoots` + mechanism uploads already use. +- The Safari extension's native handler resolves that bookmark and reads the + current pairing code itself on each bootstrap. The secret is created when no + `bridge-auth.json` exists (`prepareBridgeAuth` in `mcp/src/index.ts`) and + changes on `fsb-mcp-server pair --reset`; reading it fresh each time means + Safari follows a reset without the user doing anything. +- Must work for both the App Store and Developer ID builds (no sandbox + temporary-exception entitlement). + +### Settled earlier in this branch's discussion (carried forward) +- Bridge auth keeps one pin **per browser** instead of one global pin. + Safari's pin is scheme-level (`safari-web-extension://`), because its UUID + changes per launch; the 256-bit pairing secret is the real check there. + Chromium-family pins stay exact-origin. +- Chrome, Brave, Edge and Chromium share one origin when installed from the + Chrome Web Store (`chrome-extension://badgafnf…`), so each extension must + identify its browser itself on connect (e.g. `navigator.brave`, + `navigator.userAgentData.brands`, `FsbPlatform.id`). +- The native transport must carry the pairing subprotocols + (`[fsb-ext-v1, fsb-auth.]`) in its `open` frame; + `MCPSocketSession` sets `Sec-WebSocket-Protocol`, and a lingering native + socket is adopted only when its protocols match the new port's. + +### Claude's Discretion +- Wire format for browser identity and focus reports (extension → server). +- How `pair --reset` addresses one browser versus all, and the + `bridge-auth.json` schema migration (existing single-pin files must keep + working; `native-host/bootstrap.ts` validates the same file and must accept + the new shape). +- Where routing lives in `mcp/src` (hub versus tool layer), keeping the + `mcp/src` diff as small as the schema change allows (user preference: + the extension does the heavy lifting). +- Delegated agent runs on Safari: pairing will let a paired Safari send + `agent-spawn` requests to an already-running delegation daemon, but waking + the daemon from Safari (the Node native host's job on Chrome) is out of + scope unless it falls out naturally. +- Status/doctor output for several connected browsers. + + + + +## Specific Ideas + +- The user's framing: "choose which browser to call smartly without blindly + firing things". Ownership first, then most recently focused, never all. +- Published `fsb-mcp-server` 0.11.1 still rejects `safari-web-extension://` + origins. The allowlist exists only on this branch, so this work ships with + an MCP release. +- Tool-definition changes are pinned by a SHA-256 over the whole registry, + cross-checked between `extension/ai/tool-definitions.js` and + `mcp/ai/tool-definitions.cjs` (`tests/tool-definitions-parity.test.js`); + adding a `browser` argument must update both sides and the pin. + + + + +## Canonical References + +- `safari/README.md`: "MCP on the same port", the known gaps, and the + on-device verification table (V1–V10). +- WebKit bug 244330: Safari resets the extension UUID on every launch + (https://bugs.webkit.org/show_bug.cgi?id=244330). +- Apple Developer Forums thread 670165: Safari web extension URL and UUID + behaviour (https://developer.apple.com/forums/thread/670165). + + diff --git a/.planning/quick/261013-gau-multi-browser-mcp-routing-serve-several-/261013-gau-RESEARCH.md b/.planning/quick/261013-gau-multi-browser-mcp-routing-serve-several-/261013-gau-RESEARCH.md new file mode 100644 index 000000000..8a9ada888 --- /dev/null +++ b/.planning/quick/261013-gau-multi-browser-mcp-routing-serve-several-/261013-gau-RESEARCH.md @@ -0,0 +1,280 @@ +# Quick Task 261013-gau: Multi-browser MCP routing - Research + +**Researched:** 2026-10-13 +**Domain:** MCP bridge hub topology (Node `ws`), bridge pairing state, MV3 extension identity, Safari App Sandbox file access +**Confidence:** HIGH for the codebase map and routing design; MEDIUM for browser self-identification; LOW for the Safari folder grant until it is tested on a device (see Critical Finding 1) + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +**Default browser for a call** +- A call that names a tab, an agent/session, or a visual session goes to the + browser that owns it. Ownership always wins. +- A call that names none of those, with several browsers connected, goes to the + **most recently focused** browser: each extension reports when one of its + windows gains focus. With one browser connected, behaviour is unchanged. +- Never broadcast a call to every browser. + +**How tabs are addressed** +- `tabId` stays a **number**. `list_tabs` (and any tab listing) adds a + `browser` field, and tab-taking tools accept an optional `browser` argument. +- With one browser connected, nothing changes for clients. +- A numeric tabId that exists in more than one connected browser is + **refused** with a clear "ambiguous tab, pass browser" error unless + `browser` disambiguates. Never guess. + +**Safari pairing-code source** +- **One-time folder grant in the FSB app**: the user grants `~/.fsb` once in + the container app (NSOpenPanel with hidden files shown), stored as a + security-scoped bookmark in the App Group, reusing the `GrantedRoots` + mechanism uploads already use. +- The Safari extension's native handler resolves that bookmark and reads the + current pairing code itself on each bootstrap. The secret is created when no + `bridge-auth.json` exists (`prepareBridgeAuth` in `mcp/src/index.ts`) and + changes on `fsb-mcp-server pair --reset`; reading it fresh each time means + Safari follows a reset without the user doing anything. +- Must work for both the App Store and Developer ID builds (no sandbox + temporary-exception entitlement). + +**Settled earlier in this branch's discussion (carried forward)** +- Bridge auth keeps one pin **per browser** instead of one global pin. + Safari's pin is scheme-level (`safari-web-extension://`), because its UUID + changes per launch; the 256-bit pairing secret is the real check there. + Chromium-family pins stay exact-origin. +- Chrome, Brave, Edge and Chromium share one origin when installed from the + Chrome Web Store (`chrome-extension://badgafnf…`), so each extension must + identify its browser itself on connect (e.g. `navigator.brave`, + `navigator.userAgentData.brands`, `FsbPlatform.id`). +- The native transport must carry the pairing subprotocols + (`[fsb-ext-v1, fsb-auth.]`) in its `open` frame; + `MCPSocketSession` sets `Sec-WebSocket-Protocol`, and a lingering native + socket is adopted only when its protocols match the new port's. + +### Claude's Discretion +- Wire format for browser identity and focus reports (extension → server). +- How `pair --reset` addresses one browser versus all, and the + `bridge-auth.json` schema migration (existing single-pin files must keep + working; `native-host/bootstrap.ts` validates the same file and must accept + the new shape). +- Where routing lives in `mcp/src` (hub versus tool layer), keeping the + `mcp/src` diff as small as the schema change allows (user preference: + the extension does the heavy lifting). +- Delegated agent runs on Safari: pairing will let a paired Safari send + `agent-spawn` requests to an already-running delegation daemon, but waking + the daemon from Safari (the Node native host's job on Chrome) is out of + scope unless it falls out naturally. +- Status/doctor output for several connected browsers. + +### Deferred Ideas (OUT OF SCOPE) +None listed in CONTEXT.md. Waking the daemon from Safari is out of scope unless it falls out naturally (see Discretion). + + +## Summary + +These findings should change the plan: + +1. **The locked Safari grant fails as written.** `GrantedRoots.addGrant` creates an app-scoped bookmark (`.withSecurityScope` with `relativeTo: nil`, `GrantedRoots.swift:77`) in the container app, and the extension resolves it. Apple's documentation says such a bookmark "fails to resolve if the caller does not have the same code signing identity as the caller that created the bookmark" [CITED: developer.apple.com NSURL bookmarkData docs]. In September 2025, Apple DTS said app-scoped bookmarks "can only be opened by the executable that created them, which means a bookmark created by your main app can't be resolved by your helper process" [CITED: developer.apple.com/forums/thread/798402]. A 2016 DTS reply says the same thing, and the extension in that thread got error 259 [CITED: forums/thread/66259]. The FSB app and its `.appex` are separate executables with different bundle ids. Copying the upload mechanism for pairing will therefore fail, and the same reasoning says the **upload_file folder grant on the branch (README V8) is probably broken too.** The decision's intent can still be kept (one grant in the app, stored in the App Group, read fresh on every bootstrap), but it needs one of two mechanisms Apple supports. Both must be tested on a device first (see Open Question 1). +2. **Routing has to be decided before agent registration, in the calling MCP process, not only in the hub.** Each MCP process mints one `agentId` through `agent:register` (`agent-scope.ts` `ensure()`), and that id lives in the agent registry of the one browser that answered. Suppose browser A answered, and the process later sends a call to browser B. B replies `AGENT_NOT_REGISTERED`. The retry path (`agent-bridge.ts` `sendAgentScopedBridgeMessage`) then re-registers through the hub's default route, which picks A again, so the retry loops. Agent identity and ownership tokens therefore have to be **per browser**, and the process must know the target browser before `ensure()`. The hub broadcasts a browser roster to every MCP process, and one pure resolver function is shared by the hub and the tool layer. +3. **Keep `bridge-auth.json` exactly as it is (v1) and put the extra pins in a sidecar file.** Three things already read the v1 file with exact-key validators: older hubs (0.11.1 via `npx @latest`), the installed native host, and the packed delegation daemon. The native host also caps the file at 2048 bytes (`bootstrap.ts:5`). If any of them meets a reshaped file, it either refuses it (`bridge_session_unavailable`), or, in an older `serve` (`index.ts:433-434`), overwrites it with a new secret, which breaks pairing for every browser. So the first Chromium pin stays in `allowedExtensionOrigin`. The Safari scheme pin and any extra Chromium origins go in `~/.fsb/bridge-pins.json`, which is bound to the v1 `sessionId` and ignored when that id differs. With this layout `bootstrap.ts` needs no change. + +**Primary recommendation:** Turn the hub into a multi-slot hub. Each slot is keyed by `installInstanceId`, which the extension reports after connecting. The hub publishes a roster (browser label, `focusedAt`, tab ids) in `relay:state`. Each MCP process resolves the target browser itself (explicit `browser`, then ownership, then the tab index, then most recently focused) and sends the result in an additive top-level `browser` field. AgentScope gets a `forBrowser(key)` method that returns a separate scope per browser. Inject the `browser` tool argument in the MCP registration layer, not in the shared tool registry. + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|---|---|---|---| +| Hold N extension sockets, liveness, per-slot rejection | MCP hub (`bridge.ts`) | — | Only the hub owns sockets; relays forward raw frames (`_handleRelayClientMessage` 1391-1440) | +| Pins, secret, origin policy | `bridge-auth.ts` + sidecar | `bootstrap.ts` (unchanged) | The v1 file stays compatible with old readers | +| Browser label, focus time, tab-id set | Extension (`mcp-bridge-client.js`) | — | The user wants the extension to do the heavy lifting; only the browser knows its brand and focus | +| Target resolution (explicit, ownership, tab, focus) | Shared pure resolver (`mcp/src/browser-routing.ts`, new) | Hub (for legacy messages that carry no `browser`) | The process must know the target before `agentScope.ensure()` | +| Per-browser agent identity and ownership tokens | MCP process (`agent-scope.ts`, `agent-bridge.ts`) | Extension registry (unchanged, one per browser) | The extension mints ids per browser; the process caches them per browser | +| `browser` tool argument and `list_tabs.browser` | MCP tool layer (`manual.ts`, `read-only.ts`, `triggers.ts`, `screenshots.ts`, `autopilot.ts`) | — | Keeps the shared registry and its SHA-256 pin, and the in-extension autopilot, unchanged | +| Safari pairing code | Safari `.appex` (Swift) | Container app (one-time grant UI) | The sandboxed extension process reads the secret; JS only asks for it | + +## Critical Finding 1: Safari folder grant + +| Mechanism | Works across app and `.appex`? | Notes | +|---|---|---| +| App-scoped bookmark made by the app, resolved by the extension (current `GrantedRoots`) | **No** [CITED: Apple bookmarkData docs; DTS 2016 and 2025] | This is the literal locked decision, and upload V8 uses it today | +| **M1: Document-scoped bookmark.** The app picks the *file* `~/.fsb/bridge-auth.json`. The bookmark uses `[.withSecurityScope, .securityScopeAllowOnlyReadAccess]` and is anchored (`relativeTo:`) to a document file inside the App Group container. The extension resolves it with the same anchor. | Yes, by Apple's documentation: "any sandboxed app that has access to the bookmark data itself, and has access to the document that owns the bookmark, can obtain access" [CITED] | DTS (2025): needs the `com.apple.security.files.bookmarks.document-scope` entitlement added by hand to both targets, must point at a **file, not a folder**, and cannot target `/Library/`, `/private/` or `/.ssh/` [CITED: forums/thread/798402]. **Risk:** `writeBridgeAuthState` replaces the file with an atomic rename (`bridge-auth.ts:92-110`), which creates a new inode. Untested whether the bookmark then resolves to the new file with access. | +| **M2: Handoff.** The app picks the *folder* `~/.fsb` and writes a bookmark with no security options (`[]`, implicit scope) into the App Group. On its next run the extension resolves it, then creates **its own** app-scoped folder bookmark, stores it, and deletes the handoff. | Yes, by the documented process-to-process pattern [CITED: "Share file access between processes with URL bookmarks"] | Unknown whether the implicit grant survives the app quitting. To cover that, the app can call `SFSafariApplication.dispatchMessage` while it is still running so the extension adopts the grant immediately [ASSUMED]. A folder grant survives atomic renames. This matches "grant `~/.fsb`" most closely and would also fix upload V8. | + +**Prescriptive:** Hide the mechanism behind one Swift entry point, `PairingSource.readPairingCode() -> Result`, and make the on-device spike the first task of Wave 4 (`checkpoint:human-verify`). Try M2 first because it keeps the folder grant, then M1. Either way: +- Keep the pairing grant in its own store, not in `granted-roots.plist`. Otherwise `~/.fsb` becomes an `upload_file` root, which would leave `bridge-auth.json` protected only by the JS `/.fsb/` denylist. +- Inside the sandbox, `FileManager.homeDirectoryForCurrentUser` returns the **container**. Set `NSOpenPanel.directoryURL` from `getpwuid(getuid()).pw_dir` + `/.fsb`, and set `showsHiddenFiles = true` [ASSUMED]. +- `~/.fsb/bridge-auth.json` exists only after `fsb-mcp-server serve` has run (`index.ts:433-434`; stdio mode never creates it). The app should say so when the file is missing. + +## Integration points (confirmed; corrected line numbers) + +| Location | What it does now | Change | +|---|---|---| +| `mcp/src/bridge.ts:124` `extensionClient`, plus scalar `connected`, `lastExtensionHeartbeatAt`, `extensionHeartbeatCount`, `extensionCloseCause`, `extensionAttachment`, `extensionLivenessTimer` (107-154) | One slot | All become per-slot fields in `Map`. Keep a read-only `extensionClient` getter (sole slot, or the default slot) because tests read `hub.extensionClient` and `hub.extensionHeartbeatCount` directly (`mcp-bridge-topology.test.js` 1515, 1571, 1657, 1665). | +| `bridge.ts:271-320` `sendAndWait` | Sends to `this.extensionClient!` | Pick the slot from `msg.browser`, falling back to the resolver default. Record the target socket per pending id. | +| `bridge.ts:618-685` `_classifyUpgrade` | 403 when the origin differs from the single pin (628). `bindAllowedExtensionOrigin` **throws for any `safari-web-extension:` origin** (pattern `bridge-auth.ts:26`), so a paired Safari gets a 403 today. | Apply the per-scheme pin policy below. | +| `bridge.ts:733-760` `_handleNewConnection` | Refuses a second candidate while a slot exists (741) | Pending-identity handling (see Pitfall 3) | +| `bridge.ts:830-880` close handler | Rejects **all** pending requests with `'Extension disconnected'` | Reject only the closing slot's requests; keep the message text identical (`BRIDGE_DISCONNECT_MESSAGES` 70) | +| `bridge.ts:1086-1097` `mcp:extension-state` | Accepted only from `extensionClient`. `extensionId` must match `/^[a-p]{32}$/`, so **Safari's frame is silently dropped**. | Make it per-slot, add optional `browser` and `focusedAt`, and relax `extensionId` for safari origins (bounded string) | +| `bridge.ts:1196-1203` `_isCurrentExtAuthority` | Exact `allowedExtensionOrigin === origin` | sessionId match plus `isOriginPinned(origin)` (exact for Chromium, scheme for Safari) | +| `bridge.ts:1391-1440` relay forward | `this.extensionClient.send(raw)` | Route on `parsed.browser`. A missing slot returns `mcp:error` `browser_not_connected`. | +| `bridge.ts:1038-1060`, `1446-1458`, `types.ts:121-142` | relay state | Additive `browsers?: BrowserRosterEntry[]`. Old relays read named fields only and ignore it. `tests/mcp-reverse-channel-contract.test.js:376-381` pins only its own fixture objects. | +| `bridge-auth.ts:143-193` | rotate / bind / reset | `bindExtensionOrigin(origin)`: a Chromium origin goes into the v1 slot if it is empty, otherwise into the sidecar; Safari adds a scheme pin to the sidecar. Reset deletes the sidecar; rotate carries it over with the new sessionId. | +| `mcp/src/index.ts:444-480` `runPair` | One pin | List the pins. `--reset` keeps today's all-browser semantics. **`mcp/build/index.js` is force-tracked**: rebuild and commit it with any `index.ts` edit. | +| `mcp/src/native-host/bootstrap.ts:5-110` | Exact v1 keys, 2048-byte cap, mismatch → `extension_origin_mismatch` | No change if the sidecar is used. Optional: relax the mismatch check so a second Chromium extension id can pair (Chrome already gates the host by `allowed_origins`, one origin per manifest, `native-host-registration.ts:112`). | +| `mcp/src/agent-bridge.ts:137-165` | Single chokepoint for almost every tool | Resolve the browser, call `agentScope.forBrowser(key).ensure()`, then `sendAndWait({…, browser: key})` | +| `mcp/src/agent-scope.ts` | One `agentId`, `ownershipTokens: Map`, `connectionId` | Add `forBrowser(key)` returning a child AgentScope from a lazy map. The root acts as the legacy `''` key, so single-browser behaviour stays byte-identical. | +| `mcp/src/tools/autopilot.ts:160-180` | The `sw_evicted` path waits on `bridge.isConnected` and sends `mcp:get-task-snapshot` with no target | Wait on `isBrowserConnected(key)` and send the snapshot to the **same** key. Otherwise any other connected browser satisfies the wait and the snapshot is read from the wrong browser. | +| `mcp/src/tools/manual.ts:262-300` | Forwards the **whole** `params` into `payload.params` | Strip `browser` before forwarding. `read-only.ts:26-60` builds payloads from named params and is already safe. | +| `mcp/src/client-inventory.ts:174`, `index.ts:402` | Pushes the inventory once to "the" extension | Push to each browser when it is identified (internal system message, not a user call) | +| `mcp/src/diagnostics.ts:798, 873, 1091`; `http.ts:129` | Single `extensionAttachment` | Keep the field (default browser) and add `browsers[]` for status and doctor | +| `extension/ws/mcp-bridge-client.js:279-281, 1138-1148` | Chrome: `new WebSocket(url)` unpaired, `[fsb-ext-v1, code]` when paired. The native path drops protocols (1141-1142). | **Do not change the Chrome construction.** Tests pin the single-argument constructor and the bare URL (`mcp-bridge-client-lifecycle.test.js:1038-1060`). Pass `protocols` to `FsbNativeBridgeSocket` only. | +| `mcp-bridge-client.js:1561-1565` `_sendExtensionState`; `background.js:5872` `getMcpAttachmentMetadata` | Sends `extensionId/version/installInstanceId/normalWindowCount` | Add `browser` and `focusedAt` | +| `mcp-bridge-client.js:1603-1625` `_handleMessage` | Destructures `{id,type,payload}` | A top-level `browser` on inbound messages is harmless. No strip needed. | +| `mcp-bridge-client.js:591-605` `_ensurePairingLoaded` | Chrome pairs only on delegation preflight (`background.js` ~2196-2260 via `FsbNativeHostWake.ensureBootstrap`) | Safari-only branch (inert on Chrome): `runtime.sendNativeMessage({t:'pairing'})` before **every** connect | +| `extension/ws/mcp-native-transport.js:179-188` | The `open` frame has `url, clientId, linger, origin` | Add `protocols` (array) only when it is given | +| `safari/FSB/Shared/BridgeCoordinator.swift:96-133` | Adopts any open socket (104) | Adopt only when `origin` and `protocols` match. **Pin:** exactly 3 `self.socket === session` in the `handleOpen` slice up to `// MARK: - extension -> server` (`safari-source-parity.test.js:276-277`). | +| `safari/FSB/Shared/MCPSocketSession.swift:46-60` | Sets `Origin` at 54 | Also set `Sec-WebSocket-Protocol` to `protocols.joined(", ")`. Apple: the `protocols:` initializer "is just a convenience for setting the `Sec-WebSocket-Protocol` header field" [CITED: forums/thread/784461] | +| `safari/FSB/FSB Extension/SafariWebExtensionHandler.swift:60-105` | Dispatches on `t` | New `case "pairing"` returns `{t:'pairing', ok, pairingCode}` or a typed reason (`no_pairing_grant`, `grant_unresolvable`, `bridge_session_unavailable`). Never log the code. | + +## Recommended design (prescriptive) + +### Auth: per-scheme pin policy (hub, `_classifyUpgrade`) +| Origin | No pin for this scheme | Origin pinned | Not pinned, other pins of the same scheme exist | +|---|---|---|---| +| `chrome-extension://X` | accept (unpaired OK); a valid secret binds X | accept (unpaired OK) | valid secret: accept and bind X into the sidecar; otherwise **403** (today's behaviour) | +| `safari-web-extension://*` | accept unpaired **only if no pins exist at all** (today's behaviour); a valid secret binds the scheme pin | **secret required** (any Safari extension matches a scheme pin) | — | + +Chromium and Safari pins are judged separately. If Safari pairs first, a Chrome with no native host keeps working unpaired. Sidecar: `{version:1, sessionId, pins:[{origin, kind:'exact'|'scheme', boundAt}]}`, mode 0600, written atomically with the same helper as the v1 file, at most 16 pins. It is ignored when its `sessionId` differs from the v1 `sessionId`. That rule also cleans up after a `pair --reset` run by an old version. + +### Hub slots and identity (post-connect, because tests forbid identity in the URL or subprotocol) +- A browser-origin socket with **no** existing slot of the same origin is registered immediately as a provisional slot. Old extension builds therefore work at once. +- A socket whose origin matches an existing slot goes into **pending identity** (not routable, not counted by `_registerExtensionClient`) until `mcp:extension-state` arrives or `identityTimeoutMs` passes (default ~1500 ms; tests can inject it, like `handshakeTimeoutMs`). + - Identity arrives with a different `installInstanceId`: open a new slot (Chrome and Brave from the Web Store). + - Same `installInstanceId`: replace the old slot if `newcomer.extAuthorized || !incumbent.extAuthorized`, otherwise close with 1008. + - Timeout (legacy socket): the same contest rule, applied only against **unidentified** slots of the same origin. This keeps `runUnprivilegedCannotDisplaceExtension` passing as it is today. +- Slot key: `browser` label, plus `-` only while two connected slots share a label. The key is deterministic, so it survives a hub restart or relay promotion. +- Cap at 8 slots and at 5000 tab ids per slot. Drop and log anything over the cap. + +### Resolver (`mcp/src/browser-routing.ts`, pure, shared by the hub and processes) +Input: roster plus `{explicitBrowser?, tabId?, ownedBy?: string[], home?: string[]}`. Order: +1. Explicit `browser`: use it, or return `browser_not_connected` with the list of connected keys. +2. `tabId`: if this process's agent holds an ownership token for the tab in exactly one browser, use that browser. Otherwise use the roster tab index: one hit → that browser; more than one → `AMBIGUOUS_TAB` error naming the browsers ("ambiguous tab, pass browser"); none → step 4, and the extension returns its own not-found error. +3. No tab: the agent's "home". If it owns tokens in one browser, use that one. If several, use the most recently focused of them. A delegated child is pinned to the browser that started it (optional: the daemon sets `FSB_BROWSER` in the child's environment from the ext request's slot). +4. The most recently focused connected browser. Order by the extension-reported `focusedAt`, clamped to the hub's `now`. **Never by connect time**: an SW-eviction reconnect would otherwise move the default to a background browser. +- With a roster of 0 or 1 entries, or no roster at all (old hub), send without `browser`. That is exactly today's behaviour. +- trigger and stop_trigger: record `trigger_id → browser` from `trigger` results. `stop_task` and `get_task_status`: send to the browser running this process's `run_task`. + +### Extension frames (additive; old hubs drop unknown id-less frames harmlessly at `bridge.ts:1124-1160`) +- `mcp:extension-state` gains `browser` and `focusedAt`. +- New `mcp:browser-state` `{type, focusedAt?, tabIds?}`, sent debounced (~250 ms) from `windows.onFocusChanged` (ignore `WINDOW_ID_NONE`), `tabs.onCreated`, `onRemoved` and `onReplaced`. Register the listeners in the bridge-client constructor, which runs synchronously at SW start. +- Persist `focusedAt` in `chrome.storage.session` so a reconnect after eviction reports the true last focus. +- Label detection: `FsbPlatform && FsbPlatform.id === 'safari'` → `safari`. Otherwise read `navigator.userAgentData.brands` (available on `WorkerNavigator` [CITED: MDN]): `Microsoft Edge` → `edge`, `Opera` → `opera`, `Brave` or a defined `navigator.brave` → `brave`, `Google Chrome` → `chrome`, anything else → `chromium`. Guard for Node harnesses, where `navigator` is undefined. + +### Tool surface +Add `browser: z.string().optional()` in the MCP registration layer (`jsonSchemaToZod` callers) for every tool that takes `tab_id` (52 registry tools), plus `list_tabs`, `open_tab`, `run_task`, `stop_task`, `get_task_status` and the vault fill tools. **Do not edit `tool-definitions.js/.cjs`.** That avoids the SHA-256 pin (`tool-definitions-parity.test.js:53`) and keeps `browser` out of the in-extension autopilot's tool list. This departs from the CONTEXT note ("must update both sides and the pin"); see Assumption A6. `list_tabs` adds `browser` to every tab and, when two or more browsers are connected, a `browsers` summary (key, focused, tab count) so clients can discover the others without a broadcast. + +## Common Pitfalls + +1. **AgentScope thrash across browsers.** Covered in Summary point 2. It is invisible with one browser, so test with two. +2. **Rejecting every pending request on any close.** One browser's SW eviction would fail another browser's `run_task` with `sw_evicted:true`. Reject per slot, and keep the message text exactly the same. +3. **A same-origin newcomer evicting the paired incumbent.** Chrome and Brave share the Web Store origin, so keying by origin either drops Brave or lets an unpaired socket displace the paired one. Use pending identity (above). +4. **Responses spoofed across slots.** `_handleExtensionMessage` settles any `resp.id` from any socket. Record `id → target socket` and ignore responses from any other socket. +5. **Liveness timers are a single field.** `_stopExtensionLiveness()` at the start of `_registerExtensionClient` would kill another browser's reaper. Use one timer per slot. The slot-latch history (memory note: 58k refusals) came from exactly this class of bug. +6. **Relay requests during a disconnect.** Today the hub only deletes `messageOrigin` entries for relays (`bridge.ts:870-876`), so relay callers wait for their full timeout (600 s for `run_task`) and never get `sw_evicted`. Multi-slot must not make this worse. Optionally send `mcp:error` `'Extension disconnected'` to affected relays (an existing gap, not required). +7. **Mixed versions in the field.** Whichever process holds 7225 is the hub. A 0.11.1 hub (clients on `npx @latest`) or the **packed delegation daemon** (`~/.fsb/native-host`, port 7226) makes everything single-browser again. For UAT, every client and the reinstalled native host must run the branch build. Check the hub's binary first. +8. **Old relays connected to a new hub** send no `browser`. The hub routes them to the focused default (no tab awareness). This is acceptable, but document it. +9. **Safari `runtime.id` is not `[a-p]{32}`.** Unless the validator is relaxed, Safari never identifies itself and stays a legacy slot forever. +10. **The Safari adoption check.** Adopting a lingering socket that opened with an old secret (after `pair --reset`) or an old origin (after a Safari relaunch) would keep stale authority. Compare both, and discard on mismatch. +11. **Source pins.** Add no new extension file that needs another script import in `background.js` (pinned at 334/330), and never write that word in `background.js` comments. Safari-only JS must live in `extension/` behind inert-on-Chrome guards. Emitted files must be byte-identical (`safari-source-parity`). +12. **Focus edge cases.** Windows and some Linux window managers send `WINDOW_ID_NONE` before every window switch [CITED: MDN]. Ignore NONE. Do not let connect or heartbeat refresh `focusedAt`. Rate-limit focus frames (about 10/s) and accept them only from identified slots. +13. **CI runs Node 20.** Do not use Node 22+ globals in `mcp/src` or in tests. + +## Runtime state a source change will not update +| Category | Item | Action | +|---|---|---| +| Stored data | `~/.fsb/bridge-auth.json` v1, possibly pinned to `badgafnf…` | None. The sidecar is additive. `pair --reset` remains the recovery path. | +| Installed packages | `~/.fsb/native-host` packed copy, and serve daemon on 7226 | Reinstall and restart before UAT (packed-copy trap) | +| Live processes | 7 MCP clients on `npx fsb-mcp-server@latest` 0.11.1 | Point them at the local build for UAT, or stop them so the branch build becomes the hub | +| OS / app state | Safari App Group grants (`granted-roots.plist`) | The new pairing grant uses its own store. Existing upload grants are untouched (and probably unusable, per V8). | +| Secrets | None renamed | — | + +## Validation Architecture + +Framework: plain Node test scripts (`node tests/*.test.js`). Bridge tests import `mcp/build`, so run **`npm --prefix mcp run build` first**. + +| Area | Command | Status | +|---|---|---| +| Auth sidecar, scheme pin, v1 byte compatibility, old validator still accepts the file | `node tests/mcp-bridge-auth.test.js`; `node tests/mcp-native-host-protocol.test.js` | Extend existing files | +| Multi-slot: two browsers, pending identity, per-slot rejection and liveness, routing by `browser`, focus default, ambiguous tab, Safari pin requires the secret | `node tests/mcp-bridge-topology.test.js` | Extend. Update `runAcceptsSafariExtensionOrigin`, `runPairingAuthorityMatrix`, the reaper cases, and `extensionClient` reads. | +| Resolver | `node tests/browser-routing.test.js` | ❌ new (Wave 0) | +| Per-browser AgentScope, `browser` stripped from params, snapshot pinned to its browser | `node tests/agent-scope.test.js`, `action-tool-agent-scoped.test.js`, `list-tabs-agent-scope.test.js`, `autopilot-agent-scope.test.js` | Extend | +| Extension identity, focus and tabs frames; Safari pairing branch; Chrome constructor unchanged | `node tests/mcp-bridge-client-lifecycle.test.js`; `node tests/mcp-native-transport.test.js` | Extend | +| Swift pins (handleOpen count, entitlements, pairing case) | `npm run test:safari` | Extend `safari-source-parity` | +| Registry unchanged | `node tests/tool-definitions-parity.test.js` | Must stay green untouched | +| Phase gate | `npm test` (with the 7225 hub up; see memory) | — | + +Manual, on device: V11 pairing grant mechanism (M2, then M1) survives a Safari relaunch and `pair --reset`. V12 `Sec-WebSocket-Protocol` set by hand reaches `ws` and `didOpenWithProtocol` reports `fsb-ext-v1`. V13 `windows.onFocusChanged` fires in Safari and Chrome on app switch (macOS). + +## Suggested plan waves (sequential) +1. **Hub and auth.** Sidecar pins and per-scheme policy (`bridge-auth.ts`). Multi-slot hub with pending identity, per-slot pending, liveness and attachment, routing on `msg.browser`, roster in relay state (`bridge.ts`, `types.ts`). Topology and auth tests. No extension change is needed to test this, because test sockets send `mcp:extension-state`. +2. **Process routing and tools.** `browser-routing.ts`, `AgentScope.forBrowser`, `agent-bridge` resolution, `browser` argument injection and stripping, `list_tabs.browser` and `browsers`, `AMBIGUOUS_TAB` / `browser_not_connected` in `errors.ts`, autopilot snapshot pinning, per-browser inventory push, status and doctor `browsers[]`, `runPair` pin listing. Rebuild the force-tracked `mcp/build/index.js` if `index.ts` changes. +3. **Extension identity.** Label detection, `focusedAt` persistence, `mcp:browser-state`, the extended `extension-state`. The native transport carries `protocols`. Safari `_ensurePairingLoaded` branch. +4. **Safari native pairing.** On-device spike checkpoint (M2, then M1). `PairingSource`, `case "pairing"`, `Sec-WebSocket-Protocol`, adoption only on matching protocols and origin, app UI "Connect FSB to the MCP server…" with its own store, entitlements if M1. README updates (remove "One browser at a time", add V11-V13, record the V8 finding). + +## Security Domain +| ASVS | Applies | Control | +|---|---|---| +| V4 Access control | yes | Per-scheme pins. The Safari scheme pin always requires the secret. `_isCurrentExtAuthority` is checked per frame (existing). | +| V5 Input validation | yes | Exact-key validators on the new frames: label enum, bounded `tabIds` integers, `focusedAt` safe integer ≤ now, slot and tab caps | +| V6 Cryptography | yes | Existing `timingSafeEqual` secret comparison. No new crypto. The secret is never logged (Swift reader, JS, hub). | + +| Threat | Mitigation | +|---|---| +| Rogue extension becomes the focused default and receives tool calls | Pin policy. Unpinned Chromium origins need the secret once any Chromium pin exists. | +| A browser answers another browser's request id | Bind each response to the socket it was sent to | +| Pairing secret readable through `upload_file` | Separate grant store, plus the existing `/.fsb/` denylist | +| Focus-report flooding or spoofing | Rate limit; identified slots only | + +## Environment Availability +| Dependency | Needed by | Available | Version | +|---|---|---|---| +| Node | MCP build and tests | ✓ | v24.14.1 locally; CI uses Node 20 | +| Xcode | Wave 4 Swift build | ✓ | Xcode 27.0 | +| Safari with the unsigned-extension toggle, plus a second Chromium browser | UAT (V11-V13) | needs a human | — | + +No new npm or Swift packages, so the package legitimacy audit does not apply. + +## Assumptions Log +| # | Claim | Section | Risk if wrong | +|---|---|---|---| +| A1 | The M2 implicit-bookmark handoff stays valid long enough, or the `dispatchMessage` nudge works for Safari **web** extensions | Critical Finding 1 | Fall back to M1. If both fail, the user must revisit the locked decision. | +| A2 | A document-scoped bookmark to `bridge-auth.json` survives the atomic-rename rewrite | Critical Finding 1 | Safari pairing breaks on every `pair --reset`. Mitigation: M2, or an in-place-written pairing file. | +| A3 | Chrome fires `windows.onFocusChanged(windowId)` when the macOS app is activated, and Safari does too | Extension frames | The focus default goes stale. Fallback: also report on `tabs.onActivated`. | +| A4 | Brave reports a `Brave` brand in `userAgentData.brands` (Vivaldi and Arc report as Chrome) | Label detection | The label is wrong, but routing still works by `installInstanceId` | +| A5 | Safari `runtime.id` is a bundle-id string (≤128 chars) | Pitfall 9 | Set the validator bound accordingly | +| A6 | The user accepts `browser` added in the MCP layer instead of in the shared registry | Tool surface | Otherwise edit both registry files and the SHA pin, and the in-extension autopilot sees `browser` | +| A7 | Inside the sandbox, `homeDirectoryForCurrentUser` returns the container path | Critical Finding 1 | The panel opens in the wrong folder (UX only) | + +## Open Questions +1. **Safari grant mechanism (blocking for Wave 4).** The literal locked approach is documented not to work. Recommendation: keep the intent and use M2 or M1, chosen by the on-device spike. Confirm this with the user before Wave 4. +2. **`list_tabs` with no `browser` and several browsers connected.** It returns the focused browser's tabs plus a `browsers` summary (honours "never broadcast"). Aggregating across browsers would be friendlier but is a read broadcast. Recommendation: summary, not aggregation, unless the user says otherwise. +3. **`pair --reset` scope.** Recommendation: all browsers only (the shared secret rotates). Defer per-origin pin removal; it adds little security, because a valid secret re-pins automatically. +4. **Upload V8.** The same bookmark flaw almost certainly affects `upload_file` on Safari. Fix it here (if M2 is chosen, the fix is shared) or file it separately. + +## Sources +- Apple, NSURL `bookmarkData(options:includingResourceValuesForKeys:relativeTo:)`: app-scoped vs document-scoped resolution rules. https://developer.apple.com/documentation/foundation/nsurl/bookmarkdata(options:includingresourcevaluesforkeys:relativeto:) +- Apple, CFURLCreateBookmarkData (same rule). https://developer.apple.com/documentation/corefoundation/cfurlcreatebookmarkdata(_:_:_:_:_:_:) +- Apple, Accessing files from the macOS App Sandbox: implicit-scope bookmarks passed between processes; document-relative bookmarks; App Group rules. https://developer.apple.com/documentation/security/accessing-files-from-the-macos-app-sandbox +- Apple Developer Forums 798402 (DTS, Aug-Sep 2025): app-scoped bookmarks bound to the creating executable; document-scope entitlement, file-only, blocked directories. https://developer.apple.com/forums/thread/798402 +- Apple Developer Forums 66259 (DTS 2016): "ONLY valid within the app that created it", error 259 in an extension. https://developer.apple.com/forums/thread/66259 +- Apple Developer Forums 784461 (Frameworks Engineer, May 2025): protocols are a convenience for the `Sec-WebSocket-Protocol` header. https://developer.apple.com/forums/thread/784461 +- MDN, WorkerNavigator.userAgentData; windows.onFocusChanged (`WINDOW_ID_NONE` caveat). https://developer.mozilla.org/en-US/docs/Web/API/WorkerNavigator/userAgentData, https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/windows/onFocusChanged +- Brave detection via brands / `navigator.brave` (LOW, community). https://ctrl.blog/entry/brave-user-agent-detection +- Codebase: every file:line above was verified on branch `safari-extension-port` at 5e4c7635. + +## Metadata +- Codebase integration map: HIGH (read directly) +- Hub, auth and routing design: HIGH. It follows from the code's own constraints: tests forbid identity in the URL or subprotocol, the v1 readers are exact-key, and AgentScope holds a single id. +- Safari grant mechanism: LOW until tested on a device; the negative finding about the current approach is HIGH (Apple docs plus DTS) +- Valid until: about 30 days (bridge code changes quickly on this branch) diff --git a/esbuild.config.js b/esbuild.config.js index c3f44ea24..7931b71cc 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -57,6 +57,79 @@ const SRC_ROOT = path.join(REPO_ROOT, 'extension'); const SHOWCASE_ROOT = path.join(REPO_ROOT, 'showcase'); const OUT_ROOT = path.join(REPO_ROOT, 'extension', 'dist'); +// UAT-1 fix (2026-05-31): Lattice's dist/index.js top-level imports node:fs/promises, +// node:path, node:url, etc. for its artifact-storage submodule. Our offscreen import +// surface (checkpoint/signer/survivability) does NOT exercise those code paths at +// runtime, but ESM top-level imports cannot be tree-shaken merely by marking them +// external -- esbuild preserves the import specifier verbatim, and Chrome MV3 CSP +// (script-src 'self') rejects any surviving `node:*` import in the offscreen bundle. +// +// Fix: resolve every node:* specifier to a local stub module at build time via an +// inline esbuild plugin. The stub exports no-op shims for the fs / path / url surface +// Lattice's artifact-storage references. Dead code paths from artifact-storage stay +// in the bundle but call into local no-ops; no CSP-blocked imports survive in output. +const LATTICE_BUFFER_BANNER = [ + '// Buffer polyfill for receipts/envelope.ts base64 encoding (UAT-08 fix; lattice-side', + '// uses Buffer.from(bytes).toString("base64") which Node provides but the offscreen', + '// browser context does not. INV-06 byte-freeze stays intact -- fix is build-side, not Lattice-side.', + 'if (typeof globalThis.Buffer === "undefined") {', + ' globalThis.Buffer = {', + ' from: function (input, encoding) {', + ' if (typeof input === "string" && encoding === "base64") {', + ' var bin = atob(input);', + ' var bytes = new Uint8Array(bin.length);', + ' for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);', + ' return bytes;', + ' }', + ' if (input instanceof Uint8Array || (input && typeof input.length === "number" && typeof input !== "string")) {', + ' var bytes = input;', + ' return {', + ' toString: function (enc) {', + ' if (enc === "base64") {', + ' var s = "";', + ' for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i] & 0xFF);', + ' return btoa(s);', + ' }', + ' throw new Error("Buffer polyfill: unsupported toString encoding: " + enc);', + ' }', + ' };', + ' }', + ' throw new Error("Buffer polyfill: unsupported Buffer.from() input");', + ' }', + ' };', + '}' + ].join("\n"); + +/** + * Fresh plugin instance per build: esbuild mutates plugin state during a + * build, so the ESM and IIFE Lattice entries must not share one object. + */ +function stubNodeBuiltinsPlugin() { + return { + name: 'stub-node-builtins', + setup(build) { + build.onResolve({ filter: /^node:/ }, () => ({ + path: 'node-stub', + namespace: 'node-stub-ns', + })); + build.onLoad({ filter: /.*/, namespace: 'node-stub-ns' }, () => ({ + contents: [ + 'export default {};', + 'export const join = (...p) => p.filter(Boolean).join("/");', + 'export const fileURLToPath = (u) => String(u);', + 'export const mkdir = async () => undefined;', + 'export const readFile = async () => "";', + 'export const readdir = async () => [];', + 'export const rm = async () => undefined;', + 'export const stat = async () => ({});', + 'export const writeFile = async () => undefined;', + ].join('\n'), + loader: 'js', + })); + }, + }; +} + /** * Each entry is one bundle. The shape matches what esbuild.build accepts as * a single-build-call configuration so each entry runs as its own build (we @@ -65,97 +138,44 @@ const OUT_ROOT = path.join(REPO_ROOT, 'extension', 'dist'); */ const ENTRIES = [ { - name: 'offscreen-stt', - entryPoints: [path.join(SRC_ROOT, 'offscreen', 'stt.js')], - outfile: path.join(OUT_ROOT, 'offscreen', 'stt.js'), - format: 'iife', + name: 'offscreen-lattice-host', + entryPoints: [path.join(SRC_ROOT, 'offscreen', 'lattice-host.js')], + outfile: path.join(OUT_ROOT, 'offscreen', 'lattice-host.js'), + format: 'esm', sourcemap: 'external', platform: 'browser', target: ['chrome120'], bundle: true, legalComments: 'none', allowOverwrite: true, + banner: { js: LATTICE_BUFFER_BANNER }, + plugins: [stubNodeBuiltinsPlugin()], }, { - name: 'offscreen-lattice-host', + // Safari port: the SAME Lattice host, bundled as IIFE instead of ESM. + // + // MV3 forbids importScripts() inside a "type":"module" service worker, and + // background.js has 305 such call sites, so the SW can never be a module. + // Chrome sidesteps this with an offscreen document (which CAN use + // diff --git a/extension/offscreen/stt.js b/extension/offscreen/stt.js deleted file mode 100644 index 1a0e27349..000000000 --- a/extension/offscreen/stt.js +++ /dev/null @@ -1,80 +0,0 @@ -// Offscreen document for Speech-to-Text. -// Runs SpeechRecognition in a full browsing context — works even when -// the active tab is a restricted URL (chrome://, new tab, etc.). -// Uses the same message format as content/stt-recognition.js so that -// speech-to-text.js handles both paths with a single listener. - -let recognition = null; -let finalTranscript = ''; - -chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { - if (msg.target !== 'offscreen-stt') return; - - if (msg.action === 'start') { - if (recognition) { - try { recognition.abort(); } catch (_) {} - recognition = null; - } - finalTranscript = ''; - startRecognition(msg.lang); - sendResponse({ ok: true }); - } else if (msg.action === 'stop') { - if (recognition) { - try { recognition.stop(); } catch (_) {} - } - sendResponse({ ok: true }); - } - return true; -}); - -function startRecognition(lang) { - const SR = window.SpeechRecognition || window.webkitSpeechRecognition; - if (!SR) { - notify('error', { error: 'not-supported' }); - return; - } - - recognition = new SR(); - recognition.continuous = true; - recognition.interimResults = true; - recognition.lang = lang || 'en-US'; - - recognition.onstart = () => notify('start'); - - recognition.onresult = (event) => { - let interim = ''; - for (let i = event.resultIndex; i < event.results.length; i++) { - const t = event.results[i][0].transcript; - if (event.results[i].isFinal) { - finalTranscript += t; - } else { - interim = t; - } - } - notify('result', { text: finalTranscript + interim, isInterim: true }); - }; - - recognition.onend = () => { - notify('end', { text: finalTranscript }); - recognition = null; - }; - - recognition.onerror = (event) => { - notify('error', { error: event.error }); - recognition = null; - }; - - try { - recognition.start(); - } catch (e) { - notify('error', { error: e.message || 'start-failed' }); - recognition = null; - } -} - -function notify(event, data) { - try { - // Same format as content script relay — speech-to-text.js handles both uniformly - chrome.runtime.sendMessage({ from: 'content-stt', event, ...data }); - } catch (_) {} -} diff --git a/extension/ui/control_panel.html b/extension/ui/control_panel.html index bf08e0133..800c10e07 100644 --- a/extension/ui/control_panel.html +++ b/extension/ui/control_panel.html @@ -4,9 +4,13 @@ FSB - Control Panel - + + + diff --git a/extension/ui/onboarding.html b/extension/ui/onboarding.html index 46e507837..a1d4caf2a 100644 --- a/extension/ui/onboarding.html +++ b/extension/ui/onboarding.html @@ -4,7 +4,7 @@ FSB - Onboarding - + @@ -39,6 +39,10 @@ + + diff --git a/extension/ui/popup.html b/extension/ui/popup.html index 6b1492b9b..c9fe925cb 100644 --- a/extension/ui/popup.html +++ b/extension/ui/popup.html @@ -3,7 +3,7 @@ FSB - + @@ -72,6 +72,10 @@

FSB

+ + diff --git a/extension/ui/sidepanel.css b/extension/ui/sidepanel.css index a49a62328..682b820ff 100644 --- a/extension/ui/sidepanel.css +++ b/extension/ui/sidepanel.css @@ -68,7 +68,7 @@ body { width: 100%; - height: 100vh; + height: 100dvh; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; background: var(--bg-secondary); color: var(--text-primary); @@ -79,11 +79,25 @@ body { .sidepanel-container { display: flex; flex-direction: column; - height: 100vh; + height: 100dvh; background: var(--bg-primary); position: relative; } +/* + * Safari port: there is no sidePanel API, so the workspace opens as its own + * popup window (utils/platform-adapter.js openWorkspace). A window can be + * resized far wider than the ~350-500px rail this layout was designed for, so + * cap the content and centre it rather than stretching a chat column across a + * 27" display. Inert in a Chrome docked panel, which never reaches 900px. + */ +@media (min-width: 900px) { + .sidepanel-container { + max-width: 520px; + margin: 0 auto; + } +} + /* Header */ .sidepanel-header { background: var(--bg-primary); diff --git a/extension/ui/sidepanel.html b/extension/ui/sidepanel.html index 2d3ca967f..9857e732b 100644 --- a/extension/ui/sidepanel.html +++ b/extension/ui/sidepanel.html @@ -3,7 +3,7 @@ FSB - Side Panel - + @@ -156,6 +156,10 @@

Session History

+ + diff --git a/extension/ui/sidepanel.js b/extension/ui/sidepanel.js index b3336e71e..d10f1fe60 100644 --- a/extension/ui/sidepanel.js +++ b/extension/ui/sidepanel.js @@ -42,6 +42,46 @@ let showSidepanelProgressEnabled = true; var _tabRunningMap = new Map(); var _activeTabIdSnapshot = null; +/** + * Resolve the CONTENT tab this panel should act on. + * + * On Chrome the panel is a docked side panel -- not a tab -- so + * {active:true, currentWindow:true} correctly returns the page the user is + * looking at. On Safari there is no sidePanel API and the workspace is its own + * popup window, which makes `currentWindow` actively WRONG: it is the FSB + * window, so the query would return this very page and the agent would target + * its own UI (see the startAutomation call site below). + * + * Returns an ARRAY so every existing call site keeps its current shape. + * + * @param {{windowId?: number}} [opts] - windowId hint (focus-change path) + * @returns {Promise} zero or one tab + */ +async function getTargetTabs(opts) { + var P = globalThis.FsbPlatform; + if (P && P.caps && P.caps.sidePanel === false) { + var resolved = await P.resolveTargetTab(opts); + return resolved ? [resolved] : []; + } + var query = (opts && typeof opts.windowId === 'number') + ? { active: true, windowId: opts.windowId } + : { active: true, currentWindow: true }; + var found = await chrome.tabs.query(query); + return found || []; +} + +/** + * Dismiss the panel. On Chrome window.close() collapses the docked panel; on + * Safari the workspace is a real window, so closing it would destroy the + * user's workspace rather than collapse a panel. FsbPlatform.closeSurface() + * handles the difference. + */ +function closeWorkspaceSurface() { + var P = globalThis.FsbPlatform; + if (P && typeof P.closeSurface === 'function') { P.closeSurface(); return; } + window.close(); +} + function _getTabRunningEntry(tabId) { if (typeof tabId !== 'number') return { isRunning: false, sessionId: null, startedAt: null }; var entry = _tabRunningMap.get(tabId); @@ -214,7 +254,7 @@ async function initTabConversationStore() { } var activeTabId = null; try { - var tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + var tabs = await getTargetTabs(); if (tabs && tabs[0] && typeof tabs[0].id === 'number') activeTabId = tabs[0].id; } catch (_e) { /* swallow */ } @@ -330,7 +370,7 @@ async function ensureTabConversationForActiveTab(overwrite) { conversationId = fallback; return fallback; } - var tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + var tabs = await getTargetTabs(); var tab = tabs && tabs[0]; if (!tab || typeof tab.id !== 'number') { // Phase 11 FINT-21 WR-02 fix -- surface the no-active-tab edge @@ -3813,7 +3853,7 @@ function applyInputLockout(foreignOwned) { async function _isActiveTabForeignOwned() { try { if (typeof FSBOwnerChip === 'undefined') return false; - var tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + var tabs = await getTargetTabs(); var tab = tabs && tabs[0]; if (!tab || typeof tab.id !== 'number') return false; var stored = await chrome.storage.session.get('fsbAgentRegistry'); @@ -3851,7 +3891,7 @@ async function refreshActiveTabOwnership(tabId, surfaceGeneration) { return { verified: false, code: 'tab_lookup_mismatch' }; } } else { - const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tabs = await getTargetTabs(); tab = tabs && tabs[0]; } if (!isCurrent()) return { verified: false, stale: true }; @@ -3906,10 +3946,9 @@ async function syncActiveTabSurface(tabId, windowId) { let incomingTabId = Number.isSafeInteger(tabId) ? tabId : null; try { if (incomingTabId === null) { - const query = Number.isSafeInteger(windowId) - ? { active: true, windowId: windowId } - : { active: true, currentWindow: true }; - const tabs = await chrome.tabs.query(query); + const tabs = await getTargetTabs(Number.isSafeInteger(windowId) + ? { windowId: windowId } + : undefined); if (syncGeneration !== _activeTabSurfaceSyncGeneration) return false; incomingTabId = tabs && tabs[0] && Number.isSafeInteger(tabs[0].id) ? tabs[0].id @@ -4474,7 +4513,7 @@ async function _handleLegacySendMessage(message) { updateSendButtonState(); // Get current tab - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + const [tab] = await getTargetTabs(); // Note: Restriction checking is now handled by background script with smart navigation @@ -5383,7 +5422,7 @@ function openSettings() { chrome.runtime.openOptionsPage(); // Then close the side panel - window.close(); + closeWorkspaceSurface(); } async function openControlPanelSection(sectionId) { @@ -5396,10 +5435,10 @@ async function openControlPanelSection(sectionId) { } else { chrome.runtime.openOptionsPage(); } - window.close(); + closeWorkspaceSurface(); } catch (_error) { chrome.runtime.openOptionsPage(); - window.close(); + closeWorkspaceSurface(); } } @@ -5549,7 +5588,7 @@ function renderAutomationCompletionPayload(payload) { if (outcome === 'partial') { (async () => { try { - const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tabs = await getTargetTabs(); const currentUrl = tabs[0]?.url; if (currentUrl && currentUrl.startsWith('http')) { const domain = new URL(currentUrl).hostname; @@ -5804,7 +5843,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (isPartial && isOriginatingActive) { (async () => { try { - const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tabs = await getTargetTabs(); const currentUrl = tabs[0]?.url; if (currentUrl && currentUrl.startsWith('http')) { const domain = new URL(currentUrl).hostname; diff --git a/extension/ui/unlock.html b/extension/ui/unlock.html index e2b8bb9f0..447bfc91f 100644 --- a/extension/ui/unlock.html +++ b/extension/ui/unlock.html @@ -141,6 +141,10 @@

Unlock Vault

+ + diff --git a/extension/utils/action-icon.js b/extension/utils/action-icon.js index 96fd64c77..35775b08b 100644 --- a/extension/utils/action-icon.js +++ b/extension/utils/action-icon.js @@ -36,6 +36,24 @@ (function () { 'use strict'; + // ---- Grayscale-tinting toolbars (Safari) --------------------------------- + // Safari keeps a toolbar icon in its own colors only when -[NSImage + // safari_isGrayscale] says it is not grayscale: at least 3% of its visible + // pixels need a premultiplied channel spread (max minus min of R, G, B) of + // 25/255 or more. Any other icon is tinted. Safari keeps only its alpha and + // paints it gray, or the accent color while the extension is active, so the + // black-and-white tile below came out as a solid square. A platform whose + // adapter reports tintsGrayscaleToolbarIcon draws the tile in FSB orange with + // black lettering instead. The plate alone then keeps every frame in color, + // dimmed ones included. Orange accents would vanish into that plate, so the + // orbit, sweep and glow are drawn white there. Undefined FsbPlatform (Chrome, + // every Node harness) leaves all of this off. + var TINTED_TOOLBAR = !!(typeof globalThis !== 'undefined' && globalThis.FsbPlatform + && globalThis.FsbPlatform.caps && globalThis.FsbPlatform.caps.tintsGrayscaleToolbarIcon === true); + var TINTED_GLYPH_PATHS = { 16: 'assets/safari-toolbar-16.png', 32: 'assets/safari-toolbar-32.png' }; + var TINTED_PLATE = '#ff8c00'; + var TINTED_ACCENT = '#ffffff'; + // ---- Locked geometry + cadence ----------------------------------------- var FRAME_INTERVAL_MS = 66; // ~15fps. chrome.alarms floors at 30s. var BEAD_FRACTION = 0.12; // bead spans 12% of the square perimeter @@ -64,18 +82,21 @@ var SWEEP_FILL_FRACTION = 0.38; var SWEEP_FROM_PCT = -1.20; // translateX(-120%), of the fill's own width var SWEEP_TO_PCT = 3.20; // translateX(320%) - var SWEEP_TRACK_COLOR = 'rgba(255, 255, 255, 0.1)'; + // A faint light band on the black plate. Safari's orange plate under a white + // fill needs the opposite, a faint dark band. + var SWEEP_TRACK_COLOR = TINTED_TOOLBAR ? 'rgba(0, 0, 0, 0.15)' : 'rgba(255, 255, 255, 0.1)'; // ---- Breathe (watching) ------------------------------------------------- // Canonical cadence: the .badge-dot fsb-trigger-badge-dot keyframe, 2.4s // ease-in-out. The dot also scales 1 -> 1.15; the icon does not, because // rescaling the mark at 16px aliases it. The glow colour is the dot's own - // static box-shadow colour. - var GLOW_RGB = '255, 140, 0'; + // static box-shadow colour, except on Safari, where it would vanish into the + // orange plate. + var GLOW_RGB = TINTED_TOOLBAR ? '255, 255, 255' : '255, 140, 0'; // The plate the lockup sits on. Repainting it is not the "opaque background" // the notes warn against -- the artwork already carries this exact tile; this // just keeps it solid while the mark above it fades. - var PLATE_COLOR = '#000000'; + var PLATE_COLOR = TINTED_TOOLBAR ? TINTED_PLATE : '#000000'; var BREATHE_MIN_ALPHA = 0.45; var BREATHE_GLOW_RATIO = 0.15625; // 20/128 -> 2.5px at 16, 5px at 32 var BREATHE_GLOW_ALPHA = 0.95; @@ -150,6 +171,13 @@ breathe: { duration: 2400, form: 'breathe', from: '#ff8c00', to: '#ffa500', animated: true, bounded: BREATHE_HOLD_MS } }; + // The table above stays the canonical source. On Safari's orange plate the + // orbit and sweep keep their timing and shape but are drawn white. + if (TINTED_TOOLBAR) { + STATES.sweep.from = STATES.sweep.to = TINTED_ACCENT; + STATES.orbit.from = STATES.orbit.to = TINTED_ACCENT; + } + // Highest claim wins. A watch outranks everything on every tab: it is the one // state the user armed deliberately and is waiting on. var RANK = { breathe: 4, ring: 3, orbit: 2, sweep: 1 }; @@ -297,19 +325,21 @@ return ctx; } - // An orange halo around the MARK, not around the tile. + // A halo around the MARK, not around the tile. // - // 'multiply' on the scratch turns the white mark orange and leaves the black - // plate black (x * 0 = 0). Compositing that back with 'lighter' then adds only - // where the scratch is non-black, so the halo hugs the lettering and the plate - // contributes nothing. A filter on the tile itself would halo the SQUARE. - function drawMarkGlow(ctx, size, bitmap, spec, offset, drawn) { + // 'multiply' on the scratch turns the white mark the glow colour and leaves + // the black plate black (x * 0 = 0). Compositing that back with 'lighter' then + // adds only where the scratch is non-black, so the halo hugs the lettering and + // the plate contributes nothing. A filter on the tile itself would halo the + // SQUARE. The mask is always the black-and-white artwork: the tile itself, + // except on Safari, whose orange tile carries black lettering. + function drawMarkGlow(ctx, size, mask, spec, offset, drawn) { var scratch = scratchFor(size); scratch.globalCompositeOperation = 'source-over'; scratch.globalAlpha = 1; scratch.filter = 'none'; scratch.clearRect(0, 0, size, size); - scratch.drawImage(bitmap, offset, offset, drawn, drawn); + scratch.drawImage(mask, offset, offset, drawn, drawn); scratch.globalCompositeOperation = 'multiply'; scratch.fillStyle = alphaColor(GLOW_RGB, 1); scratch.fillRect(0, 0, size, size); @@ -327,16 +357,19 @@ // The design's breathe animates the LOCKUP inside a static black plate -- the // plate itself never moves. Fading the artwork instead fades the whole icon, // because the artwork IS the plate. So the plate is painted solid first and the - // mark composited over it: black-on-black is a no-op, so only the lettering - // varies. Dim is deliberately different -- a dropped relay recedes the whole - // icon, and it outranks the glow because grayscale would fight an orange halo. - function drawGlyph(ctx, size, bitmap, spec) { + // mark composited over it: plate-on-plate (black, or Safari's orange) is a + // no-op, so only the lettering varies. Dim is deliberately different -- a + // dropped relay recedes the whole icon, and it outranks the glow because + // grayscale would fight an orange halo. + function drawGlyph(ctx, size, bitmap, spec, mask) { var drawn = size * GLYPH_SCALE; var offset = (size - drawn) / 2; if (spec.dimmed === true) { ctx.globalAlpha = DIM_ALPHA; - ctx.filter = DIM_FILTER; + // Grayed and then halved, the orange plate would sit right at Safari's + // 25/255 threshold. Halving it alone leaves a spread of 128. + ctx.filter = TINTED_TOOLBAR ? 'none' : DIM_FILTER; ctx.drawImage(bitmap, offset, offset, drawn, drawn); ctx.globalAlpha = 1; ctx.filter = 'none'; @@ -353,7 +386,7 @@ ctx.fillStyle = PLATE_COLOR; ctx.fillRect(0, 0, size, size); - if (wantsGlow) drawMarkGlow(ctx, size, bitmap, spec, offset, drawn); + if (wantsGlow) drawMarkGlow(ctx, size, mask || bitmap, spec, offset, drawn); ctx.globalAlpha = markAlpha; ctx.drawImage(bitmap, offset, offset, drawn, drawn); ctx.globalAlpha = 1; @@ -548,7 +581,7 @@ var ctx = contextFor(size); var progress = spec.progress || 0; ctx.clearRect(0, 0, size, size); - drawGlyph(ctx, size, bitmaps[size], spec); + drawGlyph(ctx, size, bitmaps[size], spec, bitmaps.masks && bitmaps.masks[size]); if (spec.form === 'orbit') strokeSquareOrbit(ctx, size, spec.palette, progress); else if (spec.form === 'sweep') drawSweep(ctx, size, spec.palette, progress); else if (spec.form === 'ring') drawCapabilityRing(ctx, size, progress); @@ -647,23 +680,34 @@ } function closeGlyphs(bitmaps) { - for (var i = 0; i < SIZES.length; i++) { - var bitmap = bitmaps[SIZES[i]]; - if (bitmap) bitmap.close(); + var sets = bitmaps.masks && bitmaps.masks !== bitmaps ? [bitmaps, bitmaps.masks] : [bitmaps]; + for (var s = 0; s < sets.length; s++) { + for (var i = 0; i < SIZES.length; i++) { + var bitmap = sets[s][SIZES[i]]; + if (bitmap) bitmap.close(); + } } } + async function decodeGlyph(path) { + var response = await fetch(chrome.runtime.getURL(path)); + var blob = await response.blob(); + return createImageBitmap(blob); + } + // One bitmap per output size. createImageBitmap rejects SVG sources in a // worker, so PNG is the only viable base image here. A partial failure closes - // whatever already decoded rather than stranding it. + // whatever already decoded rather than stranding it. `masks` is what the glow + // is cut from: the same bitmaps, except on Safari, which also decodes the + // black-and-white tile for it. async function loadGlyphs() { var bitmaps = {}; + bitmaps.masks = TINTED_TOOLBAR ? {} : bitmaps; try { for (var i = 0; i < SIZES.length; i++) { var size = SIZES[i]; - var response = await fetch(chrome.runtime.getURL(GLYPH_PATHS[size])); - var blob = await response.blob(); - bitmaps[size] = await createImageBitmap(blob); + bitmaps[size] = await decodeGlyph((TINTED_TOOLBAR ? TINTED_GLYPH_PATHS : GLYPH_PATHS)[size]); + if (TINTED_TOOLBAR) bitmaps.masks[size] = await decodeGlyph(GLYPH_PATHS[size]); } } catch (e) { closeGlyphs(bitmaps); diff --git a/extension/utils/keyboard-emulator.js b/extension/utils/keyboard-emulator.js index 7a926e51c..66b1a4cb2 100644 --- a/extension/utils/keyboard-emulator.js +++ b/extension/utils/keyboard-emulator.js @@ -215,6 +215,19 @@ class KeyboardEmulator { * @returns {Promise} Success status */ async attachDebugger(tabId) { + // Safari has no chrome.debugger at all. Without this early return, the + // 3-attempt retry loop below (150ms backoff each) would run for EVERY + // keystroke against a shim that always rejects -- a latency storm, not a + // graceful degradation. Callers already treat `false` as "no CDP", and + // content/actions.js falls through to its untrusted KeyboardEvent path. + // + // globalThis.FsbPlatform is undefined on Chrome and in every Node test + // harness, so the existing behaviour there is untouched. + if (globalThis.FsbPlatform && globalThis.FsbPlatform.caps && + globalThis.FsbPlatform.caps.cdp === false) { + return false; + } + // If already attached to THIS tab, reuse if (this.isAttachedTo(tabId)) { return true; diff --git a/extension/utils/mcp-visual-session-lifecycle.js b/extension/utils/mcp-visual-session-lifecycle.js index 5f6ba031c..71a2a9c52 100644 --- a/extension/utils/mcp-visual-session-lifecycle.js +++ b/extension/utils/mcp-visual-session-lifecycle.js @@ -681,6 +681,55 @@ return Object.assign({ ok: true }, counters); } + /** + * Re-send the running overlay to a tab whose page has just (re)loaded. + * + * recordVisualSessionTick broadcasts once, at tick time. A tick recorded by + * open_tab or navigate lands before the destination page has a content + * script, so that broadcast has nowhere to go and the overlay stays dark + * until the next action. background.js calls this when the tab finishes + * loading and when the page's content script reports ready. + * + * Behaviour: + * - No entry: no-op. + * - Elapsed or malformed deadline: clearVisualSession(tabId, { reason: 'timeout' }). + * - isFinal entry: no-op. Its clear already ran or is about to; replaying + * it would bring a finished overlay back. + * - Otherwise broadcasts the running status. Storage and the death alarm are + * left alone, so a page load never extends the 60 s window. + * + * @param {number} tabId + * @param {object} options { now?: number } + * @returns {Promise} { ok: true, action: 'noop'|'cleared'|'replayed', entry? }; + * { ok: false, reason: 'invalid_tab_id' }. + */ + async function replayVisualSessionLifecycleForTab(tabId, options) { + var storageKey = storageKeyForTab(tabId); + if (!storageKey) { + return { ok: false, reason: 'invalid_tab_id' }; + } + + var entry = await readStorageEntry(storageKey); + if (!entry) { + return { ok: true, action: 'noop' }; + } + + var opts = options || {}; + var now = Number.isFinite(opts.now) ? Number(opts.now) : Date.now(); + var deadlineAt = Number(entry.deadlineAt); + if (!Number.isFinite(deadlineAt) || now >= deadlineAt) { + await clearVisualSession(tabId, { reason: 'timeout' }); + return { ok: true, action: 'cleared', entry: entry }; + } + + if (entry.isFinal === true) { + return { ok: true, action: 'noop', entry: entry }; + } + + var sent = await broadcastRunningStatus(entry); + return { ok: true, action: sent ? 'replayed' : 'noop', entry: entry }; + } + // --- Module exports (mirrors v0.9.36 utils export pattern at mcp-visual-session.js:553-575) --- var exportsObj = { @@ -692,7 +741,8 @@ clearVisualSessionsForAgent: clearVisualSessionsForAgent, handleVisualSessionLifecycleTabRemoved: handleVisualSessionLifecycleTabRemoved, handleVisualSessionLifecycleAlarm: handleVisualSessionLifecycleAlarm, - restoreVisualSessionLifecyclesFromStorage: restoreVisualSessionLifecyclesFromStorage + restoreVisualSessionLifecyclesFromStorage: restoreVisualSessionLifecyclesFromStorage, + replayVisualSessionLifecycleForTab: replayVisualSessionLifecycleForTab }; global.MCPVisualSessionLifecycleUtils = exportsObj; diff --git a/extension/utils/native-file-reader.js b/extension/utils/native-file-reader.js new file mode 100644 index 000000000..ed7bf3cbe --- /dev/null +++ b/extension/utils/native-file-reader.js @@ -0,0 +1,228 @@ +/** + * Safari upload_file support -- read a local file through the container app. + * + * Chrome populates an with CDP DOM.setFileInputFiles, which + * hands the browser process an absolute path and lets IT do the disk read. That + * command does not exist in Safari, and page JavaScript is forbidden from + * reading the filesystem, so the bytes have to come from native code. + * + * TWO INDEPENDENT CONSTRAINTS APPLY, and this module is only the second one: + * + * 1. extension/background.js executeUploadFile() runs the sensitive-path + * denylist + audit chokepoint FIRST, in the service worker, before any + * native message is sent. That is unchanged from Chrome. The same rules + * then run on the symlink-resolved path the host reports, before any + * bytes are fetched (screenResolvedPath below). + * 2. The container app will only serve files inside a folder the user + * explicitly granted (security-scoped bookmark, see GrantedRoots.swift). + * App Sandbox forbids reading arbitrary absolute paths -- this is true for + * the direct-download build too, not just the App Store one, because the + * extension target is sandboxed either way. + * + * A path must satisfy BOTH to be read. + * + * Safari caps a single native message near 1 MB, so a read is a handshake plus + * N chunk fetches. sendNativeMessage (one-shot) is the right primitive here: + * each step is a discrete request/response with no server-initiated push, which + * is the opposite of the MCP bridge's needs (see ws/mcp-native-transport.js for + * why THAT one uses connectNative instead). + */ + +'use strict'; + +(function (globalScope) { + const NATIVE_APP_ID = 'com.fullselfbrowsing.fsb'; + // Mirrors FileReadService.maxFileBytes. Enforced on both sides: the app so a + // huge file is never read into memory, here so a misreporting host cannot + // make the service worker reassemble something unbounded. + const MAX_FILE_BYTES = 32 * 1024 * 1024; + const MAX_CHUNKS = 512; + + /** Human-readable text for each typed reason the native host can return. */ + const REASON_TEXT = { + no_granted_folders: + 'no folders have been granted to FSB yet. Open the FSB app and use "Grant Folder Access" to choose a folder to upload from.', + outside_granted_folders: + 'the file is outside every folder granted to FSB. Open the FSB app to grant the folder that contains it.', + path_not_absolute: 'the path is not absolute.', + not_a_file: 'the path is not a readable file.', + file_too_large: 'the file exceeds the upload size limit.', + read_failed: 'the file could not be read.', + unknown_token: 'the read handle expired; retry the upload.', + chunk_out_of_range: 'the native host returned an unexpected chunk index.', + unverified_path: 'the native host did not report which file it opened, so the sensitive-path check could not run.', + 'denylist-error': 'the sensitive-path denylist failed while checking the resolved file.', + native_unavailable: + 'the FSB companion app is not reachable. Make sure the FSB app has been launched at least once.' + }; + + function describeReason(reason, detail) { + const base = REASON_TEXT[reason] || ('the native host reported "' + reason + '".'); + return detail ? base + ' (' + detail + ')' : base; + } + + function sendNative(message) { + const runtime = (globalScope.chrome && globalScope.chrome.runtime) + || (globalScope.browser && globalScope.browser.runtime) + || null; + if (!runtime || typeof runtime.sendNativeMessage !== 'function') { + return Promise.reject(Object.assign(new Error('native messaging unavailable'), { reason: 'native_unavailable' })); + } + return new Promise((resolve, reject) => { + let settled = false; + const done = (value) => { + if (settled) return; + settled = true; + const lastErr = runtime.lastError; + if (lastErr) { + reject(Object.assign(new Error(lastErr.message || String(lastErr)), { reason: 'native_unavailable' })); + return; + } + resolve(value); + }; + let out; + try { + out = runtime.sendNativeMessage(NATIVE_APP_ID, message, done); + } catch (err) { + settled = true; + reject(Object.assign(err, { reason: 'native_unavailable' })); + return; + } + if (out && typeof out.then === 'function') { + out.then((v) => { if (!settled) { settled = true; resolve(v); } }, + (e) => { if (!settled) { settled = true; reject(Object.assign(e, { reason: 'native_unavailable' })); } }); + } + }); + } + + async function releaseHandle(token) { + try { await sendNative({ v: 1, t: 'readRelease', token: token }); } catch (_e) { /* best effort */ } + } + + /** + * The caller's denylist cleared the path it was GIVEN. A symlink inside a + * granted folder can carry a harmless name while the host opened + * ~/.ssh/id_rsa, so the same rules run again on the path the host resolved. + * Fails closed: no reported path, or a screen that throws, refuses the read. + */ + function screenResolvedPath(screen, resolvedPath) { + if (typeof resolvedPath !== 'string' || !resolvedPath) { + return { ok: false, reason: 'unverified_path', message: describeReason('unverified_path') }; + } + let verdict; + try { + verdict = screen(resolvedPath); + } catch (_e) { + return { ok: false, reason: 'denylist-error', message: describeReason('denylist-error') }; + } + if (verdict && verdict.denied) { + const reason = verdict.reason || 'sensitive-path'; + return { + ok: false, + reason: reason, + message: 'the file resolves to a path that matches a sensitive-path denylist rule (' + reason + + '); uploading secrets is not permitted.' + }; + } + return null; + } + + /** + * Read a file for upload. + * + * @param {string} path absolute path, already cleared by the denylist gate + * @param {{screenResolvedPath?: function(string): {denied:boolean, reason?:string}}} [options] + * screenResolvedPath re-runs the denylist on the path the host actually + * opened, before any chunk is fetched. + * @returns {Promise<{ok:boolean, name?:string, mime?:string, size?:number, + * dataB64?:string, reason?:string, message?:string}>} + * Never throws: every failure is a typed {ok:false, reason, message} + * so executeUploadFile can audit it like any other refusal. + */ + async function readFile(path, options) { + let opened; + try { + opened = await sendNative({ v: 1, t: 'readFile', path: path }); + } catch (err) { + const reason = err && err.reason ? err.reason : 'native_unavailable'; + return { ok: false, reason: reason, message: describeReason(reason) }; + } + + if (!opened || opened.ok !== true || !opened.token) { + const reason = (opened && opened.reason) || 'read_failed'; + return { ok: false, reason: reason, message: describeReason(reason, opened && opened.detail) }; + } + + const size = Number(opened.size) || 0; + const chunks = Number(opened.chunks) || 0; + if (size > MAX_FILE_BYTES || chunks > MAX_CHUNKS || chunks < 1) { + // Do not trust the host's own bounds; release the handle and refuse. + await releaseHandle(opened.token); + return { + ok: false, + reason: 'file_too_large', + message: describeReason('file_too_large', size + ' bytes / ' + chunks + ' chunks') + }; + } + + if (options && typeof options.screenResolvedPath === 'function') { + const refusal = screenResolvedPath(options.screenResolvedPath, opened.resolvedPath); + if (refusal) { + await releaseHandle(opened.token); + return refusal; + } + } + + const parts = []; + // The host frees a handle by itself only after serving the chunk marked + // `last`. Every other exit -- a throw, a not-ok chunk, a host that never + // marks the end -- must release it, or up to MAX_FILE_BYTES stays pinned + // in the host until its TTL and each retry pins another copy. + let drained = false; + try { + for (let i = 0; i < chunks; i += 1) { + let chunk; + try { + chunk = await sendNative({ v: 1, t: 'readChunk', token: opened.token, i: i }); + } catch (err) { + const reason = err && err.reason ? err.reason : 'read_failed'; + return { ok: false, reason: reason, message: describeReason(reason) }; + } + if (!chunk || chunk.ok !== true || typeof chunk.data !== 'string') { + const reason = (chunk && chunk.reason) || 'read_failed'; + return { ok: false, reason: reason, message: describeReason(reason, chunk && chunk.detail) }; + } + parts.push(chunk.data); + if (chunk.last === true) { drained = true; break; } + } + } finally { + if (!drained) await releaseHandle(opened.token); + } + + return { + ok: true, + name: String(opened.name || ''), + mime: String(opened.mime || 'application/octet-stream'), + size: size, + // Base64 stays base64 all the way to the content script: it survives + // structured-clone to the page context without any binary marshalling. + dataB64: parts.join('') + }; + } + + /** Granted roots, for diagnostics and for actionable error text. */ + async function grantStatus() { + try { + const res = await sendNative({ v: 1, t: 'grantStatus' }); + return { ok: res && res.ok === true, roots: (res && res.roots) || [] }; + } catch (_e) { + return { ok: false, roots: [] }; + } + } + + const api = { readFile: readFile, grantStatus: grantStatus, describeReason: describeReason, + MAX_FILE_BYTES: MAX_FILE_BYTES, NATIVE_APP_ID: NATIVE_APP_ID }; + + globalScope.FsbNativeFileReader = api; + if (typeof module !== 'undefined' && module.exports) module.exports = api; +})(typeof globalThis !== 'undefined' ? globalThis : (typeof self !== 'undefined' ? self : this)); diff --git a/extension/utils/network-capture.js b/extension/utils/network-capture.js index 73fd927e8..e90936cd8 100644 --- a/extension/utils/network-capture.js +++ b/extension/utils/network-capture.js @@ -298,6 +298,20 @@ var maxMs = (typeof opts.maxMs === 'number' && opts.maxMs > 0) ? opts.maxMs : DEFAULT_MAX_MS; var maxCount = (typeof opts.maxCount === 'number' && opts.maxCount > 0) ? opts.maxCount : DEFAULT_MAX_COUNT; + // Safari: no CDP, so there is no Network domain to enable. Report the + // SAME reason as a missing debugger surface -- semantically that is exactly + // what this is, and callers already distinguish it from a consent refusal. + // + // Deliberately placed AFTER the consent gate above so consent is still + // evaluated first on every path; the gate stays the security chokepoint. + // Note this cannot be folded into the `dbg` check below: the platform shim + // DOES expose attach/sendCommand (they reject), so that check would pass + // and we would mislabel this as RECIPE_CAPTURE_ATTACH_FAILED. + if (globalThis.FsbPlatform && globalThis.FsbPlatform.caps && + globalThis.FsbPlatform.caps.networkBodies === false) { + return { ok: false, reason: 'RECIPE_CAPTURE_UNAVAILABLE' }; + } + var dbg = _chromeDebugger(); if (!dbg || typeof dbg.attach !== 'function' || typeof dbg.sendCommand !== 'function') { // No debugger surface available -- cannot capture. This is NOT a consent diff --git a/extension/utils/platform-adapter.js b/extension/utils/platform-adapter.js new file mode 100644 index 000000000..4f8275eb2 --- /dev/null +++ b/extension/utils/platform-adapter.js @@ -0,0 +1,847 @@ +/** + * FSB Safari port -- platform detection + capability shim. + * + * FSB targets Chrome MV3 as its source of truth. Safari Web Extensions lack + * three Chrome-proprietary namespaces that FSB uses: chrome.debugger (CDP), + * chrome.sidePanel, and chrome.offscreen. Rather than fork extension/ or edit + * the ~115 CDP call sites, this module installs stand-in namespaces so the + * existing call sites degrade through their EXISTING try/catch paths. + * + * Design rules: + * 1. install() is a hard NO-OP on Chrome. Nothing is patched, no globals are + * replaced. Chrome behaviour must stay byte-identical. + * 2. Everything is capability-gated, never user-agent sniffed. The Safari + * build sets globalThis.__FSB_FORCE_PLATFORM__ so production is + * deterministic; detect() heuristics are only a fallback. + * 3. debugger.detach() RESOLVES rather than rejecting -- every CDP call site + * ends in `finally { await chrome.debugger.detach(...) }` and detaching + * something that was never attached is trivially successful. Only attach + * and sendCommand reject, with a typed capability_unavailable error. + * 4. offscreen.hasDocument() resolves TRUE so ensureLatticeOffscreen() takes + * its existing `if (has) return;` early-exit and never calls + * createDocument. Zero edits to background.js for the offscreen path. + * 5. In the Safari service worker only, `WebSocket` throws SecurityError + * (guardWorkerWebSocket below): a real one deadlocks the worker's process. + * caps.tintsGrayscaleToolbarIcon tells utils/action-icon.js to keep its + * toolbar frames out of Safari's grayscale tint. + * 6. runtime.onSuspend gets a no-op stand-in when Safari lacks it, because + * background.js registers on it at top level. + * + * This file is loaded two different ways: + * - service worker: PREPENDED verbatim to build/safari/background.js by + * scripts/build-safari.mjs. It is deliberately NOT script-imported, + * because tests/lattice-provider-bridge-smoke.test.js pins background.js + * to exactly 334 script-import mentions / 330 call sites. + * - UI pages: a + + + FSB Icon +

You can turn on FSB’s extension in Safari Extensions preferences.

+

FSB’s extension is currently on. You can turn it off in Safari Extensions preferences.

+

FSB’s extension is currently off. You can turn it on in Safari Extensions preferences.

+ + +
+ +

File uploads

+

+ macOS sandboxing means FSB cannot read files from anywhere on your Mac. + Grant access to the folders you upload from — everything else stays + unreadable. Without at least one folder, upload_file reports + that the capability is unavailable. +

+
    +

    No folders granted yet.

    + + + + diff --git a/safari/FSB/FSB/Resources/Icon.png b/safari/FSB/FSB/Resources/Icon.png new file mode 100644 index 000000000..42f42ff4f Binary files /dev/null and b/safari/FSB/FSB/Resources/Icon.png differ diff --git a/safari/FSB/FSB/Resources/Script.js b/safari/FSB/FSB/Resources/Script.js new file mode 100644 index 000000000..e014647a0 --- /dev/null +++ b/safari/FSB/FSB/Resources/Script.js @@ -0,0 +1,49 @@ +function show(enabled, useSettingsInsteadOfPreferences) { + if (useSettingsInsteadOfPreferences) { + document.getElementsByClassName('state-on')[0].innerText = "FSB’s extension is currently on. You can turn it off in the Extensions section of Safari Settings."; + document.getElementsByClassName('state-off')[0].innerText = "FSB’s extension is currently off. You can turn it on in the Extensions section of Safari Settings."; + document.getElementsByClassName('state-unknown')[0].innerText = "You can turn on FSB’s extension in the Extensions section of Safari Settings."; + document.getElementsByClassName('open-preferences')[0].innerText = "Quit and Open Safari Settings…"; + } + + if (typeof enabled === "boolean") { + document.body.classList.toggle(`state-on`, enabled); + document.body.classList.toggle(`state-off`, !enabled); + } else { + document.body.classList.remove(`state-on`); + document.body.classList.remove(`state-off`); + } +} + +function openPreferences() { + webkit.messageHandlers.controller.postMessage("open-preferences"); +} + +document.querySelector("button.open-preferences").addEventListener("click", openPreferences); + +// --- upload_file folder grants ---------------------------------------------- +// Populated from Swift via evaluateJavaScript("showGrants([...])"). + +function showGrants(roots) { + const list = document.getElementById('grantList'); + const empty = document.getElementById('grantEmpty'); + if (!list || !empty) return; + + list.innerHTML = ''; + const items = Array.isArray(roots) ? roots : []; + for (const root of items) { + const li = document.createElement('li'); + li.textContent = root; + list.appendChild(li); + } + empty.style.display = items.length ? 'none' : ''; + list.style.display = items.length ? '' : 'none'; +} + +document.querySelector('button.grant-folder') + .addEventListener('click', () => webkit.messageHandlers.controller.postMessage('grant-folder')); + +document.querySelector('button.clear-grants') + .addEventListener('click', () => webkit.messageHandlers.controller.postMessage('clear-grants')); + +showGrants([]); diff --git a/safari/FSB/FSB/Resources/Style.css b/safari/FSB/FSB/Resources/Style.css new file mode 100644 index 000000000..479f80510 --- /dev/null +++ b/safari/FSB/FSB/Resources/Style.css @@ -0,0 +1,55 @@ +* { + -webkit-user-select: none; + -webkit-user-drag: none; + cursor: default; +} + +:root { + color-scheme: light dark; + + --spacing: 20px; +} + +html { + height: 100%; +} + +body { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + + gap: var(--spacing); + margin: 0 calc(var(--spacing) * 2); + height: 100%; + + font: -apple-system-short-body; + text-align: center; +} + +body:not(.state-on, .state-off) :is(.state-on, .state-off) { + display: none; +} + +body.state-on :is(.state-off, .state-unknown) { + display: none; +} + +body.state-off :is(.state-on, .state-unknown) { + display: none; +} + +button { + font-size: 1em; +} + +hr { width: 70%; margin: 24px auto; border: none; border-top: 1px solid rgba(128,128,128,.35); } +h2 { font-size: 15px; margin: 0 0 6px; } +.hint { font-size: 12px; opacity: .75; max-width: 30em; margin: 0 auto 12px; line-height: 1.45; } +.grant-list { list-style: none; padding: 0; margin: 0 auto 10px; max-width: 30em; text-align: left; display: none; } +.grant-list li { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; + padding: 4px 8px; margin-bottom: 4px; border-radius: 4px; + background: rgba(128,128,128,.12); word-break: break-all; } +.grant-empty { font-size: 12px; opacity: .55; margin: 0 0 12px; } +button.secondary { opacity: .7; } diff --git a/safari/FSB/FSB/ViewController.swift b/safari/FSB/FSB/ViewController.swift new file mode 100644 index 000000000..e97e553d6 --- /dev/null +++ b/safari/FSB/FSB/ViewController.swift @@ -0,0 +1,114 @@ +// +// ViewController.swift +// FSB +// +// Created by Lakshman on 8/21/26. +// + +import Cocoa +import SafariServices +import WebKit + +let extensionBundleIdentifier = "com.fullselfbrowsing.fsb.Extension" + +class ViewController: NSViewController, WKNavigationDelegate, WKScriptMessageHandler { + + @IBOutlet var webView: WKWebView! + + override func viewDidLoad() { + super.viewDidLoad() + + self.webView.navigationDelegate = self + + self.webView.configuration.userContentController.add(self, name: "controller") + + self.webView.loadFileURL(Bundle.main.url(forResource: "Main", withExtension: "html")!, allowingReadAccessTo: Bundle.main.resourceURL!) + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + SFSafariExtensionManager.getStateOfSafariExtension(withIdentifier: extensionBundleIdentifier) { (state, error) in + guard let state = state, error == nil else { + // Insert code to inform the user that something went wrong. + return + } + + DispatchQueue.main.async { + if #available(macOS 13, *) { + webView.evaluateJavaScript("show(\(state.isEnabled), true)") + } else { + webView.evaluateJavaScript("show(\(state.isEnabled), false)") + } + self.refreshGrantList() + } + } + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + // The original template force-cast this to String, which crashes the app + // the moment any other message shape arrives. Cast safely. + guard let command = message.body as? String else { return } + + switch command { + case "open-preferences": + SFSafariApplication.showPreferencesForExtension(withIdentifier: extensionBundleIdentifier) { _ in + DispatchQueue.main.async { + NSApplication.shared.terminate(nil) + } + } + + case "grant-folder": + presentGrantPanel() + + case "clear-grants": + GrantedRoots.clearGrants() + refreshGrantList() + + case "refresh-grants": + refreshGrantList() + + default: + break + } + } + + // MARK: - upload_file folder grants + + /// App Sandbox forbids the extension from reading arbitrary absolute paths, + /// so upload_file needs an explicit, user-chosen folder. The grant is stored + /// as a security-scoped bookmark in the shared App Group and read back by + /// the extension process (see GrantedRoots). + private func presentGrantPanel() { + let panel = NSOpenPanel() + panel.title = "Grant FSB access to a folder" + panel.message = "FSB can upload files from the folders you choose here. Everything else stays unreadable." + panel.prompt = "Grant Access" + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = true + panel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first + + panel.begin { [weak self] response in + guard response == .OK else { return } + for url in panel.urls { + do { + try GrantedRoots.addGrant(url) + } catch { + NSLog("FSB: failed to persist folder grant for %@: %@", + url.path, error.localizedDescription) + } + } + self?.refreshGrantList() + } + } + + private func refreshGrantList() { + let roots = GrantedRoots.grantedPaths() + guard let json = try? JSONSerialization.data(withJSONObject: roots), + let text = String(data: json, encoding: .utf8) + else { return } + DispatchQueue.main.async { [weak self] in + self?.webView.evaluateJavaScript("showGrants(\(text))") + } + } + +} diff --git a/safari/FSB/Shared/BridgeCoordinator.swift b/safari/FSB/Shared/BridgeCoordinator.swift new file mode 100644 index 000000000..368123718 --- /dev/null +++ b/safari/FSB/Shared/BridgeCoordinator.swift @@ -0,0 +1,515 @@ +// +// BridgeCoordinator.swift +// FSB — shared by the container app and the Safari web extension target. +// +// Owns one MCPSocketSession per channel and relays frames to/from the +// extension. "mcp" is the MCP bridge on ws://localhost:7225; "relay" is the +// dashboard relay ws/ws-client.js opens to the FSB server, which Safari's +// extension service worker cannot open itself. Each channel is a separate +// instance with its own socket, poll, linger and watchdog, so the two never +// see each other's frames. Everything below describes one channel. +// +// SOCKET LIFETIME IS SLAVED TO PORT LIFETIME — and that is deliberate. +// +// The tempting design is to keep :7225 open across service-worker evictions. +// Do not. If the socket survives, the MCP hub never observes a close, keeps +// `connected = true`, and its sendAndWait sits until the 30s timeout, then +// rejects with "Request ... timed out" — which is NOT in the server's +// BRIDGE_DISCONNECT_MESSAGES set. So isBridgeDisconnectError() returns false +// and the entire sw_evicted / partial_state recovery in the MCP tools never +// arms. Closing the socket when the port drops keeps this transport +// indistinguishable from a direct WebSocket at the server boundary, so the +// existing recovery works unmodified. +// +// THE LINGER. Closing instantly would be needlessly harsh for the common case +// (SW killed, then revived ~200ms later by webNavigation.onCommitted). So a +// disconnect starts a short linger; a new port arriving inside it ADOPTS the +// live socket and skips the redial. The window sits well under the extension's +// 10s staged-agent-release grace, so an adopted reconnect still lands inside +// it and the prior connection's staged release is cancelled normally. +// + +import Foundation + +final class BridgeCoordinator { + + /// The channels the extension may open. Anything else is refused, so a + /// malformed message cannot mint coordinators without bound. + static let channels: Set = ["mcp", "relay"] + + private static var registry: [String: BridgeCoordinator] = [:] + private static let registryLock = NSLock() + + /// The coordinator for `channel`, created on first use and kept for the + /// life of the process: Safari's handler objects are transient, so durable + /// state has to live here. nil for an unknown channel. + static func forChannel(_ channel: String) -> BridgeCoordinator? { + guard channels.contains(channel) else { return nil } + registryLock.lock() + defer { registryLock.unlock() } + if let existing = registry[channel] { return existing } + let created = BridgeCoordinator(channel: channel) + registry[channel] = created + return created + } + + /// The MCP channel. Messages from an extension that predates channels + /// carry no `ch` and land here. + static var shared: BridgeCoordinator { forChannel("mcp")! } + + let channel: String + + /// Deliberately shorter than the extension's RECONNECT_GRACE_MS (10s). + static let lingerSeconds: TimeInterval = 3 + static let lingerBufferMaxFrames = 64 + static let lingerBufferMaxBytes = 4 * 1024 * 1024 + + /// Silence that counts as "the port is gone". + /// + /// Safari hands the native side no port-teardown signal — every + /// `port.postMessage` is just another `beginRequest`, and a disconnect is + /// indistinguishable from quiet. But the extension keeps exactly ONE poll + /// outstanding at all times and re-pumps it immediately on every reply + /// (NATIVE_POLL_TIMEOUT_MS = 5000), so a gap with no poll parked and no + /// inbound message is a sound disconnect proxy. This is the fallback + /// safari/README.md V7 names. + /// + /// Worst case time-to-close is the tail of a held poll (5s) + this silence + /// window + lingerSeconds, so ~13s. What that has to beat is the server's + /// 30s sendAndWait timeout: close inside it and the hub sees a real + /// disconnect and arms sw_evicted recovery, which is the whole point of + /// slaving socket lifetime to port lifetime. It comfortably does. + /// + /// The fast-revive case never reaches any of this — a worker back in + /// ~200ms sends `open` while the socket is still live and adopts it. + static let portSilenceSeconds: TimeInterval = 5 + + private let queue: DispatchQueue + private var socket: MCPSocketSession? + private var inbound: [String] = [] + private var reassembly: [String: NativeFraming.ChunkBuffer] = [:] + private var seqOut = 0 + private var lingerTimer: DispatchSourceTimer? + private var lingerBytes = 0 + private var maxFrameBytes = NativeFraming.defaultMaxFrameBytes + + /// A parked long-poll: the extension keeps exactly one outstanding, and we + /// complete it when frames arrive or the hold expires. + private var parkedPoll: (([String: Any]) -> Void)? + private var pollTimer: DispatchSourceTimer? + + /// Control frames (`closed`, `error`) that arrived with no poll parked. + /// They MUST be buffered rather than dropped: a lost `closed` leaves the + /// extension long-polling a dead socket forever, since onclose never fires + /// and nothing schedules a reconnect. + private var pendingControl: [[String: Any]] = [] + + /// The remaining chunks of ONE oversized inbound payload, already encoded + /// and drained a chunk per poll reply. Held separately from `inbound` + /// because these are finished wire frames, not raw MCP strings. + private var pendingChunks: [[String: Any]] = [] + + /// The `open` reply, parked until the socket actually reaches :7225. Held + /// so a dial that fails (the MCP server simply isn't running — the common + /// case) can answer it with a typed error instead of letting the extension + /// burn its full 8s NATIVE_OPEN_TIMEOUT_MS. + private var pendingOpenReply: (([String: Any]) -> Void)? + + private var lastContactAt = Date() + private var watchdog: DispatchSourceTimer? + + private init(channel: String) { + self.channel = channel + self.queue = DispatchQueue(label: "com.fullselfbrowsing.fsb.bridge.\(channel)") + } + + // MARK: - open + + func handleOpen(url: String, origin: String?, reply: @escaping ([String: Any]) -> Void) { + queue.async { + self.noteContact() + // An `open` means a NEW port. Anything the previous port parked is + // unanswerable, and must not be allowed to eat the frames buffered + // during the linger — those belong to the port adopting them. + self.retirePreviousPort() + + if let socket = self.socket, socket.state == .open { + // Adoption: the SW was evicted and came back inside the linger. + reply(["v": NativeFraming.protocolVersion, "t": "opened", + "adopted": true, "socketId": socket.socketId, + "maxFrameBytes": self.maxFrameBytes]) + self.flushToPoll() + return + } + + guard let parsed = URL(string: url) else { + reply(["v": NativeFraming.protocolVersion, "t": "error", + "phase": "dial", "message": "invalid_url"]) + return + } + + // A socket that never reached .open must not survive the new port. + // Its callbacks are still attached, so it could answer the NEW + // port's pendingOpenReply with a socket this coordinator no longer + // references, and its eventual onClosed would set self.socket = nil + // -- after which every handleFrame silently drops on + // `self.socket?.send`. Reachable whenever a dial outlives the + // extension's 8s NATIVE_OPEN_TIMEOUT_MS and it reconnects. + if self.socket != nil { self.discardSocket(reason: "superseded_by_new_port") } + // A server-side close nils the socket without passing through + // discardSocket(), so half-built messages from that connection can + // still be here. Left in place, the new socket's watchdog would + // expire them and tear down a connection that did nothing wrong. + self.reassembly.removeAll() + + let session = MCPSocketSession(url: parsed, origin: origin) + self.socket = session + self.pendingOpenReply = reply + + // Every callback re-checks that its session is still the current + // one. discardSocket() nils the callbacks, but a block the old + // session already queued (a dial failing just as the new `open` + // lands) still runs afterwards -- and would otherwise nil the NEW + // socket and answer the NEW port with the old dial's error. + session.onOpen = { [weak self, weak session] socketId in + guard let self else { return } + self.queue.async { + guard let session, self.socket === session else { return } + self.noteContact() + guard let pending = self.pendingOpenReply else { return } + self.pendingOpenReply = nil + pending(["v": NativeFraming.protocolVersion, "t": "opened", + "adopted": false, "socketId": socketId, + "maxFrameBytes": self.maxFrameBytes]) + } + } + session.onText = { [weak self, weak session] text in + guard let self else { return } + self.queue.async { + guard let session, self.socket === session else { return } + self.enqueueInbound(text) + } + } + session.onClosed = { [weak self, weak session] code, reason in + guard let self else { return } + self.queue.async { + guard let session, self.socket === session else { return } + self.socket = nil + self.stopWatchdog() + if let pending = self.pendingOpenReply { + // Died before it ever opened, so this is a dial failure, + // not a disconnect. Answering here is what turns "MCP + // server not running" from an 8s stall into a fast fail: + // the extension's _onPortMessage handles `error` by + // calling _fail() immediately. + self.pendingOpenReply = nil + pending(["v": NativeFraming.protocolVersion, "t": "error", + "phase": "dial", "message": reason]) + return + } + self.deliver(["v": NativeFraming.protocolVersion, "t": "closed", + "code": code, "reason": reason]) + } + } + session.open() + // discardSocket() above stops the silence watchdog; without this + // the new socket would never get a linger/close on port silence. + self.armWatchdog() + } + } + + // MARK: - extension -> server + + func handleFrame(_ msg: [String: Any]) { + queue.async { + self.noteContact() + switch NativeFraming.ingest(msg, into: &self.reassembly) { + case .complete(let payload): + self.socket?.send(payload) + case .failed(let reason): + self.failReassembly(reason) + case .pending, .ignored: + break + } + } + } + + /// A message that cannot be reassembled is a reply the server will never + /// get. Telling only the extension is not enough: its reconnect would ADOPT + /// this still-open socket (handleOpen), the hub would never see a close, and + /// sendAndWait would sit out its 30s and reject with a timeout that does not + /// arm sw_evicted recovery. Closing :7225 is what turns the lost message + /// into a disconnect the server recovers from. + private func failReassembly(_ reason: String) { + discardSocket(reason: reason) + deliver(["v": NativeFraming.protocolVersion, "t": "error", + "phase": "frame", "message": reason]) + } + + func handleClose(code: Int, reason: String) { + queue.async { + self.cancelLinger() + self.retirePreviousPort() + self.discardSocket(reason: reason) + } + } + + // MARK: - server -> extension (long poll) + + /// The extension keeps one poll outstanding at all times. Holding it open + /// avoids depending on unsolicited host->extension push (whose support on + /// Safari is the least certain part of this design) and doubles as the + /// strongest available service-worker keepalive. + func handlePoll(waitMs: Int, reply: @escaping ([String: Any]) -> Void) { + queue.async { + self.noteContact() + if self.hasOutbound { + reply(self.nextOutbound()) + return + } + // Data first, then control: frames queued before a close must still + // reach the extension ahead of the close that followed them. + if !self.pendingControl.isEmpty { + reply(self.pendingControl.removeFirst()) + return + } + self.parkedPoll = reply + let timer = DispatchSource.makeTimerSource(queue: self.queue) + timer.schedule(deadline: .now() + .milliseconds(max(250, waitMs))) + timer.setEventHandler { [weak self] in + guard let self else { return } + self.pollTimer = nil + guard let parked = self.parkedPoll else { return } + self.parkedPoll = nil + // The port was waiting on us until now, so silence starts here, + // not when this poll arrived. Otherwise a held 5s poll used up + // the whole silence window, and every idle cycle armed a linger. + self.lastContactAt = Date() + parked(self.hasOutbound + ? self.nextOutbound() + : ["v": NativeFraming.protocolVersion, "t": "pollempty"]) + } + timer.resume() + self.pollTimer = timer + } + } + + private func enqueueInbound(_ text: String) { + inbound.append(text) + lingerBytes += text.utf8.count + enforceLingerCap() + flushToPoll() + } + + private func flushToPoll() { + guard hasOutbound, let parked = parkedPoll else { return } + parkedPoll = nil + pollTimer?.cancel() + pollTimer = nil + lastContactAt = Date() + parked(nextOutbound()) + } + + /// Anything waiting to go to the extension, chunked or not. + private var hasOutbound: Bool { !pendingChunks.isEmpty || !inbound.isEmpty } + + /// The next frame for a poll reply. + /// + /// One poll reply carries exactly one message, so an oversized payload is + /// encoded ONCE into its full chunk sequence and drained one chunk per + /// reply. The extension re-pumps immediately on every reply (see _pump in + /// ws/mcp-native-transport.js), so the sequence drains at round-trip speed + /// and _ingestChunk reassembles it on the far side. + private func nextOutbound() -> [String: Any] { + if !pendingChunks.isEmpty { return pendingChunks.removeFirst() } + + // A single payload over the frame ceiling can never be batched, so it + // is chunked here. drainBatch() refuses to APPEND past the ceiling, but + // it deliberately exempts the first frame -- otherwise an oversized head + // would produce an empty batch forever -- which makes this branch the + // only thing standing between such a head and Safari's per-message cap. + // Encode BEFORE consuming: an empty result must fall through to + // drainBatch with the payload still queued, not drop it on the floor. + if let head = inbound.first, head.utf8.count > maxFrameBytes { + let frames = NativeFraming.encode(head, seq: seqOut + 1, maxFrameBytes: maxFrameBytes) + if !frames.isEmpty { + inbound.removeFirst() + lingerBytes = max(0, lingerBytes - head.utf8.count) + seqOut += frames.count // encode() stamps seq + index per chunk + pendingChunks = Array(frames.dropFirst()) + return frames[0] + } + } + + return drainBatch() + } + + private func drainBatch() -> [String: Any] { + seqOut += 1 + var frames: [String] = [] + var bytes = 0 + while !inbound.isEmpty, frames.count < 8, bytes < 256 * 1024 { + let next = inbound[0] + // A frame past the per-message ceiling has to leave through + // nextOutbound()'s chunk path, never inside a batch. Leaving it at + // the head is what routes it there on the next poll. + // + // The !frames.isEmpty exemption is load-bearing: without it an + // oversized HEAD would yield an empty batch while hasOutbound stays + // true, and the extension would poll forever. An empty batch here is + // impossible, so the head always reaches the chunk branch instead. + if !frames.isEmpty, bytes + next.utf8.count > maxFrameBytes { break } + inbound.removeFirst() + bytes += next.utf8.count + frames.append(next) + } + lingerBytes = max(0, lingerBytes - bytes) + return ["v": NativeFraming.protocolVersion, "t": "batch", + "seq": seqOut, "frames": frames, "more": hasOutbound] + } + + /// Hand a control frame (`closed`, `error`) to the extension. + /// + /// BUFFERS when no poll is parked — it must never drop. There is always a + /// round trip between us completing a `batch` and the extension's next + /// `poll` landing, and a server close inside that window is exactly when + /// this gets called. A dropped `closed` means onclose never fires, the + /// transport polls a nil socket forever, and nothing ever reconnects. + private func deliver(_ msg: [String: Any]) { + if let parked = parkedPoll { + parkedPoll = nil + pollTimer?.cancel() + pollTimer = nil + lastContactAt = Date() + parked(msg) + return + } + pendingControl.append(msg) + } + + // MARK: - port lifetime + + /// Release the replies parked by a port that is going away, WITHOUT letting + /// them consume `inbound`. Completing the poll with `pollempty` frees its + /// NSExtensionContext cleanly; if the worker is already dead it simply goes + /// nowhere, which is the same outcome as dropping it minus the leak. + private func retirePreviousPort() { + if let stale = parkedPoll { + parkedPoll = nil + pollTimer?.cancel() + pollTimer = nil + stale(["v": NativeFraming.protocolVersion, "t": "pollempty"]) + } + if let staleOpen = pendingOpenReply { + pendingOpenReply = nil + staleOpen(["v": NativeFraming.protocolVersion, "t": "error", + "phase": "dial", "message": "superseded_by_new_port"]) + } + } + + /// Tear the socket down on a path that already knows the port is gone. + /// Detaching the callbacks first stops MCPSocketSession.close() from + /// re-entering as a `closed` control frame that nobody is left to read. + private func discardSocket(reason: String) { + if let s = socket { + s.onOpen = nil + s.onText = nil + s.onClosed = nil + s.close(code: .normalClosure, reason: reason) + } + socket = nil + reassembly.removeAll() + inbound.removeAll() + pendingChunks.removeAll() + pendingControl.removeAll() + lingerBytes = 0 + stopWatchdog() + } + + /// Any inbound message proves the port is alive: stamp it, abort a linger + /// started on suspicion, and make sure the silence watchdog is running. + private func noteContact() { + lastContactAt = Date() + cancelLinger() + armWatchdog() + } + + private func armWatchdog() { + guard watchdog == nil else { return } + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + 1, repeating: 1) + timer.setEventHandler { [weak self] in + guard let self else { return } + guard self.socket != nil else { self.stopWatchdog(); return } + // Ahead of the parked-poll guard: a lost tail chunk leaves the port + // looking perfectly healthy, polls and all, so this is the only + // place a stalled reassembly is ever noticed. + if NativeFraming.expireStalled(&self.reassembly) { + self.failReassembly("reassembly_timeout") + return + } + // A held poll is not silence — it is the extension waiting on us. + guard self.parkedPoll == nil else { return } + guard Date().timeIntervalSince(self.lastContactAt) > Self.portSilenceSeconds else { return } + self.stopWatchdog() + self.portDisconnected() + } + timer.resume() + watchdog = timer + } + + private func stopWatchdog() { + watchdog?.cancel() + watchdog = nil + } + + // MARK: - linger + + /// Called by the silence watchdog in `armWatchdog()` — Safari gives the + /// native side no real port-teardown callback, so inferred silence is the + /// only available trigger. `noteContact()` aborts the linger if the port + /// turns out to still be alive. + /// + /// Runs on `queue`, synchronously. Deferring it with `queue.async` let an + /// `open` or `poll` land between the watchdog's silence check and the + /// linger arming; that contact cancelled nothing, and the linger then + /// closed a live socket without the extension ever hearing about it. + private func portDisconnected() { + cancelLinger() + guard socket != nil else { return } + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + Self.lingerSeconds) + timer.setEventHandler { [weak self] in + guard let self else { return } + self.lingerTimer = nil + // Nobody adopted it. Close :7225 so the server sees a real + // disconnect and arms its own eviction recovery. + self.retirePreviousPort() + self.discardSocket(reason: "port_gone") + } + timer.resume() + lingerTimer = timer + enforceLingerCap() + } + + /// Buffer overflow during the linger means we can no longer present a + /// faithful stream; fall back to clean-disconnect semantics. Checked when + /// the linger arms AND on every frame that arrives while it runs -- the + /// server keeps sending into a silent port for the whole window. + /// pendingChunks counts too: nextOutbound() moves an oversized payload's + /// bytes OUT of lingerBytes when it encodes them, so without this a + /// half-drained chunk sequence would escape the bound. + private func enforceLingerCap() { + guard lingerTimer != nil else { return } + guard inbound.count + pendingChunks.count > Self.lingerBufferMaxFrames + || lingerBytes > Self.lingerBufferMaxBytes else { return } + cancelLinger() + retirePreviousPort() + discardSocket(reason: "linger_overflow") + } + + private func cancelLinger() { + lingerTimer?.cancel() + lingerTimer = nil + } +} diff --git a/safari/FSB/Shared/FileReadService.swift b/safari/FSB/Shared/FileReadService.swift new file mode 100644 index 000000000..557ed39b3 --- /dev/null +++ b/safari/FSB/Shared/FileReadService.swift @@ -0,0 +1,188 @@ +// +// FileReadService.swift +// FSB — shared by the container app and the Safari web extension target. +// +// Reads a file for upload_file, but ONLY inside a folder the user granted (see +// GrantedRoots). Safari native messaging caps a single message near 1 MB, so a +// read is a handshake plus N chunk fetches rather than one response: +// +// {t:"readFile", path} -> {ok, token, name, mime, size, chunks, resolvedPath} +// {t:"readChunk", token, i} -> {ok, i, data(base64)} +// +// Every failure is a TYPED reason string so the extension can surface +// something actionable ("outside granted folders: ~/Downloads") instead of a +// generic error. +// + +import Foundation +import UniformTypeIdentifiers + +final class FileReadService { + + static let shared = FileReadService() + + /// Base64 expands by 4/3, so the on-the-wire chunk is ~340 KB for this. + /// Keep this divisible by three: every full slice is encoded independently, + /// and padding is only valid at the end of the reassembled base64 string. + static let chunkBytes = 255 * 1024 + /// Bound the whole feature. A larger upload would exhaust the service + /// worker reassembling it long before the transport gave out. + static let maxFileBytes = 32 * 1024 * 1024 + static let handleTTL: TimeInterval = 120 + + private struct Handle { + let data: Data + let name: String + let mime: String + let createdAt: Date + } + + private let queue = DispatchQueue(label: "com.fullselfbrowsing.fsb.fileread") + private var handles: [String: Handle] = [:] + + private init() {} + + // MARK: - open + + func beginRead(path: String) -> [String: Any] { + queue.sync { + evictExpiredLocked() + + guard !path.isEmpty, path.hasPrefix("/") else { + return fail("path_not_absolute") + } + + guard let match = GrantedRoots.rootContaining(path) else { + // Report only WHETHER anything is granted, never the granted + // paths themselves. executeUploadFile deliberately keeps + // filesystem structure out of results and audit records, and the + // container app already lists the roots in its own UI. + let granted = GrantedRoots.grantedPaths() + return fail(granted.isEmpty ? "no_granted_folders" : "outside_granted_folders") + } + defer { match.root.url.stopAccessingSecurityScopedResource() } + + let url = match.target + + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir), !isDir.boolValue else { + return fail("not_a_file") + } + + // Only an early refusal. A stat that fails says nothing about the + // size, and the file can grow after it, so readBounded() below is + // what actually enforces the cap. + let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) + if let size = (attrs?[.size] as? NSNumber)?.intValue, size > Self.maxFileBytes { + return fail("file_too_large", detail: "\(size) > \(Self.maxFileBytes)") + } + + // Read eagerly, never mapped: the handle outlives the security-scoped + // access the defer above releases, so it must hold its own copy. + guard let data = Self.readBounded(url, limit: Self.maxFileBytes) else { + return fail("read_failed") + } + guard data.count <= Self.maxFileBytes else { + return fail("file_too_large", detail: "> \(Self.maxFileBytes)") + } + + let token = UUID().uuidString + handles[token] = Handle(data: data, + name: url.lastPathComponent, + mime: Self.mimeType(for: url), + createdAt: Date()) + + let chunks = data.isEmpty ? 1 : Int(ceil(Double(data.count) / Double(Self.chunkBytes))) + return [ + "v": NativeFraming.protocolVersion, + "t": "readFileOk", + "ok": true, + "token": token, + "name": url.lastPathComponent, + "mime": Self.mimeType(for: url), + "size": data.count, + "chunks": chunks, + "chunkBytes": Self.chunkBytes, + // The denylist only ever saw the REQUESTED path, and a link in a + // broad grant (say ~) can be called notes.txt while naming + // ~/.ssh/id_rsa. The extension re-runs it on this before fetching + // a single chunk, and keeps it out of results and audit records. + "resolvedPath": url.path + ] + } + } + + // MARK: - chunk + + func readChunk(token: String, index: Int) -> [String: Any] { + queue.sync { + evictExpiredLocked() + guard let handle = handles[token] else { return fail("unknown_token") } + + let start = index * Self.chunkBytes + guard index >= 0, start <= handle.data.count else { return fail("chunk_out_of_range") } + let end = min(start + Self.chunkBytes, handle.data.count) + let slice = handle.data.subdata(in: start..= handle.data.count + if isLast { handles.removeValue(forKey: token) } + + return [ + "v": NativeFraming.protocolVersion, + "t": "readChunkOk", + "ok": true, + "i": index, + "last": isLast, + "data": slice.base64EncodedString() + ] + } + } + + func release(token: String) { + queue.sync { _ = handles.removeValue(forKey: token) } + } + + // MARK: - helpers + + private func evictExpiredLocked() { + let cutoff = Date().addingTimeInterval(-Self.handleTTL) + handles = handles.filter { $0.value.createdAt > cutoff } + } + + private func fail(_ reason: String, detail: String? = nil) -> [String: Any] { + var out: [String: Any] = [ + "v": NativeFraming.protocolVersion, + "t": "readError", + "ok": false, + "reason": reason + ] + if let detail, !detail.isEmpty { out["detail"] = detail } + return out + } + + /// At most `limit + 1` bytes: one past the cap is enough to tell "too large" + /// from "exactly at the cap" without loading the rest of the file. + private static func readBounded(_ url: URL, limit: Int) -> Data? { + do { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + var data = Data() + while data.count <= limit, + let next = try handle.read(upToCount: limit + 1 - data.count), + !next.isEmpty { + data.append(next) + } + return data + } catch { + return nil + } + } + + private static func mimeType(for url: URL) -> String { + if let type = UTType(filenameExtension: url.pathExtension), + let mime = type.preferredMIMEType { + return mime + } + return "application/octet-stream" + } +} diff --git a/safari/FSB/Shared/GrantedRoots.swift b/safari/FSB/Shared/GrantedRoots.swift new file mode 100644 index 000000000..22e80e5c8 --- /dev/null +++ b/safari/FSB/Shared/GrantedRoots.swift @@ -0,0 +1,172 @@ +// +// GrantedRoots.swift +// FSB — shared by the container app and the Safari web extension target. +// +// App Sandbox means the extension CANNOT read arbitrary absolute paths, so +// upload_file cannot simply be handed a path the way Chrome's +// DOM.setFileInputFiles is. Instead the user grants a folder ONCE in the +// container app (NSOpenPanel), and the grant is persisted as a +// security-scoped bookmark in the shared App Group. The extension resolves +// those bookmarks and will only read files contained by one of them. +// +// This constraint applies to BOTH distribution channels, not just the App +// Store: the extension target is sandboxed regardless of how it ships. +// +// LAYERING: this is an ADDITIONAL constraint, never a replacement. The +// sensitive-path denylist + audit chokepoint in background.js +// (executeUploadFile) still runs first, in the extension, before any native +// message is sent, and again on the resolved path FileReadService reports +// back, before any bytes are fetched. A path must pass BOTH to be read. +// + +import Foundation +import Security + +enum GrantedRoots { + + private static let appGroupSuffix = "com.fullselfbrowsing.fsb" + private static let appGroupsEntitlement = "com.apple.security.application-groups" + private static let storeFileName = "granted-roots.plist" + + enum GrantError: LocalizedError { + case appGroupUnavailable + + var errorDescription: String? { + switch self { + case .appGroupUnavailable: + return "FSB's shared App Group is unavailable. Check the app and extension signing entitlements." + } + } + } + + /// Read the fully expanded identifier from the signed entitlement instead + /// of guessing the signing team's prefix. This returns the same value in + /// the container app and extension process. + static let appGroupId: String? = { + guard let task = SecTaskCreateFromSelf(nil), + let groups = SecTaskCopyValueForEntitlement( + task, + appGroupsEntitlement as CFString, + nil + ) as? [String] + else { return nil } + + return groups.first { + $0 == appGroupSuffix || $0.hasSuffix("." + appGroupSuffix) + } + }() + + /// The grants live in a FILE in the shared App Group container, not in an + /// App Group UserDefaults suite. Preferences are cached per process, so a + /// grant the app wrote stayed invisible to an already-running extension + /// process until Safari quit -- the upload retry right after granting still + /// said no_granted_folders. An atomically written file re-read on every + /// lookup has no such cache. + private static var storeURL: URL? { + guard let appGroupId, + let dir = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId) + else { return nil } + return dir.appendingPathComponent(storeFileName) + } + + // MARK: - Grant (container app) + + /// Persist a user-selected directory as a security-scoped bookmark. + static func addGrant(_ url: URL) throws { + guard let storeURL else { throw GrantError.appGroupUnavailable } + let bookmark = try url.bookmarkData(options: .withSecurityScope, + includingResourceValuesForKeys: nil, + relativeTo: nil) + var all = storedBookmarks() + // De-duplicate by resolved path so re-granting the same folder does not + // pile up stale bookmarks. + let incoming = url.resolvingSymlinksInPath().standardizedFileURL.path + all.removeAll { data in + guard let resolved = resolve(data) else { return false } + defer { resolved.url.stopAccessingSecurityScopedResource() } + return resolved.url.resolvingSymlinksInPath().standardizedFileURL.path == incoming + } + all.append(bookmark) + try FileManager.default.createDirectory(at: storeURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try PropertyListEncoder().encode(all).write(to: storeURL, options: .atomic) + } + + static func clearGrants() { + guard let storeURL else { return } + try? FileManager.default.removeItem(at: storeURL) + } + + /// Human-readable list of granted roots, for the app UI and for error text. + static func grantedPaths() -> [String] { + storedBookmarks().compactMap { data in + guard let resolved = resolve(data) else { return nil } + defer { resolved.url.stopAccessingSecurityScopedResource() } + return resolved.url.standardizedFileURL.path + } + } + + // MARK: - Resolve (extension) + + struct Resolved { + let url: URL + let stale: Bool + } + + private static func storedBookmarks() -> [Data] { + guard let storeURL, let data = try? Data(contentsOf: storeURL) else { return [] } + return (try? PropertyListDecoder().decode([Data].self, from: data)) ?? [] + } + + /// Resolve a bookmark and BEGIN security-scoped access. The caller owns the + /// matching stopAccessingSecurityScopedResource(). + private static func resolve(_ data: Data) -> Resolved? { + var stale = false + guard let url = try? URL(resolvingBookmarkData: data, + options: .withSecurityScope, + relativeTo: nil, + bookmarkDataIsStale: &stale) + else { return nil } + guard url.startAccessingSecurityScopedResource() else { return nil } + return Resolved(url: url, stale: stale) + } + + struct Containment { + /// Access already started; the caller owns the matching stop. + let root: Resolved + /// The target as resolved under `root`'s access. Read this url, never + /// the requested path: it is the one the containment check approved. + let target: URL + } + + /// Find the granted root that contains `path`, with access already started. + /// + /// Containment is checked on SYMLINK-RESOLVED, standardized paths and at a + /// path-COMPONENT boundary. Both matter: + /// - a plain string prefix would let /Users/me/Downloads-old satisfy a + /// grant for /Users/me/Downloads + /// - without resolving symlinks, a link placed inside the granted folder + /// could point at ~/.ssh and escape the grant entirely + /// + /// The target is resolved per root, only once that root's access has + /// started. Before then the sandbox hides a link inside the grant, and + /// resolvingSymlinksInPath does not fail on a link it cannot read -- it + /// returns the link's own path, which IS contained. Resolving up front let + /// a link to ~/.ssh pass this check and then be followed by the read. + static func rootContaining(_ path: String) -> Containment? { + for data in storedBookmarks() { + guard let resolved = resolve(data) else { continue } + let root = resolved.url.resolvingSymlinksInPath().standardizedFileURL + let target = URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL + let rootComponents = root.pathComponents + let targetComponents = target.pathComponents + + let contained = targetComponents.count > rootComponents.count + && Array(targetComponents.prefix(rootComponents.count)) == rootComponents + + if contained { return Containment(root: resolved, target: target) } + resolved.url.stopAccessingSecurityScopedResource() + } + return nil + } +} diff --git a/safari/FSB/Shared/MCPSocketSession.swift b/safari/FSB/Shared/MCPSocketSession.swift new file mode 100644 index 000000000..092a80775 --- /dev/null +++ b/safari/FSB/Shared/MCPSocketSession.swift @@ -0,0 +1,157 @@ +// +// MCPSocketSession.swift +// FSB — shared by the container app and the Safari web extension target. +// +// The real WebSocket to the FSB MCP server on ws://localhost:7225. +// +// This is what makes "same MCP, same port" true on Safari. A Safari Web +// Extension cannot open ws://localhost from JavaScript (the extension CSP +// refuses it, and com.apple.security.network.client applies to Safari APP +// Extensions, not Web Extensions). Native URLSession traffic from the +// app-extension process is a DIFFERENT subsystem, and that entitlement does +// govern it — which is why this class can dial a port the page context cannot. +// +// URLSessionWebSocketTask is not a browser and sends no Origin header of its +// own. The server treats an Origin-less socket as an MCP relay that must say +// relay:hello, never as the extension, so the extension's origin (from the +// `open` frame) is sent explicitly. +// + +import Foundation + +final class MCPSocketSession: NSObject, URLSessionWebSocketDelegate { + + enum State { case idle, dialing, open, closing, closed } + + private(set) var state: State = .idle + let url: URL + let origin: String? + let socketId = UUID().uuidString + + var onOpen: ((String) -> Void)? + var onText: ((String) -> Void)? + var onClosed: ((Int, String) -> Void)? + + private var session: URLSession? + private var task: URLSessionWebSocketTask? + private var pingTimer: DispatchSourceTimer? + private let queue = DispatchQueue(label: "com.fullselfbrowsing.fsb.socket") + + init(url: URL, origin: String?) { + self.url = url + self.origin = origin + super.init() + } + + func open() { + guard state == .idle else { return } + state = .dialing + let config = URLSessionConfiguration.default + config.waitsForConnectivity = false + let session = URLSession(configuration: config, delegate: self, delegateQueue: nil) + self.session = session + var request = URLRequest(url: url) + if let origin { request.setValue(origin, forHTTPHeaderField: "Origin") } + let task = session.webSocketTask(with: request) + self.task = task + task.resume() + pump() + } + + func send(_ text: String) { + guard let task = task, state == .open else { return } + task.send(.string(text)) { [weak self] error in + guard let self, let error else { return } + self.fail(code: 1006, reason: "send_failed:\(error.localizedDescription)") + } + } + + func close(code: URLSessionWebSocketTask.CloseCode = .normalClosure, reason: String = "intentional") { + guard state == .open || state == .dialing else { return } + state = .closing + stopPing() + task?.cancel(with: code, reason: reason.data(using: .utf8)) + task = nil + session?.invalidateAndCancel() + session = nil + state = .closed + onClosed?(Int(code.rawValue), reason) + } + + // MARK: - Receive loop + + /// URLSessionWebSocketTask.receive delivers exactly ONE message and must be + /// re-armed inside its own completion handler. Forgetting the recursion is + /// the classic bug here and yields precisely one message, forever. + private func pump() { + guard let task = task else { return } + task.receive { [weak self] result in + guard let self else { return } + switch result { + case .failure(let error): + self.fail(code: 1006, reason: "receive_failed:\(error.localizedDescription)") + case .success(let message): + switch message { + case .string(let text): + self.onText?(text) + case .data(let data): + // The `ws` npm server sends text, but never assume it. + if let text = String(data: data, encoding: .utf8) { self.onText?(text) } + @unknown default: + break + } + self.pump() + } + } + } + + private func fail(code: Int, reason: String) { + guard state != .closed else { return } + state = .closed + stopPing() + task = nil + session?.invalidateAndCancel() + session = nil + onClosed?(code, reason) + } + + // MARK: - Ping + + /// URLSession does not send protocol pings on its own. The `ws` server + /// answers them, so this is free half-open detection. + private func startPing() { + stopPing() + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + 30, repeating: 30) + timer.setEventHandler { [weak self] in + self?.task?.sendPing { error in + if let error { self?.fail(code: 1006, reason: "ping_failed:\(error.localizedDescription)") } + } + } + timer.resume() + pingTimer = timer + } + + private func stopPing() { + pingTimer?.cancel() + pingTimer = nil + } + + // MARK: - URLSessionWebSocketDelegate + + func urlSession(_ session: URLSession, + webSocketTask: URLSessionWebSocketTask, + didOpenWithProtocol protocol: String?) { + state = .open + startPing() + onOpen?(socketId) + } + + func urlSession(_ session: URLSession, + webSocketTask: URLSessionWebSocketTask, + didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, + reason: Data?) { + let text = reason.flatMap { String(data: $0, encoding: .utf8) } ?? "remote_closed" + fail(code: Int(closeCode.rawValue), reason: text) + } +} diff --git a/safari/FSB/Shared/NativeFraming.swift b/safari/FSB/Shared/NativeFraming.swift new file mode 100644 index 000000000..1ebca695a --- /dev/null +++ b/safari/FSB/Shared/NativeFraming.swift @@ -0,0 +1,155 @@ +// +// NativeFraming.swift +// FSB — shared by the container app and the Safari web extension target. +// +// Wire codec between extension/ws/mcp-native-transport.js and the native host. +// Safari caps a single native message near 1 MB, and FSB routinely exceeds +// that (read_page full:true, get_dom_snapshot with a large max_elements), so +// oversized payloads are chunked. +// +// Chunking is UTF-8 -> base64 -> slice, never slice-then-encode: base64 is +// pure ASCII, so a chunk boundary can never split a multi-byte sequence or a +// surrogate pair. That entire class of corruption is designed out rather than +// guarded against. +// + +import Foundation + +enum NativeFraming { + + static let protocolVersion = 1 + /// Conservative default. The real ceiling is measured on-device and + /// re-advertised to the extension in the `opened` frame. + static let defaultMaxFrameBytes = 512 * 1024 + static let maxReassemblyBytes = 32 * 1024 * 1024 + /// Measured from the LAST chunk, not the first. The extension posts every + /// chunk of a message back-to-back in one loop (send() in + /// ws/mcp-native-transport.js), so a gap this long means one was lost. It + /// has to fire well inside the server's 30s sendAndWait, whose clock started + /// before the tool even ran -- a 30s budget here could never win that race. + static let reassemblyTimeout: TimeInterval = 5 + + struct ChunkBuffer { + var parts: [String?] + var received: Int + var bytes: Int + var lastChunkAt: Date + } + + enum IngestResult { + case complete(String) + case pending + case ignored + case failed(String) + } + + // MARK: - Outbound (host -> extension) + + /// Split one MCP JSON string into frames the extension can reassemble. + static func encode(_ payload: String, seq: Int, maxFrameBytes: Int) -> [[String: Any]] { + let byteCount = payload.utf8.count + if byteCount <= maxFrameBytes { + return [["v": protocolVersion, "t": "frame", "seq": seq, "data": payload]] + } + + let b64 = Data(payload.utf8).base64EncodedString() + let cid = UUID().uuidString + var frames: [[String: Any]] = [] + var index = 0 + var cursor = b64.startIndex + let total = Int(ceil(Double(b64.count) / Double(maxFrameBytes))) + + while cursor < b64.endIndex { + let end = b64.index(cursor, offsetBy: maxFrameBytes, limitedBy: b64.endIndex) ?? b64.endIndex + frames.append([ + "v": protocolVersion, + "t": "chunk", + "seq": seq + index, + "cid": cid, + "i": index, + "n": total, + "enc": "b64", + "data": String(b64[cursor.. host) + + /// Accumulate an inbound frame/chunk. Returns the complete payload once all + /// parts of a chunked message have arrived. + /// + /// Reassembly is bounded on BOTH size and time. A stalled reassembly must + /// never sit silently: the corresponding sendAndWait on the server would + /// hang until its own 30s timeout, and that timeout string is not one of + /// the bridge-disconnect messages, so the sw_evicted recovery would never + /// arm. Failing loudly turns a permanent hang into a fast reconnect. + /// + /// The time check in here only catches a chunk that arrives late. The + /// bound itself is expireStalled(), which needs no further chunk to run. + static func ingest(_ msg: [String: Any], into buffers: inout [String: ChunkBuffer]) -> IngestResult { + guard let type = msg["t"] as? String else { return .ignored } + + if type == "frame" { + guard let data = msg["data"] as? String else { return .ignored } + return .complete(data) + } + + guard type == "chunk", + let cid = msg["cid"] as? String, + let i = msg["i"] as? Int, + let n = msg["n"] as? Int, + n > 0, i >= 0, i < n + else { return .ignored } + + let data = msg["data"] as? String ?? "" + + var buf = buffers[cid] ?? ChunkBuffer(parts: Array(repeating: nil, count: n), + received: 0, bytes: 0, lastChunkAt: Date()) + + if Date().timeIntervalSince(buf.lastChunkAt) > reassemblyTimeout { + buffers.removeValue(forKey: cid) + return .failed("reassembly_timeout") + } + + if buf.parts.indices.contains(i), buf.parts[i] == nil { + buf.parts[i] = data + buf.received += 1 + buf.bytes += data.utf8.count + buf.lastChunkAt = Date() + } + + if buf.bytes > maxReassemblyBytes { + buffers.removeValue(forKey: cid) + return .failed("reassembly_overflow") + } + + if buf.received == n { + buffers.removeValue(forKey: cid) + let joined = buf.parts.compactMap { $0 }.joined() + if (msg["enc"] as? String) == "b64" { + guard let decoded = Data(base64Encoded: joined), + let str = String(data: decoded, encoding: .utf8) + else { return .failed("reassembly_decode_failed") } + return .complete(str) + } + return .complete(joined) + } + + buffers[cid] = buf + return .pending + } + + /// Drop every reassembly that has gone `reassemblyTimeout` without a chunk, + /// returning whether any did. ingest() only notices a stall when the next + /// chunk of the SAME message arrives, and a lost tail chunk means it never + /// does -- so the coordinator calls this from its watchdog instead. + static func expireStalled(_ buffers: inout [String: ChunkBuffer], now: Date = Date()) -> Bool { + let before = buffers.count + buffers = buffers.filter { now.timeIntervalSince($0.value.lastChunkAt) <= reassemblyTimeout } + return buffers.count < before + } +} diff --git a/safari/README.md b/safari/README.md new file mode 100644 index 000000000..0b0bfda7d --- /dev/null +++ b/safari/README.md @@ -0,0 +1,347 @@ +# FSB for Safari + +Safari build of the FSB extension. **`extension/` is the only source tree** — +this directory holds the Xcode project and the native host, and the browser +payload is generated into `build/safari/` by `npm run build:safari`. + +``` +npm run build:safari # esbuild -> build/safari/ -> Version.xcconfig +npm run validate:safari # manifest + JS syntax + Safari negative invariants +npm run test:safari # 716 assertions across 16 suites (one needs macOS + swiftc) +``` + +## Why there is no fork + +15 test files read `extension/` source and assert on its exact contents. Two +pin counts: `background.js` is pinned to 334 script-import mentions / 330 call +sites, and the tool registry is pinned by SHA-256 (cross-checked against +`mcp/ai/tool-definitions.cjs`). So Safari behaviour is delivered by, in order +of preference: + +1. **Runtime shim** — `extension/utils/platform-adapter.js` installs stand-ins + for `chrome.debugger` / `sidePanel` / `offscreen` / `system.memory`. It is a + hard no-op on Chrome. +2. **Inert-on-Chrome guards** — gated on `globalThis.FsbPlatform`, which is + `undefined` on Chrome and in every Node harness. +3. **Build-output transform** — `scripts/build-safari.mjs`, never on `extension/`. + +`tests/safari-source-parity.test.js` enforces this: every emitted file must be +byte-identical to its `extension/` counterpart except `manifest.json` and +`background.js`. + +## Layout + +``` +safari/ + Config/ + Version.xcconfig GENERATED from extension/manifest.json + ExportOptions-DeveloperID.plist + ExportOptions-AppStore.plist + FSB/ + FSB.xcodeproj + FSB/ container app (+ FSB.entitlements) + FSB Extension/ web extension (+ FSB_Extension.entitlements) + SafariWebExtensionHandler.swift + Resources/ <- point this at ../../../build/safari + Shared/ member of BOTH targets + NativeFraming.swift wire codec + chunking + MCPSocketSession.swift one real WebSocket (per channel) + BridgeCoordinator.swift one per channel: relay + linger/adoption + GrantedRoots.swift security-scoped folder grants + FileReadService.swift sandbox-scoped chunked file reads + Tests/ + BridgeChannels/main.swift command-line harness over Shared/*.swift, + run by tests/safari-native-bridge-channels.test.js +``` + +## MCP on the same port + +The MCP server, the port (**7225**) and the wire protocol are identical to +Chrome. Only the pipe differs, and the extension picks it at runtime: + +| transport | how | server changes | +|---|---|---| +| `ws` | `new WebSocket('ws://localhost:7225')` from the service worker | `safari-web-extension://` added to the origin allowlist | +| `native` | the container app holds the socket; frames relayed over `runtime.connectNative` | the same allowlist entry: the app sends the extension's `Origin` | + +On Safari 26.5 the direct pipe is not an option at all, and not because of +CSP. Safari runs the extension's service worker on its WebContent process's +main thread, and a worker `new WebSocket()` hands its setup to the main thread +and blocks on a semaphore until it finishes: the worker waits on itself and the +whole process deadlocks. Every later fetch, extension resource load and +extension page in that process hangs with it, and Safari abandons the +background after about two minutes (`WKWebExtensionContextErrorDomain` +Code=6). Seen on-device by sampling the stuck process. + +So `extension/utils/platform-adapter.js` makes `WebSocket` throw `SecurityError` +inside the Safari worker. The extension still **probes** (direct first, pin to +native after two failures, remembered in `chrome.storage.local` for 7 days), +but it reads a construct-time `SecurityError` as final and pins native on the +first attempt. + +Both transports present the extension's own `Origin`. `URLSessionWebSocketTask` +sends none by default, so `MCPSocketSession` sets it from the `open` frame: the +server treats an Origin-less socket as an MCP relay that must say +`relay:hello`, never as the extension. The `safari-web-extension://` allowlist +entry exists only on this branch. Published `fsb-mcp-server` (0.11.1 at the +time of writing) refuses that origin on both transports, so Safari needs an MCP +release cut from here. + +**One browser at a time, and Safari stays unpaired.** The server holds one +extension socket and pins one extension origin in `~/.fsb/bridge-auth.json`. +While Chrome is paired, every other browser origin is refused at the upgrade, +paired or not; `fsb-mcp-server pair --reset` clears the pin. Safari cannot pair +yet either: + +- Chrome gets its pairing code from the Node native host, which reads `~/.fsb`. + Safari's native messages go to the sandboxed app, which cannot. +- Safari gives the extension a new `safari-web-extension://` on every + browser launch ([WebKit bug 244330](https://bugs.webkit.org/show_bug.cgi?id=244330)), + so an exact-origin pin would not survive a relaunch. + +Unpaired, the MCP tools work. Delegated agent runs, which need pairing and the +Node native host, do not. Pairing per browser and routing MCP calls to a chosen +browser are planned as a separate change. + +**Socket lifetime is slaved to port lifetime, deliberately.** If the app kept +:7225 open across a service-worker eviction, the hub would never see a close, +`sendAndWait` would sit until its 30 s timeout, and that timeout string is not +in `BRIDGE_DISCONNECT_MESSAGES` — so the server's `sw_evicted` recovery would +never arm. A 3 s linger (under the extension's 10 s staged-release grace) makes +the common evict-and-revive case adopt the live socket instead of redialling. + +**The dashboard relay rides the same pipe, on its own channel.** +`ws/ws-client.js` cannot open its socket to the FSB server either, for the same +deadlock, so `createFSBRelaySocket` hands it an `FsbNativeBridgeSocket` on the +`relay` channel wherever the worker `WebSocket` is guarded. Every native message +names its channel (`ch`, `mcp` when absent), and the host keeps one +`BridgeCoordinator` per channel: separate socket, poll, linger and watchdog, so +the two never see each other's frames. The relay server authenticates by key and +role only, so the app's socket needs no server change. +`tests/safari-native-bridge-channels.test.js` builds the shipped `Shared/*.swift` +into a command-line harness and drives both channels against two local +WebSocket servers. + +## Toolbar icon + +Safari decides whether to tint a toolbar icon with `-[NSImage +safari_isGrayscale]` (SafariShared). Calling it directly on test images gives +the rule: + +- A pixel counts as colored when its premultiplied channel spread (max minus + min of R, G, B, after multiplying by alpha) is at least 25/255. +- The icon keeps its own colors only when at least 3% of its non-transparent + pixels are colored. + +Any other icon is tinted: Safari keeps only the alpha and paints it gray, or the +accent color while the extension is active on the page. `icon*.png` are black +and white, so the tile showed as a solid square, and a letters-only template +came out as tinted lettering. A faint warm tint did not help either: only 1 to +2% of its pixels cleared the spread. + +So on Safari, `action.default_icon` points at +`extension/assets/safari-toolbar-{16,19,32,38,48,64}.png`, generated by +`scripts/generate-safari-toolbar-icons.py`. These are the same artwork in FSB +orange (`#ff8c00`) with black lettering. Almost every pixel is plate with a +spread of 255, so they stay in color by a wide margin, even at half alpha. +16/32 and 19/38 are the toolbar's 1x and 2x, depending on the macOS version. +`icons` keeps the original tile, which Safari Settings shows untinted. Chrome +keeps the black tile everywhere. + +`utils/action-icon.js` animates the toolbar with `setIcon` frames. On a platform +whose adapter sets `caps.tintsGrayscaleToolbarIcon`: + +- It draws the frames from the orange 16 and 32. +- It repaints the orange plate under the breathe. +- It draws the orbit, sweep and glow white, because orange would vanish into + the plate. The capability ring stays violet. +- It still cuts the glow from the black-and-white tile. +- It dims to half alpha without the grayscale filter. Grayed, the dimmed + plate would sit right at the threshold. + +`tests/safari-toolbar-icons.test.js` checks the assets against this rule. On +macOS it also runs Safari's own classifier on them, at full and half alpha. + +## Remaining manual Xcode steps + +The converter cannot do these, and they need the Xcode UI (target membership +and build settings live in `project.pbxproj`): + +1. ~~Add `Shared/` to BOTH targets~~ — **already done.** Every Swift file in + `safari/FSB/Shared` belongs to both the *FSB* and *FSB Extension* source + phases. +2. ~~Wire the entitlements~~ — **already done.** `CODE_SIGN_ENTITLEMENTS` points + at `FSB/FSB.entitlements` for the app target and + `FSB Extension/FSB_Extension.entitlements` for the extension target. +3. **Enable the App Group capability** on both targets and confirm the group id + matches `$(TeamIdentifierPrefix)com.fullselfbrowsing.fsb`. Do this by hand in the + two `.entitlements` files, not through the Signing & Capabilities UI — the + sandbox keys live there and Xcode rewrites the file when you toggle a + capability. In particular `com.apple.security.files.bookmarks.app-scope` has no + build-setting equivalent and is what `GrantedRoots` needs to persist and + resolve folder grants; drop it and `upload_file` silently reports + `no_granted_folders` forever. `tests/safari-source-parity.test.js` asserts both + files still declare it. +4. ~~Replace the static Resources copy~~ — **already done.** + `FSB Extension/Resources` is a symlink to `../../../build/safari`, so the + existing pbxproj path resolves to the generated payload with no Xcode + surgery. The converter's `--copy-resources` had duplicated all 518 files + (30 MB) into the tracked project; that copy is gone and + `tests/safari-source-parity.test.js` now fails if it comes back. Run + `npm run build:safari` before building, or the link dangles. +5. ~~Apply `Config/Version.xcconfig`~~ — **already done.** Project-level Debug + and Release configurations feed both targets' `MARKETING_VERSION` / + `CURRENT_PROJECT_VERSION` values. +6. ~~Set the deployment target to macOS 14.0~~ — **already done** at the + project level for both targets. +7. For local dev: *Signing → Sign to Run Locally*, then in Safari enable + *Develop ▸ Allow Unsigned Extensions*. No Developer Program needed until you + distribute. + +Do **not** re-run the converter with `--force`: it clobbers the Swift above AND +reintroduces the 30 MB duplicate payload. +`npm run build:safari` refreshes the payload in place. + +## Distribution + +Both channels come from the same archive: + +``` +node scripts/release-safari.mjs --archive +node scripts/release-safari.mjs --export=developer-id --notarize --profile= +node scripts/release-safari.mjs --export=app-store +``` + +**Ship Developer ID first.** App Review is a real risk for FSB — `` +host permissions, `nativeMessaging`, a localhost socket to an out-of-band +server, and browser automation as the core function. Treat App Store approval +as upside, not a dependency. + +## Known gaps + +- **`upload_file`** — implemented, but **requires a folder grant**. See + "File uploads" below. (`drop_file` is unaffected: it is a `_route:'content'` + DOM tool that synthesizes dropzone content and never used CDP.) +- **`network_capture`** — unavailable. Capture returns + `RECIPE_CAPTURE_UNAVAILABLE`, after the consent gate has run. Safari 18's + `webRequest` is non-blocking and exposes no response bodies, so there is no + CDP `Network` domain to replace it, and the build does not request + `webRequest` at all. The consent gate, redactor and bounds port unchanged; + only the transport is missing. +- **Device memory**: `chrome.system.memory` is Chrome-only, so the agent-cap + recommendation (`utils/agent-cap-recommendation.js`) falls back to its + default. `get_memory_stats` is unaffected: it reports FSB's memory store. +- **`capture_screenshot`**: the visible viewport only. Without CDP, + `utils/screenshot-capture.js` captures through `tabs.captureVisibleTab`: + `viewport` is that image, `region` and `element` are cropped out of it at + device pixels, and FSB's overlays are hidden for the capture as on Chrome. + `full_page`, device emulation and targets that are not on screen return + `INVALID_SCREENSHOT_ARGUMENTS` or `SCREENSHOT_REGION_OUT_OF_BOUNDS`, and a tab + that is not the active one returns a retryable `SCREENSHOT_CAPTURE_FAILED` + that says to `switch_tab` first. +- **Trusted input** — `press_key`, the drag family and `scroll_at` dispatch + untrusted DOM events. They report `trusted:false, degraded:true` rather than + claiming success, and will not work on sites gating on `event.isTrusted` or + inside cross-origin iframes. +- **Dashboard remote control** — the relay now runs through the container app + (see "MCP on the same port"); not yet verified on device. Clicks, typing and + scrolling from the dashboard run the same untrusted DOM fallbacks. A click + moves focus the way a real one does. Backspace, Delete, Enter and Tab are + performed when the page leaves the keydown alone; arrow keys are not, and + nothing reaches into iframes. A miss on the page is logged and the session + stays up. +- **Telemetry** — Safari installs send the same anonymous payload as Chrome and + are counted, but nothing in it names the browser, so the two cannot be told + apart. +- **MCP pairing and delegated agent runs** — unavailable; see "MCP on the same + port". +- **Pre-existing, not Safari-specific:** `ui/speech-to-text.js` sends + `stt-start` / `stt-stop`, but no `background.js` handler relays them to + `content/stt-recognition.js`. The content-script STT path is dead on Chrome + today; the Safari port neither fixes nor worsens it. + +## File uploads + +Chrome sets a file input with CDP `DOM.setFileInputFiles`, handing the browser +process an absolute path. Safari has no CDP, and page JavaScript cannot read the +filesystem, so the bytes must come from native code — and **App Sandbox forbids +the extension from reading arbitrary absolute paths.** That is true for the +direct-download build too, not just the App Store one: the extension target is +sandboxed either way. + +So the user grants folders once, in the FSB app ("Grant Folder Access…"). The +grant is stored as a security-scoped bookmark in the shared App Group and +resolved by the extension process. + +**Two independent constraints apply, in this order (the first one twice):** + +1. `background.js executeUploadFile()` runs the sensitive-path denylist + audit + chokepoint, in the service worker, before any native message is sent. This is + unchanged from Chrome. A denied path never reaches the native host. +2. `FileReadService` serves the file only if it is contained by a granted root. + Containment is checked on symlink-resolved paths at a path-component + boundary, so neither `…/Downloads-old` nor a symlink planted inside a granted + folder can escape the grant. The path is resolved only after the root's + security-scoped access starts: before that the sandbox hides the link, and + `resolvingSymlinksInPath` quietly returns it unresolved. +3. The host reports the resolved path back, and the denylist runs again on it + before any chunk is fetched. A link inside a broad grant (say `~`) can be + named `notes.txt` and point at `~/.ssh/id_rsa`, and step 1 only ever saw + the name. + +The read is a handshake plus N chunk fetches (`readFile` → `readChunk`) because +Safari caps a single native message near 1 MB; files are bounded at 32 MB. The +content script then builds a real `File`, puts it in a `DataTransfer`, and +assigns `input.files` — the one in-page route that works. + +Result shape is honest: `method: 'dom_set_file_input'`, `trusted: false`, +`degraded: true`. A site gating on `event.isTrusted` will still refuse the +upload even though `input.files` is genuinely populated. Refusals are typed +(`no_granted_folders`, `outside_granted_folders`, `file_too_large`, …) so the +agent gets something actionable rather than a generic failure. + +One cosmetic wart: the `upload_file` tool DESCRIPTION in +`extension/ai/tool-definitions.js` still says it works "via the browser DevTools +protocol (DOM.setFileInputFiles)", which is only true on Chrome. It is left +alone on purpose — `tests/tool-definitions-parity.test.js` pins a SHA-256 over +the whole registry, cross-checked against `mcp/ai/tool-definitions.cjs`, so +editing the prose would break two files. The model learns the truth from the +result anyway (`method: 'dom_set_file_input'`, `trusted: false`). The same stale +prose appears in `extension/site-guides/utilities/file-upload.js`. + +Granted folder paths are deliberately **not** sent back over the wire or written +to any log or audit record — `executeUploadFile` keeps filesystem structure out +of its results by design, and the app already lists the roots in its own UI. + +## Must be verified on-device + +| # | Question | Fallback if it fails | +|---|---|---| +| V1 | Does `connect-src ws://localhost:7225` unblock the direct WebSocket on Safari 26? | probe pins to native | +| V2 | Does Safari keep ONE host process alive per `connectNative` port? | move the socket to the app over XPC | +| V3 | Real single-message ceiling, and the failure mode (silent drop?) | lower `maxFrameBytes` via the `opened` frame | +| V4 | Does sandboxed `URLSession` reach `localhost:7225`, and which process does the Local Network prompt name? | — | +| V5 | Does the `Origin` the host sets on its `URLRequest` reach the server unchanged? | none: without it the server treats the socket as a relay and refuses it as the extension | +| V6 | Does `content_scripts.world:"MAIN"` work on Safari 26? The converter warns it does not, but MDN records support from Safari 18. | register the MAIN-world script via `scripting.registerContentScripts` (Safari 16.4+) | +| V7 | Does `port.onDisconnect` fire on SW eviction, or only explicit disconnect? | app-side inactivity timer closes :7225 | +| V8 | Can the **extension** process resolve a security-scoped bookmark the **app** created, via the App Group? This is the documented pattern, but it is the one part of upload_file that cannot be unit-tested. | fall back to `NSOpenPanel` per upload (worse UX, still functional) | +| V9 | After Safari evicts the background worker, does the dashboard relay come back within about a minute (reconnect alarm, then the arm on worker start)? | the 5-minute telemetry alarm also wakes the worker, and that start arms the relay | +| V10 | Does `resolveTargetTab` return the last content tab, and does `tabs.query` report the workspace window's `safari-web-extension://` URL? | the adapter rules out the workspace's tab by its window id as well as its URL, and a tab with no URL is never streamed | + +### Results (Safari 26.5, macOS 26.6, ad-hoc signed Debug build) + +Driven through MCP against a local test page, with this branch's server as the +hub on :7225. + +| # | Result | +|---|---| +| V1 | No. The direct WebSocket deadlocks the worker's process (see "MCP on the same port"); with the worker guard the bridge pins native on its first attempt. | +| V2 | Yes. One `FSB Extension` process (same PID for 6+ hours) served every native port. When the worker recycled, the host saw its port torn down and the same process took the next one. | +| V3 | Results of 0.5, 1.5 and 3 MB crossed the native transport intact in 350 to 430 ms, so chunking works past the ~1 MB single-message ceiling. The raw ceiling itself was not probed. | +| V4 | Yes. The sandboxed host reached `localhost:7225`; loopback is exempt from the Local Network prompt. | +| V5 | Yes. The hub registered the host's socket as the extension, not as an Origin-less relay. | +| V6 | Yes. `canvas-interceptor.js` ran in the page's main world (`window.__canvasInterceptorInstalled === true`). | +| V7 | Partly. On a worker recycle the host saw the port close and :7225 was re-dialled; the extension reconnected and re-armed (`lastWakeReason: delegation-authority-ready`). After a hub restart it reconnected in about 12 s. | +| V8 | Not verified: needs "Grant Folder Access" in the app. Without a grant, `upload_file` returns the typed `no_granted_folders` refusal. | +| V9 | Not verified yet. The relay could not run in the Safari worker at all; it now runs through the app on the `relay` channel. The host side is covered by tests/safari-native-bridge-channels.test.js, the Safari side still needs a device run. | +| V10 | Not verified: needs the workspace window. | diff --git a/safari/Tests/BridgeChannels/main.swift b/safari/Tests/BridgeChannels/main.swift new file mode 100644 index 000000000..3da8d51f1 --- /dev/null +++ b/safari/Tests/BridgeChannels/main.swift @@ -0,0 +1,86 @@ +// +// safari/Tests/BridgeChannels/main.swift +// +// Drives the shipped BridgeCoordinator (Shared/*.swift) from a command-line +// process, without Safari, against two local WebSocket servers: one standing +// in for the MCP hub, one for the dashboard relay. Built and run by +// tests/safari-native-bridge-channels.test.js, which starts the servers and +// checks the JSON this prints. +// +// Usage: BridgeChannelsHarness +// + +import Foundation + +let arguments = CommandLine.arguments +guard arguments.count == 3 else { + FileHandle.standardError.write("usage: BridgeChannelsHarness \n".data(using: .utf8)!) + exit(2) +} +let mcpURL = arguments[1] +let relayURL = arguments[2] +let origin = "safari-web-extension://00000000-0000-0000-0000-000000000000" + +/// Waits for one callback, on a queue other than the coordinator's. +func waitFor(timeout: TimeInterval = 10, _ start: (@escaping (T) -> Void) -> Void) -> T? { + let done = DispatchSemaphore(value: 0) + var value: T? + start { result in + value = result + done.signal() + } + _ = done.wait(timeout: .now() + timeout) + return value +} + +/// Polls until a batch arrives (or a few holds pass) and returns its frames. +func poll(_ bridge: BridgeCoordinator) -> [String] { + for _ in 0..<6 { + guard let reply = waitFor({ done in bridge.handlePoll(waitMs: 1000) { done($0) } }) else { continue } + if (reply["t"] as? String) == "batch", let frames = reply["frames"] as? [String], !frames.isEmpty { + return frames + } + } + return [] +} + +var report: [String: Any] = [:] + +guard let mcp = BridgeCoordinator.forChannel("mcp"), let relay = BridgeCoordinator.forChannel("relay") else { + print("{\"fatal\":\"known channel refused\"}") + exit(1) +} +report["unknownChannelRefused"] = BridgeCoordinator.forChannel("bogus") == nil +report["sharedIsMcp"] = BridgeCoordinator.shared === mcp +report["channelsDistinct"] = mcp !== relay + +let mcpOpen = waitFor { done in mcp.handleOpen(url: mcpURL, origin: origin) { done($0) } } +let relayOpen = waitFor { done in relay.handleOpen(url: relayURL, origin: origin) { done($0) } } +report["mcpOpen"] = mcpOpen?["t"] as? String ?? "timeout" +report["relayOpen"] = relayOpen?["t"] as? String ?? "timeout" + +mcp.handleFrame(["v": NativeFraming.protocolVersion, "t": "frame", "seq": 1, "data": "ping-a"]) +relay.handleFrame(["v": NativeFraming.protocolVersion, "t": "frame", "seq": 1, "data": "ping-b"]) +report["mcpFrames"] = poll(mcp) +report["relayFrames"] = poll(relay) + +// Closing one channel must leave the other's socket alone. +relay.handleClose(code: 1000, reason: "harness_relay_close") +mcp.handleFrame(["v": NativeFraming.protocolVersion, "t": "frame", "seq": 2, "data": "after-relay-close"]) +report["mcpAfterRelayClose"] = poll(mcp) + +// A closed channel dials a fresh socket instead of adopting anything. +let relayReopen = waitFor { done in relay.handleOpen(url: relayURL, origin: origin) { done($0) } } +report["relayReopen"] = relayReopen?["t"] as? String ?? "timeout" +report["relayReopenAdopted"] = relayReopen?["adopted"] as? Bool ?? false +relay.handleFrame(["v": NativeFraming.protocolVersion, "t": "frame", "seq": 2, "data": "ping-c"]) +report["relayAfterReopen"] = poll(relay) + +mcp.handleClose(code: 1000, reason: "harness_done") +relay.handleClose(code: 1000, reason: "harness_done") + +let json = try JSONSerialization.data(withJSONObject: report, options: [.sortedKeys]) +print(String(data: json, encoding: .utf8)!) +// Give the close frames a moment to leave before the process exits. +Thread.sleep(forTimeInterval: 0.5) +exit(0) diff --git a/scripts/build-safari.mjs b/scripts/build-safari.mjs new file mode 100644 index 000000000..a8bb3d576 --- /dev/null +++ b/scripts/build-safari.mjs @@ -0,0 +1,362 @@ +#!/usr/bin/env node +/** + * Safari build transform. + * + * FSB keeps ONE source tree. `extension/` targets Chrome MV3 and must stay + * byte-identical wherever a test reads it -- 15 test files assert on extension + * source contents, and two pin exact counts (background.js is pinned to 334 + * script-import mentions / 330 call sites; the tool registry is pinned by + * SHA-256). So Safari behaviour is delivered by transforming the OUTPUT, never + * the source. + * + * This script emits build/safari/ from extension/ with exactly three deltas: + * 1. manifest.json -- permissions/keys Safari does not implement removed, + * nativeMessaging + CSP + strict_min_version added. + * 2. background.js -- platform-adapter.js PREPENDED (never script-imported, + * which would break the 334/330 pins) and the Lattice + * IIFE bundle loaded at EOF (Safari has no offscreen + * document, so the host runs inside the SW). + * 3. dist/offscreen/lattice-host.iife.js copied in place of the ESM build. + * + * Everything else is a byte-for-byte copy, enforced by + * tests/safari-source-parity.test.js. + * + * Usage: + * node scripts/build-safari.mjs [--out=build/safari] + */ + +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, rmSync, copyFileSync } from 'node:fs'; +import { join, dirname, resolve, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); +const EXT_ROOT = join(ROOT, 'extension'); + +// --------------------------------------------------------------------------- +// Permission policy. +// +// ALLOWLIST-DRIVEN ON PURPOSE. Every permission in extension/manifest.json must +// appear in exactly one of these two lists. An unclassified permission is a +// hard error, so adding one to the Chrome manifest without deciding what Safari +// should do breaks the Safari build immediately instead of silently shipping a +// permission Safari ignores. +// --------------------------------------------------------------------------- + +const PERMISSIONS_KEEP = [ + 'activeTab', 'scripting', 'storage', 'unlimitedStorage', 'tabs', + 'windows', 'webNavigation', 'alarms', 'clipboardWrite', + 'nativeMessaging' // Chrome uses it for the native host; Safari for the container app. +]; + +const PERMISSIONS_DROP = [ + 'sidePanel', // Safari has no sidePanel API; the adapter polyfills it onto a popup window. + 'debugger', // No CDP in Safari at all; the adapter installs a rejecting shim. + 'offscreen', // No offscreen documents; the Lattice host moves into the SW. + 'system.memory' // Chrome-proprietary. +]; + +// Added even if the Chrome manifest ever drops it: the Safari MCP transport and +// upload_file both depend on the container app. +const PERMISSIONS_ADD = ['nativeMessaging']; + +// Safari baseline. content_scripts.world:"MAIN" (which canvas-interceptor.js +// needs at document_start) landed in Safari 18. +const SAFARI_MIN_VERSION = '18.0'; + +const MCP_BRIDGE_URL = 'ws://localhost:7225'; + +// extension/assets/safari-toolbar-.png. 16/32 and 19/38 are the +// toolbar's 1x and 2x, depending on the macOS version; utils/action-icon.js +// draws its animated frames from the 16 and 32. +export const SAFARI_TOOLBAR_ICON_SIZES = [16, 19, 32, 38, 48, 64]; + +// Paths never copied into the Safari build, with the reason each is excluded. +// tests/safari-source-parity.test.js asserts this list is exhaustive. +const EXCLUDE = [ + 'offscreen/lattice-host.html', // no offscreen documents in Safari + 'offscreen/lattice-host.js', // ESM source; Safari ships the IIFE bundle + 'dist/offscreen/lattice-host.js', // ESM build output + 'dist/offscreen/lattice-host.js.map' +]; + +// NOTE: extension/test-data/ is deliberately NOT excluded despite looking like +// test fixtures. utils/token-comparator.js:1226 loads it at runtime through +// chrome.runtime.getURL + fetch, and manifest.web_accessible_resources +// advertises it. Dropping 252 KB would break loadJSONBaseline(). + + +const LATTICE_IIFE_REL = 'dist/offscreen/lattice-host.iife.js'; + +// --------------------------------------------------------------------------- +// transformManifest -- pure, exported for tests +// --------------------------------------------------------------------------- + +export function transformManifest(manifest) { + const out = JSON.parse(JSON.stringify(manifest)); + const declared = out.permissions ?? []; + + const unknown = declared.filter( + (p) => !PERMISSIONS_KEEP.includes(p) && !PERMISSIONS_DROP.includes(p) + ); + if (unknown.length) { + throw new Error( + `build-safari: unclassified permission(s): ${unknown.join(', ')}. ` + + 'Add each to PERMISSIONS_KEEP or PERMISSIONS_DROP in scripts/build-safari.mjs ' + + 'so the Safari behaviour is an explicit decision.' + ); + } + + out.permissions = declared.filter((p) => !PERMISSIONS_DROP.includes(p)); + for (const p of PERMISSIONS_ADD) { + if (!out.permissions.includes(p)) out.permissions.push(p); + } + + // Safari has no sidePanel API. The adapter polyfills chrome.sidePanel onto a + // popup-type extension window, so the key itself must not survive. + delete out.side_panel; + + // Safari tints any toolbar icon it detects as grayscale, and the black-and- + // white icon*.png tiles came out as a solid square. The toolbar gets the same + // artwork in FSB orange with black lettering, which Safari shows in its own + // colors (scripts/generate-safari-toolbar-icons.py). `icons` keeps the + // original tile: Safari Settings shows it untinted. + out.action = { + ...(out.action ?? {}), + default_icon: Object.fromEntries( + SAFARI_TOOLBAR_ICON_SIZES.map((size) => [String(size), `assets/safari-toolbar-${size}.png`]) + ) + }; + + // No offscreen documents -> nothing under offscreen/ is web-accessible. + if (Array.isArray(out.web_accessible_resources)) { + out.web_accessible_resources = out.web_accessible_resources + .map((entry) => ({ + ...entry, + resources: (entry.resources ?? []).filter((r) => !r.startsWith('offscreen/')) + })) + .filter((entry) => (entry.resources ?? []).length > 0); + } + + // The direct-WebSocket experiment: Safari refuses ws://localhost from an + // extension page unless connect-src names it. If this works, the transport + // probe pins to 'ws' on first connect and the native path never runs. + // + // `http:` is NOT optional. Chrome ships no connect-src at all, so naming one + // here only ever NARROWS what already works, and FSB's local-provider paths + // dial plain HTTP: config.js defaults lmstudioBaseUrl to + // http://localhost:1234, and customEndpoint is routinely an Ollama/LM Studio + // URL -- often on another box on the LAN, which a localhost-only allowlist + // would break. The scheme is not a security boundary here either: the + // extension already holds . + // + // `ws:` follows for exactly the same reason, and is NOT covered by naming + // MCP_BRIDGE_URL alone. ws/ws-client.js derives the dashboard socket from + // the user-editable serverUrl with .replace(/^http/, 'ws'), so anyone + // pointing FSB at a self-hosted dashboard on http://host:port gets a + // ws://host:port socket that a wss:-only allowlist would refuse. + out.content_security_policy = { + ...(out.content_security_policy ?? {}), + extension_pages: [ + "script-src 'self'", + "object-src 'self'", + `connect-src 'self' ${MCP_BRIDGE_URL} http: https: ws: wss:` + ].join('; ') + }; + + out.browser_specific_settings = { + ...(out.browser_specific_settings ?? {}), + safari: { strict_min_version: SAFARI_MIN_VERSION } + }; + + return out; +} + +// --------------------------------------------------------------------------- +// buildSafari +// --------------------------------------------------------------------------- + +function isExcluded(rel) { + const norm = rel.split(sep).join('/'); + return EXCLUDE.some((ex) => norm === ex || norm.startsWith(ex + '/')); +} + +function walk(dir, base, out = []) { + for (const name of readdirSync(dir).sort()) { + if (name === 'node_modules' || name.startsWith('.')) continue; + const abs = join(dir, name); + const rel = relative(base, abs); + if (isExcluded(rel)) continue; + if (statSync(abs).isDirectory()) walk(abs, base, out); + else if (!name.endsWith('.map')) out.push(rel); + } + return out; +} + +export function buildSafari(opts = {}) { + const src = opts.root ? resolve(ROOT, opts.root) : EXT_ROOT; + const out = opts.out ? resolve(ROOT, opts.out) : join(ROOT, 'build', 'safari'); + const warnings = []; + + const adapterPath = join(src, 'utils', 'platform-adapter.js'); + if (!existsSync(adapterPath)) { + throw new Error('build-safari: extension/utils/platform-adapter.js not found'); + } + const adapterSrc = readFileSync(adapterPath, 'utf8'); + + const transportPath = join(src, 'ws', 'mcp-native-transport.js'); + if (!existsSync(transportPath)) { + throw new Error('build-safari: extension/ws/mcp-native-transport.js not found'); + } + const transportSrc = readFileSync(transportPath, 'utf8'); + + const fileReaderPath = join(src, 'utils', 'native-file-reader.js'); + if (!existsSync(fileReaderPath)) { + throw new Error('build-safari: extension/utils/native-file-reader.js not found'); + } + const fileReaderSrc = readFileSync(fileReaderPath, 'utf8'); + + const latticeIife = join(src, LATTICE_IIFE_REL); + if (!existsSync(latticeIife)) { + throw new Error( + `build-safari: ${LATTICE_IIFE_REL} not found. Run \`node esbuild.config.js\` first ` + + '-- Safari runs the Lattice host inside the service worker and needs the IIFE bundle.' + ); + } + + if (existsSync(out)) rmSync(out, { recursive: true, force: true }); + mkdirSync(out, { recursive: true }); + + const files = walk(src, src); + let copied = 0; + + for (const rel of files) { + const from = join(src, rel); + const to = join(out, rel); + mkdirSync(dirname(to), { recursive: true }); + + if (rel === 'manifest.json') { + const manifest = JSON.parse(readFileSync(from, 'utf8')); + writeFileSync(to, JSON.stringify(transformManifest(manifest), null, 2) + '\n'); + } else if (rel === 'background.js') { + writeFileSync(to, transformBackground(readFileSync(from, 'utf8'), { + adapter: adapterSrc, + modules: [ + { label: 'ws/mcp-native-transport.js -- defines FsbNativeBridgeSocket, which\n// ws/mcp-bridge-client.js resolves off the global in _createSocket().', src: transportSrc }, + { label: 'utils/native-file-reader.js -- defines FsbNativeFileReader, used by\n// executeUploadFile() when caps.cdp is false.', src: fileReaderSrc } + ] + })); + } else { + copyFileSync(from, to); + } + copied += 1; + } + + let sourceCommit = 'unknown'; + try { + sourceCommit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: ROOT, stdio: 'pipe' }) + .toString().trim(); + } catch { + warnings.push('git rev-parse failed; BUILD-INFO.sourceCommit is "unknown"'); + } + + writeFileSync(join(out, 'BUILD-INFO.json'), JSON.stringify({ + generator: 'scripts/build-safari.mjs', + sourceCommit, + generatedAt: new Date().toISOString(), + adapterSha256: createHash('sha256').update(adapterSrc).digest('hex'), + nativeTransportSha256: createHash('sha256').update(transportSrc).digest('hex'), + nativeFileReaderSha256: createHash('sha256').update(fileReaderSrc).digest('hex'), + strippedPermissions: PERMISSIONS_DROP, + addedPermissions: PERMISSIONS_ADD, + excluded: EXCLUDE, + transformedFiles: ['manifest.json', 'background.js'], + fileCount: copied + }, null, 2) + '\n'); + + return { files: copied, out, warnings }; +} + +/** + * The two background.js deltas. Both are ANCHOR-FREE (pure prepend/append) so + * they can never mis-target one of background.js's byte-frozen regions. + */ +export function transformBackground(source, opts) { + const adapterSrc = opts.adapter; + const modules = opts.modules || []; + const preamble = [ + '// ---------------------------------------------------------------------------', + '// SAFARI BUILD PREAMBLE -- generated by scripts/build-safari.mjs. Do not edit.', + '//', + '// extension/utils/platform-adapter.js is inlined here rather than loaded as a', + '// script import because tests/lattice-provider-bridge-smoke.test.js pins', + '// background.js to an exact script-import count. Inlining keeps the Chrome', + '// source untouched while still installing the shims before ANY listener', + '// registers -- which matters because the file registers debugger.onEvent and', + '// debugger.onDetach handlers at top level.', + '// ---------------------------------------------------------------------------', + 'globalThis.__FSB_FORCE_PLATFORM__ = "safari";', + '', + adapterSrc, + '', + 'globalThis.FsbPlatform.install();', + '', + '// Safari-only modules, inlined for the same reason as the adapter: they must', + '// register their globals BEFORE background.js runs, and adding script-import', + '// lines to the Chrome source would break the 334/330 count pins.', + ...modules.flatMap((m) => ['', '// ' + m.label, m.src]), + '// --------------------------- END SAFARI PREAMBLE ---------------------------', + '' + ].join('\n'); + + // Loading the Lattice host at EOF guarantees the adapter's onMessage wrapper + // is already installed when the host registers its two listeners -- without + // that ordering the in-SW loopback would miss them and every LLM call would + // fail, since agent-loop.js made the Lattice bridge the unconditional path. + // + // captureLoopback() is what makes those two listeners -- and ONLY those two -- + // join the fan-out. Capturing every onMessage listener instead would enrol + // background.js's fsbHandleRuntimeMessage, whose `default:` branch answers any + // message with no request.action (i.e. every lattice-* envelope) and, being + // registered first, would claim the reply before the host ever saw it. + const epilogue = [ + '', + '// --------------------------- SAFARI BUILD EPILOGUE -------------------------', + '// Safari has no chrome.offscreen, so the Lattice provider bus runs inside the', + '// service worker. The IIFE bundle exists precisely because a SW that uses', + '// importScripts() can never be a module.', + '//', + '// The captureLoopback() wrapper is synchronous, so importScripts still runs', + '// during the service worker\'s initial evaluation as MV3 requires.', + '//', + '// NOTE: the 334/330 count pins in tests/lattice-provider-bridge-smoke.test.js', + '// read extension/background.js. This line only ever lands in the generated', + '// build/safari/background.js, so it cannot affect them.', + 'globalThis.FsbPlatform.captureLoopback(function () {', + ` importScripts('${LATTICE_IIFE_REL}');`, + '});', + '// ------------------------- END SAFARI BUILD EPILOGUE -----------------------', + '' + ].join('\n'); + + return preamble + source + epilogue; +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +if (isMain) { + const outArg = process.argv.slice(2).find((a) => a.startsWith('--out=')); + try { + const res = buildSafari({ out: outArg ? outArg.slice(6) : undefined }); + for (const w of res.warnings) console.warn(`build-safari: WARN ${w}`); + console.log(`build-safari: OK (${res.files} files -> ${relative(ROOT, res.out)})`); + } catch (err) { + console.error(`build-safari: FAILED\n ${err.message}`); + process.exit(1); + } +} diff --git a/scripts/generate-safari-toolbar-icons.py b/scripts/generate-safari-toolbar-icons.py new file mode 100644 index 000000000..ddbe1bd11 --- /dev/null +++ b/scripts/generate-safari-toolbar-icons.py @@ -0,0 +1,58 @@ +"""Generate the Safari toolbar icons in extension/assets/safari-toolbar-*.png. + +Safari decides whether to tint a toolbar icon with -[NSImage +safari_isGrayscale] (SafariShared). Probing that method directly shows the +rule: a pixel counts as colored when its premultiplied channel spread (max +minus min of R, G, B) is at least 25/255, and the image is left in its own +colors only when at least 3% of its non-transparent pixels are colored. +Anything else is tinted: Safari keeps only the alpha and paints it gray, or +the accent color while the extension is active on the page. + +FSB's artwork (icon*.png) is black and white, so Safari drew the opaque tile +as a solid square. Nearly all of a tile is plate, so on Safari the plate +carries the color: the same artwork in FSB orange with black lettering +(white -> INK, black -> PLATE). Every pixel of the plate has a spread of 255, +so the toolbar frames stay in color even when action-icon.js dims them to +half alpha. + +Sizes: 16/32 and 19/38 (the toolbar's 1x and 2x, depending on the macOS +version), plus 48 and 64. 16 comes from icon16.png and 48 from icon48.png, +which were drawn for those sizes; the rest are reduced from icon128.png. + +Only needed when the artwork changes. Requires Pillow: + python3 scripts/generate-safari-toolbar-icons.py +""" +import os + +from PIL import Image + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +ASSETS = os.path.join(ROOT, "extension", "assets") +SIZES = (16, 19, 32, 38, 48, 64) +PLATE = (255, 140, 0) # black plate -> FSB orange (#ff8c00) +INK = (0, 0, 0) # white lettering -> black +DRAWN = {16: "icon16.png", 48: "icon48.png"} + + +def artwork(size): + if size in DRAWN: + return Image.open(os.path.join(ASSETS, DRAWN[size])).convert("L") + master = Image.open(os.path.join(ASSETS, "icon128.png")).convert("L") + return master.resize((size, size), Image.LANCZOS) + + +def recolor(gray): + channels = [gray.point(lambda v, lo=lo, hi=hi: round(lo + (hi - lo) * v / 255)) + for lo, hi in zip(PLATE, INK)] + return Image.merge("RGB", channels) + + +def main(): + for size in SIZES: + path = os.path.join(ASSETS, "safari-toolbar-%d.png" % size) + recolor(artwork(size)).save(path, optimize=True) + print("wrote", os.path.relpath(path, ROOT)) + + +if __name__ == "__main__": + main() diff --git a/scripts/release-safari.mjs b/scripts/release-safari.mjs new file mode 100644 index 000000000..bdce6525f --- /dev/null +++ b/scripts/release-safari.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node +/** + * Archive once, export twice. + * + * FSB ships the Safari app through BOTH channels, so the two must come from + * the SAME archive -- never a forked project. Only the export step differs. + * + * developer-id : signed + notarized, distributed from full-selfbrowsing.com + * app-store : uploaded to App Store Connect + * + * Ship developer-id first. App Review is a genuine risk for FSB ( + * host permissions, nativeMessaging, a localhost socket to an out-of-band MCP + * server, and browser automation as the core function), so the launch should + * not depend on it. + * + * Usage: + * node scripts/release-safari.mjs --archive + * node scripts/release-safari.mjs --export=developer-id [--notarize --profile=] + * node scripts/release-safari.mjs --export=app-store + */ + +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync } from 'node:fs'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); +const PROJECT = join(ROOT, 'safari', 'FSB', 'FSB.xcodeproj'); +const OUT = join(ROOT, 'build', 'safari-release'); +const ARCHIVE = join(OUT, 'FSB.xcarchive'); + +const argv = process.argv.slice(2); +const has = (f) => argv.includes(`--${f}`); +const val = (f) => { const h = argv.find((a) => a.startsWith(`--${f}=`)); return h ? h.slice(f.length + 3) : null; }; + +function run(cmd, args) { + console.log(`\n$ ${cmd} ${args.join(' ')}\n`); + execFileSync(cmd, args, { stdio: 'inherit', cwd: ROOT }); +} + +if (!existsSync(PROJECT)) { + console.error(`release-safari: ${PROJECT} not found. Scaffold it first (see safari/README.md).`); + process.exit(1); +} +mkdirSync(OUT, { recursive: true }); + +if (has('archive')) { + // Refresh the extension payload + version stamp before archiving so the + // bundle can never ship a stale build/safari. + run('npm', ['run', 'build:safari']); + run('xcodebuild', ['archive', '-project', PROJECT, '-scheme', 'FSB', + '-configuration', 'Release', '-archivePath', ARCHIVE]); + console.log(`\nrelease-safari: archived -> ${ARCHIVE}`); +} + +const exportKind = val('export'); +if (exportKind) { + const plist = exportKind === 'app-store' + ? join(ROOT, 'safari', 'Config', 'ExportOptions-AppStore.plist') + : join(ROOT, 'safari', 'Config', 'ExportOptions-DeveloperID.plist'); + const dest = join(OUT, exportKind); + run('xcodebuild', ['-exportArchive', '-archivePath', ARCHIVE, + '-exportOptionsPlist', plist, '-exportPath', dest]); + console.log(`\nrelease-safari: exported ${exportKind} -> ${dest}`); + + if (has('notarize')) { + const profile = val('profile'); + if (!profile) { + console.error('release-safari: --notarize requires --profile='); + process.exit(1); + } + const zip = join(dest, 'FSB.zip'); + run('ditto', ['-c', '-k', '--keepParent', join(dest, 'FSB.app'), zip]); + run('xcrun', ['notarytool', 'submit', zip, '--keychain-profile', profile, '--wait']); + run('xcrun', ['stapler', 'staple', join(dest, 'FSB.app')]); + console.log('\nrelease-safari: notarized + stapled'); + } +} + +if (!has('archive') && !exportKind) { + console.log('release-safari: nothing to do. Pass --archive and/or --export=developer-id|app-store'); +} diff --git a/scripts/sync-safari-version.mjs b/scripts/sync-safari-version.mjs new file mode 100644 index 000000000..b9aa3cef4 --- /dev/null +++ b/scripts/sync-safari-version.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node +/** + * Generates safari/Config/Version.xcconfig from extension/manifest.json. + * + * extension/manifest.json is the single source of truth for FSB's version, so + * the Xcode project must never carry its own copy. This script derives both + * Apple version fields from it and is wired into `npm run build:safari`, so the + * two cannot drift. + * + * MARKETING_VERSION -> CFBundleShortVersionString, the manifest version + * verbatim (e.g. 0.9.90). + * CURRENT_PROJECT_VERSION -> CFBundleVersion. App Store Connect requires this + * to STRICTLY INCREASE on every upload, which a + * semver string cannot guarantee on its own + * (0.9.9 -> 0.9.10 decreases lexically, and + * 0.10.0 < 0.9.90 numerically if you just strip + * dots). Encoding each component in a fixed radix + * is monotonic AND deterministic from the version + * string alone: + * + * major * 1_000_000 + minor * 1_000 + patch + * + * 0.9.90 -> 9090 + * 0.9.99 -> 9099 + * 0.10.0 -> 10000 + * 1.0.0 -> 1000000 + * + * This caps minor and patch at 999 each, which is + * enforced below rather than silently wrapping. + * + * Usage: node scripts/sync-safari-version.mjs [--check] + * --check exit non-zero if the file is stale instead of rewriting it + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); +const MANIFEST = join(ROOT, 'extension', 'manifest.json'); +const OUT = join(ROOT, 'safari', 'Config', 'Version.xcconfig'); + +export function deriveVersions(manifestVersion) { + const m = /^(\d+)\.(\d+)\.(\d+)/.exec(manifestVersion || ''); + if (!m) throw new Error(`sync-safari-version: manifest version "${manifestVersion}" is not semver-shaped`); + const [major, minor, patch] = [Number(m[1]), Number(m[2]), Number(m[3])]; + if (minor > 999 || patch > 999) { + throw new Error( + `sync-safari-version: minor/patch must be <= 999 to keep CFBundleVersion monotonic ` + + `(got ${manifestVersion}). Widen the radix in scripts/sync-safari-version.mjs before releasing this.` + ); + } + return { + marketingVersion: `${major}.${minor}.${patch}`, + currentProjectVersion: major * 1_000_000 + minor * 1_000 + patch + }; +} + +export function renderXcconfig({ marketingVersion, currentProjectVersion }) { + return [ + '// GENERATED by scripts/sync-safari-version.mjs -- DO NOT EDIT.', + '// Source of truth: extension/manifest.json. Run `npm run build:safari`.', + '', + `MARKETING_VERSION = ${marketingVersion}`, + `CURRENT_PROJECT_VERSION = ${currentProjectVersion}`, + '' + ].join('\n'); +} + +const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8')); +const derived = deriveVersions(manifest.version); +const rendered = renderXcconfig(derived); + +const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +if (isMain) { + const check = process.argv.includes('--check'); + const current = existsSync(OUT) ? readFileSync(OUT, 'utf8') : null; + if (check) { + if (current !== rendered) { + console.error('sync-safari-version: Version.xcconfig is STALE. Run `node scripts/sync-safari-version.mjs`.'); + process.exit(1); + } + console.log(`sync-safari-version: OK (${derived.marketingVersion} / ${derived.currentProjectVersion})`); + } else { + mkdirSync(dirname(OUT), { recursive: true }); + writeFileSync(OUT, rendered); + console.log(`sync-safari-version: wrote ${derived.marketingVersion} / build ${derived.currentProjectVersion}`); + } +} diff --git a/scripts/validate-extension.mjs b/scripts/validate-extension.mjs index 55c5359c2..98a37b85f 100644 --- a/scripts/validate-extension.mjs +++ b/scripts/validate-extension.mjs @@ -1,9 +1,16 @@ #!/usr/bin/env node -// Static validation gate for the Chrome extension. +// Static validation gate for the browser extension. // Runs in CI before the Node test suite. Two checks: // 1. manifest.json sanity: MV3, required fields, every referenced asset exists. // 2. JS syntax: every .js file under known extension dirs is parsed via `node --check`. // Exits non-zero with a clear message on first failure. +// +// Flags: +// --root= validate a different tree (default: extension/) +// --profile=safari validate a Safari build output: skips the repo-global +// package.json + catalog-snapshot checks (already covered +// by the default run) and asserts the Safari-specific +// NEGATIVE invariants instead. import { execFileSync } from 'node:child_process'; import { createRequire } from 'node:module'; @@ -13,9 +20,21 @@ import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..'); -const EXT_ROOT = join(ROOT, 'extension'); const require = createRequire(import.meta.url); +const argv = process.argv.slice(2); +const argOf = (name) => { + const hit = argv.find((a) => a.startsWith(`--${name}=`)); + return hit ? hit.slice(name.length + 3) : null; +}; +const PROFILE = argOf('profile') || 'chrome'; +const EXT_ROOT = argOf('root') ? resolve(ROOT, argOf('root')) : join(ROOT, 'extension'); +const IS_SAFARI = PROFILE === 'safari'; + +// Permissions that must NOT survive into a Safari build: Safari implements +// none of them, and leaving them in the manifest is how a silent no-op ships. +const SAFARI_BLOCKED_PERMISSIONS = ['sidePanel', 'debugger', 'offscreen', 'system.memory']; + const errors = []; const fail = (msg) => errors.push(msg); @@ -65,15 +84,16 @@ if (!existsSync(manifestPath)) { } } -// ---------- 2. package.json semver ---------- +// ---------- 2. package.json semver (repo-global; default profile only) ---------- const pkgPath = join(ROOT, 'package.json'); try { + if (IS_SAFARI) throw { __skip: true }; const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); if (!/^\d+\.\d+\.\d+/.test(pkg.version || '')) { fail(`package.json version "${pkg.version}" is not semver-shaped`); } } catch (e) { - fail(`package.json read failed: ${e.message}`); + if (!e || !e.__skip) fail(`package.json read failed: ${e.message}`); } // ---------- 3. Generated capability catalog snapshot ---------- @@ -100,7 +120,10 @@ function readJsonDir(absDir) { } const catalogSnapshotPath = join(EXT_ROOT, 'catalog', 'recipe-index.generated.js'); -if (!existsSync(catalogSnapshotPath)) { +if (IS_SAFARI) { + // Snapshot freshness is a property of the source tree, already asserted by + // the default run. Re-checking a copied build output adds no signal. +} else if (!existsSync(catalogSnapshotPath)) { fail('capability catalog snapshot missing: extension/catalog/recipe-index.generated.js; run npm run package:extension'); } else { try { @@ -153,10 +176,40 @@ for (const file of jsFiles) { } } +// ---------- 5. Safari profile: negative invariants ---------- +if (IS_SAFARI && existsSync(manifestPath)) { + try { + const m = JSON.parse(readFileSync(manifestPath, 'utf8')); + for (const perm of SAFARI_BLOCKED_PERMISSIONS) { + if ((m.permissions ?? []).includes(perm)) { + fail(`safari manifest still declares unsupported permission: ${perm}`); + } + } + if (m.side_panel) fail('safari manifest still declares a side_panel key (Safari has no sidePanel API)'); + if (!(m.permissions ?? []).includes('nativeMessaging')) { + fail('safari manifest is missing the nativeMessaging permission'); + } + if (m.action?.default_popup) { + fail('safari manifest declares action.default_popup; Safari only fires action.onClicked when no popup is set'); + } + const csp = m.content_security_policy?.extension_pages; + if (!csp || !csp.includes('connect-src')) { + fail('safari manifest is missing content_security_policy.extension_pages connect-src'); + } + const war = (m.web_accessible_resources ?? []).flatMap((w) => w.resources ?? []); + if (war.some((r) => r.startsWith('offscreen/'))) { + fail('safari manifest still exposes an offscreen/ web-accessible resource'); + } + } catch (e) { + fail(`safari manifest invariant check failed: ${e.message}`); + } +} + // ---------- report ---------- if (errors.length) { - console.error(`validate-extension: ${errors.length} failure(s)\n`); + console.error(`${IS_SAFARI ? 'validate-extension[safari]' : 'validate-extension'}: ${errors.length} failure(s)\n`); for (const e of errors) console.error(` - ${e}`); process.exit(1); } -console.log(`validate-extension: OK (manifest valid, ${checked} JS files parsed clean)`); +const label = IS_SAFARI ? 'validate-extension[safari]' : 'validate-extension'; +console.log(`${label}: OK (manifest valid, ${checked} JS files parsed clean)`); diff --git a/tests/action-icon-behavior.test.js b/tests/action-icon-behavior.test.js index 3e870d6c1..f990b2bf4 100644 --- a/tests/action-icon-behavior.test.js +++ b/tests/action-icon-behavior.test.js @@ -141,7 +141,8 @@ function makeWorker(opts) { ops.push({ op: 'drawImage', x, y, w, h, alpha: ctx.globalAlpha, filter: ctx.filter, composite: ctx.globalCompositeOperation, - source: bitmap && bitmap.__scratch ? 'scratch' : 'bitmap' + source: bitmap && bitmap.__scratch ? 'scratch' : 'bitmap', + url: bitmap && bitmap.__bitmap }); }, save() { ops.push({ op: 'save' }); }, @@ -287,6 +288,9 @@ function makeWorker(opts) { } }; + // The Safari build's adapter defines FsbPlatform before this module loads. + if (opts.platform) sandbox.FsbPlatform = opts.platform; + const context = vm.createContext(sandbox); vm.runInContext(ICON_SRC, context, { filename: 'action-icon.js' }); @@ -365,7 +369,9 @@ function makeWorker(opts) { } return out; }, - resetOps: (size) => { if (contexts[size]) contexts[size]._ops.length = 0; } + resetOps: (size) => { if (contexts[size]) contexts[size]._ops.length = 0; }, + // The glow's scratch surface for one size, as one flat op log. + scratchOps: (size) => (scratchContexts[size] || { _ops: [] })._ops }; } @@ -1881,6 +1887,96 @@ const opsOf = (group, op) => (group || []).filter((o) => o.op === op); 'disabling live motion does not mutate watch functionality'); } + // ------------------------------------------------------------------------- + console.log('\nICON-SAFARI: a tintsGrayscaleToolbarIcon platform keeps every frame in colour'); + // ------------------------------------------------------------------------- + + { + // Safari tints a toolbar icon unless at least 3% of its pixels carry colour + // (-[NSImage safari_isGrayscale]), so FSB's black-and-white tile came out as + // a solid square. On such a platform the tile is FSB orange with black + // lettering, the accents that would vanish into it are white, and the glow + // is still cut from the black-and-white artwork. + const SAFARI = { caps: { tintsGrayscaleToolbarIcon: true } }; + const fresh = async () => { + const t = makeWorker({ platform: SAFARI }); + await t.api.init({ hasLiveSession: () => false }); + await flush(); + return t; + }; + const dimDraw = (worker) => [].concat(...worker.groups(32)) + .find((o) => o.op === 'drawImage' && Math.abs(o.alpha - 0.5) < 1e-9); + const tileDraws = (frameSets) => [16, 32] + .flatMap((size) => [].concat(...frameSets[size])) + .filter((o) => o.op === 'drawImage' && o.source === 'bitmap'); + + const w = await fresh(); + assert(w.fetches.some((u) => u.endsWith('assets/safari-toolbar-16.png')) + && w.fetches.some((u) => u.endsWith('assets/safari-toolbar-32.png')), + 'the tile comes from the orange safari-toolbar set'); + assert(w.fetches.some((u) => u.endsWith('assets/icon16.png')) + && w.fetches.some((u) => u.endsWith('assets/icon128.png')), + 'the black-and-white tiles are decoded for the glow mask'); + const restDraws = tileDraws({ 16: w.groups(16), 32: w.groups(32) }); + assert(restDraws.length > 0 && restDraws.every((o) => /assets\/safari-toolbar-(16|32)\.png$/.test(o.url)), + 'the toolbar itself only ever shows the orange tile'); + const safariDim = dimDraw(w); + assert(!!safariDim, 'the dimmed resting frame is rendered at init'); + assertEq(safariDim && safariDim.filter, 'none', 'dimming halves the tile without graying it'); + + // The watching breathe: the orange plate stays put, the glow is white and + // cut from the black-and-white tile on the scratch surface. + const bw = await fresh(); + bw.resetOps(32); + bw.api.setWatching(true); + await flush(); + const plate = opsOf(bw.groups(32)[0] || [], 'fillRect')[0]; + assertEq(plate && plate.fillStyle, '#ff8c00', 'the breathe repaints the orange plate'); + assert(tileDraws({ 16: bw.groups(16), 32: bw.groups(32) }) + .every((o) => /assets\/safari-toolbar-(16|32)\.png$/.test(o.url)), + 'the breathe draws the orange tile over it'); + const maskDraws = opsOf(bw.scratchOps(32), 'drawImage'); + assert(maskDraws.length > 0 && maskDraws.every((o) => /assets\/icon128\.png$/.test(o.url)), + 'the glow is cut from the black-and-white tile'); + const tint = bw.scratchOps(32).find((o) => o.op === 'fillRect' && o.composite === 'multiply'); + assertEq(tint && tint.fillStyle, 'rgba(255, 255, 255, 1)', 'the glow is white'); + + // Orbit and sweep keep their shape and timing, drawn white. + const orbit = await renderActivity(await fresh(), 'orbit'); + const beads = opsOf(orbit[32][0], 'stroke'); + assert(beads.length > 0 && beads.every((s) => /^rgba\(255, 255, 255, /.test(s.strokeStyle)), + 'the orbit bead is white'); + const sweep = await renderActivity(await fresh(), 'sweep'); + const sweepFills = opsOf(sweep[32][0], 'fill'); + assertEq(sweepFills[0] && sweepFills[0].fillStyle, 'rgba(0, 0, 0, 0.15)', 'the sweep track is a faint dark band'); + const sweepGrad = opsOf(sweep[32][0], 'linear')[0] || { stops: [] }; + assertEq(sweepGrad.stops.map((s) => s.color).join(','), '#ffffff,#ffffff', 'the sweep fill is white'); + const ring = await renderCapability(await fresh(), 1); + const ringStroke = opsOf(ring[32][0], 'stroke')[0]; + assert(/139, 92, 246/.test((ringStroke && ringStroke.strokeStyle) || ''), 'the ring keeps the capability violet'); + + // Chrome, and every harness without an adapter, is unchanged. + const chromeWorker = makeWorker(); + await chromeWorker.api.init({ hasLiveSession: () => false }); + await flush(); + assert(chromeWorker.fetches.some((u) => u.endsWith('assets/icon16.png')) + && chromeWorker.fetches.some((u) => u.endsWith('assets/icon128.png')), + 'without the capability the original tiles are used'); + assert(!chromeWorker.fetches.some((u) => /safari-toolbar/.test(u)), 'and the orange set is never loaded'); + const chromeDim = dimDraw(chromeWorker); + assertEq(chromeDim && chromeDim.filter, 'grayscale(0.8)', 'and dimming still grays the tile'); + chromeWorker.resetOps(32); + chromeWorker.api.setWatching(true); + await flush(); + const chromePlate = opsOf(chromeWorker.groups(32)[0] || [], 'fillRect')[0]; + assertEq(chromePlate && chromePlate.fillStyle, '#000000', 'the Chrome breathe keeps the black plate'); + const chromeMasks = opsOf(chromeWorker.scratchOps(32), 'drawImage'); + assert(chromeMasks.length > 0 && chromeMasks.every((o) => /assets\/icon128\.png$/.test(o.url)), + 'and cuts its glow from the tile it draws'); + const chromeTint = chromeWorker.scratchOps(32).find((o) => o.op === 'fillRect' && o.composite === 'multiply'); + assertEq(chromeTint && chromeTint.fillStyle, 'rgba(255, 140, 0, 1)', 'in orange'); + } + // ------------------------------------------------------------------------- console.log('\n========================================='); console.log('action-icon-behavior.test.js results'); diff --git a/tests/actions-pointer-fallbacks.test.js b/tests/actions-pointer-fallbacks.test.js new file mode 100644 index 000000000..1435accaa --- /dev/null +++ b/tests/actions-pointer-fallbacks.test.js @@ -0,0 +1,514 @@ +/** + * Tests for the DOM pointer/wheel fallbacks in extension/content/actions.js. + * + * These are the Safari replacements for the seven _route:'cdp' tools. They are + * extracted verbatim from actions.js source and executed against a minimal DOM + * stub that records every dispatched event, so the assertions are about the + * ACTUAL shipped code, not a reimplementation. + * + * The two behaviours most worth pinning, because both are easy to get subtly + * wrong and neither fails loudly in a browser: + * - pointerDrag must fire the HTML5 drag family ONLY for draggable sources; + * HTML5 DnD does not observe pointer events at all. + * - wheelScrollAt must actually scrollBy() when unhandled, while respecting + * preventDefault() from map/canvas listeners that consume the wheel tick. + * + * Run: node tests/actions-pointer-fallbacks.test.js + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const SRC = fs.readFileSync(path.join(__dirname, '..', 'extension', 'content', 'actions.js'), 'utf8'); + +let passed = 0; +let failed = 0; +function passAssert(cond, msg) { + if (cond) { passed++; console.log(' PASS:', msg); } + else { failed++; console.error(' FAIL:', msg); } +} +function passAssertEqual(a, b, msg) { passAssert(a === b, msg + ' (got ' + JSON.stringify(a) + ')'); } + +// --- extract the shipped block verbatim ------------------------------------- +const START = ' const FSB_UNTRUSTED_NOTE'; +const END = ' // =========================================================================\n // VAULT FILL'; +const i0 = SRC.indexOf(START); +const i1 = SRC.indexOf(END, i0); +if (i0 < 0 || i1 < 0) { + console.error('FAIL: could not locate the DOM fallback block in actions.js'); + process.exit(1); +} +const BLOCK = SRC.slice(i0, i1); + +// --- DOM stub --------------------------------------------------------------- +function makeEl(tag, opts = {}) { + const el = { + tagName: tag.toUpperCase(), + id: opts.id || '', + draggable: opts.draggable === true, + value: opts.value !== undefined ? opts.value : '', + shadowRoot: opts.shadowRoot || null, + parentElement: opts.parentElement || null, + scrollTop: 0, scrollLeft: 0, + scrollHeight: opts.scrollHeight || 0, clientHeight: opts.clientHeight || 0, + scrollWidth: opts.scrollWidth || 0, clientWidth: opts.clientWidth || 0, + events: [], + clicked: 0, + focused: 0, + scrollCalls: 0, + getAttribute: (n) => (n === 'draggable' ? (opts.draggable === true ? 'true' : null) : null), + dispatchEvent(e) { + el.events.push(e.type); + if (opts.preventWheelDefault && e.type === 'wheel' && typeof e.preventDefault === 'function') { + e.preventDefault(); + } + return e.defaultPrevented !== true; + }, + click() { el.clicked += 1; }, + focus() { el.focused += 1; }, + scrollBy(o) { + el.scrollCalls += 1; + el.scrollTop += (o.top || 0); + el.scrollLeft += (o.left || 0); + } + }; + return el; +} + +function buildEnv({ hit, scroller, active, execOk = true, pageScroller, tabbables = [] }) { + const calls = { exec: [], stability: [] }; + class Ev { + constructor(type, init) { + this.type = type; + this.defaultPrevented = false; + Object.assign(this, init || {}); + } + preventDefault() { + if (this.cancelable === true) this.defaultPrevented = true; + } + } + const win = { + innerWidth: 1000, innerHeight: 800, screenX: 0, screenY: 0, + PointerEvent: Ev, MouseEvent: Ev, WheelEvent: Ev, DragEvent: Ev, KeyboardEvent: Ev, + getComputedStyle: (n) => ({ + overflowY: n === scroller ? 'auto' : 'visible', + overflowX: n === scroller ? 'auto' : 'visible' + }) + }; + const body = makeEl('body'); + const html = makeEl('html'); + const doc = { + body, documentElement: html, + scrollingElement: pageScroller || scroller || html, + activeElement: active || null, + elementFromPoint: () => hit, + querySelector: () => hit, + querySelectorAll: () => tabbables, + execCommand: (cmd, ui, val) => { calls.exec.push([cmd, val]); return execOk; } + }; + const sandbox = { + tools: {}, + window: win, document: doc, + MouseEvent: Ev, PointerEvent: Ev, WheelEvent: Ev, DragEvent: Ev, Event: Ev, + DataTransfer: function DataTransfer() { this.items = []; }, + FSB: { elementCache: new Map() }, + waitForStability: async (kind) => { calls.stability.push(kind); }, + setTimeout: (fn) => fn(), + Math, Object, Promise, JSON, String, Number, Array + }; + const fn = new Function( + 'tools', 'window', 'document', 'MouseEvent', 'PointerEvent', 'WheelEvent', + 'DragEvent', 'Event', 'DataTransfer', 'FSB', 'waitForStability', + BLOCK + '\nreturn tools;' + ); + const tools = fn( + sandbox.tools, sandbox.window, sandbox.document, Ev, Ev, Ev, Ev, Ev, + sandbox.DataTransfer, sandbox.FSB, sandbox.waitForStability + ); + return { tools, calls, doc }; +} + +// Element with the focus-related surface the click and key paths read, whose +// focus()/blur() move document.activeElement the way a browser does. +function focusableEl(env, tag, opts = {}) { + const el = makeEl(tag, opts); + const attrs = opts.attrs || {}; + Object.assign(el, { + type: opts.type || '', + disabled: opts.disabled === true, + readOnly: opts.readOnly === true, + isContentEditable: opts.contentEditable === true, + tabIndex: opts.tabIndex !== undefined ? opts.tabIndex : 0, + form: opts.form || null, + blurred: 0, + hasAttribute: (n) => Object.prototype.hasOwnProperty.call(attrs, n), + getClientRects: () => [1], + focus() { el.focused += 1; env.doc.activeElement = el; }, + blur() { el.blurred += 1; if (env.doc.activeElement === el) env.doc.activeElement = env.doc.body; } + }); + return el; +} + +const DEGRADED_NOTE = /chrome\.debugger unavailable/; + +(async function run() { + console.log('\n=== 1. pointerClickAt ==='); + { + const el = makeEl('button', { id: 'go' }); + const { tools } = buildEnv({ hit: el }); + const r = await tools.pointerClickAt({ x: 10, y: 20 }); + passAssert(r.success === true, 'succeeds'); + passAssertEqual(r.trusted, false, 'trusted:false'); + passAssertEqual(r.degraded, true, 'degraded:true'); + passAssert(DEGRADED_NOTE.test(r.cdpError), 'cdpError explains the platform gap'); + passAssertEqual(el.events.join(','), + 'pointerover,pointerenter,pointermove,pointerdown,mousedown,pointerup,mouseup,click', + 'full pointer+mouse sequence'); + passAssertEqual(el.events.filter((e) => e === 'click').length, 1, 'exactly one click event'); + passAssertEqual(el.clicked, 0, + 'native .click() NOT invoked -- a second click would double-fire listeners and undo toggles'); + passAssertEqual(r.target, '', 'target described'); + } + { + const el = makeEl('a'); + const { tools } = buildEnv({ hit: el }); + await tools.pointerClickAt({ x: 10, y: 20, ctrlKey: true }); + passAssertEqual(el.clicked, 0, 'modifier-click does NOT call native .click()'); + } + { + const { tools } = buildEnv({ hit: null }); + const r = await tools.pointerClickAt({ x: 5, y: 5 }); + passAssert(r.success === false && /No element at/.test(r.error), 'no element -> explicit failure'); + const bad = await tools.pointerClickAt({ x: -1, y: 5 }); + passAssert(bad.success === false && /outside viewport/.test(bad.error), 'out-of-viewport rejected'); + const nan = await tools.pointerClickAt({}); + passAssert(nan.success === false && /coordinates required/.test(nan.error), 'missing coords rejected'); + } + + console.log('\n=== 2. pointerDoubleClickAt ==='); + { + const el = makeEl('div'); + const { tools } = buildEnv({ hit: el }); + const r = await tools.pointerDoubleClickAt({ x: 1, y: 1 }); + passAssert(r.success === true && r.degraded === true, 'succeeds, degraded'); + passAssertEqual(el.events.filter((e) => e === 'click').length, 2, 'two click events'); + passAssertEqual(el.events.filter((e) => e === 'dblclick').length, 1, 'one dblclick'); + passAssert(el.events.indexOf('dblclick') === el.events.length - 1, 'dblclick fires last'); + } + + console.log('\n=== 3. pointerClickAndHoldAt ==='); + { + const el = makeEl('div'); + const { tools } = buildEnv({ hit: el }); + const r = await tools.pointerClickAndHoldAt({ x: 1, y: 1, holdMs: 10 }); + passAssert(r.success === true, 'succeeds'); + passAssertEqual(r.holdMs, 10, 'holdMs echoed'); + const down = el.events.indexOf('pointerdown'); + const up = el.events.indexOf('pointerup'); + passAssert(down !== -1 && up !== -1 && down < up, 'pointerdown precedes pointerup'); + } + + console.log('\n=== 4. pointerDrag -- HTML5 branch ONLY when draggable ==='); + { + const el = makeEl('div', { draggable: true }); + const { tools } = buildEnv({ hit: el }); + const r = await tools.pointerDrag({ startX: 0, startY: 0, endX: 100, endY: 100, steps: 3 }); + passAssert(r.success === true, 'succeeds'); + passAssertEqual(r.html5Drag, true, 'reports html5Drag:true'); + for (const t of ['dragstart', 'drag', 'dragover', 'drop', 'dragend']) { + passAssert(el.events.includes(t), `fires ${t}`); + } + passAssert(el.events.indexOf('dragstart') < el.events.indexOf('drop'), 'dragstart precedes drop'); + passAssert(el.events.lastIndexOf('dragend') > el.events.indexOf('drop'), 'dragend follows drop'); + } + { + const el = makeEl('div', { draggable: false }); + const { tools } = buildEnv({ hit: el }); + const r = await tools.pointerDrag({ startX: 0, startY: 0, endX: 50, endY: 50, steps: 3 }); + passAssertEqual(r.html5Drag, false, 'non-draggable reports html5Drag:false'); + for (const t of ['dragstart', 'drag', 'drop', 'dragend']) { + passAssert(!el.events.includes(t), `does NOT fire ${t} for a non-draggable source`); + } + passAssert(el.events.includes('pointermove'), 'still fires pointermove'); + } + { + const el = makeEl('div'); + const { tools } = buildEnv({ hit: el }); + const r = await tools.pointerDrag({ startX: 0, startY: 0, endX: 10 }); + passAssert(r.success === false && /endY required/.test(r.error), 'missing endY rejected'); + } + + console.log('\n=== 5. pointerDragVariableSpeed ==='); + { + const el = makeEl('div'); + const { tools } = buildEnv({ hit: el }); + const r = await tools.pointerDragVariableSpeed({ startX: 0, startY: 0, endX: 40, endY: 40 }); + passAssert(r.success === true, 'succeeds'); + passAssertEqual(r.method, 'domPointerEventsVariableSpeed', 'distinct method label'); + passAssertEqual(r.steps, 30, 'defaults to 30 steps'); + } + + console.log('\n=== 6. wheelScrollAt actually scrolls ==='); + { + const scroller = makeEl('div', { scrollHeight: 5000, clientHeight: 500 }); + const el = makeEl('span', { parentElement: scroller }); + const { tools } = buildEnv({ hit: el, scroller }); + const r = await tools.wheelScrollAt({ x: 5, y: 5, deltaY: 240 }); + passAssert(r.success === true && r.degraded === true, 'succeeds, degraded'); + passAssert(el.events.includes('wheel'), 'dispatches a wheel event for listener parity'); + passAssertEqual(scroller.scrollTop, 240, + 'performs a REAL scrollBy (an untrusted WheelEvent alone scrolls nothing)'); + passAssertEqual(r.scrolled.top, 240, 'reports the observed delta'); + } + { + const scroller = makeEl('div', { scrollHeight: 5000, clientHeight: 500 }); + const el = makeEl('canvas', { parentElement: scroller, preventWheelDefault: true }); + const { tools, calls } = buildEnv({ hit: el, scroller }); + const r = await tools.wheelScrollAt({ x: 5, y: 5, deltaY: 240 }); + passAssert(r.success === true && r.degraded === true, 'canceled wheel remains an honest degraded success'); + passAssert(el.events.includes('wheel'), 'dispatches the wheel event to the consuming map/canvas listener'); + passAssertEqual(scroller.scrollCalls, 0, 'preventDefault suppresses the programmatic scroll fallback'); + passAssertEqual(scroller.scrollTop, 0, 'a consumed map zoom does not also scroll its ancestor'); + passAssertEqual(r.scrolled.top, 0, 'reports zero observed scroll for a consumed wheel tick'); + passAssert(calls.stability.includes('scroll'), 'still waits for the listener-driven effect to settle'); + } + + console.log('\n=== 6b. wheelScrollAt walks the composed tree ==='); + // _fsbHitTest lands inside open shadow roots, where parentElement is null at + // the shadow root. A walk that stops there scrolls the document instead, so + // each case gives the document its own scroller to catch exactly that. + { + const host = makeEl('x-panel', { id: 'host', scrollHeight: 5000, clientHeight: 500 }); + const inner = makeEl('span'); + inner.parentNode = { nodeType: 11, host }; + const page = makeEl('html'); + const { tools } = buildEnv({ hit: inner, scroller: host, pageScroller: page }); + const r = await tools.wheelScrollAt({ x: 5, y: 5, deltaY: 240 }); + passAssertEqual(host.scrollTop, 240, 'shadow content scrolls its scrolling host'); + passAssertEqual(page.scrollCalls, 0, 'not the document'); + passAssertEqual(r.scroller, '', 'reports the host as the scroller'); + } + { + const outer = makeEl('div', { id: 'feed', scrollHeight: 5000, clientHeight: 500 }); + const host = makeEl('x-card', { parentElement: outer }); + const inner = makeEl('span'); + inner.parentNode = { nodeType: 11, host }; + const page = makeEl('html'); + const { tools } = buildEnv({ hit: inner, scroller: outer, pageScroller: page }); + await tools.wheelScrollAt({ x: 5, y: 5, deltaY: 240 }); + passAssertEqual(outer.scrollTop, 240, 'continues past the host to a light-DOM scroller above it'); + passAssertEqual(page.scrollCalls, 0, 'and does not fall through to the document'); + } + { + // Slotted content is laid out inside the component's own scroll box, which + // its light-DOM parentElement (the host) skips straight over. + const shadowScroller = makeEl('div', { id: 'list', scrollHeight: 5000, clientHeight: 500 }); + const slot = makeEl('slot', { parentElement: shadowScroller }); + const host = makeEl('x-list'); + const item = makeEl('li', { parentElement: host }); + item.assignedSlot = slot; + const { tools } = buildEnv({ hit: item, scroller: shadowScroller, pageScroller: makeEl('html') }); + await tools.wheelScrollAt({ x: 5, y: 5, deltaY: 240 }); + passAssertEqual(shadowScroller.scrollTop, 240, 'slotted content scrolls the scroll box inside the component'); + passAssertEqual(host.scrollCalls, 0, 'not the host it is slotted into'); + } + + console.log('\n=== 7. domInsertTextAt ==='); + { + const el = makeEl('input', { value: 'old' }); + const { tools, calls } = buildEnv({ hit: el, active: el }); + const r = await tools.domInsertTextAt({ text: 'hello', selector: '#x' }); + passAssert(r.success === true && r.degraded === true, 'succeeds, degraded'); + passAssertEqual(r.textLength, 5, 'reports text length'); + // insert_text's schema never sends clearFirst, and cdpInsertText treats it + // as opt-in. Defaulting it ON here would make Safari wipe the very field + // the model meant to insert into, while Chrome inserts at the cursor. + passAssertEqual(el.value, 'old', 'does NOT clear by default -- parity with cdpInsertText'); + passAssert(!calls.exec.some((c) => c[0] === 'selectAll' || c[0] === 'delete'), + 'no selectAll/delete without an explicit clearFirst'); + passAssert(calls.exec.some((c) => c[0] === 'insertText' && c[1] === 'hello'), + 'uses execCommand insertText (what rich editors actually observe)'); + passAssert(el.events.includes('input') && el.events.includes('change'), 'fires input + change'); + } + { + const el = makeEl('input', { value: 'old' }); + const { tools } = buildEnv({ hit: el, active: el }); + const r = await tools.domInsertTextAt({ text: 'hello', selector: '#x', clearFirst: true }); + passAssert(r.success === true && r.clearFirst === true, 'explicit clearFirst is honoured'); + passAssertEqual(el.value, '', 'explicit clearFirst empties the field'); + } + { + const el = makeEl('div'); + const { tools } = buildEnv({ hit: el, active: el, execOk: false }); + const r = await tools.domInsertTextAt({ text: 'x', selector: '#y' }); + passAssert(r.success === false && /rejected/.test(r.error), + 'non-form element with execCommand rejected -> honest failure, not a false success'); + } + { + const { tools } = buildEnv({ hit: null, active: null }); + const r = await tools.domInsertTextAt({ text: 'x' }); + passAssert(r.success === false && /No focused element/.test(r.error), 'no target -> failure'); + } + + console.log('\n=== 7b. pointerClickAt moves focus the way a real mousedown does ==='); + { + // Untrusted mousedown has no default action, so without this a remote + // click into a field leaves focus where it was and typing goes nowhere. + const env = {}; + const input = focusableEl(env, 'input', { type: 'text' }); + const realFocus = input.focus; + input.focus = function () { input.events.push('FOCUS'); realFocus.call(input); }; + Object.assign(env, buildEnv({ hit: input })); + await env.tools.pointerClickAt({ x: 10, y: 10 }); + passAssert(env.doc.activeElement === input, 'clicking an input focuses it'); + passAssertEqual(input.events.join(','), + 'pointerover,pointerenter,pointermove,pointerdown,mousedown,FOCUS,pointerup,mouseup,click', + 'focus moves between mousedown and mouseup, and the event sequence is unchanged'); + } + { + const env = {}; + const host = focusableEl(env, 'div', { contentEditable: true }); + const child = focusableEl(env, 'span', { contentEditable: true, parentElement: host }); + Object.assign(env, buildEnv({ hit: child })); + await env.tools.pointerClickAt({ x: 10, y: 10 }); + passAssert(env.doc.activeElement === host && child.focused === 0, + 'a click inside a contenteditable focuses its editing host'); + } + { + const env = {}; + const field = focusableEl(env, 'input', { type: 'text' }); + const plain = makeEl('div'); + Object.assign(env, buildEnv({ hit: plain, active: field })); + await env.tools.pointerClickAt({ x: 10, y: 10 }); + passAssertEqual(field.blurred, 1, 'a click on nothing focusable blurs the focused field'); + } + { + // Custom dropdowns cancel mousedown precisely so the input keeps focus. + const env = {}; + const field = focusableEl(env, 'input', { type: 'text' }); + const option = focusableEl(env, 'li', { attrs: { tabindex: '-1' } }); + const realDispatch = option.dispatchEvent; + option.dispatchEvent = function (e) { + if (e.type === 'mousedown') e.preventDefault(); + return realDispatch.call(option, e); + }; + Object.assign(env, buildEnv({ hit: option, active: field })); + await env.tools.pointerClickAt({ x: 10, y: 10 }); + passAssert(env.doc.activeElement === field && field.blurred === 0 && option.focused === 0, + 'a page that cancels mousedown keeps focus where it was'); + } + { + const env = {}; + const wrapper = focusableEl(env, 'div', { attrs: { tabindex: '0' } }); + const button = focusableEl(env, 'button', { disabled: true, parentElement: wrapper }); + Object.assign(env, buildEnv({ hit: button })); + await env.tools.pointerClickAt({ x: 10, y: 10 }); + passAssert(button.focused === 0 && env.doc.activeElement === wrapper, + 'a disabled control is skipped for its focusable ancestor'); + } + + console.log('\n=== 7c. domKeyPress ==='); + { + const env = {}; + const input = focusableEl(env, 'input', { type: 'text' }); + Object.assign(env, buildEnv({ hit: input, active: input })); + const r = await env.tools.domKeyPress({ key: 'Backspace', code: 'Backspace' }); + passAssert(r.success === true && r.trusted === false && r.degraded === true, 'succeeds, untrusted, degraded'); + passAssertEqual(r.method, 'domKeyEvents', 'method reported'); + passAssertEqual(input.events.join(','), 'keydown,keyup', 'keydown then keyup on the focused element'); + passAssertEqual(JSON.stringify(env.calls.exec), '[["delete",null]]', 'Backspace deletes when the page leaves it alone'); + passAssertEqual(r.defaultAction, 'delete', 'reports the default action it performed'); + passAssertEqual(env.calls.stability[0], 'type_keystroke', 'waits with the keystroke stability profile'); + } + { + const env = {}; + const input = focusableEl(env, 'input', { type: 'text' }); + const realDispatch = input.dispatchEvent; + input.dispatchEvent = function (e) { + if (e.type === 'keydown') e.preventDefault(); + return realDispatch.call(input, e); + }; + Object.assign(env, buildEnv({ hit: input, active: input })); + const r = await env.tools.domKeyPress({ key: 'Backspace' }); + passAssert(env.calls.exec.length === 0 && r.defaultAction === null, + 'a page that handles the keydown itself gets no second, emulated delete'); + } + { + const env = {}; + const input = focusableEl(env, 'input', { type: 'text' }); + Object.assign(env, buildEnv({ hit: input, active: input })); + const r = await env.tools.domKeyPress({ key: 'Delete' }); + passAssertEqual(JSON.stringify(env.calls.exec), '[["forwardDelete",null]]', 'Delete deletes forward'); + passAssertEqual(r.defaultAction, 'forwardDelete', 'reported'); + } + { + const env = {}; + const input = focusableEl(env, 'input', { type: 'text', readOnly: true }); + Object.assign(env, buildEnv({ hit: input, active: input })); + const r = await env.tools.domKeyPress({ key: 'Backspace' }); + passAssert(env.calls.exec.length === 0 && r.defaultAction === null, 'a read-only field is not edited'); + } + { + const env = {}; + const input = focusableEl(env, 'input', { type: 'text' }); + Object.assign(env, buildEnv({ hit: input, active: input })); + const r = await env.tools.domKeyPress({ key: 'Backspace', ctrlKey: true }); + passAssert(env.calls.exec.length === 0 && r.defaultAction === null, + 'a modified key is left to the page (no guessing at shortcuts)'); + } + { + const env = {}; + let submitted = 0; + const form = { requestSubmit: () => { submitted += 1; } }; + const input = focusableEl(env, 'input', { type: 'search', form }); + Object.assign(env, buildEnv({ hit: input, active: input })); + const r = await env.tools.domKeyPress({ key: 'Enter' }); + passAssertEqual(submitted, 1, 'Enter in a form field submits through requestSubmit()'); + passAssertEqual(r.defaultAction, 'submit', 'reported'); + } + { + const env = {}; + const area = focusableEl(env, 'textarea'); + Object.assign(env, buildEnv({ hit: area, active: area })); + const r = await env.tools.domKeyPress({ key: 'Enter' }); + passAssertEqual(JSON.stringify(env.calls.exec), '[["insertText","\\n"]]', 'Enter in a textarea inserts a newline'); + passAssertEqual(r.defaultAction, 'newline', 'reported'); + } + { + const env = {}; + const first = focusableEl(env, 'input', { type: 'text' }); + const second = focusableEl(env, 'button'); + Object.assign(env, buildEnv({ hit: first, active: first, tabbables: [first, second] })); + const r = await env.tools.domKeyPress({ key: 'Tab' }); + passAssert(env.doc.activeElement === second, 'Tab focuses the next tabbable element'); + passAssertEqual(r.defaultAction, 'focus-next', 'reported'); + passAssert(first.events.includes('keydown') && !first.events.includes('keyup') && second.events.includes('keyup'), + 'the keyup lands on the newly focused element, as with a real press'); + const back = await env.tools.domKeyPress({ key: 'Tab', shiftKey: true }); + passAssert(env.doc.activeElement === first && back.defaultAction === 'focus-previous', 'Shift+Tab goes back'); + } + { + const env = {}; + const input = focusableEl(env, 'input', { type: 'text' }); + Object.assign(env, buildEnv({ hit: input, active: input })); + const r = await env.tools.domKeyPress({ key: 'ArrowLeft' }); + passAssert(r.success === true && r.defaultAction === null && env.calls.exec.length === 0, + 'arrow keys reach page listeners but are not emulated'); + const missing = await env.tools.domKeyPress({}); + passAssert(missing.success === false && /key required/.test(missing.error), 'a missing key is rejected'); + } + + console.log('\n=== 8. every fallback reports untrusted ==='); + for (const name of ['pointerClickAt', 'pointerDoubleClickAt', 'pointerClickAndHoldAt', + 'pointerDrag', 'pointerDragVariableSpeed', 'wheelScrollAt', 'domInsertTextAt', + 'domKeyPress']) { + passAssert(BLOCK.includes('tools.' + name) || BLOCK.includes(name), `${name} present in shipped block`); + } + passAssert(!/trusted:\s*true/.test(BLOCK), 'no fallback ever claims trusted:true'); + + console.log('\n---'); + console.log('passed:', passed, 'failed:', failed); + if (failed > 0) process.exit(1); +})().catch((e) => { console.error('TEST HARNESS ERROR:', e); process.exit(1); }); diff --git a/tests/build-safari-manifest.test.js b/tests/build-safari-manifest.test.js new file mode 100644 index 000000000..5147754cf --- /dev/null +++ b/tests/build-safari-manifest.test.js @@ -0,0 +1,108 @@ +/** + * Unit tests for transformManifest() in scripts/build-safari.mjs + * + * The Safari manifest transform is allowlist-driven on purpose: an + * unclassified permission must be a HARD ERROR, so that adding a permission to + * extension/manifest.json without deciding what Safari should do breaks the + * Safari build immediately rather than silently shipping a permission Safari + * ignores. The negative case at the bottom is the point of this file. + * + * Run: node tests/build-safari-manifest.test.js + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +let passed = 0; +let failed = 0; +function passAssert(cond, msg) { + if (cond) { passed++; console.log(' PASS:', msg); } + else { failed++; console.error(' FAIL:', msg); } +} +function passAssertEqual(a, b, msg) { passAssert(a === b, msg + ' (got ' + JSON.stringify(a) + ')'); } + +(async function run() { + const mod = await import('../scripts/build-safari.mjs'); + const { transformManifest } = mod; + const SRC = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'extension', 'manifest.json'), 'utf8')); + + console.log('\n=== 1. blocked permissions are stripped ==='); + const out = transformManifest(SRC); + for (const p of ['sidePanel', 'debugger', 'offscreen', 'system.memory']) { + passAssert(!out.permissions.includes(p), `permission stripped: ${p}`); + } + + console.log('\n=== 2. required permissions survive / are added ==='); + for (const p of ['activeTab', 'scripting', 'storage', 'unlimitedStorage', 'tabs', 'windows', 'webNavigation', 'alarms', 'clipboardWrite']) { + passAssert(out.permissions.includes(p), `permission kept: ${p}`); + } + passAssert(out.permissions.includes('nativeMessaging'), 'nativeMessaging present'); + passAssertEqual(out.permissions.filter((p) => p === 'nativeMessaging').length, 1, + 'nativeMessaging is not duplicated when the Chrome manifest already declares it'); + passAssertEqual( + transformManifest({ ...SRC, permissions: SRC.permissions.filter((p) => p !== 'nativeMessaging') }) + .permissions.includes('nativeMessaging'), + true, + 'nativeMessaging is added even when the Chrome manifest omits it'); + + console.log('\n=== 3. side_panel key removed ==='); + passAssertEqual(out.side_panel, undefined, 'side_panel key deleted'); + passAssert(!!SRC.side_panel, 'source manifest still has side_panel (Chrome untouched)'); + + console.log('\n=== 4. no action.default_popup ==='); + passAssertEqual(out.action && out.action.default_popup, undefined, + 'no default_popup: Safari only fires action.onClicked when none is declared'); + + console.log('\n=== 5. CSP names the MCP bridge ==='); + const csp = out.content_security_policy && out.content_security_policy.extension_pages; + passAssert(typeof csp === 'string' && csp.includes('ws://localhost:7225'), + 'extension_pages CSP allows ws://localhost:7225'); + passAssert(csp.includes("script-src 'self'"), "CSP keeps script-src 'self'"); + // Naming connect-src at all only NARROWS what Chrome allows by default, and + // the local-provider paths (lmstudioBaseUrl, customEndpoint) dial plain HTTP. + passAssert(csp.includes('http:'), 'CSP still allows http: so local LLM providers keep working'); + // Must be the STANDALONE token: a bare includes('ws:') is already satisfied by + // the ws://localhost:7225 entry and would prove nothing. ws-client.js derives + // the dashboard socket from the user-editable serverUrl via + // .replace(/^http/, 'ws'), so a self-hosted dashboard on http://host:port + // needs plain ws: -- naming only the MCP bridge URL would refuse it. + passAssert(csp.split(/[\s;]+/).includes('ws:'), + 'CSP allows plain ws: so a self-hosted dashboard over http:// keeps working'); + + console.log('\n=== 6. Safari minimum version ==='); + passAssertEqual(out.browser_specific_settings.safari.strict_min_version, '18.0', + 'strict_min_version 18.0 (content_scripts.world:"MAIN" floor)'); + + console.log('\n=== 7. offscreen resources dropped from web_accessible_resources ==='); + const war = (out.web_accessible_resources || []).flatMap((w) => w.resources || []); + passAssert(!war.some((r) => r.startsWith('offscreen/')), 'no offscreen/ resource advertised'); + passAssert(war.includes('test-data/**/*'), + 'test-data WAR entry KEPT (utils/token-comparator.js fetches it at runtime)'); + + console.log('\n=== 8. content_scripts pass through byte-identical ==='); + passAssertEqual(JSON.stringify(out.content_scripts), JSON.stringify(SRC.content_scripts), + 'content_scripts unchanged (canvas-interceptor MAIN world at document_start)'); + for (const k of ['manifest_version', 'name', 'version', 'description', 'homepage_url']) { + passAssertEqual(JSON.stringify(out[k]), JSON.stringify(SRC[k]), `${k} unchanged`); + } + + console.log('\n=== 9. transform does not mutate its input ==='); + const before = JSON.stringify(SRC); + transformManifest(SRC); + passAssertEqual(JSON.stringify(SRC), before, 'input manifest object is not mutated'); + + console.log('\n=== 10. NEGATIVE: an unclassified permission is a hard error ==='); + let threw = null; + try { + transformManifest({ ...SRC, permissions: [...SRC.permissions, 'bookmarks'] }); + } catch (e) { threw = e; } + passAssert(threw !== null, 'unknown permission throws'); + passAssert(threw && /unclassified permission/i.test(threw.message) && threw.message.includes('bookmarks'), + 'error names the offending permission'); + + console.log('\n---'); + console.log('passed:', passed, 'failed:', failed); + if (failed > 0) process.exit(1); +})().catch((e) => { console.error('TEST HARNESS ERROR:', e); process.exit(1); }); diff --git a/tests/cdp-degradation-routing.test.js b/tests/cdp-degradation-routing.test.js new file mode 100644 index 000000000..5304918db --- /dev/null +++ b/tests/cdp-degradation-routing.test.js @@ -0,0 +1,253 @@ +/** + * Tests the CDP -> DOM route flip in extension/ai/tool-executor.js. + * + * On Safari the seven _route:'cdp' tools have no trusted-input path. Rather + * than hard-fail, executeCdpTool re-routes them to the DOM equivalents in + * content/actions.js. Three properties matter: + * + * 1. CHROME REGRESSION GUARD -- with FsbPlatform absent (Chrome, and every + * Node harness) the cdpHandler must still be called. This is the assertion + * that proves the Safari work did not change Chrome behaviour. + * 2. The shared tool-registry object must NOT be mutated: tool-definitions + * is pinned by SHA-256 in tests/tool-definitions-parity.test.js. + * 3. Every _cdpVerb must map to a DOM verb that actually exists in + * content/actions.js -- a typo here would silently dead-end a tool -- + * or be declared in CDP_NO_DOM_FALLBACK and fail with a typed error. + * + * Run: node tests/cdp-degradation-routing.test.js + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +let passed = 0; +let failed = 0; +function passAssert(cond, msg) { + if (cond) { passed++; console.log(' PASS:', msg); } + else { failed++; console.error(' FAIL:', msg); } +} +function passAssertEqual(a, b, msg) { passAssert(a === b, msg + ' (got ' + JSON.stringify(a) + ')'); } + +const ROOT = path.join(__dirname, '..'); +const ACTIONS_SRC = fs.readFileSync(path.join(ROOT, 'extension', 'content', 'actions.js'), 'utf8'); + +// Load the adapter into a synthetic safari-like scope to read its real map. +function loadAdapter(forced) { + const scope = { __FSB_FORCE_PLATFORM__: forced, chrome: { runtime: {} }, console: { log() {} } }; + const src = fs.readFileSync(path.join(ROOT, 'extension', 'utils', 'platform-adapter.js'), 'utf8'); + new Function('globalThis', 'self', 'module', src)(scope, scope, undefined); + return scope.FsbPlatform; +} + +const defs = require('../extension/ai/tool-definitions.js'); + +function withPlatform(platform, fn) { + const had = Object.prototype.hasOwnProperty.call(globalThis, 'FsbPlatform'); + const prev = globalThis.FsbPlatform; + if (platform === undefined) delete globalThis.FsbPlatform; + else globalThis.FsbPlatform = platform; + try { return fn(); } finally { + if (had) globalThis.FsbPlatform = prev; else delete globalThis.FsbPlatform; + } +} + +(async function run() { + // chrome.tabs.sendMessage stub shared by the content route + const sent = []; + globalThis.chrome = { + tabs: { sendMessage: async (tabId, msg) => { sent.push({ tabId, msg }); return { success: true, ok: 1 }; } }, + runtime: {} + }; + + const { executeTool } = require('../extension/ai/tool-executor.js'); + const safariPlatform = loadAdapter('safari'); + + console.log('\n=== 1. CHROME REGRESSION GUARD: no FsbPlatform -> cdpHandler still used ==='); + await withPlatform(undefined, async () => { + let calledWith = null; + const res = await executeTool('click_at', { x: 1, y: 2 }, 7, { + cdpHandler: async (verb, params, tabId) => { calledWith = { verb, params, tabId }; return { success: true }; } + }); + passAssert(calledWith !== null, 'cdpHandler WAS called on Chrome'); + passAssertEqual(calledWith && calledWith.verb, 'cdpClickAt', 'received the CDP verb'); + passAssertEqual(calledWith && calledWith.tabId, 7, 'received the tabId'); + passAssert(res.success === true, 'result succeeds'); + }); + const sentDuringChrome = sent.length; + passAssertEqual(sentDuringChrome, 0, 'Chrome path sent ZERO content-script messages'); + + console.log('\n=== 2. Safari: routes to the mapped DOM verb ==='); + await withPlatform(safariPlatform, async () => { + sent.length = 0; + let cdpCalled = false; + const res = await executeTool('click_at', { x: 3, y: 4 }, 9, { + cdpHandler: async () => { cdpCalled = true; return { success: true }; } + }); + passAssert(!cdpCalled, 'cdpHandler NOT called on Safari'); + passAssertEqual(sent.length, 1, 'exactly one content-script dispatch'); + passAssertEqual(sent[0].msg.action, 'executeAction', 'dispatched via executeAction'); + passAssertEqual(sent[0].msg.tool, 'pointerClickAt', 'mapped to the DOM verb'); + passAssertEqual(sent[0].tabId, 9, 'preserved the tabId'); + passAssertEqual(JSON.stringify(sent[0].msg.params), JSON.stringify({ x: 3, y: 4 }), 'params passed through'); + passAssert(res.success === true, 'result succeeds'); + }); + + console.log('\n=== 3. Safari path works with NO cdpHandler at all ==='); + await withPlatform(safariPlatform, async () => { + sent.length = 0; + const res = await executeTool('drag', { startX: 0, startY: 0, endX: 5, endY: 5 }, 2, {}); + passAssert(res.success === true, 'succeeds without a cdpHandler (Safari never has one)'); + passAssertEqual(sent[0].msg.tool, 'pointerDrag', 'drag -> pointerDrag'); + }); + + console.log('\n=== 4. unmapped CDP verb -> typed capability_unavailable ==='); + await withPlatform(safariPlatform, async () => { + const stripped = Object.assign(Object.create(Object.getPrototypeOf(safariPlatform)), safariPlatform, { + CDP_DOM_FALLBACKS: {} + }); + await withPlatform(stripped, async () => { + sent.length = 0; + const res = await executeTool('click_at', { x: 1, y: 1 }, 1, {}); + passAssert(res.success === false, 'fails'); + passAssert(/capability_unavailable/.test(res.error), 'error is typed capability_unavailable'); + passAssert(/click_at/.test(res.error), 'error names the tool'); + passAssertEqual(sent.length, 0, 'no content dispatch for an unmapped verb'); + }); + }); + + console.log('\n=== 4b. capture_screenshot: no DOM fallback, but platform-handled -> cdpHandler ==='); + // utils/screenshot-capture.js captures through tabs.captureVisibleTab when + // there is no chrome.debugger, so the verb still goes to the handler. + passAssert(safariPlatform.CDP_NO_DOM_FALLBACK.includes('cdpCaptureScreenshot'), + 'cdpCaptureScreenshot still has no DOM equivalent'); + passAssert(Array.isArray(safariPlatform.CDP_PLATFORM_HANDLED) + && safariPlatform.CDP_PLATFORM_HANDLED.includes('cdpCaptureScreenshot'), + 'and is declared platform-handled'); + await withPlatform(safariPlatform, async () => { + sent.length = 0; + let calledWith = null; + const res = await executeTool('capture_screenshot', { mode: 'viewport' }, 4, { + cdpHandler: async (verb, params, tabId) => { calledWith = { verb, params, tabId }; return { success: true }; } + }); + passAssertEqual(calledWith && calledWith.verb, 'cdpCaptureScreenshot', 'Safari sends capture_screenshot to the handler'); + passAssertEqual(calledWith && calledWith.tabId, 4, 'with the tabId'); + passAssert(res.success === true, 'and returns its result'); + passAssertEqual(sent.length, 0, 'no content dispatch'); + }); + + console.log('\n=== 4c. a no-fallback verb that is NOT platform-handled still fails typed ==='); + await withPlatform(safariPlatform, async () => { + const unhandled = Object.assign(Object.create(Object.getPrototypeOf(safariPlatform)), safariPlatform, { + CDP_PLATFORM_HANDLED: [] + }); + await withPlatform(unhandled, async () => { + sent.length = 0; + let cdpCalled = false; + const res = await executeTool('capture_screenshot', {}, 1, { + cdpHandler: async () => { cdpCalled = true; return { success: true }; } + }); + passAssert(res.success === false && /capability_unavailable/.test(res.error), 'typed capability_unavailable'); + passAssert(!cdpCalled, 'the handler is not called'); + passAssertEqual(sent.length, 0, 'no content dispatch'); + }); + }); + + console.log('\n=== 5. the shared registry object is NOT mutated ==='); + { + const tool = defs.getToolByName('click_at'); + const before = JSON.stringify(tool); + await withPlatform(safariPlatform, async () => { + await executeTool('click_at', { x: 1, y: 1 }, 1, {}); + }); + passAssert(JSON.stringify(defs.getToolByName('click_at')) === before, + 'registry entry unchanged (tool-definitions-parity pins its SHA-256)'); + // CDP tools ship _contentVerb:null in the registry. The route flip must + // leave it null -- if it had been written in place, the DOM verb would + // leak into the pinned registry and into mcp/ai/tool-definitions.cjs. + passAssertEqual(tool._contentVerb, null, '_contentVerb still null on the shared tool (not overwritten)'); + } + + console.log('\n=== 6. every _cdpVerb maps to a DOM verb that EXISTS in actions.js ==='); + { + const map = safariPlatform.CDP_DOM_FALLBACKS; + const noFallback = safariPlatform.CDP_NO_DOM_FALLBACK; + const cdpTools = defs.TOOL_REGISTRY.filter((t) => t._route === 'cdp'); + passAssert(cdpTools.length === 8, `found ${cdpTools.length} _route:'cdp' tools (expected 8)`); + for (const t of cdpTools) { + if (noFallback.includes(t._cdpVerb)) continue; + const domVerb = map[t._cdpVerb]; + passAssert(!!domVerb, `${t.name} (${t._cdpVerb}) has a mapping`); + if (domVerb) { + passAssert(ACTIONS_SRC.includes(`tools.${domVerb} =`), + `${domVerb} is defined in content/actions.js`); + } + } + } + + console.log('\n=== 7. remote control takes the DOM path on EVERY input, not just keys ==='); + { + // The adapter's attach shim rejects with a message containing "debugger", + // and classifyFSBRemoteControlDispatchFailure pattern-matches that word to + // 'debugger-blocked' -- which broadcasts ownership 'external-debugger' and + // kills the session. So click / key / scroll must all be handled by the + // DOM dispatcher BEFORE reaching executeCDPToolDirect. + const WS_SRC = fs.readFileSync(path.join(ROOT, 'extension', 'ws', 'ws-client.js'), 'utf8'); + passAssert(/async function _fsbRemoteControlDomDispatch\(/.test(WS_SRC), + 'ws-client.js defines the shared DOM dispatcher'); + for (const [type, handler] of [ + ['dash:remote-click', 'handleRemoteClick'], + ['dash:remote-key', 'handleRemoteKey'], + ['dash:remote-scroll', 'handleRemoteScroll'] + ]) { + const call = `if (await _fsbRemoteControlDomDispatch('${type}', payload, tabId)) return;`; + const start = WS_SRC.indexOf(`async function ${handler}(`); + const callAt = WS_SRC.indexOf(call, start); + const cdpAt = WS_SRC.indexOf('executeCDPToolDirect(', start); + // The key path takes a CDP lease and attaches the debugger directly. + const attachAt = WS_SRC.indexOf('.attach({ tabId: tabId }', start); + passAssert(start !== -1 && callAt !== -1, `${type} goes through the DOM dispatcher`); + passAssert(callAt < cdpAt || cdpAt === -1, `${type} is dispatched before any executeCDPToolDirect call`); + if (handler === 'handleRemoteKey') { + passAssert(callAt < attachAt, `${type} is dispatched before the direct debugger attach`); + } + } + const dispatcher = WS_SRC.slice( + WS_SRC.indexOf('async function _fsbRemoteControlDomDispatch('), + WS_SRC.indexOf('async function handleRemoteClick(') + ); + passAssert(!/executeCDPToolDirect|chrome\.debugger/.test(dispatcher), + 'the DOM dispatcher never touches the debugger shim'); + // Proof that the misclassification is real, so the guard cannot be dropped: + // run the classifier's own pattern against the shim's real error message. + const patternLine = WS_SRC.match(/if \((\/[^\n]+\/i)\.test\(message\)\) return 'debugger-blocked';/); + passAssert(!!patternLine, "found the 'debugger-blocked' classifier pattern"); + if (patternLine) { + const shimMessage = safariPlatform.unavailable('chrome.debugger.attach').message; + passAssert(eval(patternLine[1]).test(shimMessage), + 'the shim error WOULD be classified debugger-blocked without the guard (' + shimMessage + ')'); + } + } + + console.log('\n=== 8. the click-and-hold fallback gets the long action timeout ==='); + { + // cdpClickAndHold sleeps for the caller's holdMs in the service worker, + // where nothing caps it. Its DOM replacement sleeps in the CONTENT script, + // under messaging.js's executeAction timeout -- 10s unless the verb is on + // the long-timeout list. click_and_hold documents itself for record buttons + // and long-press menus, so a holdMs at or above 10s is an ordinary request + // and would otherwise report a spurious timeout after the press completed. + const MSG_SRC = fs.readFileSync(path.join(ROOT, 'extension', 'content', 'messaging.js'), 'utf8'); + const listLine = MSG_SRC.match(/const longTimeoutTools = \[([^\]]*)\]/); + passAssert(!!listLine, 'found longTimeoutTools in content/messaging.js'); + if (listLine) { + passAssert(listLine[1].includes("'pointerClickAndHoldAt'"), + 'pointerClickAndHoldAt is exempt from the 10s executeAction timeout'); + } + } + + console.log('\n---'); + console.log('passed:', passed, 'failed:', failed); + if (failed > 0) process.exit(1); +})().catch((e) => { console.error('TEST HARNESS ERROR:', e); process.exit(1); }); diff --git a/tests/delegation-phase-contract.test.js b/tests/delegation-phase-contract.test.js index fe9dd72a2..94ced5f5d 100644 --- a/tests/delegation-phase-contract.test.js +++ b/tests/delegation-phase-contract.test.js @@ -157,6 +157,8 @@ const REFINEMENT_ROOT_TEST_COMMANDS = Object.freeze([ 'node tests/ws-client-relay-reconnect.test.js', 'node --test tests/network-capture-debugger-lifecycle.test.js', 'node tests/benchmark-assets-parity.test.js', + 'node tests/execute-js-trusted-types.test.js', + 'node tests/drag-drop-selector-resolution.test.js', ]); const PHASE64_RETAINED_ROOT_COMMANDS = Object.freeze([ diff --git a/tests/delegation-sidepanel-ui.test.js b/tests/delegation-sidepanel-ui.test.js index 7934b4b04..745c9898a 100644 --- a/tests/delegation-sidepanel-ui.test.js +++ b/tests/delegation-sidepanel-ui.test.js @@ -768,7 +768,8 @@ assert.equal((htmlSource.match(/id="delegationControlBar"/g) || []).length, 0, assert.equal(sha256(htmlSource .replace(delegationProviderScript, '') .replace(nativeHostInstallScript, '')), - '2f2fd0bb74edc1e6a6bb5d20127d8e67943013818e9cafe3d42d51b0c460ae7a', + // Rolled for the Safari port: bundled Font Awesome and the platform shim. + '2870979deee6c925445c690f9bb5eaae9a745fdb652ae12f4372c140c2cf223c', 'the side-panel HTML includes only the intended helpers and tab-scoped ownership treatment'); const replayCssStart = cssSource.indexOf('.history-status.idle-closed {'); const replayCssEnd = cssSource.indexOf('/* Phase 11 FINT-20', replayCssStart); @@ -777,9 +778,10 @@ assert(replayCssStart !== -1 && replayCssEnd > replayCssStart, const delegationCssSource = cssSource.slice(0, replayCssStart) + cssSource.slice(replayCssEnd); // Rolled when the prefers-reduced-motion block gained .pixel-letter coverage: // the block already silenced the delegation spinner and status dot, but the -// automation loader kept animating for users who asked it not to. +// automation loader kept animating for users who asked it not to. Rolled +// again for the Safari port's dvh heights and wide-window content cap. assert.equal(sha256(delegationCssSource), - 'db8ee02f3ed61b84b9ced7af1e9c043460b4f8fa24ba222d79e235badb058ef7', + 'ec8117b3381f6ab50c26aaa915e1f3fc8b8b9f75a67680a1d497f13d22c7c197', 'the intentional delegated and ownership-status CSS deltas remain exact outside replay UI'); { diff --git a/tests/drag-drop-selector-resolution.test.js b/tests/drag-drop-selector-resolution.test.js new file mode 100644 index 000000000..1e92041b6 --- /dev/null +++ b/tests/drag-drop-selector-resolution.test.js @@ -0,0 +1,103 @@ +/** + * drag_drop resolves CSS selectors as well as element refs. + * + * The tool takes an element ref ("e5") or a CSS selector ("#card-1"), and + * mcp/src/tools/schema-bridge.ts passes either through as sourceRef/targetRef. + * content/actions.js dragdrop only ever looked them up through + * FSB.getElementByRef (which does not exist) and [data-fsb-ref] (which nothing + * sets), so a CSS selector was always "Source element not found" -- on Chrome + * and Safari alike. A selector with quotes in it also made the [data-fsb-ref] + * lookup itself throw. + * + * Runs the shipped dragdrop function in a VM and stops at the first early + * return, so resolution is what is under test, not the drag simulation. + * + * Run: node tests/drag-drop-selector-resolution.test.js + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +let passed = 0; +let failed = 0; +function passAssert(cond, msg) { + if (cond) { passed++; console.log(' PASS:', msg); } + else { failed++; console.error(' FAIL:', msg); } +} + +const SRC = fs.readFileSync(path.join(__dirname, '..', 'extension', 'content', 'actions.js'), 'utf8'); +const start = SRC.indexOf(' dragdrop: async (params) => {'); +const end = SRC.indexOf('\n }\n};', start); +passAssert(start >= 0 && end > start, 'dragdrop found in content/actions.js'); +const DRAGDROP = SRC.slice(start, end + '\n }'.length); + +function dragdropWith({ refs = {}, selectors = {}, throwOnAttributeQuery = false } = {}) { + const calls = { resolveRef: [], querySelectorWithShadow: [] }; + const sandbox = { + FSB: { + resolveRef(ref) { calls.resolveRef.push(ref); return refs[ref] || null; }, + querySelectorWithShadow(sel) { calls.querySelectorWithShadow.push(sel); return selectors[sel] || null; } + }, + document: { + querySelector(sel) { + // A real document throws on a malformed selector, which is what an + // attribute selector wrapped around input[name="q"] becomes. + if (throwOnAttributeQuery && /data-fsb-ref/.test(sel)) throw new SyntaxError('not a valid selector'); + return null; + } + }, + logger: { log() {} }, + setTimeout + }; + const dragdrop = vm.runInNewContext(`({\n${DRAGDROP}\n}).dragdrop`, sandbox); + return { dragdrop, calls }; +} + +const el = (name) => ({ name, parentElement: null }); + +(async function run() { + console.log('\n=== CSS selectors resolve ==='); + { + const { dragdrop, calls } = dragdropWith({ selectors: { '#drag-source': el('source') } }); + const res = await dragdrop({ sourceRef: '#drag-source', targetRef: '#nowhere' }); + passAssert(/^Target element not found: #nowhere$/.test(res.error), 'a CSS selector source is found (fails on the missing target instead)'); + passAssert(calls.querySelectorWithShadow.includes('#drag-source'), 'through FSB.querySelectorWithShadow, like every other tool'); + } + + console.log('\n=== element refs resolve ==='); + { + const { dragdrop, calls } = dragdropWith({ refs: { e5: el('ref-source') } }); + const res = await dragdrop({ sourceRef: 'e5', targetRef: 'e99' }); + passAssert(/^Target element not found: e99$/.test(res.error), 'a ref source is found'); + passAssert(calls.resolveRef.includes('e5'), 'through FSB.resolveRef'); + } + + console.log('\n=== a selector with quotes does not throw ==='); + { + const { dragdrop } = dragdropWith({ selectors: { 'input[name="q"]': el('quoted') }, throwOnAttributeQuery: true }); + let res = null; + let threw = null; + try { res = await dragdrop({ sourceRef: 'input[name="q"]', targetRef: '#nowhere' }); } catch (err) { threw = err; } + passAssert(threw === null, 'the malformed [data-fsb-ref] lookup is contained'); + passAssert(res && /^Target element not found/.test(res.error), 'and the selector still resolves'); + } + + console.log('\n=== nothing matches -> the same typed error ==='); + { + const { dragdrop } = dragdropWith(); + const res = await dragdrop({ sourceRef: '#missing', targetRef: '#nowhere' }); + passAssert(res.success === false && res.error === 'Source element not found: #missing', 'source not found'); + const none = await dragdrop({ sourceRef: '', targetRef: '#nowhere' }); + passAssert(/required/.test(none.error), 'an empty ref is still rejected up front'); + } + + console.log('\n---'); + console.log('passed:', passed, 'failed:', failed); + if (failed > 0) process.exit(1); +})().catch((err) => { + console.error('TEST HARNESS ERROR:', err); + process.exit(1); +}); diff --git a/tests/execute-js-trusted-types.test.js b/tests/execute-js-trusted-types.test.js new file mode 100644 index 000000000..d0a85226d --- /dev/null +++ b/tests/execute-js-trusted-types.test.js @@ -0,0 +1,97 @@ +/** + * execute_js on pages that enforce Trusted Types. + * + * execute_js runs the caller's code with new Function(code) in the page's MAIN + * world. Under require-trusted-types-for 'script' (YouTube, among others) a + * plain string is refused: "Refused to evaluate a string as JavaScript because + * this document requires a 'Trusted Type' assignment." The page function now + * routes the code through a Trusted Types policy it creates once per page. + * + * Real enforcement only exists in a browser; this was run in WebKit (Safari + * 26.5's engine) with a YouTube-style header and with trusted-types 'none'. + * Here the same function, cut from extension/ws/mcp-bridge-client.js, is run + * in a VM with a fake trustedTypes to lock the wiring: + * 1. without trustedTypes the plain path is unchanged; + * 2. the policy is named fsb-execute-js, created once, and handed the code; + * 3. a policy the page refuses falls back to the plain string; + * 4. a Trusted Types refusal carries a hint saying why. + * + * Run: node tests/execute-js-trusted-types.test.js + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +let passed = 0; +let failed = 0; +function passAssert(cond, msg) { + if (cond) { passed++; console.log(' PASS:', msg); } + else { failed++; console.error(' FAIL:', msg); } +} +function passAssertEqual(a, b, msg) { passAssert(a === b, msg + ' (got ' + JSON.stringify(a) + ')'); } + +const src = fs.readFileSync(path.join(__dirname, '..', 'extension', 'ws', 'mcp-bridge-client.js'), 'utf8'); +const start = src.indexOf('func: (userCode) => {'); +const end = src.indexOf('\n },\n args: [code],', start); +const FN_SOURCE = start >= 0 && end > start ? src.slice(start + 'func: '.length, end + '\n }'.length) : null; + +function pageFunction(globals) { + const context = vm.createContext(Object.assign({}, globals)); + return vm.runInContext(`(${FN_SOURCE})`, context); +} + +passAssert(!!FN_SOURCE, 'execute_js page function found in mcp-bridge-client.js'); + +console.log('\n=== 1. no Trusted Types API: unchanged ==='); +{ + const run = pageFunction({}); + passAssertEqual(run('return 6 * 7').value, '42', 'returns the value'); + passAssertEqual(run('return { a: 1 }').value, '{"a":1}', 'objects are JSON'); + passAssertEqual(run('throw new Error("boom")').error, 'boom', 'errors are reported'); +} + +console.log('\n=== 2. the code goes through one cached policy ==='); +{ + const created = []; + const scripts = []; + const trustedTypes = { + createPolicy(name, rules) { + created.push(name); + return { + createScript(code) { + scripts.push(code); + const value = rules.createScript(code); + return { toString() { return value; } }; + } + }; + } + }; + const run = pageFunction({ trustedTypes }); + passAssertEqual(run('return 6 * 7').value, '42', 'a TrustedScript body runs'); + passAssertEqual(run('return 1 + 1').value, '2', 'and again'); + passAssertEqual(created.length, 1, 'the policy is created once per page'); + passAssertEqual(created[0], 'fsb-execute-js', 'under the fsb-execute-js name'); + passAssertEqual(scripts[0], 'return 6 * 7', 'createScript receives the caller\'s code unchanged'); +} + +console.log('\n=== 3. a refused policy falls back to the plain string ==='); +{ + const trustedTypes = { createPolicy() { throw new TypeError('Policy "fsb-execute-js" disallowed.'); } }; + const run = pageFunction({ trustedTypes }); + passAssertEqual(run('return 6 * 7').value, '42', 'still runs where strings are allowed'); +} + +console.log('\n=== 4. a Trusted Types refusal says why ==='); +{ + const run = pageFunction({}); + const res = run('throw new EvalError("Refused to evaluate a string as JavaScript because this document requires a \'Trusted Type\' assignment.")'); + passAssert(/did not allow FSB to create a policy/.test(res.error || ''), 'the refusal carries the hint'); + passAssert(/Refused to evaluate/.test(res.error || ''), 'and keeps the browser\'s own message'); +} + +console.log('\n---'); +console.log('passed:', passed, 'failed:', failed); +if (failed > 0) process.exit(1); diff --git a/tests/mcp-bridge-client-lifecycle.test.js b/tests/mcp-bridge-client-lifecycle.test.js index 485d84310..e31f056b0 100644 --- a/tests/mcp-bridge-client-lifecycle.test.js +++ b/tests/mcp-bridge-client-lifecycle.test.js @@ -115,9 +115,9 @@ function createRuntimeOnMessageMock() { }; } -function createChromeMock(initialSession = {}) { +function createChromeMock(initialSession = {}, localSeed = {}) { const session = createStorageArea(initialSession); - const local = createStorageArea(); + const local = createStorageArea(localSeed); const alarms = new Map(); const cleared = []; return { @@ -213,7 +213,7 @@ function createFakeWebSocketClass(options = {}) { } function buildClientHarness(options = {}) { - const chrome = createChromeMock(options.session); + const chrome = createChromeMock(options.session, options.storageLocalSeed); const timers = createFakeTimers(); const FakeWebSocket = createFakeWebSocketClass(options); const deterministicMath = Object.create(Math); @@ -227,6 +227,7 @@ function buildClientHarness(options = {}) { Date, EventTarget, CustomEvent, + TextEncoder, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout, setInterval: timers.setInterval, @@ -251,6 +252,16 @@ function buildClientHarness(options = {}) { }; } + // Transport selection reads globalThis.FsbPlatform. It is undefined on + // Chrome and in every other harness here, which is exactly what keeps the + // Chrome path at `new WebSocket(...)` with no policy machinery at all. + if (options.platform === 'safari') { + context.FsbPlatform = { id: 'safari', caps: { cdp: false, trustedInput: false } }; + } + if (options.nativeSocketClass) { + context.FsbNativeBridgeSocket = options.nativeSocketClass; + } + const source = fs.readFileSync(path.join(__dirname, '..', 'extension', 'ws', 'mcp-bridge-client.js'), 'utf8'); const footer = ` this.__phase198 = { @@ -869,6 +880,149 @@ function runBackgroundArmingSourceCase() { } } +/** + * Safari transport selection (ws probe -> native pin), including the TTL. + * + * The pin is remembered in chrome.storage.local for 7 days. _transportPlan() + * stops honouring it once it ages out and goes back to probing ws -- so the + * outcome recorder MUST score those attempts. When it did not, an expired + * native pin was permanent in one direction and unreachable in the other: the + * plan said ws, the recorder said "already native, nothing to score", and the + * bridge retried a transport that cannot work on that browser forever. + */ +async function runSafariTransportPolicyCase() { + console.log('\n--- Safari transport policy ---'); + const TTL = 7 * 24 * 60 * 60 * 1000; + const KEY = 'fsbMcpTransportPolicy'; + + async function clientWith(policy) { + const harness = buildClientHarness({ + platform: 'safari', + storageLocalSeed: policy ? { [KEY]: policy } : undefined + }); + const client = new harness.exports.MCPBridgeClient(); + await client._policyHydrating; + await flushMicrotasks(); + return { harness, client }; + } + + // 1. A fresh native pin is honoured. + { + const { client } = await clientWith({ decided: 'native', decidedAt: Date.now(), wsAttempts: 2 }); + assertEqual(client._transportPlan(), 'native', 'fresh native pin selects the native transport'); + } + + // 2. An expired pin re-probes ws. + { + const { client } = await clientWith({ decided: 'native', decidedAt: Date.now() - TTL - 1, wsAttempts: 2 }); + assertEqual(client._transportPlan(), 'ws', 'expired native pin falls back to probing ws'); + + // ...and the re-probe is scored. Two strikes, from zero -- not inherited + // from the attempt count that pinned it last time. + client._activeTransport = 'ws'; + client._recordTransportOutcome('fail', 'socket_close'); + assertEqual(client._transportPlan(), 'ws', 'one failed re-probe does not re-pin'); + client._recordTransportOutcome('fail', 'socket_close'); + assertEqual(client._transportPlan(), 'native', 'a second failure re-pins to native'); + assert(client._transportPolicy.decidedAt > Date.now() - 5000, 'the renewed pin carries a fresh timestamp'); + } + + // 3. A working ws under an expired pin wins outright. + { + const { client } = await clientWith({ decided: 'native', decidedAt: Date.now() - TTL - 1, wsAttempts: 2 }); + client._activeTransport = 'ws'; + client._recordTransportOutcome('open'); + assertEqual(client._transportPlan(), 'ws', 'a successful ws re-probe pins ws'); + } + + // 4. A live native pin still suppresses scoring, so a plain server outage + // cannot flap the transport back to ws. + { + const { client } = await clientWith({ decided: 'native', decidedAt: Date.now(), wsAttempts: 2 }); + client._activeTransport = 'native'; + client._recordTransportOutcome('fail', 'socket_close'); + assertEqual(client._transportPlan(), 'native', 'native failures never re-enable ws'); + } + + // 5. Chrome never enters any of it. + { + const harness = buildClientHarness(); + const client = new harness.exports.MCPBridgeClient(); + assertEqual(client._policyHydrating, null, 'Chrome does not hydrate a transport policy'); + assertEqual(client._transportPlan(), 'ws', 'Chrome always plans ws'); + client._recordTransportOutcome('fail', 'security'); + assertEqual(client._transportPolicy, null, 'Chrome never records a transport policy'); + } + + // 6. A LIVE ws pin suppresses scoring, symmetrically with the native pin. + // + // Without this the probe is one-way: ws gets pinned on first success, then + // the very next time the MCP server is stopped -- a separate local process + // the user starts on demand -- two failed reconnects (~4s at base backoff) + // demote a transport this browser has already PROVEN, for the full 7-day + // TTL. The onclose guard cannot catch it: `!this._connected` only describes + // the current attempt, not "ws has worked here before". + { + const { client } = await clientWith({ decided: 'ws', decidedAt: Date.now(), wsAttempts: 0 }); + client._activeTransport = 'ws'; + client._recordTransportOutcome('fail', 'socket_close'); + client._recordTransportOutcome('fail', 'socket_close'); + assertEqual(client._transportPlan(), 'ws', + 'a live ws pin survives a server outage instead of demoting to native'); + assertEqual(client._transportPolicy.wsAttempts, 0, + 'failures under a live ws pin are not scored at all'); + } + + // 7. Every successful open RENEWS the ws pin. A pin that only ever got its + // timestamp on the first connect ages out while the transport is plainly + // working, which reopens case 6 the moment the TTL lapses. + { + const aging = Date.now() - TTL + 60_000; + const { client } = await clientWith({ decided: 'ws', decidedAt: aging, wsAttempts: 0 }); + client._activeTransport = 'ws'; + client._recordTransportOutcome('open'); + assert(client._transportPolicy.decidedAt > aging, 'a successful open refreshes an aging ws pin'); + assert(client._policyIsFresh(client._transportPolicy), 'the renewed ws pin is fresh again'); + } +} + +/** + * The native transport's payload ceiling is a BYTE budget, so the guard in + * _sendResult has to measure bytes. JSON.stringify(...).length counts UTF-16 + * units, which under-reports a CJK or emoji payload by up to 3x -- exactly the + * oversized result the guard exists to refuse would sail through it and get + * base64-chunked over XPC anyway. + * + * softPayloadLimit is an own property of the native transport and undefined on + * a real WebSocket, so Chrome never reaches any of this. + */ +async function runNativePayloadCapCase() { + console.log('\n--- native payload cap is measured in bytes ---'); + + const harness = buildClientHarness({ platform: 'safari' }); + const client = new harness.exports.MCPBridgeClient(); + + const CAP = 1000; + const sent = []; + client._ws = { softPayloadLimit: CAP, readyState: 1, send: (s) => sent.push(s) }; + + // 600 CJK chars = 1800 UTF-8 bytes: under the cap by .length, over it by bytes. + const text = '\u6587'.repeat(600); + assert(text.length < CAP, 'fixture is under the cap by UTF-16 length'); + assert(new TextEncoder().encode(text).length > CAP, 'fixture is over the cap in UTF-8 bytes'); + + client._sendResult('req-1', { text }); + assertEqual(sent.length, 1, 'exactly one frame goes out'); + const frame = JSON.parse(sent[0]); + assertEqual(frame.type, 'mcp:error', 'an over-cap multibyte result is refused, not chunked'); + assert(/payload_too_large/.test(frame.payload.error), 'the refusal names payload_too_large'); + + // An ASCII result that genuinely fits still goes through untouched. + sent.length = 0; + client._sendResult('req-2', { text: 'a'.repeat(100) }); + assertEqual(JSON.parse(sent[0]).type, 'mcp:result', 'an in-budget result is still delivered'); +} + const VALID_PAIRING_CODE = 'fsb-auth.' + 'A'.repeat(43); async function runPairingConstructionCases() { @@ -1922,6 +2076,8 @@ async function runReconnectAlarmBackstopCase() { } async function run() { + await runSafariTransportPolicyCase(); + await runNativePayloadCapCase(); await runBrowserFirstReconnectCase(); await runServiceWorkerWakeCase(); await runInboundAuthorityReadinessCase(); diff --git a/tests/mcp-bridge-topology.test.js b/tests/mcp-bridge-topology.test.js index 27a2662c6..62c368559 100644 --- a/tests/mcp-bridge-topology.test.js +++ b/tests/mcp-bridge-topology.test.js @@ -1104,6 +1104,53 @@ async function runRejectsUntrustedBrowserOrigin(WebSocketBridge) { } } +/** + * Safari port: the extension reaches :7225 either directly or through the + * container app's native socket, and both dial with + * Origin: safari-web-extension://. The hub must classify that as an + * extension candidate. An Origin-less socket is still only ever a relay, which + * is why the native transport has to send the Origin explicitly. + */ +async function runAcceptsSafariExtensionOrigin(WebSocketBridge) { + await withTempHome('bridge-safari-origin', async () => { + const port = await getFreePort(); + const hub = new WebSocketBridge({ + port, + host: '127.0.0.1', + instanceId: 'test-hub-safari-origin', + handshakeTimeoutMs: 25, + }); + const resources = { sockets: [], bridges: [hub] }; + const safariOrigin = 'safari-web-extension://4C6E0A1B-2F3D-4E5A-9B8C-7D6E5F4A3B2C'; + + try { + await hub.connect(); + + for (const [label, origin] of [ + ['safari Origin with path', `${safariOrigin}/path`], + ['safari Origin without host', 'safari-web-extension://'], + ]) { + const response = await rawUpgrade(port, [`Host: 127.0.0.1:${port}`, `Origin: ${origin}`]); + assert(response.startsWith('HTTP/1.1 403'), `${label} receives HTTP 403 before upgrade`); + } + + const safari = await createBrowserSocket(port, { origin: safariOrigin }); + resources.sockets.push(safari); + await waitFor(() => hub.topology.extensionConnected, 'safari extension registration', 1000, 10); + assertEqual(hub.topology.extensionConnected, true, 'hub registers a safari-web-extension:// origin as the extension'); + assertEqual(hub.topology.relayCount, 0, 'safari extension socket is not counted as a relay'); + + const originless = await createOriginlessSocket(port); + resources.sockets.push(originless); + const close = await waitForSocketClose(originless); + assertEqual(close.code, 1008, 'origin-less socket without relay:hello is closed, never made the extension'); + assertEqual(hub.topology.extensionConnected, true, 'safari extension survives the origin-less socket'); + } finally { + await cleanup(resources); + } + }); +} + async function runRejectsNonLoopbackBind(WebSocketBridge) { const rejectedHosts = ['0.0.0.0', '::', '192.168.1.20', 'bridge.local', '', '127.0.0.1:7225']; for (const host of rejectedHosts) { @@ -2530,6 +2577,7 @@ async function run() { await runCase('relay waits for extension reachability', () => runRelayWaitsForExtensionReachability(WebSocketBridge)); await runCase('extension state broadcasts to relays', () => runExtensionStateBroadcastsToRelays(WebSocketBridge)); await runCase('rejects untrusted browser relay origin', () => runRejectsUntrustedBrowserOrigin(WebSocketBridge)); + await runCase('accepts safari extension origin', () => runAcceptsSafariExtensionOrigin(WebSocketBridge)); await runCase('pre-handler Host and Origin upgrade gate', () => runPreHandlerUpgradeGate(WebSocketBridge, auth)); await runCase('pairing authority matrix', () => runPairingAuthorityMatrix(WebSocketBridge, auth)); await runCase('unprivileged sockets cannot displace the active extension', () => runUnprivilegedCannotDisplaceExtension(WebSocketBridge, auth)); diff --git a/tests/mcp-native-transport.test.js b/tests/mcp-native-transport.test.js new file mode 100644 index 000000000..8531a65da --- /dev/null +++ b/tests/mcp-native-transport.test.js @@ -0,0 +1,367 @@ +/** + * Tests for extension/ws/mcp-native-transport.js (FsbNativeBridgeSocket). + * + * This is the Safari path to the SAME MCP server on the SAME port (7225): the + * container app holds the real socket and this class relays frames over + * chrome.runtime.connectNative. + * + * The properties that matter most, because each one silently corrupts the + * bridge lifecycle if wrong: + * + * - DUCK TYPE. mcp-bridge-client.js drives this object exactly like a + * WebSocket. If readyState/send/close/on* drift, the whole 1900-line + * lifecycle breaks in ways no other test would catch. + * - TWO-STAGE OPEN. connectNative "connects" instantly; that says nothing + * about reaching :7225. onopen must wait for the host's `opened`, because + * the bridge mints its connection id and reconciles in-flight tasks there. + * - onerror THEN onclose, exactly once. The bridge stages agent release in + * onclose; a double-fire would release twice, a missing one would leak. + * - Chunk boundaries must never split a multi-byte character. + * + * Run: node tests/mcp-native-transport.test.js + */ + +'use strict'; + +let passed = 0; +let failed = 0; +function passAssert(cond, msg) { + if (cond) { passed++; console.log(' PASS:', msg); } + else { failed++; console.error(' FAIL:', msg); } +} +function passAssertEqual(a, b, msg) { passAssert(a === b, msg + ' (got ' + JSON.stringify(a) + ')'); } + +const tick = () => new Promise((r) => setTimeout(r, 0)); + +// --- fake chrome.runtime.connectNative -------------------------------------- +function installFakePort() { + const port = { + posted: [], + _msgListeners: [], + _discListeners: [], + disconnected: false, + postedBeforeDisconnect: -1, + postMessage(o) { port.posted.push(o); }, + disconnect() { port.disconnected = true; port.postedBeforeDisconnect = port.posted.length; }, + onMessage: { addListener: (f) => port._msgListeners.push(f) }, + onDisconnect: { addListener: (f) => port._discListeners.push(f) }, + emit(msg) { port._msgListeners.slice().forEach((f) => f(msg)); }, + drop() { port._discListeners.slice().forEach((f) => f()); } + }; + globalThis.chrome = { runtime: { connectNative: () => port } }; + return port; +} + +function attach(sock) { + const ev = { open: 0, close: [], error: [], messages: [] }; + sock.onopen = () => { ev.open += 1; }; + sock.onclose = (e) => { ev.close.push(e); }; + sock.onerror = (e) => { ev.error.push(e); }; + sock.onmessage = (e) => { ev.messages.push(e.data); }; + return ev; +} + +const { FsbNativeBridgeSocket, utf8ToBase64, base64ToUtf8 } = require('../extension/ws/mcp-native-transport.js'); + +(async function run() { + console.log('\n=== 1. duck-types a WebSocket ==='); + { + const port = installFakePort(); + const s = new FsbNativeBridgeSocket({ url: 'ws://localhost:7225' }); + passAssertEqual(typeof s.readyState, 'number', 'readyState is numeric'); + passAssertEqual(s.readyState, 0, 'starts CONNECTING(0)'); + for (const m of ['send', 'close']) passAssertEqual(typeof s[m], 'function', `${m}() exists`); + passAssertEqual(s.url, 'ws://localhost:7225', 'same URL, same port as Chrome'); + passAssert(typeof s.softPayloadLimit === 'number', + 'exposes softPayloadLimit (undefined on a real WebSocket -> no platform branch in the bridge)'); + const openMsg = port.posted[0]; + passAssertEqual(openMsg.t, 'open', 'dials with an open frame'); + passAssertEqual(openMsg.url, 'ws://localhost:7225', 'open frame carries the 7225 URL'); + passAssertEqual(openMsg.linger, true, 'requests linger for fast SW-eviction reconnect'); + passAssertEqual(openMsg.origin, undefined, 'omits origin when runtime.getURL is unavailable'); + } + + console.log('\n=== 1b. open frame carries the extension Origin ==='); + { + const port = installFakePort(); + globalThis.chrome.runtime.getURL = (p) => 'safari-web-extension://ABCD-1234/' + p; + new FsbNativeBridgeSocket({ url: 'ws://localhost:7225' }); + passAssertEqual(port.posted[0].origin, 'safari-web-extension://ABCD-1234', + 'origin is the bare extension origin the server classifies as the extension'); + } + + console.log('\n=== 2. TWO-STAGE open ==='); + { + const port = installFakePort(); + const s = new FsbNativeBridgeSocket({ url: 'ws://localhost:7225' }); + const ev = attach(s); + await tick(); + passAssertEqual(ev.open, 0, 'no onopen merely because connectNative returned a port'); + passAssertEqual(s.readyState, 0, 'still CONNECTING'); + port.emit({ v: 1, t: 'opened', socketId: 'sock1' }); + passAssertEqual(ev.open, 1, 'onopen fires only after the host confirms it reached :7225'); + passAssertEqual(s.readyState, 1, 'OPEN(1)'); + passAssert(port.posted.some((m) => m.t === 'poll'), 'starts the long-poll loop on open'); + } + + console.log('\n=== 3. host maxFrameBytes narrows ours, never widens ==='); + { + const port = installFakePort(); + const s = new FsbNativeBridgeSocket({ url: 'u', maxFrameBytes: 1000 }); + attach(s); + port.emit({ v: 1, t: 'opened', maxFrameBytes: 400 }); + passAssertEqual(s._maxFrameBytes, 400, 'takes the smaller host ceiling'); + const port2 = installFakePort(); + const s2 = new FsbNativeBridgeSocket({ url: 'u', maxFrameBytes: 1000 }); + attach(s2); + port2.emit({ v: 1, t: 'opened', maxFrameBytes: 999999 }); + passAssertEqual(s2._maxFrameBytes, 1000, 'ignores a larger host claim'); + } + + console.log('\n=== 4. inbound frames reach onmessage verbatim ==='); + { + const port = installFakePort(); + const s = new FsbNativeBridgeSocket({ url: 'u' }); + const ev = attach(s); + port.emit({ v: 1, t: 'opened' }); + const wire = JSON.stringify({ id: 'mcp_1', type: 'mcp:result', payload: { ok: true } }); + port.emit({ v: 1, t: 'batch', frames: [wire] }); + passAssertEqual(ev.messages.length, 1, 'one message'); + passAssertEqual(ev.messages[0], wire, 'raw JSON string passed through byte-for-byte'); + const before = port.posted.filter((m) => m.t === 'poll').length; + port.emit({ v: 1, t: 'pollempty' }); + passAssert(port.posted.filter((m) => m.t === 'poll').length > before, 'poll loop re-arms'); + } + + console.log('\n=== 5. outbound: small frames raw, large frames chunked ==='); + { + const port = installFakePort(); + const s = new FsbNativeBridgeSocket({ url: 'u', maxFrameBytes: 64 }); + attach(s); + port.emit({ v: 1, t: 'opened' }); + + port.posted.length = 0; + s.send('{"id":"a","type":"mcp:ping"}'); + passAssertEqual(port.posted.length, 1, 'small payload -> one frame'); + passAssertEqual(port.posted[0].t, 'frame', 'sent as t:frame'); + passAssertEqual(port.posted[0].data, '{"id":"a","type":"mcp:ping"}', 'raw string, not re-serialized'); + + port.posted.length = 0; + const big = 'x'.repeat(500); + s.send(big); + const chunks = port.posted.filter((m) => m.t === 'chunk'); + passAssert(chunks.length > 1, `large payload -> ${chunks.length} chunks`); + passAssertEqual(chunks[0].enc, 'b64', 'chunks are base64'); + passAssert(chunks.every((c) => c.n === chunks.length), 'every chunk agrees on the total'); + passAssert(chunks.every((c) => c.cid === chunks[0].cid), 'shared correlation id'); + passAssertEqual(base64ToUtf8(chunks.map((c) => c.data).join('')), big, 'chunks reassemble to the original'); + } + + console.log('\n=== 6. chunking cannot split a multi-byte character ==='); + { + // Base64-before-slice is the whole reason this is safe: base64 is ASCII, so + // a boundary can never land mid-codepoint or mid-surrogate-pair. + const tricky = '日本語テキスト🚀🎉' .repeat(40) + 'café—naïve'; + const b64 = utf8ToBase64(tricky); + passAssert(/^[A-Za-z0-9+/=]+$/.test(b64), 'base64 output is pure ASCII'); + for (const size of [7, 13, 64, 100]) { + const parts = []; + for (let i = 0; i < b64.length; i += size) parts.push(b64.slice(i, i + size)); + passAssertEqual(base64ToUtf8(parts.join('')), tricky, `round-trips at chunk size ${size}`); + } + } + + console.log('\n=== 7. inbound chunk reassembly ==='); + { + const port = installFakePort(); + const s = new FsbNativeBridgeSocket({ url: 'u' }); + const ev = attach(s); + port.emit({ v: 1, t: 'opened' }); + const payload = JSON.stringify({ id: 'z', type: 'mcp:result', payload: { big: '£'.repeat(200) } }); + const b64 = utf8ToBase64(payload); + const size = 40; + const n = Math.ceil(b64.length / size); + for (let i = 0; i < n; i += 1) { + port.emit({ v: 1, t: 'chunk', cid: 'c1', i, n, enc: 'b64', data: b64.slice(i * size, (i + 1) * size) }); + } + passAssertEqual(ev.messages.length, 1, 'emits exactly one reassembled message'); + passAssertEqual(ev.messages[0], payload, 'reassembled payload is exact'); + } + + console.log('\n=== 8. failure emits onerror THEN onclose, exactly once ==='); + { + const port = installFakePort(); + const s = new FsbNativeBridgeSocket({ url: 'u' }); + const ev = attach(s); + port.emit({ v: 1, t: 'opened' }); + port.drop(); + await tick(); + passAssertEqual(ev.error.length, 1, 'onerror once'); + passAssertEqual(ev.close.length, 1, 'onclose once'); + passAssertEqual(s.readyState, 3, 'CLOSED(3)'); + // The bridge stages agent release in onclose; a second one would release twice. + port.drop(); + await tick(); + passAssertEqual(ev.close.length, 1, 'a second disconnect does NOT re-fire onclose'); + } + + console.log('\n=== 9. host-reported close / error surface as onclose ==='); + { + const port = installFakePort(); + const s = new FsbNativeBridgeSocket({ url: 'u' }); + const ev = attach(s); + port.emit({ v: 1, t: 'opened' }); + port.emit({ v: 1, t: 'closed', code: 1006, reason: 'connection_refused' }); + await tick(); + passAssertEqual(ev.close.length, 1, 'closed frame -> onclose'); + passAssertEqual(ev.close[0].reason, 'connection_refused', 'reason propagated (server was not up)'); + } + { + const port = installFakePort(); + const s = new FsbNativeBridgeSocket({ url: 'u' }); + const ev = attach(s); + port.emit({ v: 1, t: 'error', phase: 'dial', message: 'no route' }); + await tick(); + passAssertEqual(ev.open, 0, 'a dial error never produces a spurious onopen'); + passAssertEqual(ev.close.length, 1, 'error frame -> onclose'); + } + + console.log('\n=== 10. missing native host fails cleanly ==='); + { + globalThis.chrome = { runtime: {} }; + const s = new FsbNativeBridgeSocket({ url: 'u' }); + const ev = attach(s); + await tick(); + passAssertEqual(ev.close.length, 1, 'no connectNative -> onclose, not a throw'); + passAssert(/native_messaging_unavailable/.test(ev.close[0].reason), 'reason names the cause'); + } + + console.log('\n=== 11. intentional close is clean ==='); + { + const port = installFakePort(); + const s = new FsbNativeBridgeSocket({ url: 'u' }); + const ev = attach(s); + port.emit({ v: 1, t: 'opened' }); + s.close(1000, 'intentional'); + passAssert(port.posted.some((m) => m.t === 'close'), 'tells the host to close :7225'); + await tick(); + passAssertEqual(ev.close[0].wasClean, true, 'wasClean:true'); + passAssertEqual(s.readyState, 3, 'CLOSED'); + passAssert(port.disconnected, 'native port disconnected'); + } + + console.log('\n=== 12. send() before open is dropped, not thrown ==='); + { + installFakePort(); + const s = new FsbNativeBridgeSocket({ url: 'u' }); + attach(s); + let threw = false; + try { s.send('{"a":1}'); } catch (_e) { threw = true; } + passAssert(!threw, 'send() while CONNECTING does not throw'); + } + + // Splits a payload into the chunk frames the host would drain, one per poll. + function chunkFrames(payload, cid, size) { + const b64 = utf8ToBase64(payload); + const n = Math.ceil(b64.length / size); + const frames = []; + for (let i = 0; i < n; i += 1) { + frames.push({ v: 1, t: 'chunk', cid, i, n, enc: 'b64', data: b64.slice(i * size, (i + 1) * size) }); + } + return frames; + } + + console.log('\n=== 13. a stalled inbound reassembly closes :7225, not just the port ==='); + { + // Safari gives the host no signal when the port drops. Without an explicit + // close the reconnect adopts the still-open socket, the server never sees + // the loss, and its sendAndWait times out without arming sw_evicted. + const port = installFakePort(); + const s = new FsbNativeBridgeSocket({ url: 'u', reassemblyTimeoutMs: 30 }); + const ev = attach(s); + port.emit({ v: 1, t: 'opened' }); + const frames = chunkFrames('x'.repeat(300), 'stall', 40); + passAssert(frames.length > 2, `payload spans ${frames.length} chunks`); + port.emit(frames[0]); + await new Promise((r) => setTimeout(r, 80)); + passAssertEqual(ev.close.length, 1, 'onclose once'); + passAssertEqual(ev.close[0] && ev.close[0].reason, 'reassembly_timeout', 'reason names the stall'); + const closeAt = port.posted.findIndex((m) => m.t === 'close'); + passAssert(closeAt >= 0, 'tells the host to close :7225'); + passAssertEqual(port.posted[closeAt] && port.posted[closeAt].reason, 'reassembly_timeout', + 'the close carries the failure reason'); + passAssert(port.disconnected && closeAt < port.postedBeforeDisconnect, + 'the close is posted BEFORE the port is disconnected'); + } + + console.log('\n=== 14. every accepted chunk restarts the reassembly deadline ==='); + { + // 3 chunks 50ms apart take 100ms in total, past an 80ms budget counted from + // the first chunk, but never 80ms between two chunks. + const port = installFakePort(); + const s = new FsbNativeBridgeSocket({ url: 'u', reassemblyTimeoutMs: 80 }); + const ev = attach(s); + port.emit({ v: 1, t: 'opened' }); + const payload = JSON.stringify({ id: 'slow', type: 'mcp:result', payload: { s: 'y'.repeat(60) } }); + const frames = chunkFrames(payload, 'slow', Math.ceil(utf8ToBase64(payload).length / 3)); + passAssertEqual(frames.length, 3, 'payload spans 3 chunks'); + for (let i = 0; i < frames.length; i += 1) { + if (i > 0) await new Promise((r) => setTimeout(r, 50)); + port.emit(frames[i]); + } + passAssertEqual(ev.messages.length, 1, 'a slow but steady message still reassembles'); + passAssertEqual(ev.messages[0], payload, 'reassembled payload is exact'); + await new Promise((r) => setTimeout(r, 120)); + passAssertEqual(ev.close.length, 0, 'no late reassembly_timeout after completion'); + } + + console.log('\n=== 15. the default reassembly budget beats the server\'s 30s sendAndWait ==='); + { + const ms = FsbNativeBridgeSocket.NATIVE_REASSEMBLY_TIMEOUT_MS; + passAssert(ms > 0 && ms <= 10000, `NATIVE_REASSEMBLY_TIMEOUT_MS leaves room inside 30s (got ${ms})`); + } + + console.log('\n=== 16. a dropped port posts no close ==='); + { + const port = installFakePort(); + const s = new FsbNativeBridgeSocket({ url: 'u' }); + attach(s); + port.emit({ v: 1, t: 'opened' }); + port.drop(); + await tick(); + passAssert(!port.posted.some((m) => m.t === 'close'), 'nothing is posted to a port that is gone'); + } + + console.log('\n=== 17. a throwing port fails exactly once ==='); + { + // _fail() posts the close itself; routing it through _post() would re-enter + // _fail() on the same throw, and its CLOSED guard does not cover CLOSING. + const port = installFakePort(); + const s = new FsbNativeBridgeSocket({ url: 'u' }); + const ev = attach(s); + port.emit({ v: 1, t: 'opened' }); + let calls = 0; + port.postMessage = () => { calls += 1; throw new Error('port gone'); }; + s.send('{"a":1}'); + await tick(); + passAssertEqual(ev.error.length, 1, 'onerror once'); + passAssertEqual(ev.close.length, 1, 'onclose once'); + passAssert(/^post_failed:/.test(ev.close[0] && ev.close[0].reason), 'reason names the failed post'); + passAssertEqual(calls, 2, 'the frame, then one close attempt -- no recursion'); + } + { + const port = installFakePort(); + const s = new FsbNativeBridgeSocket({ url: 'u' }); + const ev = attach(s); + port.emit({ v: 1, t: 'opened' }); + port.postMessage = () => { throw new Error('port gone'); }; + s.close(1000, 'intentional'); + await tick(); + passAssertEqual(ev.close.length, 1, 'close() whose post throws still fires onclose once'); + passAssertEqual(ev.close[0] && ev.close[0].wasClean, true, 'and it stays the clean close'); + } + + console.log('\n---'); + console.log('passed:', passed, 'failed:', failed); + if (failed > 0) process.exit(1); +})().catch((e) => { console.error('TEST HARNESS ERROR:', e); process.exit(1); }); diff --git a/tests/mcp-visual-tick-lifecycle.test.js b/tests/mcp-visual-tick-lifecycle.test.js index e07293015..cc547b44f 100644 --- a/tests/mcp-visual-tick-lifecycle.test.js +++ b/tests/mcp-visual-tick-lifecycle.test.js @@ -38,6 +38,9 @@ * (delegation-terminal immediate teardown) and leaves other agents' * entries untouched; unknown agent is a counted no-op; invalid * agentId rejects. + * N. replayVisualSessionLifecycleForTab re-sends a live entry without + * extending its window, clears an elapsed one, leaves is_final alone, + * and background.js calls it on content-script ready and tab load. * * Run: node tests/mcp-visual-tick-lifecycle.test.js */ @@ -529,6 +532,73 @@ async function caseM() { // Driver // ------------------------------------------------------------------ +// ------------------------------------------------------------------ +// Case N -- replay on page load. open_tab / navigate record their tick +// before the destination page has a content script, so the tick's own +// broadcast is lost; background.js replays the entry when the tab finishes +// loading and when the page's content script reports ready. +// ------------------------------------------------------------------ + +async function caseN() { + console.log('\n--- Case N: replayVisualSessionLifecycleForTab (overlay after open_tab / navigate) ---'); + const { chromeMock, lc, capturedStatusBroadcasts } = setupHarness(); + + const none = await lc.replayVisualSessionLifecycleForTab(500); + check(none && none.ok === true && none.action === 'noop', 'N.1 no entry -> noop'); + check(capturedStatusBroadcasts.length === 0, 'N.2 no entry -> nothing broadcast'); + + await lc.recordVisualSessionTick(501, 'agent_replay', { visualReason: 'Opening the page', client: 'Claude' }); + const before = (await chromeMock.storage.session.get(['mcpVisualSession:501']))['mcpVisualSession:501']; + const createsBefore = chromeMock.alarms._createHistory().length; + capturedStatusBroadcasts.length = 0; + + const live = await lc.replayVisualSessionLifecycleForTab(501); + const after = (await chromeMock.storage.session.get(['mcpVisualSession:501']))['mcpVisualSession:501']; + check(live && live.ok === true && live.action === 'replayed', 'N.3 live entry -> replayed'); + check(capturedStatusBroadcasts.length === 1 && capturedStatusBroadcasts[0].tabId === 501, 'N.4 one running broadcast to the tab'); + check(capturedStatusBroadcasts[0] && capturedStatusBroadcasts[0].statusData + && capturedStatusBroadcasts[0].statusData.clientLabel === 'Claude', 'N.5 broadcast carries the client label'); + check(after && after.deadlineAt === before.deadlineAt && after.lastTickAt === before.lastTickAt, + 'N.6 a replay does not extend the 60 s window'); + check(chromeMock.alarms._createHistory().length === createsBefore, 'N.7 a replay arms no new alarm'); + + await chromeMock.storage.session.set({ 'mcpVisualSession:502': { + tabId: 502, agentId: 'agent_replay', client: 'Claude', visualReason: 'Old step', + startedAt: Date.now() - 120000, lastTickAt: Date.now() - 90000, deadlineAt: Date.now() - 30000, isFinal: false + } }); + capturedStatusBroadcasts.length = 0; + const elapsed = await lc.replayVisualSessionLifecycleForTab(502); + const gone = (await chromeMock.storage.session.get(['mcpVisualSession:502']))['mcpVisualSession:502']; + check(elapsed && elapsed.action === 'cleared', 'N.8 elapsed entry -> cleared instead of replayed'); + check(gone === undefined, 'N.9 elapsed entry removed from storage'); + check(capturedStatusBroadcasts.length === 1 && capturedStatusBroadcasts[0].statusData.phase === 'ended', + 'N.10 elapsed entry broadcasts the clear payload, not a running one'); + + await chromeMock.storage.session.set({ 'mcpVisualSession:503': { + tabId: 503, agentId: 'agent_replay', client: 'Claude', visualReason: 'Done', + startedAt: Date.now(), lastTickAt: Date.now(), deadlineAt: Date.now() + 60000, isFinal: true + } }); + capturedStatusBroadcasts.length = 0; + const finished = await lc.replayVisualSessionLifecycleForTab(503); + check(finished && finished.action === 'noop' && capturedStatusBroadcasts.length === 0, + 'N.11 an is_final entry is not brought back'); + + const invalid = await lc.replayVisualSessionLifecycleForTab(0); + check(invalid && invalid.ok === false && invalid.reason === 'invalid_tab_id', 'N.12 invalid tab id rejected'); + + // background.js wiring: both content-script ready signals and tab load. + const fs = require('fs'); + const bg = fs.readFileSync(path.join(__dirname, '..', 'extension', 'background.js'), 'utf8'); + const chained = bg.match(/\.then\(\(replayed\) => replayed \|\| replayMcpVisualLifecycleForTab\(tabId\)\)/g) || []; + check(chained.length === 2, 'N.13 port_ready and contentScriptReady fall through to the implicit replay'); + const onUpdatedStart = bg.indexOf("fsbTriggerRearmLiveObserversForTab(tabId, 'tabs.onUpdated.complete')"); + const onUpdatedEnd = onUpdatedStart >= 0 ? bg.indexOf('\n});\n', onUpdatedStart) : -1; + const onUpdatedBlock = onUpdatedEnd > onUpdatedStart ? bg.slice(onUpdatedStart, onUpdatedEnd) : ''; + check(onUpdatedBlock.includes('replayMcpVisualLifecycleForTab(tabId)'), 'N.14 tabs.onUpdated complete replays the implicit session'); + check(/function replayMcpVisualLifecycleForTab\(tabId\) \{\n if \(hasExplicitMcpVisualSession\(tabId\)\) return Promise\.resolve\(null\);/.test(bg), + 'N.15 an explicit start_visual_session session on the tab takes precedence'); +} + (async () => { await caseA(); await caseB(); @@ -543,6 +613,7 @@ async function caseM() { await caseK(); await caseL(); await caseM(); + await caseN(); console.log('\n=== Results: ' + passed + ' passed, ' + failed + ' failed ==='); process.exit(failed > 0 ? 1 : 0); diff --git a/tests/onboarding-agent-provider-clicks.test.js b/tests/onboarding-agent-provider-clicks.test.js index a9ec7a0a7..b90c6e468 100644 --- a/tests/onboarding-agent-provider-clicks.test.js +++ b/tests/onboarding-agent-provider-clicks.test.js @@ -159,7 +159,8 @@ function createHarness(initialStore, options) { assert.strictEqual( sha256(ONBOARDING_HTML_PATH), - 'ba4c297095e896bbabdc369a6bee114031df380a1cc75cc7431bed58176b634c', + // Rolled for the Safari port: bundled Font Awesome path and the platform shim. + 'd58dfdf789c8d22b21e602b8242a179f4f201e5b4f8afef8a615b920f3a3e81f', 'onboarding.html has no Phase 57 change' ); assert.strictEqual( diff --git a/tests/owner-chip.test.js b/tests/owner-chip.test.js index fdf818ed8..c4bb1fff1 100644 --- a/tests/owner-chip.test.js +++ b/tests/owner-chip.test.js @@ -501,7 +501,7 @@ async function runSidepanelOwnershipLockContract(source) { applyInputLockout(value) { lockoutCalls.push(value); } }; vm.createContext(context); - for (const name of ['_renderHeaderStatus', '_setHeaderStatus', 'refreshActiveTabOwnership']) { + for (const name of ['getTargetTabs', '_renderHeaderStatus', '_setHeaderStatus', 'refreshActiveTabOwnership']) { const definition = extractNamedFunction(source, name); ok(!!definition, 'sidepanel: ' + name + ' is extractable for ownership-lock behavior'); if (definition) vm.runInContext(definition, context); diff --git a/tests/platform-adapter.test.js b/tests/platform-adapter.test.js new file mode 100644 index 000000000..64ecc791d --- /dev/null +++ b/tests/platform-adapter.test.js @@ -0,0 +1,608 @@ +/** + * Unit tests for extension/utils/platform-adapter.js + * + * The adapter is the Safari port's central shim. Its single most important + * property is that it is a HARD NO-OP on Chrome -- if install() mutates + * anything on a Chromium runtime the entire 626-file suite is at risk. + * + * Test sections (in order): + * 1. detect() -- forced override + capability heuristics + * 2. caps derivation + * 3. install() on Chrome mutates NOTHING + * 4. install() on Safari: chrome.debugger shim semantics + * 5. install() on Safari: offscreen / system.memory shims + * 6. install() idempotence + * 7. sidePanel polyfill -> popup window, find-or-focus + * 8. resolveTargetTab() never returns an extension page + * 9. Lattice loopback round-trip + re-entrancy cap + * 10. CDP_DOM_FALLBACKS covers every _route:'cdp' verb + * 11. source hygiene + * 12. Safari service worker: WebSocket guard + runtime.onSuspend stand-in + * + * Run: node tests/platform-adapter.test.js + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const MODULE_PATH = path.join(__dirname, '..', 'extension', 'utils', 'platform-adapter.js'); +const SOURCE = fs.readFileSync(MODULE_PATH, 'utf8'); + +let passed = 0; +let failed = 0; + +function passAssert(cond, msg) { + if (cond) { passed++; console.log(' PASS:', msg); } + else { failed++; console.error(' FAIL:', msg); } +} + +function passAssertEqual(actual, expected, msg) { + passAssert(actual === expected, msg + ' (got ' + JSON.stringify(actual) + ')'); +} + +/** + * Evaluate the adapter against a synthetic global scope. The adapter is an + * IIFE that takes its scope as an argument, so we can instantiate it many + * times over different fake runtimes without cross-talk. + */ +function loadAdapter(scope) { + const sandbox = scope; + sandbox.console = sandbox.console || { log() {}, warn() {}, error() {} }; + const fn = new Function('globalThis', 'self', 'module', SOURCE + '\nreturn globalThis.FsbPlatform;'); + return fn(sandbox, sandbox, undefined); +} + +function chromeLikeScope() { + return { + chrome: { + runtime: { id: 'abc', getURL: (p) => 'chrome-extension://abc/' + p, onMessage: { addListener() {}, removeListener() {} }, sendMessage() {} }, + debugger: { attach() {}, detach() {}, sendCommand() {}, onEvent: { addListener() {} }, onDetach: { addListener() {} } }, + sidePanel: { open() {}, setOptions() {}, close() {}, setPanelBehavior() {} }, + offscreen: { hasDocument() {}, createDocument() {} }, + tabs: { query() {}, get() {}, create() {}, update() {}, onActivated: { addListener() {} }, onRemoved: { addListener() {} } }, + windows: { create() {}, update() {}, remove() {}, onRemoved: { addListener() {} } }, + storage: { session: { set() {} } } + } + }; +} + +function safariLikeScope(overrides) { + const calls = { windowsCreate: [], tabsCreate: [], windowsUpdate: [], tabsUpdate: [], windowsRemove: [] }; + const scope = { + __FSB_FORCE_PLATFORM__: 'safari', + _calls: calls, + chrome: { + runtime: { + id: 'sfr', + getURL: (p) => 'safari-web-extension://sfr/' + p, + onMessage: { addListener() {}, removeListener() {} }, + sendMessage() { return Promise.resolve(); } + }, + tabs: { + query: () => Promise.resolve([]), + get: (id) => Promise.resolve({ id, url: 'https://example.com/' }), + create: (o) => { calls.tabsCreate.push(o); return Promise.resolve({ id: 77 }); }, + update: (id, o) => { calls.tabsUpdate.push([id, o]); return Promise.resolve({ id }); }, + onActivated: { addListener() {} }, + onRemoved: { addListener() {} } + }, + windows: { + create: (o) => { calls.windowsCreate.push(o); return Promise.resolve({ id: 5, tabs: [{ id: 55 }] }); }, + update: (id, o) => { calls.windowsUpdate.push([id, o]); return Promise.resolve({ id }); }, + remove: (id) => { calls.windowsRemove.push(id); return Promise.resolve(); }, + onRemoved: { addListener() {} } + }, + storage: { session: { set: () => Promise.resolve() } } + } + }; + if (overrides) overrides(scope); + return scope; +} + +(async function run() { + console.log('\n=== 1. detect() ==='); + passAssertEqual(loadAdapter({ __FSB_FORCE_PLATFORM__: 'safari' }).id, 'safari', 'forced override -> safari'); + passAssertEqual(loadAdapter({ __FSB_FORCE_PLATFORM__: 'chrome', chrome: {} }).id, 'chrome', 'forced override -> chrome'); + passAssertEqual(loadAdapter(chromeLikeScope()).id, 'chrome', 'sidePanel+debugger+offscreen present -> chrome'); + passAssertEqual(loadAdapter({ chrome: { runtime: { getURL: (p) => 'safari-web-extension://x/' + p } } }).id, + 'safari', 'safari-web-extension:// getURL -> safari'); + // The shape of a CHROME content script: chrome.runtime, but none of the + // privileged namespaces. Safari content scripts look identical apart from the + // URL scheme, which is why detection keys on getURL and not on debugger. + passAssertEqual(loadAdapter({ chrome: { runtime: { getURL: (p) => 'chrome-extension://x/' + p } } }).id, + 'chrome', 'chrome-extension:// getURL -> chrome (content-script shape)'); + passAssertEqual(loadAdapter({ chrome: { runtime: {} } }).id, 'chrome', 'runtime with no getURL -> chrome (safe default)'); + passAssertEqual(loadAdapter({}).id, 'chrome', 'no extension API at all -> chrome (safe default)'); + + console.log('\n=== 2. caps ==='); + const chromeCaps = loadAdapter(chromeLikeScope()).caps; + passAssert(chromeCaps.cdp && chromeCaps.trustedInput && chromeCaps.sidePanel && chromeCaps.offscreen, 'chrome caps all true'); + passAssertEqual(chromeCaps.nativeMessaging, false, 'chrome nativeMessaging false'); + const safariCaps = loadAdapter(safariLikeScope()).caps; + passAssert(!safariCaps.cdp && !safariCaps.trustedInput && !safariCaps.sidePanel && !safariCaps.offscreen, 'safari caps all false'); + passAssertEqual(safariCaps.nativeMessaging, true, 'safari nativeMessaging true'); + + console.log('\n=== 3. install() on Chrome mutates NOTHING ==='); + const cScope = chromeLikeScope(); + const beforeKeys = Object.keys(cScope.chrome).sort().join(','); + const beforeDebugger = cScope.chrome.debugger; + const beforeSidePanel = cScope.chrome.sidePanel; + const beforeSend = cScope.chrome.runtime.sendMessage; + const beforeAdd = cScope.chrome.runtime.onMessage.addListener; + const cPlat = loadAdapter(cScope); + const res = cPlat.install(); + passAssertEqual(Object.keys(cScope.chrome).sort().join(','), beforeKeys, 'chrome key set unchanged'); + passAssert(cScope.chrome.debugger === beforeDebugger, 'chrome.debugger identity unchanged'); + passAssert(cScope.chrome.sidePanel === beforeSidePanel, 'chrome.sidePanel identity unchanged'); + passAssert(cScope.chrome.runtime.sendMessage === beforeSend, 'runtime.sendMessage NOT wrapped on Chrome'); + passAssert(cScope.chrome.runtime.onMessage.addListener === beforeAdd, 'onMessage.addListener NOT wrapped on Chrome'); + passAssertEqual(res.installed.length, 0, 'install() reports zero installs on Chrome'); + passAssert(cScope.chrome.runtime.__fsbLoopbackInstalled === undefined, 'no loopback marker on Chrome'); + + console.log('\n=== 4. chrome.debugger shim semantics ==='); + const sScope = safariLikeScope(); + const sPlat = loadAdapter(sScope); + sPlat.install(); + passAssert(!!sScope.chrome.debugger, 'debugger namespace installed'); + let attachErr = null; + try { await sScope.chrome.debugger.attach({ tabId: 1 }, '1.3'); } catch (e) { attachErr = e; } + passAssertEqual(attachErr && attachErr.code, 'capability_unavailable', 'attach() rejects with capability_unavailable'); + let cmdErr = null; + try { await sScope.chrome.debugger.sendCommand({ tabId: 1 }, 'Input.dispatchMouseEvent', {}); } catch (e) { cmdErr = e; } + passAssertEqual(cmdErr && cmdErr.code, 'capability_unavailable', 'sendCommand() rejects with capability_unavailable'); + let detachOk = false; + try { await sScope.chrome.debugger.detach({ tabId: 1 }); detachOk = true; } catch (_e) { detachOk = false; } + passAssert(detachOk, 'detach() RESOLVES (every CDP site ends in finally{await detach()})'); + let evtOk = true; + try { + sScope.chrome.debugger.onEvent.addListener(() => {}); + sScope.chrome.debugger.onDetach.addListener(() => {}); + } catch (_e) { evtOk = false; } + passAssert(evtOk, 'onEvent/onDetach addListener are callable no-ops'); + + console.log('\n=== 5. offscreen + system.memory ==='); + passAssertEqual(await sScope.chrome.offscreen.hasDocument(), true, + 'offscreen.hasDocument() resolves TRUE so ensureLatticeOffscreen early-exits'); + let memErr = null; + try { await sScope.chrome.system.memory.getInfo(); } catch (e) { memErr = e; } + passAssertEqual(memErr && memErr.code, 'capability_unavailable', 'system.memory.getInfo rejects typed'); + + console.log('\n=== 6. idempotence ==='); + const firstDebugger = sScope.chrome.debugger; + const again = sPlat.install(); + passAssertEqual(again.installed.length, 0, 'second install() is a no-op'); + passAssert(sScope.chrome.debugger === firstDebugger, 'debugger identity stable across installs'); + + console.log('\n=== 7. sidePanel polyfill -> popup window ==='); + const wScope = safariLikeScope(); + const wPlat = loadAdapter(wScope); + wPlat.install(); + const opened = await wScope.chrome.sidePanel.open({ tabId: 11 }); + passAssertEqual(opened.surface, 'window', 'open() resolves with a window surface'); + passAssertEqual(wScope._calls.windowsCreate.length, 1, 'exactly one windows.create'); + passAssertEqual(wScope._calls.windowsCreate[0].type, 'popup', "windows.create type is 'popup'"); + passAssert(/ui\/sidepanel\.html$/.test(wScope._calls.windowsCreate[0].url), 'window opens ui/sidepanel.html'); + passAssertEqual(wScope._calls.tabsCreate.length, 0, 'no tab created when windows.create succeeds'); + await wScope.chrome.sidePanel.open({ tabId: 11 }); + passAssertEqual(wScope._calls.windowsCreate.length, 1, 're-open FOCUSES, never creates a second workspace'); + passAssertEqual(wScope._calls.windowsUpdate.length, 1, 're-open called windows.update({focused:true})'); + passAssertEqual(wScope._calls.windowsUpdate[0][1].focused, true, 'focus flag set'); + + console.log('\n=== 7b. tab fallback when windows.create rejects ==='); + const tScope = safariLikeScope((s) => { + s.chrome.windows.create = () => Promise.reject(new Error('no windows')); + }); + const tPlat = loadAdapter(tScope); + tPlat.install(); + const tOpened = await tScope.chrome.sidePanel.open({}); + passAssertEqual(tOpened.surface, 'tab', 'falls back to a tab surface'); + passAssertEqual(tScope._calls.tabsCreate.length, 1, 'exactly one tabs.create in fallback'); + + console.log('\n=== 8. resolveTargetTab() excludes extension pages ==='); + const rScope = safariLikeScope((s) => { + s.chrome.tabs.query = () => Promise.resolve([ + { id: 90, url: 'safari-web-extension://sfr/ui/sidepanel.html' }, + { id: 91, url: 'https://news.example.com/' } + ]); + s.chrome.tabs.get = () => Promise.reject(new Error('gone')); + }); + const rPlat = loadAdapter(rScope); + rPlat.install(); + const target = await rPlat.resolveTargetTab(); + passAssertEqual(target && target.id, 91, 'skips the extension page, returns the content tab'); + + const emptyScope = safariLikeScope((s) => { + s.chrome.tabs.query = () => Promise.resolve([{ id: 92, url: 'safari-web-extension://sfr/ui/sidepanel.html' }]); + s.chrome.tabs.get = () => Promise.reject(new Error('gone')); + }); + const ePlat = loadAdapter(emptyScope); + ePlat.install(); + passAssertEqual(await ePlat.resolveTargetTab(), null, 'returns null when only extension pages exist'); + + // Extension PAGES install with trackTabs:false, so only the service worker + // updates lastContentTabId. Without a re-read per call the page would keep + // driving whatever tab was active when the workspace window opened, however + // many times the user switched tabs afterwards. + let readCount = 0; + const staleScope = safariLikeScope((s) => { + s.chrome.storage.session.get = () => { + readCount += 1; + // 1st read is install-time hydrate; the worker moves on afterwards. + return Promise.resolve({ fsbSafariContentTab: readCount <= 1 ? 42 : 43 }); + }; + s.chrome.tabs.get = (id) => Promise.resolve({ id, url: 'https://example.com/' }); + }); + const stalePlat = loadAdapter(staleScope); + stalePlat.install({ loopback: false, trackTabs: false }); + const moved = await stalePlat.resolveTargetTab(); + passAssertEqual(moved && moved.id, 43, 're-reads the persisted record instead of trusting the hydrated id'); + passAssert(readCount >= 2, 'resolveTargetTab() performed its own storage read'); + + console.log('\n=== 8b. window focus retargets the content tab ==='); + // Switching to a window whose tab is already active fires no onActivated, + // so window focus is the only signal that the user moved. + let focusListener = null; + const persisted = []; + const fScope = safariLikeScope((s) => { + const byWindow = { + 5: [{ id: 55, url: 'safari-web-extension://sfr/ui/sidepanel.html' }], + 7: [{ id: 70, url: 'https://seven.example.com/' }] + }; + s.chrome.tabs.query = (q) => Promise.resolve( + q && typeof q.windowId === 'number' ? (byWindow[q.windowId] || []) : [{ id: 99, url: 'https://any.example.com/' }]); + s.chrome.windows.onFocusChanged = { addListener(fn) { focusListener = fn; } }; + s.chrome.storage.session.set = (p) => { persisted.push(p); return Promise.resolve(); }; + }); + const fPlat = loadAdapter(fScope); + fPlat.install(); + const tick = () => new Promise((r) => setTimeout(r, 0)); + const fState = fPlat._workspaceState(); + fState.windowId = 5; + passAssert(typeof focusListener === 'function', 'trackContentTabs registers windows.onFocusChanged'); + + focusListener(7); + await tick(); + passAssertEqual(fState.lastContentTabId, 70, 'focusing a window adopts its active content tab'); + passAssert(persisted.some((p) => p.fsbSafariContentTab === 70), 'and persists it'); + + fState.lastContentTabId = 11; + focusListener(-1); + focusListener(5); + await tick(); + passAssertEqual(fState.lastContentTabId, 11, 'WINDOW_ID_NONE and the workspace window are ignored'); + + passAssertEqual((await fPlat.resolveTargetTab({ windowId: 7 })).id, 70, + 'a windowId hint beats a stale cached tab'); + passAssertEqual((await fPlat.resolveTargetTab({ windowId: 5 })).id, 11, + 'a workspace-window hint falls through to the cache'); + passAssertEqual((await fPlat.resolveTargetTab()).id, 11, 'no hint still uses the cache'); + + console.log('\n=== 8b2. a cold cache resolves to the focused window, not any window ==='); + // An unscoped {active:true} query returns every window's active tab in no + // particular order. With the background window listed first, only the + // last-focused lookup gets the right one. + { + const unscoped = [{ id: 30, url: 'https://background.example.com/' }, { id: 70, url: 'https://focused.example.com/' }]; + const coldScope = (lastFocused, getInfoLog) => safariLikeScope((s) => { + const byWindow = { 3: [unscoped[0]], 5: [{ id: 55, url: 'safari-web-extension://sfr/ui/sidepanel.html' }], 7: [unscoped[1]] }; + s.chrome.tabs.query = (q) => Promise.resolve( + q && typeof q.windowId === 'number' ? (byWindow[q.windowId] || []) : unscoped); + s.chrome.tabs.get = () => Promise.reject(new Error('gone')); + if (lastFocused !== undefined) { + s.chrome.windows.getLastFocused = (info) => { + if (getInfoLog) getInfoLog.push(info); + return lastFocused instanceof Error ? Promise.reject(lastFocused) : Promise.resolve(lastFocused); + }; + } + }); + const load = (scope) => { + const plat = loadAdapter(scope); + plat.install({ loopback: false, trackTabs: false }); + plat._workspaceState().windowId = 5; + return plat; + }; + + const infos = []; + passAssertEqual((await load(coldScope({ id: 7 }, infos)).resolveTargetTab()).id, 70, + 'uses the last-focused window\'s active tab'); + passAssert(infos.length === 1 && JSON.stringify(infos[0].windowTypes) === '["normal"]', + 'asks for normal windows only, which excludes the popup workspace'); + passAssertEqual((await load(coldScope({ id: 5 })).resolveTargetTab()).id, 30, + 'a browser that returns the workspace anyway falls back to the unscoped query'); + passAssertEqual((await load(coldScope(new Error('No window'))).resolveTargetTab()).id, 30, + 'a rejected getLastFocused falls back to the unscoped query'); + passAssertEqual((await load(coldScope(undefined)).resolveTargetTab()).id, 30, + 'no windows.getLastFocused falls back to the unscoped query'); + } + + console.log('\n=== 8c. the content tab has exactly one writer ==='); + // A page's lastContentTabId is only as fresh as its last hydrate. Closing the + // workspace from a page used to persist that stale id over the worker's. + { + const writes = []; + const pScope = safariLikeScope((s) => { + s.chrome.storage.session.get = () => Promise.resolve({ + fsbSafariWorkspace: { workspaceWindowId: 5, workspaceTabId: 55 }, + fsbSafariContentTab: 42 + }); + s.chrome.storage.session.set = (p) => { writes.push(p); return Promise.resolve(); }; + }); + const pPlat = loadAdapter(pScope); + pPlat.install({ loopback: false, trackTabs: false }); + await tick(); + pPlat.closeSurface(); + passAssertEqual(pScope._calls.windowsRemove[0], 5, 'page closes the workspace window'); + const record = writes.map((p) => p.fsbSafariWorkspace).filter(Boolean).pop(); + passAssert(record && record.workspaceWindowId === null, 'page clears the window record'); + passAssert(writes.every((p) => !('fsbSafariContentTab' in p)), 'page never writes the content tab'); + } + + // An event that wakes an evicted worker runs before install()'s hydration + // lands. The listener must wait for it, or it cannot recognise the workspace + // window and the repair is skipped. + { + let openGate; + const gate = new Promise((r) => { openGate = r; }); + let windowRemoved = null; + const writes = []; + const wScope = safariLikeScope((s) => { + s.chrome.storage.session.get = () => gate.then(() => ({ + fsbSafariWorkspace: { workspaceWindowId: 5, workspaceTabId: 55 }, + fsbSafariContentTab: 42 + })); + s.chrome.storage.session.set = (p) => { writes.push(p); return Promise.resolve(); }; + s.chrome.windows.onRemoved = { addListener(fn) { windowRemoved = fn; } }; + }); + const wPlat = loadAdapter(wScope); + wPlat.install(); + windowRemoved(5); // fires before hydration + await tick(); + passAssertEqual(writes.length, 0, 'nothing is written before hydration lands'); + openGate(); + await tick(); + const wState = wPlat._workspaceState(); + passAssertEqual(wState.windowId, null, 'restarted worker still recognises and clears the closed workspace'); + const record = writes.map((p) => p.fsbSafariWorkspace).filter(Boolean).pop(); + passAssert(record && record.workspaceWindowId === null, 'and persists the cleared record'); + passAssertEqual(wState.lastContentTabId, 42, 'the content tab survives'); + passAssert(writes.every((p) => !('fsbSafariContentTab' in p)), 'closing the workspace does not touch the content tab'); + } + + console.log('\n=== 8d. the workspace tab is ruled out by its window, not just its URL ==='); + { + // If Safari withholds the workspace tab's URL, the URL check alone reads it + // as a content page; the dashboard relay would then navigate or drive FSB's + // own window. + let activatedListener = null; + const wScope = safariLikeScope((s) => { + s.chrome.tabs.onActivated = { addListener(fn) { activatedListener = fn; } }; + s.chrome.tabs.get = (id) => Promise.resolve(id === 55 + ? { id: 55, windowId: 5 } + : { id, windowId: 7, url: 'https://seven.example.com/' }); + s.chrome.tabs.query = () => Promise.resolve([ + { id: 55, windowId: 5 }, + { id: 70, windowId: 7, url: 'https://seven.example.com/' } + ]); + }); + const wPlat = loadAdapter(wScope); + wPlat.install(); + const wState = wPlat._workspaceState(); + wState.windowId = 5; + wState.lastContentTabId = 70; + passAssert(typeof activatedListener === 'function', 'trackContentTabs registers tabs.onActivated'); + activatedListener({ tabId: 55, windowId: 5 }); + await new Promise((r) => setTimeout(r, 0)); + passAssertEqual(wState.lastContentTabId, 70, 'activating the URL-less workspace tab does not adopt it'); + + wState.lastContentTabId = 55; + const picked = await wPlat.resolveTargetTab(); + passAssertEqual(picked && picked.id, 70, 'a cached id that points at the workspace tab is passed over'); + } + + console.log('\n=== 9. Lattice loopback ==='); + const lScope = safariLikeScope(); + const lPlat = loadAdapter(lScope); + lPlat.install(); + lPlat.captureLoopback(() => { + lScope.chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + if (msg.type !== 'lattice-provider-execute') return false; + setTimeout(() => sendResponse({ ok: true, echo: msg.requestId }), 0); + return true; + }); + }); + const loopRes = await lScope.chrome.runtime.sendMessage({ type: 'lattice-provider-execute', requestId: 'r1' }); + passAssert(loopRes && loopRes.ok === true && loopRes.echo === 'r1', 'lattice-* message loops back in-context'); + + // REGRESSION: background.js registers fsbHandleRuntimeMessage long before the + // build epilogue imports the Lattice host, and its `default:` branch answers + // any message with no request.action -- which is every lattice-* envelope. If + // capture were global it would claim the reply first and the host would never + // see the message, so every LLM call on Safari would fail. + const oScope = safariLikeScope(); + const oPlat = loadAdapter(oScope); + oPlat.install(); + let hostSaw = false; + oScope.chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + switch (request.action) { + default: sendResponse({ error: 'Unknown action' }); + } + }); + oPlat.captureLoopback(() => { + oScope.chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + if (msg.type !== 'lattice-provider-execute') return false; + hostSaw = true; + sendResponse({ ok: true }); + return true; + }); + }); + const ordered = await oScope.chrome.runtime.sendMessage({ type: 'lattice-provider-execute', requestId: 'r2' }); + passAssert(hostSaw, 'the Lattice host receives the message even though a catch-all router registered first'); + passAssert(ordered && ordered.ok === true, + 'the catch-all router does not hijack the reply (got ' + JSON.stringify(ordered) + ')'); + + let passthrough = false; + const pScope = safariLikeScope((s) => { + s.chrome.runtime.sendMessage = () => { passthrough = true; return Promise.resolve('real'); }; + }); + const pPlat = loadAdapter(pScope); + pPlat.install(); + await pScope.chrome.runtime.sendMessage({ action: 'startAutomation' }); + passAssert(passthrough, 'non-lattice messages pass through to the real sendMessage'); + + let unclaimed = await lScope.chrome.runtime.sendMessage({ type: 'lattice-unknown' }); + passAssertEqual(unclaimed, undefined, 'unclaimed lattice message resolves undefined'); + + console.log('\n=== 9b. re-entrancy cap ==='); + const rcScope = safariLikeScope(); + const rcPlat = loadAdapter(rcScope); + rcPlat.install(); + rcPlat.captureLoopback(() => { + rcScope.chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + // Unconditionally re-send. Without the depth cap this recurses forever. + // Each level propagates whatever it received so the cap error surfaces at + // the outermost caller instead of being absorbed by an intermediate level. + rcScope.chrome.runtime.sendMessage({ type: 'lattice-loop' }).then( + (v) => sendResponse(v === undefined ? 'ok' : v), + (err) => sendResponse('capped:' + err.message) + ); + return true; + }); + }); + const capped = await rcScope.chrome.runtime.sendMessage({ type: 'lattice-loop' }); + passAssert(typeof capped === 'string' && capped.indexOf('capped:fsb_loopback_depth_exceeded') === 0, + 'unbounded re-entrancy terminates via the depth cap (got ' + JSON.stringify(capped) + ')'); + + console.log('\n=== 9c. extension pages self-install ==='); + { + // MV3's script-src 'self' forbids an inline