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..0775944 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 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. - 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..f7a5fdd --- /dev/null +++ b/src/ask/selection-button.ts @@ -0,0 +1,131 @@ +/** + * 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 = + ''; + +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 && (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 { + // 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)); +} + +export function selectedText(doc: Document): { text: string; rect: DOMRect } | null { + const selection = doc.getSelection(); + if (!selection || selection.isCollapsed || selection.rangeCount === 0) 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. `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( + 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) { + // 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()); + // 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(); + }); + 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..a2b87c0 --- /dev/null +++ b/src/background/ask-selection.ts @@ -0,0 +1,84 @@ +/** + * 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(); +/** 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, 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: false }]); + } else if (!wanted && registered) { + await chrome.scripting.unregisterContentScripts({ ids: [ASK_SCRIPT_ID] }); + } + active = wanted; + return wanted; + }); + syncing = run.catch(() => undefined); + return run; +} + +/** 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.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; +} + +/** + * 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); + // 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 () => { + // 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 2978119..b1bc1af 100644 --- a/src/background/service-worker.ts +++ b/src/background/service-worker.ts @@ -5,12 +5,14 @@ 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, 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; @@ -39,18 +41,42 @@ async function handle(message: unknown, senderId: string | undefined): Promise { - void handle(message, sender.id).then(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).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); + // 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) => { 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..3f09dee --- /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|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 { + 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..38a72af 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,8 @@ export function ChatShell({ ref={composerRef} agentName={agentName} busy={busy} + 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 1c94fe5..e6c7b8f 100644 --- a/src/sidepanel/components/Composer.tsx +++ b/src/sidepanel/components/Composer.tsx @@ -6,10 +6,30 @@ 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; + /** 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, 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. + 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 +41,19 @@ export const Composer = forwardRef { + const el = inputRef.current; + 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 // 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..7b8464d --- /dev/null +++ b/src/sidepanel/use-ask-draft.ts @@ -0,0 +1,40 @@ +/** + * 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 { 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 `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); + + 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); + }; + }, []); + + 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 new file mode 100644 index 0000000..fdfe63b --- /dev/null +++ b/tests/unit/ask/selection-button.test.ts @@ -0,0 +1,108 @@ +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)); + +/** 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[]; + + beforeAll(() => { + // jsdom has no layout. + Range.prototype.getBoundingClientRect ??= () => new DOMRect(100, 100, 50, 20); + }); + beforeEach(() => { + document.body.innerHTML = '

Dewee runs agents.

draft
note
'; + document.title = 'Docs'; + sent = []; + mounted = mountAskButton(document, (message) => sent.push(message), 'Ask Dewee', byPerson); + }); + 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([]); + 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); + }); + + 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); + 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 })); + 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(); + }); +}); +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 new file mode 100644 index 0000000..944326a --- /dev/null +++ b/tests/unit/background/ask-selection.test.ts @@ -0,0 +1,120 @@ +import { fakeBrowser } from 'wxt/testing/fake-browser'; +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 }))), + 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); + // 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); + }); +}); + +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', () => { + // 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); + 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, persistAcrossSessions: false })]); + + await setAsk(false); + 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 new file mode 100644 index 0000000..7530f4c --- /dev/null +++ b/tests/unit/protocol/ask-selection.test.ts @@ -0,0 +1,51 @@ +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 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'); + }); + + 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..b2e8ea6 100644 --- a/tests/unit/sidepanel/Composer.test.tsx +++ b/tests/unit/sidepanel/Composer.test.tsx @@ -52,4 +52,33 @@ 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'); + }); + + 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 new file mode 100644 index 0000000..71238b0 --- /dev/null +++ b/tests/unit/sidepanel/use-ask-draft.test.ts @@ -0,0 +1,54 @@ +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.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.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.draft).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.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.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 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' },