From 8bb9bdb11b651f771251d77ab5eca433826d3bc8 Mon Sep 17 00:00:00 2001 From: Lakshman Turlapati Date: Tue, 29 Sep 2026 14:39:43 -0500 Subject: [PATCH 1/4] feat(safari): port the FSB extension to Safari via a native macOS wrapper Add a Safari Web Extension target (safari/FSB Xcode project with a Swift app, extension handler, and native MCP bridge) plus build/release/version scripts. The extension gains a platform adapter that degrades CDP-only features (pointer, keyboard, network capture, file upload) to content-script fallbacks, and a native-messaging MCP transport used in place of the WebSocket bridge. Font Awesome moves to assets/vendor, and the Chrome-only offscreen STT page is removed. New tests cover the adapter, native transport, manifest build, and source/version parity. --- .planning/SAFARI-PORT-PLAN.md | 335 +++++++++ esbuild.config.js | 176 +++-- extension/ai/tool-executor.js | 26 + .../fontawesome/webfonts/fa-brands-400.ttf | Bin 209376 -> 0 bytes .../fontawesome/webfonts/fa-regular-400.ttf | Bin 67976 -> 0 bytes .../fontawesome/webfonts/fa-solid-900.ttf | Bin 423676 -> 0 bytes .../webfonts/fa-v4compatibility.ttf | Bin 10836 -> 0 bytes .../fontawesome/css/all.min.css | 2 +- .../fontawesome/webfonts/fa-brands-400.woff2 | Bin .../fontawesome/webfonts/fa-regular-400.woff2 | Bin .../fontawesome/webfonts/fa-solid-900.woff2 | Bin .../webfonts/fa-v4compatibility.woff2 | Bin extension/background.js | 59 ++ extension/content/actions.js | 422 +++++++++++ extension/content/messaging.js | 7 +- extension/offscreen/stt.html | 2 - extension/offscreen/stt.js | 80 -- extension/ui/control_panel.html | 6 +- extension/ui/onboarding.html | 6 +- extension/ui/popup.html | 6 +- extension/ui/sidepanel.css | 18 +- extension/ui/sidepanel.html | 6 +- extension/ui/sidepanel.js | 62 +- extension/ui/unlock.html | 4 + extension/utils/keyboard-emulator.js | 13 + extension/utils/native-file-reader.js | 172 +++++ extension/utils/network-capture.js | 14 + extension/utils/platform-adapter.js | 699 +++++++++++++++++ extension/ws/mcp-bridge-client.js | 274 ++++++- extension/ws/mcp-native-transport.js | 408 ++++++++++ extension/ws/ws-client.js | 33 + mcp/src/bridge.ts | 19 +- package.json | 5 +- safari/Config/ExportOptions-AppStore.plist | 12 + safari/Config/ExportOptions-DeveloperID.plist | 12 + safari/Config/Version.xcconfig | 5 + .../FSB Extension/FSB_Extension.entitlements | 26 + safari/FSB/FSB Extension/Info.plist | 15 + safari/FSB/FSB Extension/Resources | 1 + .../SafariWebExtensionHandler.swift | 108 +++ safari/FSB/FSB.xcodeproj/project.pbxproj | 709 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + safari/FSB/FSB/AppDelegate.swift | 21 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 68 ++ .../AppIcon.appiconset/mac-icon-128@1x.png | Bin 0 -> 5991 bytes .../AppIcon.appiconset/mac-icon-128@2x.png | Bin 0 -> 16464 bytes .../AppIcon.appiconset/mac-icon-16@1x.png | Bin 0 -> 528 bytes .../AppIcon.appiconset/mac-icon-16@2x.png | Bin 0 -> 1309 bytes .../AppIcon.appiconset/mac-icon-256@1x.png | Bin 0 -> 16464 bytes .../AppIcon.appiconset/mac-icon-256@2x.png | Bin 0 -> 43821 bytes .../AppIcon.appiconset/mac-icon-32@1x.png | Bin 0 -> 1309 bytes .../AppIcon.appiconset/mac-icon-32@2x.png | Bin 0 -> 2552 bytes .../AppIcon.appiconset/mac-icon-512@1x.png | Bin 0 -> 43821 bytes .../AppIcon.appiconset/mac-icon-512@2x.png | Bin 0 -> 130139 bytes safari/FSB/FSB/Assets.xcassets/Contents.json | 6 + .../LargeIcon.imageset/Contents.json | 20 + safari/FSB/FSB/Base.lproj/Main.storyboard | 124 +++ safari/FSB/FSB/FSB.entitlements | 26 + safari/FSB/FSB/Info.plist | 10 + safari/FSB/FSB/Resources/Base.lproj/Main.html | 33 + safari/FSB/FSB/Resources/Icon.png | Bin 0 -> 2402 bytes safari/FSB/FSB/Resources/Script.js | 49 ++ safari/FSB/FSB/Resources/Style.css | 55 ++ safari/FSB/FSB/ViewController.swift | 114 +++ safari/FSB/Shared/BridgeCoordinator.swift | 451 +++++++++++ safari/FSB/Shared/FileReadService.swift | 158 ++++ safari/FSB/Shared/GrantedRoots.swift | 148 ++++ safari/FSB/Shared/MCPSocketSession.swift | 152 ++++ safari/FSB/Shared/NativeFraming.swift | 136 ++++ safari/README.md | 214 ++++++ scripts/build-safari.mjs | 342 +++++++++ scripts/release-safari.mjs | 83 ++ scripts/sync-safari-version.mjs | 90 +++ scripts/validate-extension.mjs | 67 +- tests/actions-pointer-fallbacks.test.js | 303 ++++++++ tests/build-safari-manifest.test.js | 103 +++ tests/cdp-degradation-routing.test.js | 194 +++++ tests/mcp-bridge-client-lifecycle.test.js | 162 +++- tests/mcp-bridge-topology.test.js | 75 ++ tests/mcp-native-transport.test.js | 255 +++++++ tests/platform-adapter.test.js | 406 ++++++++++ tests/safari-preamble-smoke.test.js | 120 +++ tests/safari-source-parity.test.js | 364 +++++++++ tests/safari-version-parity.test.js | 82 ++ tests/upload-file-safari-native.test.js | 383 ++++++++++ 86 files changed, 8379 insertions(+), 195 deletions(-) create mode 100644 .planning/SAFARI-PORT-PLAN.md delete mode 100644 extension/assets/onboarding/fontawesome/webfonts/fa-brands-400.ttf delete mode 100644 extension/assets/onboarding/fontawesome/webfonts/fa-regular-400.ttf delete mode 100644 extension/assets/onboarding/fontawesome/webfonts/fa-solid-900.ttf delete mode 100644 extension/assets/onboarding/fontawesome/webfonts/fa-v4compatibility.ttf rename extension/assets/{onboarding => vendor}/fontawesome/css/all.min.css (77%) rename extension/assets/{onboarding => vendor}/fontawesome/webfonts/fa-brands-400.woff2 (100%) rename extension/assets/{onboarding => vendor}/fontawesome/webfonts/fa-regular-400.woff2 (100%) rename extension/assets/{onboarding => vendor}/fontawesome/webfonts/fa-solid-900.woff2 (100%) rename extension/assets/{onboarding => vendor}/fontawesome/webfonts/fa-v4compatibility.woff2 (100%) delete mode 100644 extension/offscreen/stt.html delete mode 100644 extension/offscreen/stt.js create mode 100644 extension/utils/native-file-reader.js create mode 100644 extension/utils/platform-adapter.js create mode 100644 extension/ws/mcp-native-transport.js create mode 100644 safari/Config/ExportOptions-AppStore.plist create mode 100644 safari/Config/ExportOptions-DeveloperID.plist create mode 100644 safari/Config/Version.xcconfig create mode 100644 safari/FSB/FSB Extension/FSB_Extension.entitlements create mode 100644 safari/FSB/FSB Extension/Info.plist create mode 120000 safari/FSB/FSB Extension/Resources create mode 100644 safari/FSB/FSB Extension/SafariWebExtensionHandler.swift create mode 100644 safari/FSB/FSB.xcodeproj/project.pbxproj create mode 100644 safari/FSB/FSB.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 safari/FSB/FSB/AppDelegate.swift create mode 100644 safari/FSB/FSB/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 safari/FSB/FSB/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 safari/FSB/FSB/Assets.xcassets/AppIcon.appiconset/mac-icon-128@1x.png create mode 100644 safari/FSB/FSB/Assets.xcassets/AppIcon.appiconset/mac-icon-128@2x.png create mode 100644 safari/FSB/FSB/Assets.xcassets/AppIcon.appiconset/mac-icon-16@1x.png create mode 100644 safari/FSB/FSB/Assets.xcassets/AppIcon.appiconset/mac-icon-16@2x.png create mode 100644 safari/FSB/FSB/Assets.xcassets/AppIcon.appiconset/mac-icon-256@1x.png create mode 100644 safari/FSB/FSB/Assets.xcassets/AppIcon.appiconset/mac-icon-256@2x.png create mode 100644 safari/FSB/FSB/Assets.xcassets/AppIcon.appiconset/mac-icon-32@1x.png create mode 100644 safari/FSB/FSB/Assets.xcassets/AppIcon.appiconset/mac-icon-32@2x.png create mode 100644 safari/FSB/FSB/Assets.xcassets/AppIcon.appiconset/mac-icon-512@1x.png create mode 100644 safari/FSB/FSB/Assets.xcassets/AppIcon.appiconset/mac-icon-512@2x.png create mode 100644 safari/FSB/FSB/Assets.xcassets/Contents.json create mode 100644 safari/FSB/FSB/Assets.xcassets/LargeIcon.imageset/Contents.json create mode 100644 safari/FSB/FSB/Base.lproj/Main.storyboard create mode 100644 safari/FSB/FSB/FSB.entitlements create mode 100644 safari/FSB/FSB/Info.plist create mode 100644 safari/FSB/FSB/Resources/Base.lproj/Main.html create mode 100644 safari/FSB/FSB/Resources/Icon.png create mode 100644 safari/FSB/FSB/Resources/Script.js create mode 100644 safari/FSB/FSB/Resources/Style.css create mode 100644 safari/FSB/FSB/ViewController.swift create mode 100644 safari/FSB/Shared/BridgeCoordinator.swift create mode 100644 safari/FSB/Shared/FileReadService.swift create mode 100644 safari/FSB/Shared/GrantedRoots.swift create mode 100644 safari/FSB/Shared/MCPSocketSession.swift create mode 100644 safari/FSB/Shared/NativeFraming.swift create mode 100644 safari/README.md create mode 100644 scripts/build-safari.mjs create mode 100644 scripts/release-safari.mjs create mode 100644 scripts/sync-safari-version.mjs create mode 100644 tests/actions-pointer-fallbacks.test.js create mode 100644 tests/build-safari-manifest.test.js create mode 100644 tests/cdp-degradation-routing.test.js create mode 100644 tests/mcp-native-transport.test.js create mode 100644 tests/platform-adapter.test.js create mode 100644 tests/safari-preamble-smoke.test.js create mode 100644 tests/safari-source-parity.test.js create mode 100644 tests/safari-version-parity.test.js create mode 100644 tests/upload-file-safari-native.test.js 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/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 6f079dcef..d230adcf6 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 61d51afb5..2cf0c6dca 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 a5e75c8b9..f86b9e366 100644 --- a/extension/ui/popup.html +++ b/extension/ui/popup.html @@ -3,7 +3,7 @@ FSB - + @@ -77,6 +77,10 @@

FSB

+ + diff --git a/extension/ui/sidepanel.css b/extension/ui/sidepanel.css index 141e42f34..559b9d38b 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 b12b4ffa0..0340e22f0 100644 --- a/extension/ui/sidepanel.html +++ b/extension/ui/sidepanel.html @@ -3,7 +3,7 @@ FSB - Side Panel - + @@ -144,6 +144,10 @@

Session History

+ + diff --git a/extension/ui/sidepanel.js b/extension/ui/sidepanel.js index 19c14a83a..d2ebe1996 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 */ } @@ -317,7 +357,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 @@ -957,7 +997,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'); @@ -993,7 +1033,7 @@ async function refreshOwnerChip() { return; } - const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tabs = await getTargetTabs(); const tab = tabs && tabs[0]; if (!tab || typeof tab.id !== 'number') { chipEl.style.display = 'none'; @@ -1202,7 +1242,7 @@ try { try { if (typeof windowId !== 'number' || windowId < 0) return; await refreshOwnerChip(); - var tabs = await chrome.tabs.query({ active: true, windowId: windowId }); + var tabs = await getTargetTabs({ windowId: windowId }); if (tabs && tabs[0] && typeof tabs[0].id === 'number') { _activeTabIdSnapshot = tabs[0].id; // QT-93i-02 await swapToTabConversation(tabs[0].id); @@ -1492,7 +1532,7 @@ async function handleSendMessage() { 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 @@ -2391,7 +2431,7 @@ function openSettings() { chrome.runtime.openOptionsPage(); // Then close the side panel - window.close(); + closeWorkspaceSurface(); } async function openControlPanelSection(sectionId) { @@ -2404,10 +2444,10 @@ async function openControlPanelSection(sectionId) { } else { chrome.runtime.openOptionsPage(); } - window.close(); + closeWorkspaceSurface(); } catch (_error) { chrome.runtime.openOptionsPage(); - window.close(); + closeWorkspaceSurface(); } } @@ -2557,7 +2597,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; @@ -2812,7 +2852,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/keyboard-emulator.js b/extension/utils/keyboard-emulator.js index 2869f60c2..190d54a22 100644 --- a/extension/utils/keyboard-emulator.js +++ b/extension/utils/keyboard-emulator.js @@ -198,6 +198,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.debuggerAttached && this.attachedTabId === tabId) { return true; diff --git a/extension/utils/native-file-reader.js b/extension/utils/native-file-reader.js new file mode 100644 index 000000000..e58ee9aef --- /dev/null +++ b/extension/utils/native-file-reader.js @@ -0,0 +1,172 @@ +/** + * 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. + * 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.', + 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' })); } }); + } + }); + } + + /** + * Read a file for upload. + * + * @param {string} path absolute path, already cleared by the denylist gate + * @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) { + 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. + try { await sendNative({ v: 1, t: 'readRelease', token: opened.token }); } catch (_e) { /* best effort */ } + return { + ok: false, + reason: 'file_too_large', + message: describeReason('file_too_large', size + ' bytes / ' + chunks + ' chunks') + }; + } + + const parts = []; + 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) break; + } + + 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 61325b769..6f5473e0e 100644 --- a/extension/utils/network-capture.js +++ b/extension/utils/network-capture.js @@ -284,6 +284,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..486e8c6c0 --- /dev/null +++ b/extension/utils/platform-adapter.js @@ -0,0 +1,699 @@ +/** + * 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. + * + * 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 309 script-import mentions / 305 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 0000000000000000000000000000000000000000..42f42ff4f40ae78c46b51f80dd805a518e01db73 GIT binary patch literal 2402 zcmY*Z2{;s58$Lsf1|`v4M3d5pWUQHy7-LM6ktMr|!3@JJ%wjFsB1^e)DM`xyBujqe zB8|P`LAutmL?la5`c-27al6lb{?7A!=Y7xjeeXHX*}g>E(*$XlA`Ac^O+0B~FT|)V zlMr@b*6{>f2ng?!_SOJ|9}@cG0QkNYp96r00$|<~0L)DQ;DNX6?T!lrrztK(suidydbYkLJ+8cEs3YTh}x+r^o+@BChe#$lNGJIAV&l}QiTj_j54Y?1P}Q+(Cx zb-scAWTR`^q0_}`DA7KCQ0g&}vsA5ZzddoEaL~?t ze-K7)xFfdfY*~!dT(k^xMfJVe5r=0N&Y;o^`=`Ioldzs9xyT_s7vnoj{3n@42+l6y z-4!>V?Z@<6&c#z^s;6W^9^pkFiKI;0V)L}j6kf++YCNwH+EkurIeEfkZSD^RtEkH`spY6Y z>YBe6O#7x?F~Gf#>HeTJy7(4XLcvSmD|>R|WTm8s^Y*@zZqd#Q;BLF18Fv6;D?cay zyqWmocx4G+Eh*~2QVlPuBhKTXk4UhlVwd!>ilLkR0k@l|BT2+N02CWBnU&6n*5yeQA?79UI=Wh2clkGY; zh#RN}_EKu2MY3(H2{z$5I(HX)WJlwl^-_t+vyyVFN$nE}8-pNg%KzKz$c6?cGDng- z0eE#Ui*AMj%~yR3_FmCXGR+ z(>Pp&Ii2hsfUsr>XJsPHc^n#(!r>rDEML}!LyVKK{4t3ZSclNjr9msdvyQ((mjz+n zZH5CH7VyG;r{;rw4sJ2~^RsOm0-7msN11vyGSjPFgZHYw-!XR5@>H_qKFJL8SJwLr z3e{?=Wv=X$I2dH6q+}d2dxx`m^nQVbIQG<{Gh=zAL5zY5i>!z4el;j-7cI4)@1tX19d{YCro?3W=eyM~P>5U{cT^AGm6r_eBV^u6V)P@ZpPm1JW~PuN+G?w+sk8iiSIL)xYx`<0sZ(7I{LDN z>sBAl^3kBz#*3q*Y<3It?zYue$N&1PEq;$anDv)Qn1)Au7>Jf+5pE0#a$;DY4};3- z;k74d-8%l8K}VUVtw9`_tP;YT! z`uk&v8uJc+mFO?U!J^t-uRGiI^Cd1mPp{Nk2PT?!hp*gLcXJ6StLd0!g)0-?(6_`V zWh5Mob4TO`IP@~vCUh?3#tSG(C8xQ|yN@p)H)x!E+Bdo3hF^-!qb06*I;pBvy$)mA z>l`hq;4yvU@*S~^Yz9PqIN7IU$p4Gkoi4+?jfH^AFS8|0qzT%!A;r;avsDk7kHysa zlQkKI{xxo8PH^gp+O!#Yg~E~Q{#}kyBgRH|OHF&t^o)_;KMQ`q?j{?G+R1?cU5iL47?Yf{3-pt5d|)r6b-IPBT~Un-8qaJWQ 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..add6973de --- /dev/null +++ b/safari/FSB/Shared/BridgeCoordinator.swift @@ -0,0 +1,451 @@ +// +// BridgeCoordinator.swift +// FSB — shared by the container app and the Safari web extension target. +// +// Owns the single MCPSocketSession and relays frames to/from the extension. +// +// 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 { + + static let shared = BridgeCoordinator() + + /// 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(label: "com.fullselfbrowsing.fsb.bridge") + 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() {} + + // MARK: - open + + func handleOpen(url: 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") } + + let session = MCPSocketSession(url: parsed) + 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.deliver(["v": NativeFraming.protocolVersion, "t": "error", + "phase": "frame", "message": reason]) + case .pending, .ignored: + break + } + } + } + + 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 + 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 + 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 + 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 } + // 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. + func portDisconnected() { + queue.async { + self.cancelLinger() + guard self.socket != nil else { return } + let timer = DispatchSource.makeTimerSource(queue: self.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() + self.lingerTimer = timer + self.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..9ba4d7e6a --- /dev/null +++ b/safari/FSB/Shared/FileReadService.swift @@ -0,0 +1,158 @@ +// +// 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} +// {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 root = 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 { root.url.stopAccessingSecurityScopedResource() } + + let url = URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL + + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir), !isDir.boolValue else { + return fail("not_a_file") + } + + let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) + let size = (attrs?[.size] as? NSNumber)?.intValue ?? 0 + guard size <= Self.maxFileBytes else { + return fail("file_too_large", detail: "\(size) > \(Self.maxFileBytes)") + } + + guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]) else { + return fail("read_failed") + } + + 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 + ] + } + } + + // 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 + } + + 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..c0047b9ca --- /dev/null +++ b/safari/FSB/Shared/GrantedRoots.swift @@ -0,0 +1,148 @@ +// +// 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. 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 defaultsKey = "fsbGrantedRootBookmarks" + + 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) + } + }() + + private static var defaults: UserDefaults? { + // The App Group suite is what makes a grant taken in the app visible to + // the extension process. + guard let appGroupId else { return nil } + return UserDefaults(suiteName: appGroupId) + } + + // MARK: - Grant (container app) + + /// Persist a user-selected directory as a security-scoped bookmark. + static func addGrant(_ url: URL) throws { + guard let defaults 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) + defaults.set(all, forKey: defaultsKey) + } + + static func clearGrants() { + defaults?.removeObject(forKey: defaultsKey) + } + + /// 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] { + defaults?.array(forKey: defaultsKey) as? [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) + } + + /// 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 + static func rootContaining(_ path: String) -> Resolved? { + let target = URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL + let targetComponents = target.pathComponents + + for data in storedBookmarks() { + guard let resolved = resolve(data) else { continue } + let root = resolved.url.resolvingSymlinksInPath().standardizedFileURL + let rootComponents = root.pathComponents + + let contained = targetComponents.count > rootComponents.count + && Array(targetComponents.prefix(rootComponents.count)) == rootComponents + + if contained { return Resolved(url: resolved.url, stale: resolved.stale) } + 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..323b9ebef --- /dev/null +++ b/safari/FSB/Shared/MCPSocketSession.swift @@ -0,0 +1,152 @@ +// +// 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. +// +// The mcp/ server needs no changes for this path: URLSessionWebSocketTask is +// not a browser and sends no Origin header, and the server's origin check +// returns true when Origin is absent. +// + +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 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) { + self.url = url + 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 + let task = session.webSocketTask(with: url) + 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..4d912d9db --- /dev/null +++ b/safari/FSB/Shared/NativeFraming.swift @@ -0,0 +1,136 @@ +// +// 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 + static let reassemblyTimeout: TimeInterval = 30 + + struct ChunkBuffer { + var parts: [String?] + var received: Int + var bytes: Int + var startedAt: 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. + 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, startedAt: Date()) + + if Date().timeIntervalSince(buf.startedAt) > 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 + } + + 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 + } +} diff --git a/safari/README.md b/safari/README.md new file mode 100644 index 000000000..51211a1f3 --- /dev/null +++ b/safari/README.md @@ -0,0 +1,214 @@ +# 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 # 291 assertions across 8 suites +``` + +## 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 309 script-import mentions / 305 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 the real socket to ws://localhost:7225 + BridgeCoordinator.swift relay + linger/adoption + GrantedRoots.swift security-scoped folder grants + FileReadService.swift sandbox-scoped chunked file reads +``` + +## 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 | one line: `safari-web-extension://` added to the origin allowlist | +| `native` | the container app holds the socket; frames relayed over `runtime.connectNative` | **none** | + +Safari's ability to open `ws://localhost` from an extension page is genuinely +undetermined — Apple forum reports of CSP refusals run from Safari 14 through +2025, but none tried an explicit MV3 `extension_pages` `connect-src` (which the +build now emits). So the extension **probes**: direct first, pin to native +after two failures, remember the decision in `chrome.storage.local` for 7 days. +Worst case is ~7 s once per install. + +The native path needs no server change because `URLSessionWebSocketTask` sends +no `Origin` header, and the server accepts origin-less clients. That branch in +`mcp/src/bridge.ts` is marked load-bearing — do not "harden" it away. + +**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. + +## 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`** — degraded to metadata. Safari 18's `webRequest` is + non-blocking and exposes no response bodies. The existing consent gate, + redactor and bounds all port unchanged; only the transport is missing. +- **`get_memory_stats`** — `chrome.system.memory` is Chrome-only. Returns + `capability_unavailable`. +- **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. +- **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:** + +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 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 `URLSessionWebSocketTask` send an `Origin` header? | allowlist entry already shipped | +| 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) | diff --git a/scripts/build-safari.mjs b/scripts/build-safari.mjs new file mode 100644 index 000000000..9ab13a351 --- /dev/null +++ b/scripts/build-safari.mjs @@ -0,0 +1,342 @@ +#!/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 309 + * script-import mentions / 305 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 309/305 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' +]; + +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. +]; + +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'; + +// 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; + + // 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 309/305 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 309/305 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/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/actions-pointer-fallbacks.test.js b/tests/actions-pointer-fallbacks.test.js new file mode 100644 index 000000000..dae82bab5 --- /dev/null +++ b/tests/actions-pointer-fallbacks.test.js @@ -0,0 +1,303 @@ +/** + * 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 }) { + 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, + 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: scroller || html, + activeElement: active || null, + elementFromPoint: () => hit, + querySelector: () => hit, + 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 }; +} + +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=== 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=== 8. every fallback reports untrusted ==='); + for (const name of ['pointerClickAt', 'pointerDoubleClickAt', 'pointerClickAndHoldAt', + 'pointerDrag', 'pointerDragVariableSpeed', 'wheelScrollAt', 'domInsertTextAt']) { + 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..5df14c2ed --- /dev/null +++ b/tests/build-safari-manifest.test.js @@ -0,0 +1,103 @@ +/** + * 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 added'); + passAssert(!SRC.permissions.includes('nativeMessaging'), + 'nativeMessaging is NOT in the Chrome manifest (it would add a scary CWS install prompt)'); + + 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..b2086e619 --- /dev/null +++ b/tests/cdp-degradation-routing.test.js @@ -0,0 +1,194 @@ +/** + * 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. + * + * 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=== 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 cdpTools = defs.TOOL_REGISTRY.filter((t) => t._route === 'cdp'); + passAssert(cdpTools.length === 7, `found ${cdpTools.length} _route:'cdp' tools (expected 7)`); + for (const t of cdpTools) { + 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 degrades on EVERY input path, 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 bail out BEFORE + // reaching executeCDPToolDirect. + const WS_SRC = fs.readFileSync(path.join(ROOT, 'extension', 'ws', 'ws-client.js'), 'utf8'); + passAssert(/function _fsbRemoteControlCdpUnavailable\(/.test(WS_SRC), + 'ws-client.js defines the shared capability guard'); + for (const type of ['dash:remote-click', 'dash:remote-key', 'dash:remote-scroll']) { + passAssert(WS_SRC.includes(`_fsbRemoteControlCdpUnavailable('${type}', payload, tabId)`), + `${type} is guarded before dispatch`); + } + // 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/mcp-bridge-client-lifecycle.test.js b/tests/mcp-bridge-client-lifecycle.test.js index 37dd125bf..8c3166c2c 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() { +function createChromeMock(localSeed) { const session = createStorageArea(); - const local = createStorageArea(); + const local = createStorageArea(localSeed || {}); const alarms = new Map(); const cleared = []; return { @@ -182,7 +182,7 @@ function createFakeWebSocketClass(options = {}) { } function buildClientHarness(options = {}) { - const chrome = createChromeMock(); + const chrome = createChromeMock(options.storageLocalSeed); const timers = createFakeTimers(); const FakeWebSocket = createFakeWebSocketClass(options); const deterministicMath = Object.create(Math); @@ -196,6 +196,7 @@ function buildClientHarness(options = {}) { Date, EventTarget, CustomEvent, + TextEncoder, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout, setInterval: timers.setInterval, @@ -210,6 +211,16 @@ function buildClientHarness(options = {}) { context.fsbAutomationLifecycleBus = new EventTarget(); } + // 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 = { @@ -574,7 +585,152 @@ 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'); +} + async function run() { + await runSafariTransportPolicyCase(); + await runNativePayloadCapCase(); await runBrowserFirstReconnectCase(); await runServiceWorkerWakeCase(); await runConnectedTransitionCase(); diff --git a/tests/mcp-bridge-topology.test.js b/tests/mcp-bridge-topology.test.js index 287b7ea85..0130ef1bf 100644 --- a/tests/mcp-bridge-topology.test.js +++ b/tests/mcp-bridge-topology.test.js @@ -239,6 +239,80 @@ async function runRejectsUntrustedBrowserOrigin(WebSocketBridge) { } } +/** + * Connect with (or without) an Origin header and report whether the hub closed + * us. Unlike attemptRelayWithOrigin, a socket that STAYS OPEN is the success + * case here, so the timer resolves rather than rejects. + */ +function relayOriginOutcome(port, origin) { + return new Promise((resolve, reject) => { + const options = origin ? { headers: { Origin: origin } } : {}; + const socket = new WebSocket(`ws://127.0.0.1:${port}`, options); + let closed = null; + const timeout = setTimeout(() => { + socket.close(); + resolve({ accepted: closed === null, close: closed }); + }, 300); + + socket.once('open', () => { + socket.send(JSON.stringify({ type: 'relay:hello', instanceId: 'origin-probe' })); + }); + socket.once('close', (code, reason) => { + closed = { code, reason: reason.toString() }; + clearTimeout(timeout); + resolve({ accepted: false, close: closed }); + }); + socket.once('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + }); +} + +/** + * Safari port: the extension may reach :7225 two ways, and BOTH must be + * accepted with no server configuration. + * + * direct -- Origin: safari-web-extension://, needs the allowlist entry + * native -- the container app dials with URLSessionWebSocketTask, which + * sends NO Origin header. That relies on the load-bearing + * `if (!originHeader) return true` branch in + * isAllowedWebSocketOrigin; this test is what stops someone + * "hardening" it away. + */ +async function runAcceptsSafariAndOriginlessClients(WebSocketBridge) { + const port = await getFreePort(); + const resources = { + sockets: [], + bridges: [ + new WebSocketBridge({ + port, + host: '127.0.0.1', + instanceId: 'test-hub-safari-origin', + handshakeTimeoutMs: 25 + }) + ] + }; + + try { + const hub = resources.bridges[0]; + await hub.connect(); + + const safari = await relayOriginOutcome(port, 'safari-web-extension://4C6E0A1B-2F3D-4E5A-9B8C-7D6E5F4A3B2C'); + assertEqual(safari.accepted, true, 'hub accepts a safari-web-extension:// origin'); + + const originless = await relayOriginOutcome(port, null); + assertEqual(originless.accepted, true, + 'hub accepts an origin-less client (the Safari container app native transport)'); + + const evil = await relayOriginOutcome(port, 'https://evil.example'); + assertEqual(evil.accepted, false, 'a untrusted web origin is still rejected'); + assertEqual(evil.close.code, 1008, 'untrusted origin still closed with 1008'); + } finally { + await cleanup(resources); + } +} + async function runHubExitPromotion(WebSocketBridge) { const resources = await createBridgePair(WebSocketBridge); try { @@ -267,6 +341,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 + origin-less clients', () => runAcceptsSafariAndOriginlessClients(WebSocketBridge)); await runCase('hub-exit-promotion', () => runHubExitPromotion(WebSocketBridge)); console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); diff --git a/tests/mcp-native-transport.test.js b/tests/mcp-native-transport.test.js new file mode 100644 index 000000000..36c4fc241 --- /dev/null +++ b/tests/mcp-native-transport.test.js @@ -0,0 +1,255 @@ +/** + * 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, + postMessage(o) { port.posted.push(o); }, + disconnect() { port.disconnected = true; }, + 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'); + } + + 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'); + } + + 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/platform-adapter.test.js b/tests/platform-adapter.test.js new file mode 100644 index 000000000..8769c383e --- /dev/null +++ b/tests/platform-adapter.test.js @@ -0,0 +1,406 @@ +/** + * 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 + * + * 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({ fsbSafariWorkspace: { lastContentTabId: 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.fsbSafariWorkspace); 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'); + passAssertEqual(persisted.length && persisted[persisted.length - 1].lastContentTabId, 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=== 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