diff --git a/.github/scripts/http-e2e.sh b/.github/scripts/http-e2e.sh index dde32172607..8c5f185e7cd 100755 --- a/.github/scripts/http-e2e.sh +++ b/.github/scripts/http-e2e.sh @@ -279,6 +279,7 @@ case "$group" in export REDIS_URL=redis://127.0.0.1:6379 export NEXT_PUBLIC_FORCE_HOSTED=false export KNOWLEDGE_MEMBER_ACCESS=true CREDENTIAL_GROUPS=true ACCESS_CONTROL_ENABLED=true + export SIM_LINKS=true export NEXT_PUBLIC_ACCESS_CONTROL_ENABLED=true export API_ENCRYPTION_KEY="$ENCRYPTION_KEY" start_browser_install "$group" --no-shell chromium diff --git a/apps/docs/content/docs/browser-extension/index.mdx b/apps/docs/content/docs/browser-extension/index.mdx index 20eb0d16ba8..b6f722e4a97 100644 --- a/apps/docs/content/docs/browser-extension/index.mdx +++ b/apps/docs/content/docs/browser-extension/index.mdx @@ -1,38 +1,48 @@ --- title: Sim browser extension -description: Ask Sim about the page you’re reading, share a screenshot, and open your team’s go/ links from Chrome. +description: Ask Sim about a page, dictate a message, and open your team’s go/ links from Chrome. --- -[Install Sim for Chrome](https://chromewebstore.google.com/detail/sim/oanlcahddodnjpdoopcpjlcageejjpmg) to ask questions about a page without leaving it. Open Chrome’s **Extensions** menu and pin **Sim** to keep it one click away. +[Install Sim for Chrome](https://chromewebstore.google.com/detail/sim/oanlcahddodnjpdoopcpjlcageejjpmg), then pin **Sim** in Chrome’s **Extensions** menu. ## Ask about a page -1. Open a web page and click **Sim** in the Chrome toolbar. Sign in to Sim if prompted. -2. In the side panel, select the attachment chip to review the captured text, or use its **×** to remove it. To attach the page again after it changes, use the globe next to the message field. -3. Ask a question and send it. For example, on a release notes page, ask “Which changes affect my team?” +1. Open a page and click **Sim** in the toolbar. Sign in if prompted. +2. Review the attached page by clicking its chip. Use **×** to remove it. +3. Type your question and send it. -To ask about a passage, select the text on the page, right-click, and choose **Ask Sim about selection**. You can attach up to three snapshots to compare pages. A snapshot stays as it was when you captured it. Chrome settings pages, other extensions, and pages without readable text can’t be attached. +To ask about a passage, select it, right-click, and choose **Ask Sim about selection**. To add another page, open it and use **+ → Current page**. You can attach up to three snapshots; each stays as it was when captured. -Open **+ → Reference current page on send** to include the active tab each time you send a message. This is off by default. The setting appears above the workspace list, and Sim remembers your choice in this Chrome profile. The globe shows **Current page** while this is on. If you attached a page or selection yourself, Sim uses those snapshots instead. Turn the setting off in the same menu. +Choose **+ → Page access → Allow across websites** and approve Chrome’s prompt once to use pages across websites. Your draft stays unsent during setup. To change access later, choose **Page access → Manage in browser**. -If Chrome needs access after you switch tabs, your message stays in the composer. Select **Request site access**. If Chrome hasn’t shared the site address with Sim, paste it into **Site address**. Check the site shown on **Allow access**, approve it in Chrome’s permission dialog, then send again. For one-time access, click the Sim toolbar icon on the active tab and retry. Open **+ → Site access** to remove a saved permission. +Turn on **+ → Reference current page on send** to include the active page whenever you send. Sim remembers your choice. Manually attached pages or selections take priority. -To share a screenshot, drag the image file onto the open chat. It uploads to Sim and appears as an attachment you can remove before sending. If a website supplies only an image URL, paste the image itself or use the file picker. +Chrome settings, other extensions, and pages without readable text can’t be attached. -For faster access, press **Command + Shift + S** on macOS or **Alt + Shift + S** on Windows and Linux. The shortcut attaches highlighted text when you have a selection. It won't substitute the whole page for an unreadable selection. With no selection, it attaches the page or uses your send-time reference setting. On an already-open sidebar, Chrome may keep keyboard focus on the website. Click the message field, or use Chrome’s pane navigation (on Mac, **Command + Option + Up**). +## Dictate or attach a screenshot -If Chrome needs access to a new tab, the sidebar shows the shortcut assigned in this Chrome profile, or **Set shortcut** if none is assigned. You can change it under **Extensions → Keyboard shortcuts**. +Click the microphone beside Send to dictate. Allow microphone access if Chrome asks, then click again to stop. Edit the draft and send when ready. If access is blocked, choose **Open settings**. -## Roll out to a team +Drag a screenshot file onto Chat, or use the file picker. It uploads as an attachment you can remove before sending. Dragging an image URL from a website doesn’t attach the image itself. -Share the chat extension’s install link with your team. In managed Chrome, an admin can [allow the extension for an organizational unit](https://support.google.com/chrome/a/answer/6306504) so people can install it themselves. +## Keyboard shortcut -If your organization has [Links](/search/sim-links), type `sim`, press **Space**, and enter part of a link name for suggestions. To open a shortcut by typing it directly, include the trailing slash, as in `go/handbook/` or `sim/handbook/`. Teams that already use an internal host named `go` or `sim`, or another go-links extension, should keep that conflict in mind: the extension routes those navigations to Sim, and when two extensions redirect the same address, Chrome uses the most recently installed one. +Press **Command + Shift + S** on macOS or **Alt + Shift + S** on Windows and Linux. Sim attaches your selection, or the current page when **Reference current page** is off. With it on, the page is included when you send. An unreadable selection stays unsent. -**+ → Browser shortcuts** can recover explicit `go/handbook` and `sim/handbook` searches on Google, Bing, DuckDuckGo, and Yahoo. Enable your provider and approve the site access shown by Chrome. This leaves ordinary searches and your default search engine unchanged. See [shortcut setup and supported inputs](/search/sim-links#open-links-from-the-address-bar). +Change the binding in Chrome’s **Extensions → Keyboard shortcuts**. If Chrome keeps focus on the page, click the message field or use its pane-navigation shortcut (**Command + Option + Up** on Mac). -## Page access and privacy +## Open team links -Sim reads a page or selection when you attach it, or when you send a message with **Reference current page** enabled. For repeat use on one site, choose **+ → Site access** to grant or revoke that site’s permission. Granting access doesn't start background capture. The page text goes to the hosted composer when attached and becomes part of the conversation when you send it. +Open **Links → Browser shortcuts** in Sim and select **Enable go/ shortcuts**. Then type a shortcut such as `go/handbook` in Chrome’s address bar. You can enable `sim/handbook` there too. + +For suggestions, type `sim`, press **Space**, and enter part of a link name. If Chrome searches instead of opening a shortcut, enable your search engine under the extension’s **+ → Browser shortcuts**. Other searches stay unchanged. + +See [Links](/search/sim-links#open-links-from-the-address-bar) for supported inputs, Glean conflicts, and team rollout. Administrators can [allow the extension for a Chrome organizational unit](https://support.google.com/chrome/a/answer/6306504). + +## Privacy + +Sim reads page text when you attach it or send with **Reference current page** on. Approval alone doesn’t start background capture. Attached text appears in the Sim composer before you send; submitted content follows your conversation’s retention settings. + +Voice input sends microphone audio to Sim’s transcription provider while recording. It does not record tab audio or start when you open the panel. The transcript stays in your draft until you send. See [Sim’s browser extension privacy disclosure](https://www.sim.ai/privacy#browser-extension). diff --git a/apps/docs/content/docs/search/sim-links.mdx b/apps/docs/content/docs/search/sim-links.mdx index ed30b37e25a..11a79f74813 100644 --- a/apps/docs/content/docs/search/sim-links.mdx +++ b/apps/docs/content/docs/search/sim-links.mdx @@ -15,15 +15,25 @@ Names ignore case and punctuation, so `sim/Team-Handbook` and `sim/teamhandbook` ## Open links from the address bar -In Sim Cloud, open **Links → Browser shortcuts** to install [Sim for Chrome](https://chromewebstore.google.com/detail/sim/oanlcahddodnjpdoopcpjlcageejjpmg) in your work profile. Type `sim` in Chrome’s address bar, press **Space**, and enter part of a link name. Chrome shows matching links, most opened first. Unlisted links can still open by name. +[Install Sim for Chrome](https://chromewebstore.google.com/detail/sim/oanlcahddodnjpdoopcpjlcageejjpmg) in your work profile. In Sim, open **Links → Browser shortcuts** and select **Enable go/ shortcuts**. Type `go/handbook` in the address bar to open a link. Enable **sim/ shortcuts** there if you prefer `sim/handbook`. -To open links as `go/handbook` or `sim/handbook`, select **Enable go/ shortcuts** and **Enable sim/ shortcuts** in that dialog once each, using the Chrome profile where Sim is installed. Each opens your Links directory in a new tab and lets Chrome remember the short host in that profile. If Chrome still sends a shortcut to search, enter `http://sim/handbook` or `sim/handbook/` with a trailing slash. Use **Copy link** in Sim to share an address that also works without the extension. +For suggestions, type `sim`, press **Space**, and enter part of a name. Matching links appear with the most opened first. Unlisted links still open by name. -Open the extension sidebar’s **+ → Browser shortcuts** and enable your search provider to recover `go/handbook` or `sim/handbook` when Chrome treats it as a search. Approve access to the exact sites shown. This is optional and supports Google (`google.com`), Bing, DuckDuckGo, and Yahoo. Sim remembers the choice in this Chrome profile. Ordinary searches still use your search engine; its default is unchanged. +### If a shortcut opens search -Recovery matches a whole `go/` or `sim/` shortcut, including simple slash-separated values such as `go/jira/123`. For values containing spaces, query parameters, unsupported search domains, or a managed browser that blocks site access, use `sim` + **Space** or the full `https://www.sim.ai/sim/` address. Turn recovery off in **Browser shortcuts**; this keeps the site permission, which you can remove through **Site access** or Chrome’s extension settings. +Open the extension’s **+ → Browser shortcuts** and turn on your search engine. Approve Chrome’s site-access prompt if shown. Google (`google.com`), Bing, DuckDuckGo, and Yahoo are supported. Other searches and your default search engine stay unchanged. -If `sim` + **Space** sends link names to your search engine even when the address bar shows **Sim**, a Chrome site search may already use the `sim` shortcut. Enter `http://sim/handbook` to open the link directly. To restore suggestions, temporarily turn off the Sim extension, rename the old shortcut in `chrome://settings/searchEngines`, then turn Sim back on. If your organization manages the shortcut, ask your administrator to rename it. +This handles whole shortcuts such as `go/handbook` or `go/jira/123`. For spaces, query parameters, unsupported search sites, or blocked site access, use `sim` + **Space** or the full `https://www.sim.ai/sim/` address. A trailing slash (`go/handbook/`) or `http://go/handbook` also tells Chrome to navigate directly. + +Turning shortcuts off keeps site access. To remove it, open Chrome’s **Extensions → Manage extensions → Sim → Details**. + +### If another tool uses go/ or sim + +Sim routes both `go/` and `sim/` to your organization’s links. If Glean or another extension also handles `go/`, Chrome uses the most recently installed extension. Disable the other tool’s go-link handling or remove that extension to use Sim consistently. An existing intranet host named `go` or `sim` conflicts too. + +If `sim` + **Space** still searches while the address bar shows **Sim**, an old Chrome site-search shortcut may use the same keyword. Turn off the Sim extension, rename that shortcut in `chrome://settings/searchEngines`, then turn Sim back on. Ask your administrator if the shortcut is managed. + +### Use links without the extension Without the extension, add Sim as a Chrome site search shortcut to type `sim handbook` instead: diff --git a/apps/extension/DESIGN.md b/apps/extension/DESIGN.md index bff44d7135d..4cfec47d023 100644 --- a/apps/extension/DESIGN.md +++ b/apps/extension/DESIGN.md @@ -10,14 +10,20 @@ conversation persistence, streaming recovery, tool permissions, and organization or workspace authorization. The panel keeps Fast mode and omits the model selector. A conversation can continue in the full app. +Voice input uses the existing microphone control beside Send. The browser asks for +microphone permission on first use. Transcription updates the editable draft and +never sends it automatically. Stop ends recording; Send and conversation changes +discard unfinished transcription. The iframe delegates microphone access only to +the configured Sim origin, and blocked access offers the browser's microphone settings. + The user signs in in a normal Sim tab and lands in their organization chat. Choosing a workspace is optional; members without Build access use Assistant. They can open or create a conversation and explicitly attach the current page or highlighted text. The composer’s + menu offers Current page and Selected text. Pending snapshots sit inside the composer as compact removable chips alongside file attachments. Opening one shows the captured text, source, capture time, and conversation-retention disclosure. -Partial captures remain labeled in the attachment row. Site access lives in the -composer’s Site access action, which opens an extension-owned dialog; successful delivery adds no persistent status banner. +Partial captures remain labeled in the attachment row. Page access lives in the +composer’s Page access action, which opens an extension-owned dialog; successful delivery adds no persistent status banner. Snapshots are immutable: navigating to another page cannot silently change an attached source. Removing an attachment, changing organization or workspace, or starting a new conversation clears pending page context. Several explicitly attached pages can support comparison. @@ -34,7 +40,7 @@ Existing Sim server tools remain subject to their existing authorization and app ## Ownership and data flow 1. `apps/extension`: packaged MV3 service worker, side-panel host, bounded content - extraction, per-site permission controls, build artifacts, browser verification. + extraction, browser-wide page permission setup, build artifacts, browser verification. 2. `packages/browser-extension-protocol`: versioned, strictly validated messages and immutable page snapshots shared by the extension, app, and server. 3. `apps/sim/app/extension`: session gate, organization landing, optional workspace picker, app providers, @@ -57,9 +63,9 @@ other workspace resources or browser-control tools. and short-lived session storage for selection handoff. Required host access is limited to the configured Sim origin and to `http(s)://sim/*` and `http(s)://go/*`, which exist only so one static redirect rule can send top-level navigations to those hosts to the link resolver. -- Other HTTP(S) origins are optional grants requested for the current site from a - user gesture. Each grant can be revoked. Keeping the panel open is not a grant - to read every subsequently selected tab. +- HTTP(S) page access is one optional browser-wide grant, requested from a direct + user gesture. The browser owns revocation and restrictions. Approval enables + capture across sites; switching tabs alone does not extract content. - No history, bookmarks, cookie API, debugger, native messaging, external messaging, or arbitrary remote code execution. The only network interception is the static `sim/` and `go/` redirect rule above, plus opt-in search recovery. @@ -126,7 +132,7 @@ An absent backend or account is an unverified gate, not a pass. - [Chrome activeTab](https://developer.chrome.com/docs/extensions/develop/concepts/activeTab) and [optional permissions](https://developer.chrome.com/docs/extensions/reference/api/permissions): temporary access follows user invocation; cross-origin navigation requires fresh - authority. Request persistent access per site only when selected by the user. + authority. Request optional access across HTTP(S) websites once, matching Glean’s all-websites page-context option. - [Chrome extension security](https://developer.chrome.com/docs/extensions/develop/security-privacy/stay-secure) and [OWASP extension guidance](https://cheatsheetseries.owasp.org/cheatsheets/Browser_Extension_Vulnerabilities_Cheat_Sheet.html): minimize privileges and treat all incoming page messages as attacker-controlled. diff --git a/apps/extension/README.md b/apps/extension/README.md index 0a194b3b8d9..0291e34b570 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -17,7 +17,7 @@ bun run --cwd apps/extension build In Chrome, open `chrome://extensions`, enable Developer mode, choose **Load unpacked**, and select `apps/extension/dist`. Copy the extension ID into the Sim server's `BROWSER_EXTENSION_IDS` configuration. It accepts comma-separated Chrome extension IDs. Restart the app after changing that configuration. Unconfigured IDs cannot embed the app. -Click the Sim toolbar icon to open chat and attach the current page. **+ → Current page** adds a fresh snapshot; **Selected text** attaches the highlighted passage. Attachments appear as chips inside the composer. Attach up to three pages or selections to summarize, extract facts, or compare sources. To quote a passage, select it and choose **Ask Sim about selection** from the context menu. Review or remove the attachment before sending. **+ → Site access** opens the extension-owned permission dialog: **Allow access to [site]** grants access only to that site; **Remove access to [site]** revokes it. Persistent site permission never enables background capture. +Click the Sim toolbar icon to open chat and attach the current page. **+ → Current page** adds a fresh snapshot; **Selected text** attaches the highlighted passage. Attachments appear as chips inside the composer. Attach up to three pages or selections to summarize, extract facts, or compare sources. To quote a passage, select it and choose **Ask Sim about selection** from the context menu. Review or remove the attachment before sending. **+ → Page access** offers **Allow across websites**. Approve the browser prompt once to use pages across HTTP and HTTPS sites without per-site setup. Manage access in the browser’s extension settings. Permission alone never enables background capture. For local development: @@ -33,17 +33,17 @@ SIM_EXTENSION_DEV=1 SIM_EXTENSION_ORIGIN=http://localhost:3000 bun run --cwd app - Type `sim`, press Space, enter the name, and press Enter to open an organization's [link](https://docs.sim.ai/search/sim-links). `go/` opens the same link. Chrome can treat bare `go/` or `sim/` as a search before the redirect rule runs. Typing `http://go/` once lets Chrome learn the short host in that profile. A trailing slash (`go//`) also selects navigation without relying on that history. A single static redirect rule rewrites top-level navigations to `http(s)://sim/*` and `http(s)://go/*` to `/sim/*` with the path and query unchanged. The `sim` and `go` host permissions exist only for that rule: every navigation to those hosts is redirected before it loads, and no other request is matched or observed. An organization that already serves an intranet host named `sim` or `go` should not install this build, since its links would be redirected to Sim. When another extension (such as another go-links tool) also redirects `go/`, Chrome applies the most recently installed extension's rule. - The `sim` address-bar keyword (`sim`, then Space) lists matching Sim links while you type and opens what you enter through the same resolver. For these suggestions, the background worker sends the text typed before the first `/` or `?`, and nothing else, unless that text contains a space (a search) or is longer than a link name can be, to `/api/sim-links/suggestions` with the signed-in session, and the extension's content security policy allows connections to that origin alone. Unlisted links are never suggested. - **+ → Browser shortcuts** separately opts into recovery for exact Google, Bing, DuckDuckGo, or Yahoo HTTPS search hosts. After a direct Chrome permission grant, dynamic request rules redirect only top-level GET requests with one query parameter containing a whole `go/` or `sim/` shortcut. Only that value reaches `/sim-links/recover`, which validates it and redirects to the existing authenticated resolver. Ordinary search terms, additional search parameters, subframes, and unsupported inputs are not forwarded. Chrome applies these rules without an extension search-URL observer. Turning a provider off removes its rules while preserving site access; revoking access clears its recovery choice. This does not enable page reference or change the default search provider. -- Optional HTTP/HTTPS site access is requested only by a menu action naming that site. Chrome's `activeTab` access is temporary and does not follow arbitrary new sites. +- Optional HTTP/HTTPS access is requested together by **Allow across websites**, from a direct user action. Chrome's `activeTab` access is temporary and does not follow arbitrary new sites. - Page capture runs on demand in Chrome's isolated world. It excludes editable fields, inputs, hidden elements, scripts, and embedded documents. It reads only the top frame. PDF viewers and canvas documents may have no accessible text. - Extraction stops at 50,000 text characters, 150,000 serialized UTF-8 bytes, 15,000 visited nodes, or 150ms. The composer exposes truncation. - Source URLs omit credentials, query parameters, and fragments. Human-readable text can still contain sensitive information; it is the user-selected material they review and send into their conversation. - Snapshots stay in memory until handed to Sim and acknowledged or expired after 30 seconds. Extension storage contains only a short-lived intent with a tab/document identity and a SHA-256 URL digest, never raw URLs, captured text, or credentials. Closing a tab or navigating invalidates its pending intent. Sim applies its normal conversation retention once a message is sent. - No browser history, bookmarks, cookie access, debugger access, remote extension JavaScript, arbitrary network proxy, or background content collection is requested. Incognito use is disabled. -- The iframe bridge checks both the exact configured origin and the exact frame, then validates versioned messages. The hosted composer may request only page capture, selection capture, or the native site-access and browser-shortcut dialogs. Requests require the current conversation nonce and Chrome transient user activation; arbitrary URLs, tabs, script execution, and network operations are never accepted. Permission grants still require a click in the extension-owned dialog. Shortcut settings are advertised separately so older installed shells keep working without the new action. +- The iframe bridge checks both the exact configured origin and the exact frame, then validates versioned messages. The hosted composer may request only page capture, selection capture, the native page-access and browser-shortcut dialogs, or the browser's fixed microphone settings page. Requests require the current conversation nonce and Chrome transient user activation; arbitrary URLs, tabs, script execution, and network operations are never accepted. Permission grants still require a click in the extension-owned page-access control and browser approval. Shortcut settings are advertised separately so older installed shells keep working without the new action. - Captures and queued toolbar intents are scoped to the receiving conversation. Changing conversation, workspace, or account discards an unfinished handoff. Captured attachments remain explicit snapshots when the source tab changes. -- The complete hosted document, including portaled source dialogs and menus, is blocked from session replay. Clipboard writing is delegated only to the configured Sim origin. Clipboard reading and microphone capture are not delegated; voice input remains available in the main app. +- The complete hosted document, including portaled source dialogs and menus, is blocked from session replay. Clipboard writing and microphone capture are delegated only to the configured Sim origin; clipboard reading and camera access are not delegated. Voice input starts only after the user clicks the microphone and the browser grants permission. The shared Sim voice service streams microphone audio to ElevenLabs for transcription into the editable draft. Stop ends capture while allowing the final transcript to arrive; Send and conversation changes cancel unfinished transcription. There is no tab-audio capture or background recording. -With **Reference current page** enabled, a composer Send requests a single fresh snapshot for that conversation. The extension stores only the preference in local storage. A tab change does not read any content. Explicit snapshots take precedence; access denial, navigation and timeouts keep the draft unsent. When Chrome hides the active address, **Site access** lets the user enter a site and confirm its origin in Chrome’s permission dialog. The browser grants the selected scheme, host and port. Uploaded screenshot files stay in the hosted app’s normal attachment flow. +With **Reference current page** enabled, a composer Send requests a single fresh snapshot for that conversation. The extension stores only the preference in local storage. A tab change does not read any content. Explicit snapshots take precedence; access denial, navigation and timeouts keep the draft unsent. **Allow across websites** works even when Chrome hides the active address; no address entry is required. The grant covers HTTP and HTTPS sites across tabs until the user revokes or restricts it in browser settings. Uploaded screenshot files stay in the hosted app’s normal attachment flow. ## Verification diff --git a/apps/extension/package.json b/apps/extension/package.json index 577d69f3edf..655e487affe 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -1,6 +1,6 @@ { "name": "@sim/extension", - "version": "0.1.3", + "version": "0.1.4", "private": true, "type": "module", "license": "Apache-2.0", diff --git a/apps/extension/scripts/test-e2e.ts b/apps/extension/scripts/test-e2e.ts index 83421d35c3d..32700032bd3 100644 --- a/apps/extension/scripts/test-e2e.ts +++ b/apps/extension/scripts/test-e2e.ts @@ -451,7 +451,7 @@ try { await check('Capture stays local until the trusted hosted frame is ready', async () => { await capture() - await panel.getByRole('status').filter({ hasText: 'Waiting for Sim' }).waitFor() + await panel.getByRole('status').filter({ hasText: 'Attaching the page…' }).waitFor() assert((await snapshots()).length === 0, 'Capture crossed before readiness') await panel.evaluate( (sessionId) => @@ -512,7 +512,7 @@ try { await chrome.storage.session.set({ [`captureIntent:${pending.windowId}:expired`]: pending }) }, intent) await expect(panel.getByRole('status')).toHaveText( - 'The capture expired. Attach the page again.' + 'The page attachment expired. Attach it again.' ) assert((await snapshots()).length === before, 'Expired toolbar intent delivered content') } @@ -691,7 +691,7 @@ try { }, intent) await panel .getByRole('status') - .filter({ hasText: /document|frame|changed|cannot/i }) + .filter({ hasText: /document|frame|changed|cannot|unavailable/i }) .waitFor() assert((await snapshots()).length === 3, 'Stale document handoff captured a new document') }) @@ -708,7 +708,7 @@ try { }, intent) await panel .getByRole('status') - .filter({ hasText: /changed before reading/i }) + .filter({ hasText: /The page changed/ }) .waitFor() assert((await snapshots()).length === 3, 'Stale URL handoff captured a different SPA route') }) @@ -718,10 +718,9 @@ try { ) await page.goto('https://ungranted.example/') await capture() - await panel - .getByRole('status') - .filter({ hasText: /access|permission|cannot/i }) - .waitFor() + await expect( + panel.getByRole('button', { name: 'Allow across websites', exact: true }) + ).toBeEnabled() assert((await snapshots()).length === 3, 'Unpermitted page was captured') }) await check('Changing the receiving conversation expires an undelivered capture', async () => { @@ -730,7 +729,7 @@ try { document.body.dataset.ignoreCaptures = 'true' }) await capture() - await panel.getByRole('status').filter({ hasText: 'Waiting for Sim' }).waitFor() + await panel.getByRole('status').filter({ hasText: 'Attaching the page…' }).waitFor() const queuedIntent = await pendingIntent() await worker.evaluate(async (intent) => { await chrome.storage.session.set({ [`captureIntent:${intent.windowId}:queued`]: intent }) @@ -740,14 +739,16 @@ try { delete document.body.dataset.ignoreCaptures }) await ready() - await panel - .getByRole('status') - .filter({ hasText: /conversation changed|previous conversation was discarded/ }) - .waitFor() - await panel - .getByRole('status') - .filter({ hasText: /previous conversation was discarded/ }) - .waitFor() + await expect(panel.getByRole('status')).toHaveText('The chat changed. Attach the page again.') + await expect + .poll(() => + worker.evaluate( + async (key) => (await chrome.storage.session.get(key))[key], + `captureIntent:${queuedIntent.windowId}:queued` + ) + ) + .toBeUndefined() + await settleMessages() assert((await snapshots()).length === 3, 'A capture crossed into another conversation') await capture() await app.waitForFunction( @@ -781,7 +782,10 @@ try { ) await receipt(generateId()) await capture() - await panel.getByRole('status').filter({ hasText: 'previous capture' }).waitFor() + await panel + .getByRole('status') + .filter({ hasText: 'Wait for the page attachment, then try again.' }) + .waitFor() assert((await snapshots()).length === 5, 'A stale receipt released the pending capture') await receipt(receiverSessionId) await panel.waitForFunction(() => document.querySelector('[role="status"]')?.textContent === '') @@ -1061,7 +1065,7 @@ try { ) await panel.bringToFront() await openAccess() - const dialog = panel.getByRole('dialog', { name: 'Site access' }) + const dialog = panel.getByRole('dialog', { name: 'Page access' }) await expect(dialog).toBeVisible() await settleMessages() assert(policyErrors.length === 0, `Site access violated CSP: ${policyErrors.join('\n')}`) @@ -1324,7 +1328,7 @@ try { } ) await check( - 'Site access requests only the address the user confirms, without reading the page', + 'Page access asks the browser for website access without reading before approval', async () => { const source = 'https://private-source.example' const privateUrl = `${source}/sensitive-path?token=PRIVATE_URL_SENTINEL#fragment` @@ -1358,34 +1362,11 @@ try { return execute(...args) }) as typeof chrome.scripting.executeScript }) - await panel.getByRole('button', { name: 'Request site access' }).click() - const dialog = panel.getByRole('dialog', { name: 'Site access' }) - await expect(dialog).toBeVisible() - const address = dialog.getByRole('textbox', { name: 'Site address' }) - await expect(address).toHaveValue('') - const allow = dialog.getByRole('button', { name: /^Allow access/ }) - for (const unsafe of [ - 'https://user:password@example.com/', - 'https://*.example.com/', - 'https://%2A.example.com/', - 'https://*.example.com/', - '', - 'chrome://settings/', - ]) { - await address.fill(unsafe) - await expect(allow, `Do not offer to grant access for ${unsafe}`).toHaveAccessibleName( - 'Allow access' - ) - await expect(allow).toBeDisabled() - } - await address.fill('http://intranet.example:8080/private-path') - await expect(allow).toHaveAccessibleName('Allow access to http://intranet.example:8080') - const allowedHost = 'https://approved-site.example' - await address.fill(`${allowedHost}/private-path?token=INPUT_SECRET#fragment`) - await expect(allow).toHaveAccessibleName(`Allow access to ${allowedHost}`) + const allow = panel.getByRole('button', { name: 'Allow across websites', exact: true }) + await expect(allow).toBeEnabled() assert( (await panel.locator('body').getAttribute('data-requested-access')) === null, - 'Typing initiated a permission request' + 'A failed capture initiated a permission request without an explicit action' ) const grantedSites = () => worker.evaluate(async () => ((await chrome.permissions.getAll()).origins ?? []).sort()) @@ -1394,7 +1375,7 @@ try { await expect(panel.locator('body')).toHaveAttribute('data-request-gesture', 'true') const requested = await panel.locator('body').getAttribute('data-requested-access') assert(requested, 'The user action never reached Chrome') - expect(JSON.parse(requested)).toEqual([{ origins: ['https://approved-site.example:443/*'] }]) + expect(JSON.parse(requested)).toEqual([{ origins: ['https://*/*', 'http://*/*'] }]) expect(await grantedSites(), 'A site received persistent access without approval').toEqual( previousAccess ) diff --git a/apps/extension/src/capture.ts b/apps/extension/src/capture.ts index 984874204a9..da26f4597a9 100644 --- a/apps/extension/src/capture.ts +++ b/apps/extension/src/capture.ts @@ -4,7 +4,7 @@ import { pageSnapshotSchema, } from '@sim/browser-extension-protocol' import { generateId } from '@sim/utils/id' -import { SIM_ORIGIN } from '@/config' +import { PAGE_ACCESS_ORIGINS, SIM_ORIGIN } from '@/config' import { extractPageText } from '@/extract' import { digestUrl } from '@/utils' @@ -15,7 +15,7 @@ export class PageCaptureError extends Error {} export function captureErrorMessage(error: unknown): string { return error instanceof PageCaptureError ? error.message - : 'This page could not be read. Check site access and try again.' + : 'This page could not be read. Try again.' } /** Only emitted after verifying the selected document is still active. */ @@ -34,7 +34,7 @@ export class SiteAccessRequiredError extends PageCaptureError { readonly tabId: number, readonly windowId: number ) { - super('Allow site access to attach this page.') + super('Page access is required.') } } @@ -49,9 +49,14 @@ export async function captureActivePage( if (tab?.id === undefined || tab.incognito) throw new PageCaptureError('This page cannot be attached.') if (expected && expected.tabId !== tab.id) { - throw new PageCaptureError('The page changed before reading. Attach it again.') + throw new PageCaptureError('The page changed. Attach it again.') + } + if (!tab.url) { + const allowed = await chrome.permissions.contains({ origins: PAGE_ACCESS_ORIGINS }) + signal?.throwIfAborted() + if (allowed) throw new PageCaptureError('This page cannot be attached.') + throw new SiteAccessRequiredError(tab.id, tab.windowId) } - if (!tab.url) throw new SiteAccessRequiredError(tab.id, tab.windowId) let changed = false const activated = (active: chrome.tabs.OnActivatedInfo) => { if (active.windowId === tab.windowId && active.tabId !== tab.id) changed = true @@ -70,14 +75,12 @@ export async function captureActivePage( signal?.addEventListener('abort', removeListeners, { once: true }) try { const snapshot = await readTab({ tabId: tab.id, url: tab.url }, kind, expected, signal) - if (changed) throw new PageCaptureError('The page changed while reading. Attach it again.') + if (changed) throw new PageCaptureError('The page changed. Attach it again.') return snapshot } catch (error) { - if (changed) throw new PageCaptureError('The page changed while reading. Attach it again.') + if (changed) throw new PageCaptureError('The page changed. Attach it again.') if (expected && !(error instanceof PageCaptureError)) - throw new PageCaptureError( - 'The original document cannot be read. Attach the current page again.' - ) + throw new PageCaptureError('The original page is unavailable. Attach the current page.') throw error } finally { removeListeners() @@ -92,7 +95,7 @@ async function readTab( ) { const url = new URL(tab.url) if (expected && (await digestUrl(tab.url)) !== expected.urlDigest) { - throw new PageCaptureError('The page changed before reading. Attach it again.') + throw new PageCaptureError('The page changed. Attach it again.') } let pathname = url.pathname if (url.origin === SIM_ORIGIN) { @@ -129,12 +132,12 @@ async function readTab( world: 'ISOLATED', }) if (identity[0]?.documentId !== result.documentId || identity[0]?.result !== tab.url) { - throw new PageCaptureError('The page changed while reading. Attach it again.') + throw new PageCaptureError('The page changed. Attach it again.') } const [current] = await chrome.tabs.query({ active: true, currentWindow: true }) signal?.throwIfAborted() if (current?.id !== tab.tabId || current.url !== tab.url || current.status === 'loading') { - throw new PageCaptureError('The page changed while reading. Attach it again.') + throw new PageCaptureError('The page changed. Attach it again.') } if (result.result.error) { if (kind === 'selection') diff --git a/apps/extension/src/config.ts b/apps/extension/src/config.ts index b6d38c9745b..e2fb17f4f76 100644 --- a/apps/extension/src/config.ts +++ b/apps/extension/src/config.ts @@ -1,5 +1,6 @@ declare const __SIM_ORIGIN__: string export const SIM_ORIGIN = __SIM_ORIGIN__ +export const PAGE_ACCESS_ORIGINS = ['https://*/*', 'http://*/*'] export const REFERENCE_PAGE_KEY = 'referenceCurrentPage' export const OPEN_SIM_COMMAND = 'open-sim' export const CAPTURE_INTENT_KEY = 'captureIntent' diff --git a/apps/extension/src/extract.ts b/apps/extension/src/extract.ts index b96882f64f2..61182298272 100644 --- a/apps/extension/src/extract.ts +++ b/apps/extension/src/extract.ts @@ -110,8 +110,7 @@ export function extractPageText( } } const text = parts.join('\n') - if (!text) - return { error: 'No readable text was found. This page may use a document viewer or canvas.' } + if (!text) return { error: 'No readable text was found on this page.' } return { url: source.href, title: document.title.slice(0, 512), diff --git a/apps/extension/src/reference.ts b/apps/extension/src/reference.ts index eafd28b586d..46e0f717a25 100644 --- a/apps/extension/src/reference.ts +++ b/apps/extension/src/reference.ts @@ -124,8 +124,7 @@ export function createReferenceCapture({ replyError( sessionId, requestId, - manualError?.message ?? - 'A page attachment is still being prepared. Try again once it appears.', + manualError?.message ?? 'Wait for the page attachment, then try again.', manualError?.reason ) return @@ -169,8 +168,7 @@ export function createReferenceCapture({ refreshPreference, setPreference, busy: () => pending !== null, - manualRequested: () => - cancel('An explicit attachment was requested. Try again after it appears.'), + manualRequested: () => cancel('Wait for the page attachment, then send again.'), preferenceChanged(value: unknown) { if (preferenceWrite) return revision++ diff --git a/apps/extension/src/search-recovery-dialog.tsx b/apps/extension/src/search-recovery-dialog.tsx index cdf7ae1d24f..0d5806e9c46 100644 --- a/apps/extension/src/search-recovery-dialog.tsx +++ b/apps/extension/src/search-recovery-dialog.tsx @@ -75,7 +75,7 @@ export function SearchRecoveryDialog({ onClose }: SearchRecoveryDialogProps) { if (enabled && !(await chrome.permissions.request({ origins: [...provider.origins] }))) { setNotices((previous) => ({ ...previous, - [provider.id]: 'Site access was not granted. This shortcut is still off.', + [provider.id]: 'Allow site access to turn on shortcuts.', })) return } @@ -112,8 +112,7 @@ export function SearchRecoveryDialog({ onClose }: SearchRecoveryDialogProps) { - Send explicit go/ and sim/ searches to your links in Sim. Ordinary searches stay with - your search engine. + Open go/ and sim/ links from search. Other searches stay unchanged. {SEARCH_RECOVERY_PROVIDERS.map((provider) => ( void changeProvider(provider, enabled)} /> } - hint={provider.origins.map((origin) => new URL(origin).origin).join(', ')} submitOnEnter={false} > {notices[provider.id] && ( @@ -140,8 +138,7 @@ export function SearchRecoveryDialog({ onClose }: SearchRecoveryDialogProps) { ))}

- Chrome asks for access to the sites shown above. Page reference stays unchanged. Turning - a shortcut off keeps site access. + Turning shortcuts off keeps browser site access.

{loadError && (
diff --git a/apps/extension/src/shortcut-hint.tsx b/apps/extension/src/shortcut-hint.tsx deleted file mode 100644 index 543390bdb1c..00000000000 --- a/apps/extension/src/shortcut-hint.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useEffect, useState } from 'react' -import { OPEN_SIM_COMMAND } from '@/config' - -/** Reads the binding in this Chrome profile, including changes made in the shortcut settings. */ -export function ShortcutHint() { - const [shortcut, setShortcut] = useState(null) - useEffect(() => { - let revision = 0 - const read = () => { - const current = ++revision - void chrome.commands - .getAll() - .then((commands) => { - if (current === revision) - setShortcut(commands.find((entry) => entry.name === OPEN_SIM_COMMAND)?.shortcut ?? '') - }) - .catch(() => { - if (current === revision) setShortcut(null) - }) - } - read() - window.addEventListener('focus', read) - return () => { - revision++ - window.removeEventListener('focus', read) - } - }, []) - if (shortcut === null) return null - return ( -

- {shortcut ? ( - <> - For one-time access, press {shortcut} on the page. - - ) : ( - - )} -

- ) -} diff --git a/apps/extension/src/sidepanel.tsx b/apps/extension/src/sidepanel.tsx index dc74d3d56f8..73beab91033 100644 --- a/apps/extension/src/sidepanel.tsx +++ b/apps/extension/src/sidepanel.tsx @@ -4,7 +4,6 @@ import { type ExtensionToAppMessage, type PageSnapshot, } from '@sim/browser-extension-protocol' -import { Chip } from '@sim/emcn' import { createRoot } from 'react-dom/client' import { captureActivePage, @@ -17,15 +16,14 @@ import { CAPTURE_INTENT_KEY, CAPTURE_INTENT_TTL_MS, REFERENCE_PAGE_KEY, SIM_ORIG import { type CaptureIntent, captureIntentSchema } from '@/intent' import { createReferenceCapture } from '@/reference' import { SearchRecoveryDialog } from '@/search-recovery-dialog' -import { ShortcutHint } from '@/shortcut-hint' -import { SiteAccessDialog } from '@/site-access' +import { PageAccessButton, SiteAccessDialog } from '@/site-access' import '@/shell.css' const BLOCKED_SELECTION = 'Select readable text again, attach the page explicitly, or turn off Reference current page.' const KEYBOARD_FOCUS_HINT = navigator.platform.startsWith('Mac') - ? 'To type here, click the message field or press ⌘⌥↑ to move focus.' - : 'To type here, click the message field or use F6 to move focus.' + ? 'Click the message field, or press ⌘⌥↑ to move focus.' + : 'Click the message field, or press F6 to move focus.' interface SidePanelProps { origin: string @@ -52,7 +50,7 @@ function SidePanel({ origin }: SidePanelProps) { const navigated = (tabId: number, change: chrome.tabs.OnUpdatedInfo) => { if (tabId === status.tabId && (change.status === 'loading' || change.url !== undefined)) { setStatus((current) => - current === status ? 'The page changed. Try attaching it again.' : current + current === status ? 'The page changed. Attach it again.' : current ) } } @@ -76,7 +74,7 @@ function SidePanel({ origin }: SidePanelProps) { if (Date.now() - Date.parse(snapshot.capturedAt) > CAPTURE_INTENT_TTL_MS) { pendingRef.current = null focusRequested.current = false - setStatus('The capture expired. Attach the page again.') + setStatus('The page attachment expired. Attach it again.') return } const message: ExtensionToAppMessage = { @@ -112,8 +110,7 @@ function SidePanel({ origin }: SidePanelProps) { intentCutoffRef.current = Date.now() setAccessOpen(false) setShortcutsOpen(false) - if (pendingRef.current || busyRef.current) - setStatus('The conversation changed before delivery. Attach the page again.') + if (pendingRef.current || busyRef.current) setStatus('The chat changed. Attach the page again.') captureGenerationRef.current++ pendingRef.current = null deliveredRef.current = null @@ -133,12 +130,12 @@ function SidePanel({ origin }: SidePanelProps) { } ) { if (referenceRef.current?.busy()) { - setStatus('Wait for the current message to finish attaching its page.') + setStatus('Wait for the page attachment, then try again.') return } if (busyRef.current) return if (pendingRef.current) { - setStatus('Waiting for Sim to receive the previous capture. Try again after it appears.') + setStatus('Wait for the page attachment, then try again.') return } busyRef.current = true @@ -150,7 +147,7 @@ function SidePanel({ origin }: SidePanelProps) { focusRequested.current = expected?.focusComposer === true unreadableSelection.current = null pendingRef.current = snapshot - setStatus('Waiting for Sim to receive the attachment…') + setStatus('Attaching the page…') deliver() } catch (error) { if (generation !== captureGenerationRef.current) return @@ -174,7 +171,7 @@ function SidePanel({ origin }: SidePanelProps) { manualCaptureError: () => { if (busyRef.current || pendingRef.current || processingManual || manualIntents.size) return { - message: 'A page attachment is still being prepared. Try again once it appears.', + message: 'Wait for the page attachment, then try again.', } if (unreadableSelection.current) return { message: BLOCKED_SELECTION, reason: 'selection' } }, @@ -247,15 +244,12 @@ function SidePanel({ origin }: SidePanelProps) { for (const key of keys) manualIntents.delete(key) const intent = intents[0] if (intent && intent.createdAt <= intentCutoffRef.current) { - if (!disposed) - setStatus( - 'A capture from the previous conversation was discarded. Attach the page again.' - ) + if (!disposed) setStatus('The chat changed. Attach the page again.') return } if (disposed || !intent) return if (!currentIntent(intent, window.id)) { - setStatus('The capture expired. Attach the page again.') + setStatus('The page attachment expired. Attach it again.') return } if (intent.kind === 'focus') { @@ -266,7 +260,7 @@ function SidePanel({ origin }: SidePanelProps) { } } else await capture(intent.kind, intent) } catch { - if (!disposed) setStatus('Could not receive the capture request. Attach the page again.') + if (!disposed) setStatus('Could not attach the page. Try again.') } finally { processingManual = false consumingIntent = false @@ -351,6 +345,8 @@ function SidePanel({ origin }: SidePanelProps) { if (!navigator.userActivation.isActive) return if (parsed.data.action === 'site-access') setAccessOpen(true) else if (parsed.data.action === 'browser-shortcuts') setShortcutsOpen(true) + else if (parsed.data.action === 'microphone-settings') + void chrome.tabs.create({ url: 'chrome://settings/content/microphone' }) else void capture(parsed.data.action === 'capture-page' ? 'page' : 'selection') } else if (pendingRef.current?.id === parsed.data.snapshotId) { pendingRef.current = null @@ -366,7 +362,7 @@ function SidePanel({ origin }: SidePanelProps) { ) { pendingRef.current = null focusRequested.current = false - setStatus('The capture expired. Attach the page again.') + setStatus('The page attachment expired. Attach it again.') } void consumeIntent() }, 5_000) @@ -402,18 +398,20 @@ function SidePanel({ origin }: SidePanelProps) { } role='status' > - {typeof status === 'string' ? status : status.message} - {status instanceof SiteAccessRequiredError && ( - <> - setAccessOpen(true)}> - Request site access - - - + {status instanceof SiteAccessRequiredError ? ( + + setStatus((current) => (current instanceof SiteAccessRequiredError ? '' : current)) + } + /> + ) : typeof status === 'string' ? ( + status + ) : ( + status.message )}