From 5f5a7d7d1b18ab13f85f951736ced5c9383be823 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Tue, 13 Oct 2026 22:45:48 +0700 Subject: [PATCH 1/3] feat: ask Dewee about selected page text An opt-in setting shows an "Ask Dewee" button next to text selected on https pages. Clicking it opens the side panel and puts the quoted selection with the page title and address into the composer as an editable draft; nothing is sent until the person submits. Turning the setting on requests https site access and registers the page script at runtime; turning it off unregisters it. Selections in form fields are ignored, credential-like query values are dropped from the address, and messages from pages only reach the selection handler. Refs #14 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- docs/architecture.md | 2 +- docs/auth-threat-model.md | 5 +- src/ask/selection-button.ts | 112 ++++++++++++++++++++ src/background/ask-selection.ts | 56 ++++++++++ src/background/service-worker.ts | 30 +++++- src/entrypoints/ask-dewee.content.ts | 13 +++ src/options/Settings.tsx | 45 ++++++++ src/protocol/ask-selection.ts | 68 ++++++++++++ src/shared/components.css | 4 + src/shared/i18n.ts | 10 +- src/shared/settings.ts | 4 +- src/sidepanel/ChatShell.tsx | 3 + src/sidepanel/components/Composer.tsx | 31 +++++- src/sidepanel/use-ask-draft.ts | 38 +++++++ tests/unit/ask/selection-button.test.ts | 77 ++++++++++++++ tests/unit/background/ask-selection.test.ts | 85 +++++++++++++++ tests/unit/protocol/ask-selection.test.ts | 47 ++++++++ tests/unit/shared/settings.test.ts | 6 +- tests/unit/sidepanel/Composer.test.tsx | 23 ++++ tests/unit/sidepanel/use-ask-draft.test.ts | 44 ++++++++ wxt.config.ts | 13 ++- 22 files changed, 703 insertions(+), 15 deletions(-) create mode 100644 src/ask/selection-button.ts create mode 100644 src/background/ask-selection.ts create mode 100644 src/entrypoints/ask-dewee.content.ts create mode 100644 src/protocol/ask-selection.ts create mode 100644 src/sidepanel/use-ask-draft.ts create mode 100644 tests/unit/ask/selection-button.test.ts create mode 100644 tests/unit/background/ask-selection.test.ts create mode 100644 tests/unit/protocol/ask-selection.test.ts create mode 100644 tests/unit/sidepanel/use-ask-draft.test.ts diff --git a/AGENTS.md b/AGENTS.md index 300f5ff..dcdac15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ Rules: - Never use the Dewee gateway master token as a client credential. Self-hosted mode uses a personal scoped API key (`operator.read`/`operator.write`). - Never render agent-generated HTML. Cards render from a fixed list of kinds validated with Zod; markdown is sanitized with DOMPurify. - No remote code, no `eval`, strict extension CSP. Host permissions are requested per gateway origin at runtime, never ``. -- The side panel never reads the active page. +- The side panel never reads the active page. The only exception is the opt-in "Ask Dewee" page script, which sends the text the person selected (never form fields) when they click its button, and only as an editable draft. ## Code layout diff --git a/docs/architecture.md b/docs/architecture.md index 01e2746..d69b43e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,7 +48,7 @@ The extension is a thin Chrome MV3 client. Dewee (Gateway, or the CCP app for De - The panel calls the API directly. Moving every call through the service worker would add a message hop per SSE frame and a second place where tokens live. Only OAuth refresh, which must be single flight across pages, runs in the background. - Secrets never cross `chrome.runtime` messaging. The background message schema has no secret fields. - Card field labels and target names come from the extension's own copy. Agent text is shown as plain text, so an agent cannot dress up a phishing form. -- Host permissions are optional and requested per origin at connect time. There is no `` and no content script. +- Host permissions are optional and requested per origin at connect time. There is no ``. The only page script is the opt-in "Ask Dewee" button: turning it on in Settings requests `https://*/*` and registers the script at runtime; turning it off unregisters it. A click sends the selected text, title and address to the service worker, which opens the side panel and leaves a quoted draft in `storage.session` for the composer of that window. Nothing is sent to Dewee until the person submits. - One workspace per view. A load error in one workspace never falls back to another workspace or profile. See [api-contract.md](api-contract.md) for the wire contract and the two threat models for security reasoning. diff --git a/docs/auth-threat-model.md b/docs/auth-threat-model.md index 5ae555e..7347762 100644 --- a/docs/auth-threat-model.md +++ b/docs/auth-threat-model.md @@ -27,11 +27,14 @@ The Dewee gateway master token is never accepted as an extension credential, and | Wrong workspace or tenant | Every call names the workspace; Dewee enforces membership and returns 404 for non-members. The client never falls back to another workspace or profile. | | Expired or revoked credential | 401 → one renew (Cloud) → on failure the credential is cleared and the panel asks to reconnect that same profile. | | Sign-out leaves a live token | Sign-out calls revoke (best effort) and clears both storage areas. Removing a profile also drops its host permission when no other profile uses the origin. | -| Messages from other extensions or pages | The background accepts messages only from its own extension id and validates them with Zod. It returns the `Authorization` value only to those pages, which hold it in memory for the request. A network failure while renewing keeps the credential; only a 401 signs the profile out. | +| Messages from other extensions or pages | The background accepts messages only from its own extension id and validates them with Zod. It returns the `Authorization` value only to extension pages, which hold it in memory for the request. A network failure while renewing keeps the credential; only a 401 signs the profile out. | +| A web page abusing the "Ask Dewee" script | The script runs only when the person turns the feature on and grants site access. A message from it (a tab sender, top frame, non-extension URL) goes only to the selection handler: one schema-checked `ask.selection` that opens the panel and leaves a quoted draft in `storage.session`, never sent automatically. It never reaches the auth handlers or a credential. The page sees only a closed shadow-root button. | +| Page secrets carried into a draft | Selections inside inputs, text areas and editable regions are ignored. The address drops its fragment, user info and query values whose names look like credentials. The draft is plain text the person reads and edits before sending. | | Remote code | Strict CSP (`script-src 'self'`), no `eval`, no remote scripts. | ## Residual risks - Malware with access to the Chrome profile can read `storage.session` while the browser runs. Tokens are short-lived and revocable. - "Remember" keeps the credential on disk in the Chrome profile; the person accepts this when they tick it. +- "Ask Dewee" needs read access to every https page while it is on. Turning it off removes the script, but Chrome keeps the site access until the person removes it in the extension settings, because removing it could also drop gateway origins covered by the same grant. `storage.local` is limited to extension pages where Chrome supports it. - Self-hosted operators must allowlist the extension origin (`GOCLAW_ALLOWED_ORIGINS`) and keep API keys scoped. diff --git a/src/ask/selection-button.ts b/src/ask/selection-button.ts new file mode 100644 index 0000000..83b9f4c --- /dev/null +++ b/src/ask/selection-button.ts @@ -0,0 +1,112 @@ +/** + * The "Ask Dewee" button shown next to text the person selects on a web page. It reads only the + * selection, the page title and the address, and only when the button is clicked. Selections in + * form fields and editable areas are ignored, so typed passwords and drafts are never offered. + */ +import { MAX_SELECTION, type AskSelection } from '@/protocol/ask-selection'; + +const STYLE = ` +:host { all: initial; } +button { + position: fixed; z-index: 2147483647; display: inline-flex; align-items: center; gap: 6px; + padding: 6px 10px; border: 1px solid rgb(255 255 255 / 0.14); border-radius: 999px; + background: #1b1840; color: #f4f2ff; box-shadow: 0 6px 20px rgb(15 12 40 / 0.28); + font: 500 12px/1 system-ui, -apple-system, "Segoe UI", sans-serif; letter-spacing: 0; cursor: pointer; +} +button:hover { background: #2a2560; } +button:focus-visible { outline: 2px solid #8f7cff; outline-offset: 2px; } +svg { width: 13px; height: 13px; } +`; + +const SPARKLE = + ''; + +/** Fields whose selection must never be sent: inputs, text areas and editable regions. */ +function inEditable(node: Node | null): boolean { + const element = node instanceof Element ? node : node?.parentElement; + return Boolean(element?.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"], [contenteditable="plaintext-only"]')); +} + +export function selectedText(doc: Document): { text: string; rect: DOMRect } | null { + const selection = doc.getSelection(); + if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null; + if (inEditable(selection.anchorNode) || inEditable(selection.focusNode)) return null; + const text = selection.toString().trim(); + if (!text) return null; + return { text: text.slice(0, MAX_SELECTION + 1), rect: selection.getRangeAt(0).getBoundingClientRect() }; +} + +/** Mounts the hidden button and its listeners; dispose removes both. */ +export function mountAskButton(doc: Document, send: (message: AskSelection) => unknown, label = defaultLabel(doc)): { button: HTMLButtonElement; dispose(): void } { + const host = doc.createElement('dewee-ask'); + const root = host.attachShadow({ mode: 'closed' }); + if ('adoptedStyleSheets' in root && typeof CSSStyleSheet === 'function' && 'replaceSync' in CSSStyleSheet.prototype) { + // Constructed sheets are not blocked by a page's style-src policy. + const sheet = new CSSStyleSheet(); + sheet.replaceSync(STYLE); + root.adoptedStyleSheets = [sheet]; + } else { + const style = doc.createElement('style'); + style.textContent = STYLE; + root.append(style); + } + const button = doc.createElement('button'); + button.type = 'button'; + button.innerHTML = SPARKLE; // Static markup from this file, never page or agent content. + button.append(doc.createTextNode(label)); + button.hidden = true; + root.append(button); + let current: string | null = null; + + const hide = () => { + button.hidden = true; + current = null; + }; + const show = () => { + const found = selectedText(doc); + if (!found) return hide(); + current = found.text; + const view = doc.defaultView; + const width = view?.innerWidth ?? 1024; + const height = view?.innerHeight ?? 768; + const top = found.rect.bottom + 8 > height - 40 ? Math.max(8, found.rect.top - 40) : found.rect.bottom + 8; + button.style.top = `${Math.round(top)}px`; + button.style.left = `${Math.round(Math.min(Math.max(8, found.rect.right - 100), width - 120))}px`; + if (!host.isConnected) doc.documentElement.append(host); + button.hidden = false; + }; + // Wait for the selection to settle; events on the button itself must not show it again after a click. + const later = (event: Event) => { + if (!event.composedPath().includes(host)) setTimeout(show, 0); + }; + const onKey = (event: KeyboardEvent) => (event.key === 'Escape' ? hide() : later(event)); + const onDown = (event: MouseEvent) => { + if (!event.composedPath().includes(host)) hide(); + }; + + // Keep the page selection while the button is pressed. + button.addEventListener('mousedown', (event) => event.preventDefault()); + button.addEventListener('click', () => { + if (!current) return; + send({ type: 'ask.selection', text: current, url: doc.location.href, title: doc.title }); + hide(); + }); + doc.addEventListener('mouseup', later); + doc.addEventListener('keyup', onKey); + doc.addEventListener('mousedown', onDown, true); + doc.defaultView?.addEventListener('scroll', hide, { passive: true, capture: true }); + + const dispose = () => { + doc.removeEventListener('mouseup', later); + doc.removeEventListener('keyup', onKey); + doc.removeEventListener('mousedown', onDown, true); + doc.defaultView?.removeEventListener('scroll', hide, { capture: true }); + host.remove(); + }; + return { button, dispose }; +} + +function defaultLabel(doc: Document): string { + const lang = doc.defaultView?.navigator.language ?? 'en'; + return lang.toLowerCase().startsWith('vi') ? 'Hỏi Dewee' : 'Ask Dewee'; +} diff --git a/src/background/ask-selection.ts b/src/background/ask-selection.ts new file mode 100644 index 0000000..ae17580 --- /dev/null +++ b/src/background/ask-selection.ts @@ -0,0 +1,56 @@ +/** + * Opt-in "Ask Dewee" on selected page text. The content script is registered only while the + * preference is on and the person has granted site access; otherwise it is unregistered and the + * extension runs nothing in web pages. + */ +import { browser } from 'wxt/browser'; +import { ASK_DRAFT_KEY, ASK_ORIGINS, askSelectionSchema, formatAskDraft, type AskDraft } from '@/protocol/ask-selection'; +import { loadSettings } from '@/shared/settings'; + +export const ASK_SCRIPT_ID = 'ask-dewee'; +const ASK_SCRIPT_FILE = 'content-scripts/ask-dewee.js'; + +let syncing: Promise = Promise.resolve(); + +/** Registers or removes the content script to match the preference and the granted access. Runs one at a time. */ +export function syncAskScript(): Promise { + const run = syncing.then(async () => { + const [settings, allowed] = await Promise.all([loadSettings(), browser.permissions.contains({ origins: ASK_ORIGINS })]); + const wanted = settings.prefs.askOnSelection && allowed; + const registered = (await chrome.scripting.getRegisteredContentScripts({ ids: [ASK_SCRIPT_ID] })).length > 0; + if (wanted && !registered) { + await chrome.scripting.registerContentScripts([{ id: ASK_SCRIPT_ID, js: [ASK_SCRIPT_FILE], matches: ASK_ORIGINS, runAt: 'document_idle', allFrames: false, persistAcrossSessions: true }]); + } else if (!wanted && registered) { + await chrome.scripting.unregisterContentScripts({ ids: [ASK_SCRIPT_ID] }); + } + return wanted; + }); + syncing = run.catch(() => undefined); + return run; +} + +/** A message sent by a content script in a web page (not by one of this extension's own pages). */ +export function isFromWebPage(sender: chrome.runtime.MessageSender): sender is chrome.runtime.MessageSender & { tab: chrome.tabs.Tab } { + if (sender.id !== browser.runtime.id || !sender.tab || sender.frameId !== 0 || !sender.url) return false; + return !sender.url.startsWith(browser.runtime.getURL('/')); +} + +/** + * Handles the button click from a web page. The panel must open while the click still counts as a + * user gesture, so `sidePanel.open` runs before anything is awaited; the draft is saved after. + * Returns false for anything that is not a valid `ask.selection` from a top-level web page. + */ +export function handleAskSelection(message: unknown, sender: chrome.runtime.MessageSender): boolean { + if (!isFromWebPage(sender)) return false; + const parsed = askSelectionSchema.safeParse(message); + if (!parsed.success) return false; + const windowId = sender.tab.windowId; + void chrome.sidePanel.open({ windowId }).catch(() => undefined); + void (async () => { + // A page loaded while the feature was on keeps its script after it is turned off; ignore it then. + if (!(await loadSettings()).prefs.askOnSelection) return; + const draft: AskDraft = { id: crypto.randomUUID(), text: formatAskDraft({ ...parsed.data, url: sender.tab.url ?? parsed.data.url }), createdAt: Date.now(), windowId }; + await browser.storage.session.set({ [ASK_DRAFT_KEY]: draft }); + })(); + return true; +} diff --git a/src/background/service-worker.ts b/src/background/service-worker.ts index 2978119..8746281 100644 --- a/src/background/service-worker.ts +++ b/src/background/service-worker.ts @@ -5,6 +5,8 @@ import { browser } from 'wxt/browser'; import { isApiError } from '@/api/errors'; import { requestSchema, type BackgroundResponse } from '@/protocol/messages'; +import { SETTINGS_KEY } from '@/shared/settings'; +import { handleAskSelection, isFromWebPage, syncAskScript } from './ask-selection'; import { BADGE_ALARM, refreshPendingBadge } from './pending-badge'; import { authorizationFor, renewFor, signIn, signOut } from './token-manager'; @@ -39,18 +41,40 @@ async function handle(message: unknown, senderId: string | undefined): Promise undefined); browser.runtime.onMessage.addListener((message, sender, sendResponse) => { + // A web page's content script may only send `ask.selection`; it never reaches the auth handlers. + if (isFromWebPage(sender)) { + handleAskSelection(message, sender); + return false; + } void handle(message, sender.id).then(sendResponse); return true; }); + // The page script follows the preference and the granted site access. + const syncAsk = () => void syncAskScript().catch(() => undefined); + browser.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && SETTINGS_KEY in changes) syncAsk(); + }); + browser.permissions.onAdded.addListener(syncAsk); + browser.permissions.onRemoved.addListener(syncAsk); + void browser.alarms.create(BADGE_ALARM, { periodInMinutes: 1 }); browser.alarms.onAlarm.addListener((alarm) => { if (alarm.name === BADGE_ALARM) void refreshPendingBadge(); }); - browser.runtime.onStartup.addListener(() => void refreshPendingBadge()); - browser.runtime.onInstalled.addListener(() => void refreshPendingBadge()); + browser.runtime.onStartup.addListener(() => { + void refreshPendingBadge(); + syncAsk(); + }); + browser.runtime.onInstalled.addListener(() => { + void refreshPendingBadge(); + syncAsk(); + }); } diff --git a/src/entrypoints/ask-dewee.content.ts b/src/entrypoints/ask-dewee.content.ts new file mode 100644 index 0000000..79231b8 --- /dev/null +++ b/src/entrypoints/ask-dewee.content.ts @@ -0,0 +1,13 @@ +/** + * Opt-in page script for "Ask Dewee" on selected text. It is not in the manifest: the service worker + * registers it only after the person turns the feature on and grants site access. + */ +import { mountAskButton } from '@/ask/selection-button'; + +export default defineContentScript({ + matches: ['https://*/*'], + registration: 'runtime', + main() { + mountAskButton(document, (message) => void browser.runtime.sendMessage(message).catch(() => undefined)); + }, +}); \ No newline at end of file diff --git a/src/options/Settings.tsx b/src/options/Settings.tsx index 43f0f94..6cc2d35 100644 --- a/src/options/Settings.tsx +++ b/src/options/Settings.tsx @@ -3,6 +3,8 @@ * preferences. Workspace settings stay in Dewee; these are only this browser's preferences. */ import { useState } from 'react'; +import { browser } from 'wxt/browser'; +import { ASK_ORIGINS } from '@/protocol/ask-selection'; import { ConnectForm } from '@/shared/ConnectForm'; import { removeProfile, signOutProfile, verifyProfile } from '@/shared/connect'; import { errorText, I18nContext, useI18n } from '@/shared/i18n'; @@ -109,6 +111,48 @@ function PrefToggle({ pref, label, hint, settings, update }: { pref: BooleanPref ); } +/** + * "Ask Dewee" on selected text. Turning it on asks Chrome for site access first (in the click, which + * Chrome requires); the service worker then registers the page script. Turning it off removes the script. + */ +function AskToggle({ settings, update }: { settings: StoredSettings | null; update: SettingsState['update'] }) { + const { t } = useI18n(); + const [denied, setDenied] = useState(false); + return ( + + ); +} + export function Settings() { const state = useSettings(); const { settings, connected, update, t, locale } = state; @@ -176,6 +220,7 @@ export function Settings() {
+

diff --git a/src/protocol/ask-selection.ts b/src/protocol/ask-selection.ts new file mode 100644 index 0000000..8d5e65a --- /dev/null +++ b/src/protocol/ask-selection.ts @@ -0,0 +1,68 @@ +/** + * "Ask Dewee" about selected page text. The opt-in content script sends one `ask.selection` message + * when the person clicks its button; the service worker opens the side panel and keeps the draft + * in `storage.session` until the panel puts it in the composer. Nothing is sent to Dewee until the + * person adds a question and presses Send. + */ +import { z } from 'zod'; + +/** Longest selection kept; the rest is cut with a marker. */ +export const MAX_SELECTION = 4000; +export const ASK_DRAFT_KEY = 'askDraft'; +/** Site access the feature asks for when it is turned on (the manifest lists it as optional). */ +export const ASK_ORIGINS = ['https://*/*']; +/** A draft older than this is dropped instead of filling the composer later by surprise. */ +export const ASK_DRAFT_TTL_MS = 5 * 60_000; + +const httpUrl = z + .string() + .max(4096) + .refine((value) => { + try { + return ['http:', 'https:'].includes(new URL(value).protocol); + } catch { + return false; + } + }); + +export const askSelectionSchema = z.object({ + type: z.literal('ask.selection'), + text: z.string().trim().min(1).max(MAX_SELECTION * 2), + url: httpUrl, + title: z.string().max(1000).default(''), +}); +export type AskSelection = z.infer; + +export const askDraftSchema = z.object({ + id: z.string().min(1), + text: z.string().min(1), + createdAt: z.number(), + /** The window whose side panel was opened; other windows' panels leave the draft alone. */ + windowId: z.number().int(), +}); +export type AskDraft = z.infer; + +const SECRET_PARAM = /pass(word)?|secret|token|key|auth|code|session|sig(nature)?|state|ticket|jwt/i; + +/** The page address without its fragment and without query values that could carry a credential. */ +export function shareableUrl(raw: string): string { + const url = new URL(raw); + url.hash = ''; + url.username = ''; + url.password = ''; + for (const name of [...url.searchParams.keys()]) if (SECRET_PARAM.test(name)) url.searchParams.delete(name); + return url.toString(); +} + +/** Composer text for a selection: the quote, then where it came from, then an empty line for the question. */ +export function formatAskDraft({ text, url, title }: Pick): string { + const clipped = text.length > MAX_SELECTION ? `${text.slice(0, MAX_SELECTION)}…` : text; + const quote = clipped + .replace(/\r\n?/g, '\n') + .split('\n') + .map((line) => (line.trim() ? `> ${line}` : '>')) + .join('\n'); + const name = title.replace(/\s+/g, ' ').trim().slice(0, 200); + const link = shareableUrl(url); + return `${quote}\n\n${name ? `Source: ${name} — ${link}` : `Source: ${link}`}\n\n`; +} diff --git a/src/shared/components.css b/src/shared/components.css index f735133..61d972b 100644 --- a/src/shared/components.css +++ b/src/shared/components.css @@ -158,3 +158,7 @@ select.input.compact { color: var(--danger); font-size: 12px; } + +.text-danger { + color: var(--danger); +} \ No newline at end of file diff --git a/src/shared/i18n.ts b/src/shared/i18n.ts index 5dce5fa..7712b96 100644 --- a/src/shared/i18n.ts +++ b/src/shared/i18n.ts @@ -192,13 +192,16 @@ const en = { 'settings.theme.light': 'Light', 'settings.theme.dark': 'Dark', 'settings.display': 'Chat display', + 'settings.askOnSelection': 'Ask Dewee about selected text', + 'settings.askOnSelection.hint': 'Shows an Ask Dewee button when you select text on a web page. Clicking it opens the side panel with the quote, page title and address in the message box; nothing is sent until you press Send. Needs access to websites; applies to pages opened after turning it on. Turning it off removes the button; to remove site access too, use Chrome’s extension settings.', + 'settings.askOnSelection.denied': 'Chrome did not grant website access, so the button stays off.', 'settings.showReasoning': 'Show reasoning', 'settings.showReasoning.hint': 'Show the agent’s thinking under each reply, collapsed.', 'settings.showToolDetails': 'Show tool call details', 'settings.showToolDetails.hint': 'Show each tool’s input, output and error. Common key, token and password formats are hidden, but not every secret can be recognised.', 'settings.language': 'Language', 'settings.language.auto': 'Browser default', - 'settings.privacy': 'The extension never reads the pages you visit. Credentials are kept only in this browser and sent only to the connection they belong to.', + 'settings.privacy': 'The extension never reads the pages you visit, except text you select and send with Ask Dewee when you turn that on. Credentials are kept only in this browser and sent only to the connection they belong to.', } as const; export type MessageKey = keyof typeof en; @@ -390,13 +393,16 @@ const vi: Record = { 'settings.theme.light': 'Sáng', 'settings.theme.dark': 'Tối', 'settings.display': 'Hiển thị hội thoại', + 'settings.askOnSelection': 'Hỏi Dewee về đoạn văn bản đã chọn', + 'settings.askOnSelection.hint': 'Hiện nút Hỏi Dewee khi bạn bôi đen văn bản trên trang web. Bấm nút sẽ mở side panel, đưa trích dẫn, tiêu đề và địa chỉ trang vào ô nhập; không gửi gì cho tới khi bạn bấm Gửi. Cần quyền truy cập website; áp dụng cho các trang mở sau khi bật. Tắt sẽ gỡ nút; muốn thu hồi cả quyền truy cập website, dùng phần cài đặt extension của Chrome.', + 'settings.askOnSelection.denied': 'Chrome chưa cấp quyền truy cập website nên nút vẫn tắt.', 'settings.showReasoning': 'Hiện phần suy luận', 'settings.showReasoning.hint': 'Hiện suy nghĩ của agent bên dưới mỗi câu trả lời, ở dạng thu gọn.', 'settings.showToolDetails': 'Hiện chi tiết tool call', 'settings.showToolDetails.hint': 'Hiện đầu vào, kết quả và lỗi của từng tool. Các dạng key, token và mật khẩu phổ biến được ẩn, nhưng không phải bí mật nào cũng nhận ra được.', 'settings.language': 'Ngôn ngữ', 'settings.language.auto': 'Theo trình duyệt', - 'settings.privacy': 'Extension không bao giờ đọc các trang bạn truy cập. Thông tin xác thực chỉ nằm trong trình duyệt này và chỉ được gửi tới đúng kết nối của nó.', + 'settings.privacy': 'Extension không bao giờ đọc các trang bạn truy cập, trừ đoạn văn bản bạn chọn và bấm Hỏi Dewee khi bạn bật tính năng đó. Thông tin xác thực chỉ nằm trong trình duyệt này và chỉ được gửi tới đúng kết nối của nó.', }; export type Locale = 'en' | 'vi'; diff --git a/src/shared/settings.ts b/src/shared/settings.ts index bdca2dc..fe693e5 100644 --- a/src/shared/settings.ts +++ b/src/shared/settings.ts @@ -48,8 +48,10 @@ const settingsSchema = z.object({ showReasoning: z.boolean().default(false), /** Show each tool call's input, output and error. */ showToolDetails: z.boolean().default(false), + /** Show an "Ask Dewee" button on selected page text (needs site access, requested when turned on). */ + askOnSelection: z.boolean().default(false), }) - .default({ theme: 'system', locale: 'auto', showReasoning: false, showToolDetails: false }), + .default({ theme: 'system', locale: 'auto', showReasoning: false, showToolDetails: false, askOnSelection: false }), }); export type Settings = z.infer; diff --git a/src/sidepanel/ChatShell.tsx b/src/sidepanel/ChatShell.tsx index 4239168..7f6042c 100644 --- a/src/sidepanel/ChatShell.tsx +++ b/src/sidepanel/ChatShell.tsx @@ -17,6 +17,7 @@ import { AgentPicker } from './components/AgentPicker'; import { Composer } from './components/Composer'; import { MessageList } from './components/MessageList'; import { SessionList } from './components/SessionList'; +import { useAskDraft } from './use-ask-draft'; import { useAgents, useSessions, useWorkspaces } from './use-directory'; /** Largest limit the sessions endpoint accepts. */ @@ -63,6 +64,7 @@ export function ChatShell({ const drawerWasOpen = useRef(false); const workspaces = useWorkspaces(client, onAuthError); + const askDraft = useAskDraft(); const { showReasoning, showToolDetails } = settings.prefs; const display = useMemo(() => ({ reasoning: showReasoning, toolDetails: showToolDetails }), [showReasoning, showToolDetails]); const remembered = settings.selections[profile.id]?.workspaceId; @@ -320,6 +322,7 @@ export function ChatShell({ ref={composerRef} agentName={agentName} busy={busy} + draft={askDraft} disabled={chat.state.phase === 'reconciling' || chat.state.phase === 'loading'} onSend={(text) => chat.send(text)} onStop={() => void chat.stop()} diff --git a/src/sidepanel/components/Composer.tsx b/src/sidepanel/components/Composer.tsx index 1c94fe5..3bb9820 100644 --- a/src/sidepanel/components/Composer.tsx +++ b/src/sidepanel/components/Composer.tsx @@ -6,10 +6,28 @@ import { IconArrowUp, IconStop } from '@/shared/icons'; /** Tallest the box grows before it scrolls, in pixels. */ const MAX_HEIGHT = 200; -export const Composer = forwardRef( - function Composer({ agentName, busy, disabled, onSend, onStop }, ref) { +export const Composer = forwardRef< + HTMLTextAreaElement, + { + agentName: string; + busy: boolean; + disabled?: boolean; + /** Text to put in the box once per id (an "Ask Dewee" quote); never sent without the person pressing Send. */ + draft?: { id: string; text: string } | null; + /** False when the send was refused locally and nothing left; the text is then kept. */ + onSend(text: string): boolean | void; + onStop(): void; + } +>( + function Composer({ agentName, busy, disabled, draft, onSend, onStop }, ref) { const { t } = useI18n(); const [text, setText] = useState(''); + // A new draft goes above whatever is already typed, so nothing the person wrote is lost. + const [draftId, setDraftId] = useState(); + if (draft && draft.id !== draftId) { + setDraftId(draft.id); + setText((current) => (current.trim() ? `${draft.text}${current}` : draft.text)); + } const inputRef = useRef(null); useImperativeHandle(ref, () => inputRef.current as HTMLTextAreaElement); @@ -21,6 +39,15 @@ export const Composer = forwardRef { + const el = inputRef.current; + if (!draftId || !el) return; + el.focus({ preventScroll: true }); + el.setSelectionRange(el.value.length, el.value.length); + el.scrollTop = el.scrollHeight; + }, [draftId]); + // Send and Stop are different buttons, and the box is disabled while a conversation loads; both // drop focus to the page. Put it back in the box once it can take focus again. const locked = busy || Boolean(disabled); diff --git a/src/sidepanel/use-ask-draft.ts b/src/sidepanel/use-ask-draft.ts new file mode 100644 index 0000000..67e691c --- /dev/null +++ b/src/sidepanel/use-ask-draft.ts @@ -0,0 +1,38 @@ +/** + * Takes an "Ask Dewee" draft from `storage.session` for this window's side panel: the one waiting + * when the panel opens, and any that arrive while it is open. Each draft is used once, and a stale + * one is dropped. + */ +import { useEffect, useState } from 'react'; +import { browser } from 'wxt/browser'; +import { ASK_DRAFT_KEY, ASK_DRAFT_TTL_MS, askDraftSchema, type AskDraft } from '@/protocol/ask-selection'; + +export function useAskDraft(): AskDraft | null { + const [draft, setDraft] = useState(null); + + useEffect(() => { + let live = true; + // Unknown window: no draft matches, so another window's draft is never taken here. + const windowId = browser.windows.getCurrent().then((w) => w?.id, () => undefined); + const take = async () => { + const raw: unknown = (await browser.storage.session.get(ASK_DRAFT_KEY))[ASK_DRAFT_KEY]; + if (raw === undefined) return; + const parsed = askDraftSchema.safeParse(raw); + if (parsed.success && parsed.data.windowId !== (await windowId)) return; + await browser.storage.session.remove(ASK_DRAFT_KEY); + if (live && parsed.success && Date.now() - parsed.data.createdAt <= ASK_DRAFT_TTL_MS) setDraft(parsed.data); + }; + const run = () => void take().catch(() => undefined); + run(); + const listener = (changes: Record, area: string) => { + if (area === 'session' && changes[ASK_DRAFT_KEY]?.newValue !== undefined) run(); + }; + browser.storage.onChanged.addListener(listener); + return () => { + live = false; + browser.storage.onChanged.removeListener(listener); + }; + }, []); + + return draft; +} \ No newline at end of file diff --git a/tests/unit/ask/selection-button.test.ts b/tests/unit/ask/selection-button.test.ts new file mode 100644 index 0000000..9f13e6d --- /dev/null +++ b/tests/unit/ask/selection-button.test.ts @@ -0,0 +1,77 @@ +import { mountAskButton, selectedText } from '../../../src/ask/selection-button'; +import type { AskSelection } from '../../../src/protocol/ask-selection'; + +function select(node: Node) { + const range = document.createRange(); + range.selectNodeContents(node); + const selection = document.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); +} + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('Ask Dewee button', () => { + let mounted: ReturnType | undefined; + let sent: AskSelection[]; + + beforeAll(() => { + // jsdom has no layout. + Range.prototype.getBoundingClientRect ??= () => new DOMRect(100, 100, 50, 20); + }); + beforeEach(() => { + document.body.innerHTML = '

Dewee runs agents.

draft
'; + document.title = 'Docs'; + sent = []; + mounted = mountAskButton(document, (message) => sent.push(message), 'Ask Dewee'); + }); + afterEach(() => { + mounted?.dispose(); + document.getSelection()?.removeAllRanges(); + }); + + it('shows after a selection and sends the text, address and title once on click', async () => { + select(document.getElementById('text')!); + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + await flush(); + expect(mounted!.button.hidden).toBe(false); + expect(mounted!.button.textContent).toBe('Ask Dewee'); + + mounted!.button.click(); + expect(sent).toEqual([{ type: 'ask.selection', text: 'Dewee runs agents.', url: document.location.href, title: 'Docs' }]); + expect(mounted!.button.hidden).toBe(true); + }); + + it('ignores selections inside form fields and editable areas', async () => { + select(document.getElementById('edit')!); + expect(selectedText(document)).toBeNull(); + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + await flush(); + expect(mounted!.button.hidden).toBe(true); + mounted!.button.click(); + expect(sent).toEqual([]); + }); + + it('hides on Escape and when the selection is cleared', async () => { + select(document.getElementById('text')!); + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + await flush(); + document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape', bubbles: true })); + expect(mounted!.button.hidden).toBe(true); + + document.getSelection()!.removeAllRanges(); + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + await flush(); + expect(mounted!.button.hidden).toBe(true); + }); + + it('removes its element and listeners on dispose', async () => { + select(document.getElementById('text')!); + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + await flush(); + expect(document.querySelector('dewee-ask')).not.toBeNull(); + mounted!.dispose(); + mounted = undefined; + expect(document.querySelector('dewee-ask')).toBeNull(); + }); +}); \ No newline at end of file diff --git a/tests/unit/background/ask-selection.test.ts b/tests/unit/background/ask-selection.test.ts new file mode 100644 index 0000000..2546169 --- /dev/null +++ b/tests/unit/background/ask-selection.test.ts @@ -0,0 +1,85 @@ +import { fakeBrowser } from 'wxt/testing/fake-browser'; +import { handleAskSelection, isFromWebPage, syncAskScript } from '../../../src/background/ask-selection'; +import { ASK_DRAFT_KEY } from '../../../src/protocol/ask-selection'; +import { saveSettings, defaultSettings } from '../../../src/shared/settings'; + +const registered = new Set(); +const chromeStub = { + sidePanel: { open: vi.fn(async () => {}) }, + scripting: { + getRegisteredContentScripts: vi.fn(async ({ ids }: { ids: string[] }) => ids.filter((id) => registered.has(id)).map((id) => ({ id }))), + registerContentScripts: vi.fn(async (scripts: { id: string }[]) => scripts.forEach((s) => registered.add(s.id))), + unregisterContentScripts: vi.fn(async ({ ids }: { ids: string[] }) => ids.forEach((id) => registered.delete(id))), + }, +}; + +async function setAsk(on: boolean) { + const settings = defaultSettings(); + await saveSettings({ ...settings, prefs: { ...settings.prefs, askOnSelection: on } }); +} + +const tab = { id: 7, windowId: 3, url: 'https://example.com/page?token=abc' } as chrome.tabs.Tab; +const pageSender = (patch: Partial = {}): chrome.runtime.MessageSender => ({ id: fakeBrowser.runtime.id, tab, frameId: 0, url: tab.url, ...patch }); +const message = { type: 'ask.selection', text: 'Selected words', url: 'https://example.com/page', title: 'Example' }; + +beforeEach(() => { + registered.clear(); + vi.clearAllMocks(); + vi.stubGlobal('chrome', chromeStub); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe('isFromWebPage', () => { + it('accepts only this extension’s content script in a top-level web page', () => { + expect(isFromWebPage(pageSender())).toBe(true); + expect(isFromWebPage(pageSender({ id: 'other-extension' }))).toBe(false); + expect(isFromWebPage(pageSender({ frameId: 2 }))).toBe(false); + expect(isFromWebPage(pageSender({ tab: undefined }))).toBe(false); + expect(isFromWebPage(pageSender({ url: fakeBrowser.runtime.getURL('/options.html') }))).toBe(false); + }); +}); + +describe('handleAskSelection', () => { + it('opens the side panel before awaiting anything, then stores the draft for that window', async () => { + await setAsk(true); + expect(handleAskSelection(message, pageSender())).toBe(true); + expect(chromeStub.sidePanel.open).toHaveBeenCalledWith({ windowId: 3 }); + + await vi.waitFor(async () => expect((await fakeBrowser.storage.session.get(ASK_DRAFT_KEY))[ASK_DRAFT_KEY]).toBeDefined()); + const draft = (await fakeBrowser.storage.session.get(ASK_DRAFT_KEY))[ASK_DRAFT_KEY] as { text: string; windowId: number }; + expect(draft.windowId).toBe(3); + expect(draft.text).toBe('> Selected words\n\nSource: Example — https://example.com/page\n\n'); + }); + + it('opens nothing and stores nothing for an invalid message or an extension page', async () => { + await setAsk(true); + expect(handleAskSelection({ ...message, type: 'auth.signOut' }, pageSender())).toBe(false); + expect(handleAskSelection(message, pageSender({ url: fakeBrowser.runtime.getURL('/sidepanel.html') }))).toBe(false); + expect(chromeStub.sidePanel.open).not.toHaveBeenCalled(); + }); + + it('stores no draft once the feature is turned off, even from a page that still has the script', async () => { + await setAsk(false); + handleAskSelection(message, pageSender()); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(await fakeBrowser.storage.session.get(ASK_DRAFT_KEY)).toEqual({}); + }); +}); + +describe('syncAskScript', () => { + it('registers the page script only when the preference is on and site access is granted', async () => { + const contains = vi.spyOn(fakeBrowser.permissions, 'contains'); + await setAsk(true); + contains.mockResolvedValue(false as never); + expect(await syncAskScript()).toBe(false); + expect(registered.size).toBe(0); + + contains.mockResolvedValue(true as never); + expect(await syncAskScript()).toBe(true); + expect(chromeStub.scripting.registerContentScripts).toHaveBeenCalledWith([expect.objectContaining({ id: 'ask-dewee', matches: ['https://*/*'], allFrames: false })]); + + await setAsk(false); + expect(await syncAskScript()).toBe(false); + expect(registered.size).toBe(0); + }); +}); \ No newline at end of file diff --git a/tests/unit/protocol/ask-selection.test.ts b/tests/unit/protocol/ask-selection.test.ts new file mode 100644 index 0000000..5dc28be --- /dev/null +++ b/tests/unit/protocol/ask-selection.test.ts @@ -0,0 +1,47 @@ +import { askDraftSchema, askSelectionSchema, formatAskDraft, MAX_SELECTION, shareableUrl } from '../../../src/protocol/ask-selection'; + +describe('ask.selection message', () => { + it('accepts a selection from an http(s) page and defaults the title', () => { + const parsed = askSelectionSchema.parse({ type: 'ask.selection', text: ' quoted ', url: 'https://example.com/a' }); + expect(parsed).toEqual({ type: 'ask.selection', text: 'quoted', url: 'https://example.com/a', title: '' }); + }); + + it.each([ + ['an empty selection', { text: ' ', url: 'https://example.com' }], + ['a non-web address', { text: 'x', url: 'javascript:alert(1)' }], + ['an extension page', { text: 'x', url: 'chrome-extension://abc/options.html' }], + ['a huge selection', { text: 'x'.repeat(MAX_SELECTION * 2 + 1), url: 'https://example.com' }], + ['another message type', { type: 'auth.signOut', text: 'x', url: 'https://example.com' }], + ])('rejects %s', (_label, patch) => { + expect(askSelectionSchema.safeParse({ type: 'ask.selection', ...patch }).success).toBe(false); + }); + + it('requires an id, text, time and window for a stored draft', () => { + expect(askDraftSchema.safeParse({ id: 'd1', text: 'x', createdAt: 1, windowId: 2 }).success).toBe(true); + expect(askDraftSchema.safeParse({ id: 'd1', text: 'x', createdAt: 1 }).success).toBe(false); + }); +}); + +describe('shareableUrl', () => { + it('drops the fragment, credentials in the address and secret-looking query values', () => { + expect(shareableUrl('https://ann:pw@example.com/p?q=dewee&token=abc&page=2&code=xyz#section')).toBe('https://example.com/p?q=dewee&page=2'); + }); + + it('keeps a plain address unchanged', () => { + expect(shareableUrl('https://example.com/docs/intro')).toBe('https://example.com/docs/intro'); + }); +}); + +describe('formatAskDraft', () => { + it('quotes every line, names the source and leaves a blank line for the question', () => { + expect(formatAskDraft({ text: 'First line\r\n\r\nThird line', url: 'https://example.com/a?session=s#top', title: ' Example page ' })).toBe( + '> First line\n>\n> Third line\n\nSource: Example page — https://example.com/a\n\n', + ); + }); + + it('uses the address alone when the page has no title and clips a long selection', () => { + const draft = formatAskDraft({ text: 'y'.repeat(MAX_SELECTION + 50), url: 'https://example.com/', title: '' }); + expect(draft).toContain(`> ${'y'.repeat(MAX_SELECTION)}…`); + expect(draft).toContain('Source: https://example.com/'); + }); +}); \ No newline at end of file diff --git a/tests/unit/shared/settings.test.ts b/tests/unit/shared/settings.test.ts index 00f058f..49bde91 100644 --- a/tests/unit/shared/settings.test.ts +++ b/tests/unit/shared/settings.test.ts @@ -36,7 +36,7 @@ describe('updateSettings', () => { describe('loadSettings and saveSettings', () => { it('returns the defaults when nothing is stored', async () => { expect(await loadSettings()).toEqual(defaultSettings()); - expect(defaultSettings()).toMatchObject({ version: 1, profiles: [], selections: {}, prefs: { theme: 'system', locale: 'auto', showReasoning: false, showToolDetails: false } }); + expect(defaultSettings()).toMatchObject({ version: 1, profiles: [], selections: {}, prefs: { theme: 'system', locale: 'auto', showReasoning: false, showToolDetails: false, askOnSelection: false } }); }); it('falls back to the defaults when the stored value is corrupt', async () => { @@ -47,7 +47,7 @@ describe('loadSettings and saveSettings', () => { it('round-trips a saved value', async () => { const settings: Settings = { ...withProfiles([cloud, gateway], 'p_gw'), - prefs: { theme: 'dark', locale: 'vi', showReasoning: true, showToolDetails: false }, + prefs: { theme: 'dark', locale: 'vi', showReasoning: true, showToolDetails: false, askOnSelection: true }, }; await saveSettings(settings); expect(await loadSettings()).toEqual(settings); @@ -55,7 +55,7 @@ describe('loadSettings and saveSettings', () => { it('keeps stored theme and language and turns the display options off when an older value lacks them', async () => { await fakeBrowser.storage.local.set({ [SETTINGS_KEY]: { prefs: { theme: 'dark', locale: 'vi' } } }); - expect((await loadSettings()).prefs).toEqual({ theme: 'dark', locale: 'vi', showReasoning: false, showToolDetails: false }); + expect((await loadSettings()).prefs).toEqual({ theme: 'dark', locale: 'vi', showReasoning: false, showToolDetails: false, askOnSelection: false }); }); it('defaults a profile without the remember flag to false', async () => { diff --git a/tests/unit/sidepanel/Composer.test.tsx b/tests/unit/sidepanel/Composer.test.tsx index c654935..9017681 100644 --- a/tests/unit/sidepanel/Composer.test.tsx +++ b/tests/unit/sidepanel/Composer.test.tsx @@ -52,4 +52,27 @@ describe('Composer', () => { ); expect(document.activeElement).toBe(other); }); +}); + +describe('Composer draft', () => { + const base = { agentName: 'Super Agent', busy: false, onSend: vi.fn(), onStop: vi.fn() }; + + it('puts a draft in the box once, above anything typed, with the caret at the end and nothing sent', () => { + const { rerender } = render(); + const input = screen.getByRole('textbox') as HTMLTextAreaElement; + fireEvent.change(input, { target: { value: 'my note' } }); + + rerender( quote\n\n' }} />); + expect(input.value).toBe('> quote\n\nmy note'); + expect(document.activeElement).toBe(input); + expect(input.selectionStart).toBe(input.value.length); + expect(base.onSend).not.toHaveBeenCalled(); + + // The same draft rendered again is not added twice. + rerender( quote\n\n' }} />); + expect(input.value).toBe('> quote\n\nmy note'); + + rerender( second\n\n' }} />); + expect(input.value).toBe('> second\n\n> quote\n\nmy note'); + }); }); \ No newline at end of file diff --git a/tests/unit/sidepanel/use-ask-draft.test.ts b/tests/unit/sidepanel/use-ask-draft.test.ts new file mode 100644 index 0000000..adb03f0 --- /dev/null +++ b/tests/unit/sidepanel/use-ask-draft.test.ts @@ -0,0 +1,44 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { fakeBrowser } from 'wxt/testing/fake-browser'; +import { ASK_DRAFT_KEY, ASK_DRAFT_TTL_MS } from '../../../src/protocol/ask-selection'; +import { useAskDraft } from '../../../src/sidepanel/use-ask-draft'; + +const draft = (patch: Record = {}) => ({ id: 'd1', text: '> quote\n\n', createdAt: Date.now(), windowId: 1, ...patch }); + +beforeEach(() => { + vi.spyOn(fakeBrowser.windows, 'getCurrent').mockResolvedValue({ id: 1 } as never); +}); + +describe('useAskDraft', () => { + it('takes a waiting draft once and removes it from session storage', async () => { + await fakeBrowser.storage.session.set({ [ASK_DRAFT_KEY]: draft() }); + const { result } = renderHook(() => useAskDraft()); + await waitFor(() => expect(result.current?.id).toBe('d1')); + expect(await fakeBrowser.storage.session.get(ASK_DRAFT_KEY)).toEqual({}); + }); + + it('takes a draft that arrives while the panel is open', async () => { + const { result } = renderHook(() => useAskDraft()); + await act(() => fakeBrowser.storage.session.set({ [ASK_DRAFT_KEY]: draft({ id: 'd2' }) })); + await waitFor(() => expect(result.current?.id).toBe('d2')); + }); + + it('leaves a draft for another window alone', async () => { + await fakeBrowser.storage.session.set({ [ASK_DRAFT_KEY]: draft({ windowId: 9 }) }); + const { result } = renderHook(() => useAskDraft()); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(result.current).toBeNull(); + expect((await fakeBrowser.storage.session.get(ASK_DRAFT_KEY))[ASK_DRAFT_KEY]).toBeDefined(); + }); + + it('drops a stale or malformed draft without using it', async () => { + await fakeBrowser.storage.session.set({ [ASK_DRAFT_KEY]: draft({ createdAt: Date.now() - ASK_DRAFT_TTL_MS - 1000 }) }); + const { result } = renderHook(() => useAskDraft()); + await waitFor(async () => expect(await fakeBrowser.storage.session.get(ASK_DRAFT_KEY)).toEqual({})); + expect(result.current).toBeNull(); + + await act(() => fakeBrowser.storage.session.set({ [ASK_DRAFT_KEY]: { text: 'no id' } })); + await waitFor(async () => expect(await fakeBrowser.storage.session.get(ASK_DRAFT_KEY)).toEqual({})); + expect(result.current).toBeNull(); + }); +}); \ No newline at end of file diff --git a/wxt.config.ts b/wxt.config.ts index aab4bd6..9c1a211 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -12,6 +12,15 @@ export default defineConfig({ srcDir: 'src', outDir: e2e ? '.output-e2e' : store ? '.output-store' : '.output', modules: ['@wxt-dev/module-react'], + hooks: { + // WXT copies a runtime-registered content script's `matches` into required `host_permissions`. The + // "Ask Dewee" script must stay optional (asked for in Settings), so drop that copy again. + 'build:manifestGenerated': (_wxt, manifest) => { + const required = (manifest.host_permissions ?? []).filter((pattern: string) => pattern !== 'https://*/*'); + if (required.length) manifest.host_permissions = required; + else delete manifest.host_permissions; + }, + }, manifest: { name: 'Dewee', // Public key only: pins the unpacked/dev extension ID (poicgajeapmbcilfbeobdfedibiffbbm) so OAuth redirect URIs and @@ -20,8 +29,10 @@ export default defineConfig({ description: 'Chat with your Dewee agents and Super Agent in a side panel, and answer credential requests without exposing secrets.', minimum_chrome_version: '116', - permissions: ['sidePanel', 'storage', 'identity', 'alarms'], + // scripting registers the opt-in "Ask Dewee" page script only after the person turns it on. + permissions: ['sidePanel', 'storage', 'identity', 'alarms', 'scripting'], // Gateway origins are requested at runtime, one origin at a time, after the user adds a profile. + // "Ask Dewee" on selected text asks for https://*/* when it is turned on in Settings. optional_host_permissions: ['https://*/*', 'http://localhost/*', 'http://127.0.0.1/*'], ...(e2e ? { host_permissions: ['http://localhost/*', 'http://127.0.0.1/*'] } : {}), icons: { 16: 'icons/16.png', 32: 'icons/32.png', 48: 'icons/48.png', 128: 'icons/128.png' }, From 834a365efe87a613526f4f4495169adcc492151b Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Tue, 13 Oct 2026 22:54:43 +0700 Subject: [PATCH 2/3] fix: keep page scripts away from auth and stale Ask Dewee drafts Auth requests are now answered only for this extension's own pages; a content script shares the extension id but carries a web URL. The page script is registered only after storage.local is limited to extension pages, a script left on a page after the feature is turned off can no longer open the panel, and top-level pages are recognised by frame type. The button ignores clicks made by page script, selections made while a field has focus and editable areas in any letter case. More signed-URL parameters are dropped from the address. A composer that remounts no longer adds a draft it already used. Refs #14 Co-Authored-By: Claude Opus 5.5 --- docs/auth-threat-model.md | 8 ++-- src/ask/selection-button.ts | 29 +++++++++++--- src/background/ask-selection.ts | 43 +++++++++++++++++---- src/background/service-worker.ts | 14 ++++--- src/protocol/ask-selection.ts | 2 +- src/sidepanel/ChatShell.tsx | 3 +- src/sidepanel/components/Composer.tsx | 10 ++++- src/sidepanel/use-ask-draft.ts | 8 ++-- tests/e2e/smoke.spec.ts | 18 +++++++++ tests/unit/ask/selection-button.test.ts | 37 ++++++++++++++++-- tests/unit/background/ask-selection.test.ts | 37 +++++++++++++++++- tests/unit/protocol/ask-selection.test.ts | 4 ++ tests/unit/sidepanel/Composer.test.tsx | 6 +++ tests/unit/sidepanel/use-ask-draft.test.ts | 20 +++++++--- 14 files changed, 199 insertions(+), 40 deletions(-) diff --git a/docs/auth-threat-model.md b/docs/auth-threat-model.md index 7347762..0775944 100644 --- a/docs/auth-threat-model.md +++ b/docs/auth-threat-model.md @@ -27,14 +27,14 @@ The Dewee gateway master token is never accepted as an extension credential, and | Wrong workspace or tenant | Every call names the workspace; Dewee enforces membership and returns 404 for non-members. The client never falls back to another workspace or profile. | | Expired or revoked credential | 401 → one renew (Cloud) → on failure the credential is cleared and the panel asks to reconnect that same profile. | | Sign-out leaves a live token | Sign-out calls revoke (best effort) and clears both storage areas. Removing a profile also drops its host permission when no other profile uses the origin. | -| Messages from other extensions or pages | The background accepts messages only from its own extension id and validates them with Zod. It returns the `Authorization` value only to extension pages, which hold it in memory for the request. A network failure while renewing keeps the credential; only a 401 signs the profile out. | -| A web page abusing the "Ask Dewee" script | The script runs only when the person turns the feature on and grants site access. A message from it (a tab sender, top frame, non-extension URL) goes only to the selection handler: one schema-checked `ask.selection` that opens the panel and leaves a quoted draft in `storage.session`, never sent automatically. It never reaches the auth handlers or a credential. The page sees only a closed shadow-root button. | -| Page secrets carried into a draft | Selections inside inputs, text areas and editable regions are ignored. The address drops its fragment, user info and query values whose names look like credentials. The draft is plain text the person reads and edits before sending. | +| Messages from other extensions or pages | The background accepts messages only from its own extension id and validates them with Zod. It answers auth requests only from its own extension pages (a content script shares the id but has a web URL), which hold the `Authorization` value in memory for the request. A network failure while renewing keeps the credential; only a 401 signs the profile out. | +| A web page abusing the "Ask Dewee" script | The script runs only when the person turns the feature on and grants site access. The script is registered only after `storage.local` (remembered credentials) is limited to extension pages. A message from it (a tab sender, top frame, non-extension URL) goes only to the selection handler: one schema-checked `ask.selection` that opens the panel and leaves a quoted draft in `storage.session`, never sent automatically. It never reaches the auth handlers or a credential. The page sees only a closed shadow-root button, which ignores clicks made by page script. Once the feature is off, a page that still has the script cannot open the panel. | +| Page secrets carried into a draft | Selections inside inputs, text areas and editable regions, or made while one has focus, are ignored. The address drops its fragment, user info and query values whose names look like credentials. The draft is plain text the person reads and edits before sending. | | Remote code | Strict CSP (`script-src 'self'`), no `eval`, no remote scripts. | ## Residual risks - Malware with access to the Chrome profile can read `storage.session` while the browser runs. Tokens are short-lived and revocable. - "Remember" keeps the credential on disk in the Chrome profile; the person accepts this when they tick it. -- "Ask Dewee" needs read access to every https page while it is on. Turning it off removes the script, but Chrome keeps the site access until the person removes it in the extension settings, because removing it could also drop gateway origins covered by the same grant. `storage.local` is limited to extension pages where Chrome supports it. +- "Ask Dewee" needs read access to every https page while it is on. Turning it off removes the script, but Chrome keeps the site access until the person removes it in the extension settings, because removing it could also drop gateway origins covered by the same grant. - Self-hosted operators must allowlist the extension origin (`GOCLAW_ALLOWED_ORIGINS`) and keep API keys scoped. diff --git a/src/ask/selection-button.ts b/src/ask/selection-button.ts index 83b9f4c..9f4b046 100644 --- a/src/ask/selection-button.ts +++ b/src/ask/selection-button.ts @@ -21,23 +21,39 @@ svg { width: 13px; height: 13px; } const SPARKLE = ''; +const FIELD = 'input, textarea, select'; + /** Fields whose selection must never be sent: inputs, text areas and editable regions. */ function inEditable(node: Node | null): boolean { const element = node instanceof Element ? node : node?.parentElement; - return Boolean(element?.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"], [contenteditable="plaintext-only"]')); + return Boolean(element && (element.closest(FIELD) || (element as HTMLElement).isContentEditable || element.closest('[contenteditable]:not([contenteditable="false" i])'))); +} + +/** Chrome reports text selected inside an input or text area with the field's parent as anchor, so check focus too. */ +function focusInField(doc: Document): boolean { + const active = doc.activeElement; + return Boolean(active && (active.matches(FIELD) || (active as HTMLElement).isContentEditable)); } export function selectedText(doc: Document): { text: string; rect: DOMRect } | null { const selection = doc.getSelection(); if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null; - if (inEditable(selection.anchorNode) || inEditable(selection.focusNode)) return null; + if (focusInField(doc) || inEditable(selection.anchorNode) || inEditable(selection.focusNode)) return null; const text = selection.toString().trim(); if (!text) return null; return { text: text.slice(0, MAX_SELECTION + 1), rect: selection.getRangeAt(0).getBoundingClientRect() }; } -/** Mounts the hidden button and its listeners; dispose removes both. */ -export function mountAskButton(doc: Document, send: (message: AskSelection) => unknown, label = defaultLabel(doc)): { button: HTMLButtonElement; dispose(): void } { +/** + * Mounts the hidden button and its listeners; dispose removes both. yPerson decides whether a click + * came from the person rather than page script (tests replace it, since they cannot make trusted events). + */ +export function mountAskButton( + doc: Document, + send: (message: AskSelection) => unknown, + label = defaultLabel(doc), + byPerson: (event: MouseEvent) => boolean = (event) => event.isTrusted, +): { button: HTMLButtonElement; dispose(): void } { const host = doc.createElement('dewee-ask'); const root = host.attachShadow({ mode: 'closed' }); if ('adoptedStyleSheets' in root && typeof CSSStyleSheet === 'function' && 'replaceSync' in CSSStyleSheet.prototype) { @@ -86,8 +102,9 @@ export function mountAskButton(doc: Document, send: (message: AskSelection) => u // Keep the page selection while the button is pressed. button.addEventListener('mousedown', (event) => event.preventDefault()); - button.addEventListener('click', () => { - if (!current) return; + // Only a real click: a page cannot press the button from script to slip its own text into a draft. + button.addEventListener('click', (event) => { + if (!current || !byPerson(event)) return; send({ type: 'ask.selection', text: current, url: doc.location.href, title: doc.title }); hide(); }); diff --git a/src/background/ask-selection.ts b/src/background/ask-selection.ts index ae17580..43ecfd0 100644 --- a/src/background/ask-selection.ts +++ b/src/background/ask-selection.ts @@ -11,28 +11,54 @@ export const ASK_SCRIPT_ID = 'ask-dewee'; const ASK_SCRIPT_FILE = 'content-scripts/ask-dewee.js'; let syncing: Promise = Promise.resolve(); +/** Last known state of the page script; undefined until the first sync in this worker. */ +let active: boolean | undefined; +let localLocked = false; + +/** + * Limits storage.local, where remembered credentials live, to extension pages. The page script is + * registered only once this has succeeded. + */ +export async function lockLocalStorage(): Promise { + if (localLocked) return true; + try { + await chrome.storage.local.setAccessLevel({ accessLevel: 'TRUSTED_CONTEXTS' }); + localLocked = true; + } catch { + // Not supported or refused: the page script stays off, and the next sync tries again. + } + return localLocked; +} /** Registers or removes the content script to match the preference and the granted access. Runs one at a time. */ export function syncAskScript(): Promise { const run = syncing.then(async () => { - const [settings, allowed] = await Promise.all([loadSettings(), browser.permissions.contains({ origins: ASK_ORIGINS })]); - const wanted = settings.prefs.askOnSelection && allowed; + const [settings, allowed, locked] = await Promise.all([loadSettings(), browser.permissions.contains({ origins: ASK_ORIGINS }), lockLocalStorage()]); + const wanted = settings.prefs.askOnSelection && allowed && locked; const registered = (await chrome.scripting.getRegisteredContentScripts({ ids: [ASK_SCRIPT_ID] })).length > 0; if (wanted && !registered) { await chrome.scripting.registerContentScripts([{ id: ASK_SCRIPT_ID, js: [ASK_SCRIPT_FILE], matches: ASK_ORIGINS, runAt: 'document_idle', allFrames: false, persistAcrossSessions: true }]); } else if (!wanted && registered) { await chrome.scripting.unregisterContentScripts({ ids: [ASK_SCRIPT_ID] }); } + active = wanted; return wanted; }); syncing = run.catch(() => undefined); return run; } -/** A message sent by a content script in a web page (not by one of this extension's own pages). */ +/** A message from one of this extension's own pages (side panel, options). Only these reach the auth handlers. */ +export function isFromExtensionPage(sender: chrome.runtime.MessageSender): boolean { + return sender.id === browser.runtime.id && Boolean(sender.url?.startsWith(browser.runtime.getURL('/'))); +} + +/** A message sent by the content script in a tab's top-level web page. */ export function isFromWebPage(sender: chrome.runtime.MessageSender): sender is chrome.runtime.MessageSender & { tab: chrome.tabs.Tab } { - if (sender.id !== browser.runtime.id || !sender.tab || sender.frameId !== 0 || !sender.url) return false; - return !sender.url.startsWith(browser.runtime.getURL('/')); + if (sender.id !== browser.runtime.id || !sender.tab || !sender.url || isFromExtensionPage(sender)) return false; + // A prerendered page keeps a non-zero frame id after activation, so prefer the frame type when Chrome reports it. + const { frameType } = sender as { frameType?: string }; + return frameType ? frameType === 'outermost_frame' : sender.frameId === 0; } /** @@ -43,14 +69,15 @@ export function isFromWebPage(sender: chrome.runtime.MessageSender): sender is c export function handleAskSelection(message: unknown, sender: chrome.runtime.MessageSender): boolean { if (!isFromWebPage(sender)) return false; const parsed = askSelectionSchema.safeParse(message); - if (!parsed.success) return false; + // A page loaded while the feature was on keeps its script after it is turned off; ignore it then. + if (!parsed.success || active === false) return false; const windowId = sender.tab.windowId; void chrome.sidePanel.open({ windowId }).catch(() => undefined); void (async () => { - // A page loaded while the feature was on keeps its script after it is turned off; ignore it then. + // The worker may have just started and not synced yet: check the preference before keeping the draft. if (!(await loadSettings()).prefs.askOnSelection) return; const draft: AskDraft = { id: crypto.randomUUID(), text: formatAskDraft({ ...parsed.data, url: sender.tab.url ?? parsed.data.url }), createdAt: Date.now(), windowId }; await browser.storage.session.set({ [ASK_DRAFT_KEY]: draft }); - })(); + })().catch(() => undefined); return true; } diff --git a/src/background/service-worker.ts b/src/background/service-worker.ts index 8746281..b1bc1af 100644 --- a/src/background/service-worker.ts +++ b/src/background/service-worker.ts @@ -6,13 +6,13 @@ import { browser } from 'wxt/browser'; import { isApiError } from '@/api/errors'; import { requestSchema, type BackgroundResponse } from '@/protocol/messages'; import { SETTINGS_KEY } from '@/shared/settings'; -import { handleAskSelection, isFromWebPage, syncAskScript } from './ask-selection'; +import { handleAskSelection, isFromExtensionPage, isFromWebPage, lockLocalStorage, syncAskScript } from './ask-selection'; import { BADGE_ALARM, refreshPendingBadge } from './pending-badge'; import { authorizationFor, renewFor, signIn, signOut } from './token-manager'; -async function handle(message: unknown, senderId: string | undefined): Promise { - // Only this extension's own pages may talk to the worker. - if (senderId !== browser.runtime.id) return { ok: false, code: 'forbidden' }; +async function handle(message: unknown, sender: chrome.runtime.MessageSender): Promise { + // Only this extension's own pages may talk to the worker; a content script carries the same id but a web URL. + if (!isFromExtensionPage(sender)) return { ok: false, code: 'forbidden' }; const parsed = requestSchema.safeParse(message); if (!parsed.success) return { ok: false, code: 'invalid_request' }; const request = parsed.data; @@ -45,7 +45,7 @@ export function startServiceWorker(): void { // the person turns on "Ask Dewee" for selected text and grants site access. void chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }); // Remembered credentials live in `storage.local`; keep it out of reach of the opt-in page script. - void chrome.storage.local.setAccessLevel?.({ accessLevel: 'TRUSTED_CONTEXTS' }).catch(() => undefined); + void lockLocalStorage(); browser.runtime.onMessage.addListener((message, sender, sendResponse) => { // A web page's content script may only send `ask.selection`; it never reaches the auth handlers. @@ -53,7 +53,7 @@ export function startServiceWorker(): void { handleAskSelection(message, sender); return false; } - void handle(message, sender.id).then(sendResponse); + void handle(message, sender).then(sendResponse); return true; }); @@ -64,6 +64,8 @@ export function startServiceWorker(): void { }); browser.permissions.onAdded.addListener(syncAsk); browser.permissions.onRemoved.addListener(syncAsk); + // Learn the current state in a freshly started worker, so a stale page script is ignored. + syncAsk(); void browser.alarms.create(BADGE_ALARM, { periodInMinutes: 1 }); browser.alarms.onAlarm.addListener((alarm) => { diff --git a/src/protocol/ask-selection.ts b/src/protocol/ask-selection.ts index 8d5e65a..3f09dee 100644 --- a/src/protocol/ask-selection.ts +++ b/src/protocol/ask-selection.ts @@ -42,7 +42,7 @@ export const askDraftSchema = z.object({ }); export type AskDraft = z.infer; -const SECRET_PARAM = /pass(word)?|secret|token|key|auth|code|session|sig(nature)?|state|ticket|jwt/i; +const SECRET_PARAM = /pass(word)?|secret|token|key|auth|code|session|sig(nature)?|state|ticket|jwt|credential|^(t|s|k|sid|x-amz-.*|x-goog-.*)$/i; /** The page address without its fragment and without query values that could carry a credential. */ export function shareableUrl(raw: string): string { diff --git a/src/sidepanel/ChatShell.tsx b/src/sidepanel/ChatShell.tsx index 7f6042c..38a72af 100644 --- a/src/sidepanel/ChatShell.tsx +++ b/src/sidepanel/ChatShell.tsx @@ -322,7 +322,8 @@ export function ChatShell({ ref={composerRef} agentName={agentName} busy={busy} - draft={askDraft} + draft={askDraft.draft} + onDraftTaken={askDraft.taken} disabled={chat.state.phase === 'reconciling' || chat.state.phase === 'loading'} onSend={(text) => chat.send(text)} onStop={() => void chat.stop()} diff --git a/src/sidepanel/components/Composer.tsx b/src/sidepanel/components/Composer.tsx index 3bb9820..e6c7b8f 100644 --- a/src/sidepanel/components/Composer.tsx +++ b/src/sidepanel/components/Composer.tsx @@ -14,12 +14,14 @@ export const Composer = forwardRef< disabled?: boolean; /** Text to put in the box once per id (an "Ask Dewee" quote); never sent without the person pressing Send. */ draft?: { id: string; text: string } | null; + /** Called once the draft is in the box, so a remounted composer does not add it again. */ + onDraftTaken?(id: string): void; /** False when the send was refused locally and nothing left; the text is then kept. */ onSend(text: string): boolean | void; onStop(): void; } >( - function Composer({ agentName, busy, disabled, draft, onSend, onStop }, ref) { + function Composer({ agentName, busy, disabled, draft, onDraftTaken, onSend, onStop }, ref) { const { t } = useI18n(); const [text, setText] = useState(''); // A new draft goes above whatever is already typed, so nothing the person wrote is lost. @@ -42,10 +44,14 @@ export const Composer = forwardRef< // Put the caret after the quote so the person can type the question straight away. useLayoutEffect(() => { const el = inputRef.current; - if (!draftId || !el) return; + if (!draftId) return; + onDraftTaken?.(draftId); + if (!el) return; el.focus({ preventScroll: true }); el.setSelectionRange(el.value.length, el.value.length); el.scrollTop = el.scrollHeight; + // Only a new draft id should run this. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [draftId]); // Send and Stop are different buttons, and the box is disabled while a conversation loads; both diff --git a/src/sidepanel/use-ask-draft.ts b/src/sidepanel/use-ask-draft.ts index 67e691c..15fc852 100644 --- a/src/sidepanel/use-ask-draft.ts +++ b/src/sidepanel/use-ask-draft.ts @@ -3,11 +3,12 @@ * when the panel opens, and any that arrive while it is open. Each draft is used once, and a stale * one is dropped. */ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { browser } from 'wxt/browser'; import { ASK_DRAFT_KEY, ASK_DRAFT_TTL_MS, askDraftSchema, type AskDraft } from '@/protocol/ask-selection'; -export function useAskDraft(): AskDraft | null { +/** The waiting draft, and aken(id) to drop it once the composer holds it. */ +export function useAskDraft(): { draft: AskDraft | null; taken(id: string): void } { const [draft, setDraft] = useState(null); useEffect(() => { @@ -34,5 +35,6 @@ export function useAskDraft(): AskDraft | null { }; }, []); - return draft; + const taken = useCallback((id: string) => setDraft((current) => (current?.id === id ? null : current)), []); + return { draft, taken }; } \ No newline at end of file diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts index 1ac9d5e..8ce1aed 100644 --- a/tests/e2e/smoke.spec.ts +++ b/tests/e2e/smoke.spec.ts @@ -44,3 +44,21 @@ test('options page switches theme and language', async ({ openOptions }) => { await options.getByLabel('Language').selectOption('vi'); await expect(options.locator('html')).toHaveAttribute('lang', 'vi'); }); + +test('install asks for no site access, and Ask Dewee site access stays optional', async ({ serviceWorker }) => { + const manifest = await serviceWorker.evaluate(() => chrome.runtime.getManifest()); + expect(manifest.host_permissions ?? []).not.toContain('https://*/*'); + expect(manifest.optional_host_permissions).toContain('https://*/*'); +}); + +test('storage.local can be limited to extension pages before the Ask Dewee script runs', async ({ serviceWorker }) => { + const result = await serviceWorker.evaluate(async () => { + try { + await chrome.storage.local.setAccessLevel({ accessLevel: 'TRUSTED_CONTEXTS' }); + return 'ok'; + } catch (error) { + return String(error); + } + }); + expect(result).toBe('ok'); +}); diff --git a/tests/unit/ask/selection-button.test.ts b/tests/unit/ask/selection-button.test.ts index 9f13e6d..fdfe63b 100644 --- a/tests/unit/ask/selection-button.test.ts +++ b/tests/unit/ask/selection-button.test.ts @@ -11,6 +11,12 @@ function select(node: Node) { const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); +/** jsdom cannot make trusted events; a click with detail 1 stands in for one a person made. */ +const byPerson = (event: MouseEvent) => event.detail === 1; +function personClick(button: HTMLButtonElement) { + button.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 })); +} + describe('Ask Dewee button', () => { let mounted: ReturnType | undefined; let sent: AskSelection[]; @@ -20,10 +26,10 @@ describe('Ask Dewee button', () => { Range.prototype.getBoundingClientRect ??= () => new DOMRect(100, 100, 50, 20); }); beforeEach(() => { - document.body.innerHTML = '

Dewee runs agents.

draft
'; + document.body.innerHTML = '

Dewee runs agents.

draft
note
'; document.title = 'Docs'; sent = []; - mounted = mountAskButton(document, (message) => sent.push(message), 'Ask Dewee'); + mounted = mountAskButton(document, (message) => sent.push(message), 'Ask Dewee', byPerson); }); afterEach(() => { mounted?.dispose(); @@ -38,6 +44,8 @@ describe('Ask Dewee button', () => { expect(mounted!.button.textContent).toBe('Ask Dewee'); mounted!.button.click(); + expect(sent).toEqual([]); + personClick(mounted!.button); expect(sent).toEqual([{ type: 'ask.selection', text: 'Dewee runs agents.', url: document.location.href, title: 'Docs' }]); expect(mounted!.button.hidden).toBe(true); }); @@ -48,10 +56,20 @@ describe('Ask Dewee button', () => { document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); await flush(); expect(mounted!.button.hidden).toBe(true); - mounted!.button.click(); + personClick(mounted!.button); expect(sent).toEqual([]); }); + it('ignores editable areas in any case and text selected while a field has focus', () => { + select(document.getElementById('inner')!); + expect(selectedText(document)).toBeNull(); + + // Chrome anchors a selection inside an input at the field's parent; focus gives it away. + select(document.getElementById('text')!); + document.getElementById('field')!.focus(); + expect(selectedText(document)).toBeNull(); + }); + it('hides on Escape and when the selection is cleared', async () => { select(document.getElementById('text')!); document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); @@ -74,4 +92,17 @@ describe('Ask Dewee button', () => { mounted = undefined; expect(document.querySelector('dewee-ask')).toBeNull(); }); +}); +describe('Ask Dewee button defaults', () => { + it('ignores a click made by page script', async () => { + document.body.innerHTML = '

Dewee runs agents.

'; + const sent: AskSelection[] = []; + const mounted = mountAskButton(document, (message) => sent.push(message), 'Ask Dewee'); + select(document.getElementById('text')!); + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + await flush(); + mounted.button.click(); + expect(sent).toEqual([]); + mounted.dispose(); + }); }); \ No newline at end of file diff --git a/tests/unit/background/ask-selection.test.ts b/tests/unit/background/ask-selection.test.ts index 2546169..8bff286 100644 --- a/tests/unit/background/ask-selection.test.ts +++ b/tests/unit/background/ask-selection.test.ts @@ -1,10 +1,12 @@ import { fakeBrowser } from 'wxt/testing/fake-browser'; -import { handleAskSelection, isFromWebPage, syncAskScript } from '../../../src/background/ask-selection'; +import { handleAskSelection, isFromExtensionPage, isFromWebPage, syncAskScript } from '../../../src/background/ask-selection'; import { ASK_DRAFT_KEY } from '../../../src/protocol/ask-selection'; import { saveSettings, defaultSettings } from '../../../src/shared/settings'; const registered = new Set(); +const setAccessLevel = vi.fn(async () => {}); const chromeStub = { + storage: { local: { setAccessLevel } }, sidePanel: { open: vi.fn(async () => {}) }, scripting: { getRegisteredContentScripts: vi.fn(async ({ ids }: { ids: string[] }) => ids.filter((id) => registered.has(id)).map((id) => ({ id }))), @@ -36,6 +38,18 @@ describe('isFromWebPage', () => { expect(isFromWebPage(pageSender({ frameId: 2 }))).toBe(false); expect(isFromWebPage(pageSender({ tab: undefined }))).toBe(false); expect(isFromWebPage(pageSender({ url: fakeBrowser.runtime.getURL('/options.html') }))).toBe(false); + // Chrome's frame type wins over the id, which stays non-zero for an activated prerendered page. + expect(isFromWebPage(pageSender({ frameId: 5, frameType: 'outermost_frame' } as Partial))).toBe(true); + expect(isFromWebPage(pageSender({ frameId: 0, frameType: 'sub_frame' } as Partial))).toBe(false); + }); +}); + +describe('isFromExtensionPage', () => { + it('accepts only this extension’s own pages, never a content script in a web page or frame', () => { + expect(isFromExtensionPage({ id: fakeBrowser.runtime.id, url: fakeBrowser.runtime.getURL('/sidepanel.html') })).toBe(true); + expect(isFromExtensionPage(pageSender())).toBe(false); + expect(isFromExtensionPage(pageSender({ frameId: 3, url: 'https://ads.example.net/frame' }))).toBe(false); + expect(isFromExtensionPage({ id: 'other-extension', url: fakeBrowser.runtime.getURL('/sidepanel.html') })).toBe(false); }); }); @@ -67,6 +81,17 @@ describe('handleAskSelection', () => { }); describe('syncAskScript', () => { + // Runs first: once storage.local is locked, the module remembers it. + it('keeps the page script off while storage.local cannot be limited to extension pages', async () => { + vi.spyOn(fakeBrowser.permissions, 'contains').mockResolvedValue(true as never); + await setAsk(true); + setAccessLevel.mockRejectedValueOnce(new Error('unsupported')); + expect(await syncAskScript()).toBe(false); + expect(registered.size).toBe(0); + expect(await syncAskScript()).toBe(true); + expect(setAccessLevel).toHaveBeenCalledWith({ accessLevel: 'TRUSTED_CONTEXTS' }); + }); + it('registers the page script only when the preference is on and site access is granted', async () => { const contains = vi.spyOn(fakeBrowser.permissions, 'contains'); await setAsk(true); @@ -82,4 +107,14 @@ describe('syncAskScript', () => { expect(await syncAskScript()).toBe(false); expect(registered.size).toBe(0); }); +}); + +describe('a page script left over after the feature is turned off', () => { + it('opens no panel once the worker knows the script is off', async () => { + vi.spyOn(fakeBrowser.permissions, 'contains').mockResolvedValue(true as never); + await setAsk(false); + expect(await syncAskScript()).toBe(false); + expect(handleAskSelection(message, pageSender())).toBe(false); + expect(chromeStub.sidePanel.open).not.toHaveBeenCalled(); + }); }); \ No newline at end of file diff --git a/tests/unit/protocol/ask-selection.test.ts b/tests/unit/protocol/ask-selection.test.ts index 5dc28be..7530f4c 100644 --- a/tests/unit/protocol/ask-selection.test.ts +++ b/tests/unit/protocol/ask-selection.test.ts @@ -23,6 +23,10 @@ describe('ask.selection message', () => { }); describe('shareableUrl', () => { + it('drops short and signed-URL credential parameters', () => { + expect(shareableUrl('https://s3.example.com/f?X-Amz-Credential=a&X-Amz-Signature=b&t=c&sid=d&credential=e&lang=vi')).toBe('https://s3.example.com/f?lang=vi'); + }); + it('drops the fragment, credentials in the address and secret-looking query values', () => { expect(shareableUrl('https://ann:pw@example.com/p?q=dewee&token=abc&page=2&code=xyz#section')).toBe('https://example.com/p?q=dewee&page=2'); }); diff --git a/tests/unit/sidepanel/Composer.test.tsx b/tests/unit/sidepanel/Composer.test.tsx index 9017681..b2e8ea6 100644 --- a/tests/unit/sidepanel/Composer.test.tsx +++ b/tests/unit/sidepanel/Composer.test.tsx @@ -75,4 +75,10 @@ describe('Composer draft', () => { rerender( second\n\n' }} />); expect(input.value).toBe('> second\n\n> quote\n\nmy note'); }); + + it('reports the draft as taken, so a remounted composer does not add it again', () => { + const onDraftTaken = vi.fn(); + render( quote\n\n' }} onDraftTaken={onDraftTaken} />); + expect(onDraftTaken).toHaveBeenCalledWith('d9'); + }); }); \ No newline at end of file diff --git a/tests/unit/sidepanel/use-ask-draft.test.ts b/tests/unit/sidepanel/use-ask-draft.test.ts index adb03f0..71238b0 100644 --- a/tests/unit/sidepanel/use-ask-draft.test.ts +++ b/tests/unit/sidepanel/use-ask-draft.test.ts @@ -13,21 +13,21 @@ describe('useAskDraft', () => { it('takes a waiting draft once and removes it from session storage', async () => { await fakeBrowser.storage.session.set({ [ASK_DRAFT_KEY]: draft() }); const { result } = renderHook(() => useAskDraft()); - await waitFor(() => expect(result.current?.id).toBe('d1')); + await waitFor(() => expect(result.current.draft?.id).toBe('d1')); expect(await fakeBrowser.storage.session.get(ASK_DRAFT_KEY)).toEqual({}); }); it('takes a draft that arrives while the panel is open', async () => { const { result } = renderHook(() => useAskDraft()); await act(() => fakeBrowser.storage.session.set({ [ASK_DRAFT_KEY]: draft({ id: 'd2' }) })); - await waitFor(() => expect(result.current?.id).toBe('d2')); + await waitFor(() => expect(result.current.draft?.id).toBe('d2')); }); it('leaves a draft for another window alone', async () => { await fakeBrowser.storage.session.set({ [ASK_DRAFT_KEY]: draft({ windowId: 9 }) }); const { result } = renderHook(() => useAskDraft()); await new Promise((resolve) => setTimeout(resolve, 20)); - expect(result.current).toBeNull(); + expect(result.current.draft).toBeNull(); expect((await fakeBrowser.storage.session.get(ASK_DRAFT_KEY))[ASK_DRAFT_KEY]).toBeDefined(); }); @@ -35,10 +35,20 @@ describe('useAskDraft', () => { await fakeBrowser.storage.session.set({ [ASK_DRAFT_KEY]: draft({ createdAt: Date.now() - ASK_DRAFT_TTL_MS - 1000 }) }); const { result } = renderHook(() => useAskDraft()); await waitFor(async () => expect(await fakeBrowser.storage.session.get(ASK_DRAFT_KEY)).toEqual({})); - expect(result.current).toBeNull(); + expect(result.current.draft).toBeNull(); await act(() => fakeBrowser.storage.session.set({ [ASK_DRAFT_KEY]: { text: 'no id' } })); await waitFor(async () => expect(await fakeBrowser.storage.session.get(ASK_DRAFT_KEY)).toEqual({})); - expect(result.current).toBeNull(); + expect(result.current.draft).toBeNull(); + }); + + it('forgets a draft once the composer has taken it', async () => { + await fakeBrowser.storage.session.set({ [ASK_DRAFT_KEY]: draft({ id: 'd3' }) }); + const { result } = renderHook(() => useAskDraft()); + await waitFor(() => expect(result.current.draft?.id).toBe('d3')); + act(() => result.current.taken('other')); + expect(result.current.draft?.id).toBe('d3'); + act(() => result.current.taken('d3')); + expect(result.current.draft).toBeNull(); }); }); \ No newline at end of file From 9d5c4710dc6164044156d711062dbacb2cdcd21b Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Tue, 13 Oct 2026 22:56:53 +0700 Subject: [PATCH 3/3] fix: register Ask Dewee per browser session and follow focus into web components Restored tabs no longer run the page script before the worker limits storage.local again after a browser restart, and text selected while a field inside a web component has focus is ignored. Two comments lost characters to shell escapes and are repaired. Refs #14 Co-Authored-By: Claude Opus 5.5 --- src/ask/selection-button.ts | 6 ++++-- src/background/ask-selection.ts | 3 ++- src/sidepanel/use-ask-draft.ts | 2 +- tests/unit/background/ask-selection.test.ts | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/ask/selection-button.ts b/src/ask/selection-button.ts index 9f4b046..f7a5fdd 100644 --- a/src/ask/selection-button.ts +++ b/src/ask/selection-button.ts @@ -31,7 +31,9 @@ function inEditable(node: Node | null): boolean { /** Chrome reports text selected inside an input or text area with the field's parent as anchor, so check focus too. */ function focusInField(doc: Document): boolean { - const active = doc.activeElement; + // Follow focus into web components, whose host element is all the document reports. + let active = doc.activeElement; + while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement; return Boolean(active && (active.matches(FIELD) || (active as HTMLElement).isContentEditable)); } @@ -45,7 +47,7 @@ export function selectedText(doc: Document): { text: string; rect: DOMRect } | n } /** - * Mounts the hidden button and its listeners; dispose removes both. yPerson decides whether a click + * Mounts the hidden button and its listeners; dispose removes both. `byPerson` decides whether a click * came from the person rather than page script (tests replace it, since they cannot make trusted events). */ export function mountAskButton( diff --git a/src/background/ask-selection.ts b/src/background/ask-selection.ts index 43ecfd0..a2b87c0 100644 --- a/src/background/ask-selection.ts +++ b/src/background/ask-selection.ts @@ -36,8 +36,9 @@ export function syncAskScript(): Promise { const [settings, allowed, locked] = await Promise.all([loadSettings(), browser.permissions.contains({ origins: ASK_ORIGINS }), lockLocalStorage()]); const wanted = settings.prefs.askOnSelection && allowed && locked; const registered = (await chrome.scripting.getRegisteredContentScripts({ ids: [ASK_SCRIPT_ID] })).length > 0; + // Not kept across browser restarts: restored tabs must not run the script before the worker locks storage.local again. if (wanted && !registered) { - await chrome.scripting.registerContentScripts([{ id: ASK_SCRIPT_ID, js: [ASK_SCRIPT_FILE], matches: ASK_ORIGINS, runAt: 'document_idle', allFrames: false, persistAcrossSessions: true }]); + await chrome.scripting.registerContentScripts([{ id: ASK_SCRIPT_ID, js: [ASK_SCRIPT_FILE], matches: ASK_ORIGINS, runAt: 'document_idle', allFrames: false, persistAcrossSessions: false }]); } else if (!wanted && registered) { await chrome.scripting.unregisterContentScripts({ ids: [ASK_SCRIPT_ID] }); } diff --git a/src/sidepanel/use-ask-draft.ts b/src/sidepanel/use-ask-draft.ts index 15fc852..7b8464d 100644 --- a/src/sidepanel/use-ask-draft.ts +++ b/src/sidepanel/use-ask-draft.ts @@ -7,7 +7,7 @@ import { useCallback, useEffect, useState } from 'react'; import { browser } from 'wxt/browser'; import { ASK_DRAFT_KEY, ASK_DRAFT_TTL_MS, askDraftSchema, type AskDraft } from '@/protocol/ask-selection'; -/** The waiting draft, and aken(id) to drop it once the composer holds it. */ +/** The waiting draft, and `taken(id)` to drop it once the composer holds it. */ export function useAskDraft(): { draft: AskDraft | null; taken(id: string): void } { const [draft, setDraft] = useState(null); diff --git a/tests/unit/background/ask-selection.test.ts b/tests/unit/background/ask-selection.test.ts index 8bff286..944326a 100644 --- a/tests/unit/background/ask-selection.test.ts +++ b/tests/unit/background/ask-selection.test.ts @@ -101,7 +101,7 @@ describe('syncAskScript', () => { contains.mockResolvedValue(true as never); expect(await syncAskScript()).toBe(true); - expect(chromeStub.scripting.registerContentScripts).toHaveBeenCalledWith([expect.objectContaining({ id: 'ask-dewee', matches: ['https://*/*'], allFrames: false })]); + expect(chromeStub.scripting.registerContentScripts).toHaveBeenCalledWith([expect.objectContaining({ id: 'ask-dewee', matches: ['https://*/*'], allFrames: false, persistAcrossSessions: false })]); await setAsk(false); expect(await syncAskScript()).toBe(false);