diff --git a/AGENTS.md b/AGENTS.md index 4ee9dd03..c773955d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,8 @@ reconnect/reselect/save, never automatic retry. Metadata grants no autonomy or d Full Task8 remains open. Combined assembled migration-stack acceptance remains separate from this source-bound producer fixture. +Local browser (`#/browser-authorization`): `packages/chrome-extension` pairs with a main-owned loopback host by one-time code; the user shares tabs one by one and the agent gets `browser_tabs`/`browser_read` for those only. See `docs/browser-connector.md`. + Shell/settings: the Cortex sidebar adds a Workspace group (Space, Scheduled tasks, Planning, Local browser, Extensions and skills) and the signed-in account's first Bot; remote and local chats share one Recents list. Signed-in Profile reads `/v1/me` (`app.me`); the rail shows account initials. Settings links Models, diff --git a/docs/browser-connector.md b/docs/browser-connector.md new file mode 100644 index 00000000..00342aef --- /dev/null +++ b/docs/browser-connector.md @@ -0,0 +1,15 @@ +# Chrome connector + +`packages/chrome-extension` (Manifest V3, load unpacked from the folder the **Install extension** button opens) talks to the +desktop app over a loopback HTTP endpoint (`127.0.0.1`, ports 47821-47825) owned by `packages/desktop/src/browser-host.ts`. +Chrome native messaging was not used: it needs a per-OS manifest registration and an extension ID that only exists after store publishing. + +- **Pairing**: the page asks main for a one-time 8-character code (5 min, burns after 5 wrong guesses). The extension posts it to + `/pair` and receives a random bearer token kept in `chrome.storage.session`. Pairing again invalidates the old token. +- **Origin checks**: requests need a `chrome-extension://` Origin and a literal `127.0.0.1:` Host; web pages are refused. +- **Consent**: the user shares the active tab from the extension popup (`activeTab` only; no cookies, history or host permissions beyond loopback). + Only `http(s)` pages without embedded credentials can be shared. A shared tab that navigates elsewhere loses consent. Either side can revoke. +- **Agent tools**: `browser_tabs` and `browser_read` (`packages/core/src/tool.ts`) are offered only while a tab is shared; reads of any other tab are refused in `BrowserBridge` before reaching the browser and ask for permission each time. +- State is memory-only: restarting the app drops pairing and consents. The renderer sees status only, never page text. + +Not done: Chrome Web Store publishing (the extension is loaded unpacked) and Firefox/Safari. diff --git a/electron-builder.yml b/electron-builder.yml index 9288e98a..f16a1562 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -18,6 +18,9 @@ files: extraResources: - from: skills to: skills + - from: packages/chrome-extension + to: chrome-extension + filter: ["**/*", "!**/*.d.ts"] - from: packages/i18n/locales to: locales filter: ["**/*.json", "!**/*.source.json", "!**/fixtures/**"] diff --git a/eslint.config.mjs b/eslint.config.mjs index 358850a9..e2c63591 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,6 +17,7 @@ export default tseslint.config( }, }, { files: ["**/test/**", "tests/**"], rules: { "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-unused-expressions": "off" } }, + { files: ["packages/chrome-extension/**/*.js"], languageOptions: { globals: { chrome: "readonly", document: "readonly", AbortSignal: "readonly" } } }, { files: ["scripts/dev-smoke.mjs"], languageOptions: { globals: { document: "readonly" } } }, { files: ["scripts/smoke.mjs"], languageOptions: { globals: { WebSocket: "readonly" } } }, { files: ["tests/e2e/**"], rules: { "react-hooks/rules-of-hooks": "off", "no-empty-pattern": "off" } }, diff --git a/evidence/browser-connector/connected-dark.png b/evidence/browser-connector/connected-dark.png new file mode 100644 index 00000000..581fa74e Binary files /dev/null and b/evidence/browser-connector/connected-dark.png differ diff --git a/evidence/browser-connector/connected-light.png b/evidence/browser-connector/connected-light.png new file mode 100644 index 00000000..0d270205 Binary files /dev/null and b/evidence/browser-connector/connected-light.png differ diff --git a/evidence/browser-connector/empty-dark.png b/evidence/browser-connector/empty-dark.png new file mode 100644 index 00000000..540235b0 Binary files /dev/null and b/evidence/browser-connector/empty-dark.png differ diff --git a/evidence/browser-connector/empty-light.png b/evidence/browser-connector/empty-light.png new file mode 100644 index 00000000..61618fd9 Binary files /dev/null and b/evidence/browser-connector/empty-light.png differ diff --git a/evidence/browser-connector/pairing-dark.png b/evidence/browser-connector/pairing-dark.png new file mode 100644 index 00000000..1a7b41db Binary files /dev/null and b/evidence/browser-connector/pairing-dark.png differ diff --git a/evidence/browser-connector/pairing-light.png b/evidence/browser-connector/pairing-light.png new file mode 100644 index 00000000..3b051c2a Binary files /dev/null and b/evidence/browser-connector/pairing-light.png differ diff --git a/packages/app/src/api.ts b/packages/app/src/api.ts index cf263d49..0e149023 100644 --- a/packages/app/src/api.ts +++ b/packages/app/src/api.ts @@ -16,6 +16,18 @@ type Bridge = { onMenu?: (cb: (cmd: string) => void) => () => void; update?: { status: () => Promise; check: () => Promise; install: () => Promise; onState: (cb: (s: UpdateState) => void) => () => void }; call?: CallBridge; + browser?: BrowserBridgeApi; +}; +export type BrowserTab = { id: number; title: string; url: string; sharedAt: number }; +export type BrowserState = { listening: boolean; port?: number; connected: boolean; pairing?: { code: string; expiresAt: number }; tabs: BrowserTab[] }; +export type BrowserBridgeApi = { + status(): Promise; + pair(): Promise<{ code: string; expiresAt: number }>; + revoke(tabId: number): Promise; + disconnect(): Promise; + extensionDir(): Promise; + revealExtension(): Promise; + onChange(cb: (s: BrowserState) => void): () => void; }; export type CallSnapshot = { phase: "connecting" | "listening" | "hearing" | "thinking" | "speaking" | "reconnecting" | "ended" | "error"; muted: boolean; heard: readonly string[]; partial: string; streaming: boolean; end?: "ended" | "auth_revoked" | "superseded" | "unavailable" | "busy" | "failed" | "mic_lost" }; export type CallBridge = { diff --git a/packages/app/src/screens/extras/browser.tsx b/packages/app/src/screens/extras/browser.tsx new file mode 100644 index 00000000..416b075b --- /dev/null +++ b/packages/app/src/screens/extras/browser.tsx @@ -0,0 +1,71 @@ +// Local browser: pair the Cortex Chrome extension and manage the tabs the user shared. Page text never reaches this screen. +import * as React from "react"; +import { bridge, type BrowserState } from "../../api"; +import { useT } from "../../i18n"; +import { Icon } from "../../kit/ui"; +import { Page, Empty } from "./extras"; + +const R = "workspace-extras"; +const PAIRING_MINUTES = 5; +const EMPTY: BrowserState = { listening: false, connected: false, tabs: [] }; + +/** Live bridge state: initial read, pushes from main, and a refresh when a pairing code expires. */ +export function useBrowserState() { + const api = bridge()?.browser; + const [state, setState] = React.useState(api ? undefined : EMPTY); + React.useEffect(() => { + if (!api) return; + let live = true; + void api.status().then(s => live && setState(s), () => live && setState(EMPTY)); + const off = api.onChange(s => live && setState(s)); + // The extension's long poll keeps `connected` fresh; this catches silence after it goes away. + const tick = setInterval(() => void api.status().then(s => live && setState(s), () => {}), 15_000); + return () => { live = false; off(); clearInterval(tick); }; + }, [api]); + const expiresAt = state?.pairing?.expiresAt; + React.useEffect(() => { + if (!api || !expiresAt) return; + const timer = setTimeout(() => void api.status().then(setState, () => {}), Math.max(0, expiresAt - Date.now()) + 50); + return () => clearTimeout(timer); + }, [api, expiresAt]); + return { state, api }; +} + +export function Browser() { + const t = useT(), { state, api } = useBrowserState(); + const [copied, setCopied] = React.useState(false); + const mode = !state ? "loading" : !state.listening ? "noHost" : state.connected ? "connected" : state.pairing ? "waiting" : "off"; + const badge = mode === "connected" ? "ok" : mode === "waiting" ? "wait" : mode === "noHost" ? "err" : ""; + const code = state?.pairing?.code; + const copy = async () => { if (!code) return; try { await navigator.clipboard.writeText(code); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { /* clipboard blocked: the code stays visible */ } }; + return +

{t("extras.browser.h1")}

{t("extras.browser.lead")}

+ {state &&
+
+ +

{t(`extras.browser.status.${mode === "waiting" ? "waiting" : mode === "connected" ? "connected" : mode === "noHost" ? "noHost" : "off"}`)}

+

{t(mode === "connected" ? "extras.browser.connectedBody" : mode === "noHost" ? "extras.browser.noHostBody" : "extras.browser.offBody")}

+ {t(`extras.browser.status.${mode === "waiting" ? "waiting" : mode === "connected" ? "connected" : mode === "noHost" ? "noHost" : "off"}`)} +
+ {mode === "connected" &&
} + {(mode === "off" || mode === "waiting") &&
+
1

{t("extras.browser.install")}

{t("extras.browser.installHelp")}

+
+
2

{t("extras.browser.pairCode")}

+ {code ? <>

{code.slice(0, 4)}{code.slice(4)}

{t("extras.browser.pairHelp", { minutes: PAIRING_MINUTES })}

:

{t("extras.browser.offBody")}

}
+
+ {code && } +
+
} +
} + {state && mode !== "noHost" &&
+

{t("extras.browser.tabs")}

+ {state.tabs.length ?
    {state.tabs.map(tab =>
  • + +
    {tab.title || tab.url}{tab.url}
    +
  • )}
+ : {t(mode === "connected" ? "extras.browser.emptyBody" : "extras.browser.offBody")}} +
} +

{t("extras.browser.privacy")}

+
; +} diff --git a/packages/app/src/screens/extras/extras.css b/packages/app/src/screens/extras/extras.css index 03e71417..9d6e4076 100644 --- a/packages/app/src/screens/extras/extras.css +++ b/packages/app/src/screens/extras/extras.css @@ -162,3 +162,27 @@ .workspace-extras .workspace-extras-matrix .input { min-height: 40px; } .workspace-extras-people > li { flex-wrap: wrap; } } + +/* Local browser: connection card, pairing steps and shared tabs (extends the design lot-workspace-extras tokens). */ +.workspace-extras-conn { border: 1px solid var(--line); border-radius: var(--r-card); background: var(--card); margin: 18px 0 24px; overflow: hidden; } +.workspace-extras-conn-head { display: flex; align-items: center; gap: 14px; padding: 18px; } +.workspace-extras-conn-head h2, .workspace-extras-conn-head p { margin: 0; } +.workspace-extras-conn-head p { color: var(--t2); font-size: 12px; } +.workspace-extras-conn-icon, .workspace-extras-tab-icon { width: 36px; height: 36px; flex-shrink: 0; display: grid; place-items: center; border-radius: var(--r-ctl); background: var(--sidebar-bg); color: var(--i1); } +.workspace-extras-conn[data-mode="connected"] .workspace-extras-conn-icon { color: var(--green); } +.workspace-extras-conn > .workspace-extras-actions { margin: 0; padding: 0 18px 18px; } +.workspace-extras-conn-steps { border-top: 1px solid var(--line); } +.workspace-extras-conn-step { display: flex; align-items: center; gap: 14px; padding: 16px 18px; } +.workspace-extras-conn-step + .workspace-extras-conn-step { border-top: 1px solid var(--line); } +.workspace-extras-conn-step p { margin: 0; color: var(--t2); font-size: 12px; } +.workspace-extras-conn-buttons { display: flex; gap: 8px; flex-shrink: 0; } +.workspace-extras-step-n { width: 22px; height: 22px; flex-shrink: 0; display: grid; place-items: center; border-radius: 50%; border: 1px solid var(--line); color: var(--t2); font-size: 11px; } +.workspace-extras .workspace-extras-pair-code { font-family: var(--mono); font-size: 24px; line-height: 32px; letter-spacing: .14em; color: var(--t1); font-weight: 500; margin: 2px 0; } +.workspace-extras-pair-code span { color: var(--t2); margin-inline: .1em; } +.workspace-extras-tabs-list { list-style: none; margin: 0 0 16px; padding: 0; border-block: 1px solid var(--line); } +.workspace-extras-tabs-list li { display: flex; align-items: center; gap: 12px; padding: 12px 0; } +.workspace-extras-tabs-list li + li { border-top: 1px solid var(--line); } +.workspace-extras-tabs-list strong, .workspace-extras-tab-url { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.workspace-extras-tab-url { font-size: 11px; } +.workspace-extras-footnote > svg { vertical-align: -2px; } +@media (max-width: 720px) { .workspace-extras-conn-step { flex-wrap: wrap; } .workspace-extras-conn-buttons { width: 100%; } } diff --git a/packages/app/src/screens/extras/extras.tsx b/packages/app/src/screens/extras/extras.tsx index 7ba6ff8d..cce1cae3 100644 --- a/packages/app/src/screens/extras/extras.tsx +++ b/packages/app/src/screens/extras/extras.tsx @@ -1,6 +1,6 @@ // Workspace extras (design lot-workspace-extras.tsx) on the real trunk routes through the main-process contract -// transport: /v1/task-plans, /v1/approval-policy and /v1/orgs. Widgets and the local browser operator have no producer -// route, so they show their honest unavailable state (as the web app does). +// transport: /v1/task-plans, /v1/approval-policy and /v1/orgs. Widgets have no producer route, so they show their +// honest unavailable state (as the web app does). The local browser screen lives in browser.tsx. import * as React from "react"; import { Dialog } from "@base-ui/react/dialog"; import type { ContractCall } from "@cortex/schema"; @@ -34,7 +34,7 @@ function useOwner() { : connection.state !== "ready" || owner.state !== "ready" ? { state: "loading" as const } : owner.data ? { state: "ready" as const, epoch: owner.data } : { state: "local" as const }; } -function Page({ title, back, action, children }: { title: string; back?: string; action?: React.ReactNode; children: React.ReactNode }) { +export function Page({ title, back, action, children }: { title: string; back?: string; action?: React.ReactNode; children: React.ReactNode }) { const t = useT(), { go } = useNav(); return
{back && go(back)} />}{title}
{action} }>{LINKS.map(([route, k, icon]) => go(route)}>{t(`extras.link.${k}`)})}
@@ -43,7 +43,7 @@ function Page({ title, back, action, children }: { title: string; back?: string; function Notice({ children, error }: { children: React.ReactNode; error?: boolean }) { return
{children}
; } -function Empty({ title, icon = "history", action, children }: { title: string; icon?: string; action?: React.ReactNode; children: React.ReactNode }) { +export function Empty({ title, icon = "history", action, children }: { title: string; icon?: string; action?: React.ReactNode; children: React.ReactNode }) { return

{title}

{children}

{action}
; } function Skeleton() { @@ -90,15 +90,6 @@ export function Widgets() { ; } -export function Browser() { - const t = useT(); - return -

{t("extras.browser.h1")}

{t("extras.browser.lead")}

- {t("extras.browser.notice")} - {t("extras.browser.unavailable")}}>{t("extras.browser.emptyBody")} -
; -} - export function Planning() { const t = useT(), { go } = useNav(); const [creating, setCreating] = React.useState(false), owner = useOwner(); diff --git a/packages/app/src/screens/extras/index.tsx b/packages/app/src/screens/extras/index.tsx index e3507c39..894b6dca 100644 --- a/packages/app/src/screens/extras/index.tsx +++ b/packages/app/src/screens/extras/index.tsx @@ -1,5 +1,6 @@ import type { ScreenDef } from "../../registry"; -import { Widgets, Browser, Planning, PlanDetail, Organisation, Approvals, WhatsNew } from "./extras"; +import { Browser } from "./browser"; +import { Widgets, Planning, PlanDetail, Organisation, Approvals, WhatsNew } from "./extras"; const G = "extras.group"; // Design lot-workspace-extras.tsx. Live screens only: no preview fixtures, so the gallery shows the signed-out state. diff --git a/packages/chrome-extension/background.js b/packages/chrome-extension/background.js new file mode 100644 index 00000000..08fea203 --- /dev/null +++ b/packages/chrome-extension/background.js @@ -0,0 +1,111 @@ +// Service worker: pairs with the Cortex desktop app, keeps the list of tabs the user shared, answers read requests. +// It never touches cookies, storage of pages or credentials: a read returns title, URL and visible text of a shared tab only. +import { PORTS, pairOnPorts, normalizeCode, shareableUrl, consentAdd, consentRemove, consentHas, consentValid, clipText } from "./lib.js"; + +const store = chrome.storage.session; +let looping = false; + +const load = async () => ({ token: null, port: null, shared: {}, ...(await store.get(["token", "port", "shared"])) }); +const origin = (port) => `http://127.0.0.1:${port}`; + +async function call(path, { method = "POST", body, signal } = {}) { + const { token, port } = await load(); + if (!token || !port) throw new Error("not_paired"); + const res = await fetch(origin(port) + path, { method, signal, headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) }); + if (res.status === 401) { await forget(); throw new Error("not_paired"); } + if (!res.ok) throw new Error(`http_${res.status}`); + return res.status === 204 ? null : res.json(); +} +const forget = () => store.set({ token: null, port: null, shared: {} }); + +async function pair(input) { + const code = normalizeCode(input); + if (!code) throw new Error("bad_code"); + const { token, port } = await pairOnPorts(code, PORTS); + await store.set({ token, port, shared: {} }); + void loop(); +} + +async function share(tab) { + if (!tab || !shareableUrl(tab.url)) throw new Error("not_shareable"); + const { shared } = await load(); + await call("/share", { body: { id: tab.id, title: tab.title ?? "", url: tab.url } }); + await store.set({ shared: consentAdd(shared, tab) }); +} + +async function stop(tabId, notify = true) { + const { shared } = await load(); + if (!consentHas(shared, tabId)) return; + await store.set({ shared: consentRemove(shared, tabId) }); + if (notify) await call("/revoke", { body: { tabId } }).catch(() => {}); +} + +async function read(tabId) { + const { shared } = await load(); + const tab = await chrome.tabs.get(tabId).catch(() => null); + if (!tab || !consentValid(shared, tab)) throw new Error("tab_not_shared"); + // activeTab grants this script only on the tab the user shared, and only until it navigates. + const [{ result }] = await chrome.scripting.executeScript({ target: { tabId }, func: () => document.body?.innerText ?? "" }); + return { title: tab.title ?? "", url: tab.url, text: clipText(result) }; +} + +async function handle(cmd) { + if (cmd.type === "revoke") return stop(cmd.tabId, false); + if (cmd.type !== "read") return; + try { await call("/result", { body: { id: cmd.id, ok: true, ...(await read(cmd.tabId)) } }); } + catch (e) { + if (e.message === "tab_not_shared") await stop(cmd.tabId, false); + await call("/result", { body: { id: cmd.id, ok: false, error: e.message } }).catch(() => {}); + } +} + +async function loop() { + if (looping) return; + looping = true; + try { + for (;;) { + const { token } = await load(); + if (!token) return; + try { + const { commands } = await call("/poll", { method: "GET", signal: AbortSignal.timeout(30_000) }); + for (const c of commands) await handle(c); + } catch (e) { + if (e.message === "not_paired") return; + await new Promise((r) => setTimeout(r, 3000)); + } + } + } finally { looping = false; } +} + +async function disconnect() { + await call("/disconnect", { body: {} }).catch(() => {}); + await forget(); +} + +async function state() { + const { token, shared } = await load(); + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + return { + paired: !!token, + shared: Object.entries(shared).map(([id, t]) => ({ id: Number(id), ...t })), + current: tab ? { id: tab.id, title: tab.title ?? "", url: tab.url ?? "", shareable: shareableUrl(tab.url), shared: consentHas(shared, tab.id) } : null, + }; +} + +chrome.runtime.onMessage.addListener((msg, sender, reply) => { + if (sender.id !== chrome.runtime.id) return false; + (async () => { + if (msg.type === "pair") await pair(msg.code); + else if (msg.type === "share") await share((await chrome.tabs.query({ active: true, currentWindow: true }))[0]); + else if (msg.type === "stop") await stop(msg.tabId); + else if (msg.type === "disconnect") await disconnect(); + return state(); + })().then((s) => reply({ ok: true, state: s }), (e) => reply({ ok: false, error: e.message })); + return true; +}); + +chrome.tabs.onRemoved.addListener((tabId) => void stop(tabId)); +chrome.alarms.create("poll", { periodInMinutes: 0.5 }); +chrome.alarms.onAlarm.addListener(() => void loop()); +chrome.runtime.onStartup.addListener(() => void loop()); +void loop(); diff --git a/packages/chrome-extension/lib.d.ts b/packages/chrome-extension/lib.d.ts new file mode 100644 index 00000000..eec54f93 --- /dev/null +++ b/packages/chrome-extension/lib.d.ts @@ -0,0 +1,11 @@ +export const PORTS: number[]; +export const MAX_TEXT: number; +export type Consent = Record; +export function shareableUrl(url: string): boolean; +export function normalizeCode(input: unknown): string | null; +export function consentAdd(state: Consent, tab: { id: number; title?: string; url: string }): Consent; +export function consentRemove(state: Consent, tabId: number): Consent; +export function consentHas(state: Consent, tabId: number): boolean; +export function consentValid(state: Consent, tab: { id: number; url: string }): boolean; +export function clipText(text: unknown): string; +export function pairOnPorts(code: string, ports: number[], fetchFn?: typeof fetch, timeoutMs?: number): Promise<{ token: string; port: number }>; diff --git a/packages/chrome-extension/lib.js b/packages/chrome-extension/lib.js new file mode 100644 index 00000000..b89b8879 --- /dev/null +++ b/packages/chrome-extension/lib.js @@ -0,0 +1,50 @@ +// Pure helpers shared by the service worker and the popup (and unit-tested from the desktop repo). +export const PORTS = [47821, 47822, 47823, 47824, 47825]; +export const MAX_TEXT = 50_000; +const CODE_ALPHABET = /^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{8}$/; + +/** Only plain web pages can be shared: never chrome://, file:, extension pages or URLs with embedded credentials. */ +export function shareableUrl(url) { + try { + const u = new URL(url); + return (u.protocol === "https:" || u.protocol === "http:") && !u.username && !u.password; + } catch { + return false; + } +} + +/** "abcd-2345" -> "ABCD2345"; null when it cannot be a pairing code. */ +export function normalizeCode(input) { + const code = String(input ?? "").replace(/[\s-]/g, "").toUpperCase(); + return CODE_ALPHABET.test(code) ? code : null; +} + +/** Consent state is a plain object keyed by tab id: { [tabId]: { title, url } }. Every function returns a new object. */ +export const consentAdd = (state, tab) => shareableUrl(tab.url) ? { ...state, [tab.id]: { title: String(tab.title ?? "").slice(0, 300), url: tab.url } } : state; +export const consentRemove = (state, tabId) => { const rest = { ...state }; delete rest[tabId]; return rest; }; +export const consentHas = (state, tabId) => Object.hasOwn(state, tabId); +/** A shared tab that navigated elsewhere is no longer the page the user agreed to share. */ +export const consentValid = (state, tab) => consentHas(state, tab.id) && state[tab.id].url === tab.url; + +/** + * Tries each port in turn. Only a 403 whose JSON body says `permission_denied` is Cortex refusing the code; + * any other 403 (another local service on that port) moves on. Returns { token, port }. + */ +export async function pairOnPorts(code, ports, fetchFn = fetch, timeoutMs = 3000) { + for (const port of ports) { + // One deadline covers the request and the body read, so a silent service cannot block the later ports. + const signal = AbortSignal.timeout(timeoutMs); + try { + const res = await fetchFn(`http://127.0.0.1:${port}/pair`, { method: "POST", signal, headers: { "content-type": "application/json" }, body: JSON.stringify({ code }) }); + const body = await res.json().catch(() => null); + if (res.status === 403 && body?.error === "permission_denied") throw new Error("wrong_code"); + // Anything but a well-formed Cortex answer means another service owns this port. + if (res.ok && typeof body?.token === "string" && body.token) return { token: body.token, port }; + } catch (e) { + if (e.message === "wrong_code") throw e; + } + } + throw new Error("app_not_running"); +} + +export const clipText = (text) => String(text ?? "").replace(/\n{3,}/g, "\n\n").trim().slice(0, MAX_TEXT); diff --git a/packages/chrome-extension/manifest.json b/packages/chrome-extension/manifest.json new file mode 100644 index 00000000..9bfd46c7 --- /dev/null +++ b/packages/chrome-extension/manifest.json @@ -0,0 +1,24 @@ +{ + "manifest_version": 3, + "name": "Cortex Tab Connector", + "version": "0.1.0", + "description": "Share one browser tab at a time with the Cortex desktop app. Cookies and credentials are never read.", + "minimum_chrome_version": "116", + "permissions": [ + "activeTab", + "scripting", + "storage", + "alarms" + ], + "host_permissions": [ + "http://127.0.0.1/*" + ], + "background": { + "service_worker": "background.js", + "type": "module" + }, + "action": { + "default_title": "Cortex", + "default_popup": "popup.html" + } +} diff --git a/packages/chrome-extension/popup.css b/packages/chrome-extension/popup.css new file mode 100644 index 00000000..56133451 --- /dev/null +++ b/packages/chrome-extension/popup.css @@ -0,0 +1,21 @@ +:root { --bg: #fff; --fg: #1a1a1a; --muted: #6b6b6b; --line: #e3e3e3; --solid: #1a1a1a; --on-solid: #fff; --err: #c62828; color-scheme: light dark; } +@media (prefers-color-scheme: dark) { :root { --bg: #1b1b1b; --fg: #f2f2f2; --muted: #9a9a9a; --line: #333; --solid: #f2f2f2; --on-solid: #1a1a1a; --err: #ff8a80; } } +body { width: 320px; margin: 0; padding: 14px; background: var(--bg); color: var(--fg); font: 13px/20px system-ui, sans-serif; } +header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; } +.badge { font-size: 11px; color: var(--muted); border: 1px solid var(--line); border-radius: 99px; padding: 0 8px; } +h2 { font-size: 12px; font-weight: 500; color: var(--muted); margin: 16px 0 6px; } +p { margin: 0 0 10px; } +.muted, small { color: var(--muted); } +#error { color: var(--err); } +form { display: flex; gap: 8px; } +input { flex: 1; min-width: 0; font: 600 14px ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; padding: 6px 8px; border: 1px solid var(--line); border-radius: 8px; background: transparent; color: inherit; } +button { font: inherit; padding: 6px 12px; border-radius: 8px; border: 1px solid var(--line); background: transparent; color: inherit; cursor: pointer; } +button.primary { background: var(--solid); color: var(--on-solid); border-color: var(--solid); } +button:disabled { opacity: .5; cursor: not-allowed; } +button.link { border: 0; padding: 0; margin-top: 14px; color: var(--muted); text-decoration: underline; } +.card { display: flex; align-items: center; gap: 10px; border: 1px solid var(--line); border-radius: 10px; padding: 10px; } +.meta { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.meta span, .meta small, li span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +ul { list-style: none; margin: 0; padding: 0; } +li { display: flex; align-items: center; gap: 8px; padding: 6px 0; border-top: 1px solid var(--line); } +li span { flex: 1; min-width: 0; } diff --git a/packages/chrome-extension/popup.html b/packages/chrome-extension/popup.html new file mode 100644 index 00000000..5f157330 --- /dev/null +++ b/packages/chrome-extension/popup.html @@ -0,0 +1,24 @@ + + + + + + + + +
Cortex
+ + + + + + diff --git a/packages/chrome-extension/popup.js b/packages/chrome-extension/popup.js new file mode 100644 index 00000000..3ebaffbc --- /dev/null +++ b/packages/chrome-extension/popup.js @@ -0,0 +1,41 @@ +import { normalizeCode } from "./lib.js"; + +const $ = (id) => document.getElementById(id); +const MESSAGES = { + bad_code: "That is not a pairing code. It has 8 letters and digits.", + wrong_code: "Wrong or expired code. Generate a new one in Cortex.", + app_not_running: "Cortex is not running. Open the desktop app and try again.", + not_shareable: "This page cannot be shared.", +}; + +function render(s) { + $("pair").hidden = s.paired; + $("main").hidden = !s.paired; + $("status").textContent = s.paired ? "Connected" : "Not connected"; + if (!s.paired) return; + const c = s.current; + $("title").textContent = c?.title || "No tab"; + $("url").textContent = c ? new URL(c.url || "about:blank").host : ""; + $("share").disabled = !c?.shareable || c.shared; + $("share").textContent = c?.shared ? "Shared" : "Share this tab"; + $("empty").hidden = s.shared.length > 0; + $("list").replaceChildren(...s.shared.map((t) => { + const li = document.createElement("li"), name = document.createElement("span"), b = document.createElement("button"); + name.textContent = t.title || t.url; name.title = t.url; + b.textContent = "Stop"; b.onclick = () => send({ type: "stop", tabId: t.id }); + li.append(name, b); + return li; + })); +} + +async function send(msg) { + const res = await chrome.runtime.sendMessage(msg); + $("error").hidden = res.ok; + if (!res.ok) $("error").textContent = MESSAGES[res.error] ?? "Something went wrong. Try again."; + else render(res.state); +} + +$("pair-form").onsubmit = (e) => { e.preventDefault(); const code = normalizeCode($("code").value); void send({ type: "pair", code: code ?? $("code").value }); }; +$("share").onclick = () => void send({ type: "share" }); +$("disconnect").onclick = () => void send({ type: "disconnect" }); +void send({ type: "state" }); diff --git a/packages/core/src/browser.ts b/packages/core/src/browser.ts new file mode 100644 index 00000000..89598ea5 --- /dev/null +++ b/packages/core/src/browser.ts @@ -0,0 +1,199 @@ +import { randomBytes, randomInt, timingSafeEqual, createHash } from "node:crypto" +import { CortexError } from "./error" + +/** One tab the user chose to share. Only title, URL and readable text ever leave the browser. */ +export interface SharedTab { + id: number + title: string + url: string + sharedAt: number +} +export interface BrowserStatus { + /** The local pairing endpoint accepts connections. */ + listening: boolean + port?: number + /** The extension polled recently. */ + connected: boolean + pairing?: { code: string; expiresAt: number } + tabs: SharedTab[] +} +export type BrowserCommand = { id: string; type: "read"; tabId: number } | { id: string; type: "revoke"; tabId: number } +export interface TabText { + title: string + url: string + text: string +} + +export const PAIRING_TTL_MS = 5 * 60_000 +export const MAX_PAIRING_ATTEMPTS = 5 +const CONNECTED_MS = 40_000 +const READ_TIMEOUT_MS = 15_000 +const MAX_TEXT = 50_000 +const CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" + +const digest = (s: string) => createHash("sha256").update(s).digest() +const sameSecret = (a: string, b: string) => timingSafeEqual(digest(a), digest(b)) + +/** Only web pages can be shared: never chrome://, file:, extension or devtools pages. */ +export const shareableUrl = (url: string) => { + try { + const u = new URL(url) + return (u.protocol === "https:" || u.protocol === "http:") && !u.username && !u.password + } catch { + return false + } +} + +/** + * Local browser bridge: one-time pairing, per-tab consent and read requests. + * State lives in memory on purpose: restarting the app drops every pairing and every consent. + */ +export class BrowserBridge { + private pairing?: { code: string; expiresAt: number; failures: number } + private token?: string + private lastSeen = 0 + private tabs = new Map() + private queue: BrowserCommand[] = [] + private waiters: (() => void)[] = [] + private pending = new Map() + private listeners = new Set<() => void>() + private endpoint: { port?: number; listening: boolean } = { listening: false } + + constructor(private now: () => number = Date.now) {} + + onChange(fn: () => void) { + this.listeners.add(fn) + return () => void this.listeners.delete(fn) + } + private emit() { + for (const fn of this.listeners) fn() + } + + setEndpoint(e: { port?: number; listening: boolean }) { + this.endpoint = e + this.emit() + } + + status(): BrowserStatus { + const pairing = this.pairing && this.pairing.expiresAt > this.now() ? { code: this.pairing.code, expiresAt: this.pairing.expiresAt } : undefined + return { ...this.endpoint, connected: !!this.token && this.now() - this.lastSeen < CONNECTED_MS, pairing, tabs: [...this.tabs.values()] } + } + + /** New single-use code; replaces any earlier one. */ + startPairing() { + const code = Array.from({ length: 8 }, () => CODE_ALPHABET[randomInt(CODE_ALPHABET.length)]).join("") + this.pairing = { code, expiresAt: this.now() + PAIRING_TTL_MS, failures: 0 } + this.emit() + return { code, expiresAt: this.pairing.expiresAt } + } + + /** Consumes the code. A wrong, expired or reused code never yields a token; five wrong guesses burn the code. */ + pair(input: string): string { + const p = this.pairing + const code = input.replace(/[\s-]/g, "").toUpperCase() + if (!p || p.expiresAt <= this.now()) throw new CortexError("permission_denied", "No active pairing code") + if (!sameSecret(code, p.code)) { + if (++p.failures >= MAX_PAIRING_ATTEMPTS) this.pairing = undefined + this.emit() + throw new CortexError("permission_denied", "Wrong pairing code") + } + this.pairing = undefined + this.resetSession() + this.token = randomBytes(32).toString("hex") + this.lastSeen = this.now() + this.emit() + return this.token + } + + authenticate(token: string | undefined): boolean { + if (!token || !this.token || !sameSecret(token, this.token)) return false + this.lastSeen = this.now() + return true + } + + /** Drops the pairing, every consent and every pending read. */ + disconnect() { + this.resetSession() + this.token = undefined + this.pairing = undefined + this.emit() + } + private resetSession() { + this.tabs.clear() + this.queue = [] + for (const p of this.pending.values()) p.reject(new CortexError("tool_failed", "Browser disconnected")) + this.pending.clear() + this.wake() + } + private wake() { + for (const w of this.waiters.splice(0)) w() + } + + /** The user shared this tab from the extension. */ + share(tab: { id: number; title: string; url: string }) { + if (!Number.isInteger(tab.id) || !shareableUrl(tab.url)) throw new CortexError("invalid_request", "This page cannot be shared") + this.tabs.set(tab.id, { id: tab.id, title: String(tab.title ?? "").slice(0, 300), url: tab.url, sharedAt: this.now() }) + this.emit() + } + + /** Revoked from the extension (`notify` false) or from the desktop page (`notify` true tells the extension). */ + revoke(tabId: number, notify = false) { + if (!this.tabs.delete(tabId)) return + for (const [id, p] of this.pending) { + if (p.tabId !== tabId) continue + this.pending.delete(id) + p.reject(new CortexError("permission_denied", "Tab access was revoked")) + } + if (notify) { + this.queue.push({ id: randomBytes(8).toString("hex"), type: "revoke", tabId }) + this.wake() + } + this.emit() + } + + /** Long-poll: resolves with queued commands, or an empty list after `waitMs`. */ + async poll(waitMs: number, signal?: AbortSignal): Promise { + if (!this.queue.length && waitMs > 0 && !signal?.aborted) { + await new Promise((resolve) => { + const done = () => (clearTimeout(timer), signal?.removeEventListener("abort", done), resolve()) + const timer = setTimeout(done, waitMs) + signal?.addEventListener("abort", done, { once: true }) + this.waiters.push(done) + }) + } + return this.queue.splice(0) + } + + /** Extension answer to a read command. */ + complete(id: string, result: { ok: true; title: string; url: string; text: string } | { ok: false; error: string }) { + const p = this.pending.get(id) + if (!p) return + this.pending.delete(id) + const tab = this.tabs.get(p.tabId) + if (!result.ok) return p.reject(new CortexError("tool_failed", result.error.slice(0, 200))) + // The page may have navigated after consent: drop the consent instead of returning another page. + if (!tab || result.url !== tab.url) { + this.revoke(p.tabId, true) + return p.reject(new CortexError("permission_denied", "The shared tab changed pages; share it again")) + } + p.resolve({ title: result.title, url: result.url, text: result.text.slice(0, MAX_TEXT) }) + } + + /** Agent side: readable text of a consented tab; anything else is refused before reaching the browser. */ + read(tabId: number, signal?: AbortSignal): Promise { + if (!this.tabs.has(tabId)) return Promise.reject(new CortexError("permission_denied", "The user has not shared this tab")) + if (!this.status().connected) return Promise.reject(new CortexError("tool_failed", "The browser extension is not connected")) + const id = randomBytes(8).toString("hex") + if (signal?.aborted) return Promise.reject(new CortexError("aborted", "Read aborted")) + return new Promise((resolve, reject) => { + const cleanup = () => (clearTimeout(timer), signal?.removeEventListener("abort", onAbort), this.pending.delete(id)) + const fail = (e: Error) => (cleanup(), reject(e)) + const onAbort = () => fail(new CortexError("aborted", "Read aborted")) + const timer = setTimeout(() => fail(new CortexError("tool_failed", "The browser did not answer")), READ_TIMEOUT_MS) + signal?.addEventListener("abort", onAbort, { once: true }) + this.pending.set(id, { tabId, resolve: (t) => (cleanup(), resolve(t)), reject: fail }) + this.queue.push({ id, type: "read", tabId }) + this.wake() + }) + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 853dde68..680c01bf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,7 @@ import { PermissionRule, RuntimeSettings, RuntimeSettingsUpdateInput, type Permi import { z } from "zod" import { AGENTS } from "./agent" import { BotService } from "./bot" +import { BrowserBridge } from "./browser" import { Bus } from "./bus" import { Catalog } from "./catalog" import { ConnectionService, type RemoteAuth, type RemoteProbe } from "./connection" @@ -94,7 +95,9 @@ export function createCore(opts: CoreOptions) { return r }, } + const browser = new BrowserBridge() const sessions = new SessionService({ + browser, storage, bus, catalog, @@ -131,6 +134,7 @@ export function createCore(opts: CoreOptions) { skills, plugins, mcp, + browser, sessions, remoteSessions, code, @@ -149,6 +153,7 @@ export function createCore(opts: CoreOptions) { scheduler.start(o.schedulerIntervalMs) }, async close() { + browser.disconnect() remoteSessions.close() code.close() workBot.close() @@ -165,6 +170,7 @@ export * from "./agent" export * from "./code" export * from "./work-bot" export * from "./bot" +export * from "./browser" export * from "./bus" export * from "./catalog" export * from "./computer-use" diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index e968bbbe..47ca2b0b 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -28,6 +28,7 @@ import type { ProjectService } from "./project" import { resolveModel, type ProviderSettings } from "./provider" import type { SkillService } from "./skill" import type { Storage } from "./storage" +import type { BrowserBridge } from "./browser" import { BUILTIN_TOOLS, type ToolContext, type ToolDef, type ToolServices } from "./tool" export const DEFAULT_TITLE = "" @@ -55,6 +56,7 @@ export interface SessionDeps { /** Global user permission rules (Settings), layered after defaults. */ rules?: () => PermissionRule[] maxSteps?: number + browser?: BrowserBridge } const zeroUsage = (): Usage => ({ input: 0, output: 0, reasoning: 0, cost: 0 }) @@ -233,6 +235,7 @@ export class SessionService { private toolServices(): ToolServices { return { fetch: this.d.fetch ?? fetch, + browser: this.d.browser, todos: { get: (id) => this.todos.get(id) ?? [], set: (id, t) => void this.todos.set(id, t) }, loadSkill: async (name, dir) => (await this.d.skills.load(name, dir))?.content, runSubagent: async ({ parentID, agent, prompt, description, signal }) => { @@ -259,6 +262,7 @@ export class SessionService { toolAllowed(agent.tools, t.name) && (!bot || toolAllowed(bot.tools, t.name)) && (!t.needsDirectory || !!session.directory) && + (!t.name.startsWith("browser_") || !!this.d.browser?.status().tabs.length) && !(t.name === "task" && session.parentID), // no nested task ) } diff --git a/packages/core/src/tool.ts b/packages/core/src/tool.ts index b550f700..7954d534 100644 --- a/packages/core/src/tool.ts +++ b/packages/core/src/tool.ts @@ -2,7 +2,8 @@ import { exec } from "node:child_process" import { glob as fsGlob, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises" import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path" import { z } from "zod" -import { CortexError } from "./error" +import type { BrowserBridge } from "./browser" +import { CortexError, fail } from "./error" export interface ToolResult { title?: string @@ -28,6 +29,8 @@ export interface ToolServices { loadSkill(name: string, directory?: string): Promise todos: { get(sessionID: string): unknown[]; set(sessionID: string, todos: unknown[]): void } fetch: typeof fetch + /** Local browser bridge; only tabs the user shared are readable. */ + browser?: BrowserBridge } export interface ToolDef

{ @@ -195,6 +198,31 @@ const webfetch = defineTool({ }, }) +const needBrowser = (ctx: ToolContext) => ctx.services.browser ?? fail("tool_failed", "No local browser is available") + +const browserTabs = defineTool({ + name: "browser_tabs", + description: "List the browser tabs the user explicitly shared with Cortex (id, title, URL). Other tabs are not visible.", + parameters: z.object({}), + async execute(_input, ctx) { + const b = needBrowser(ctx) + const tabs = b.status().tabs + return { title: "Shared tabs", output: tabs.length ? tabs.map((t) => `${t.id}\t${t.title}\t${t.url}`).join("\n") : "No tab is shared. Ask the user to share a tab from the Cortex Chrome extension.", metadata: { count: tabs.length } } + }, +}) + +const browserRead = defineTool({ + name: "browser_read", + description: "Read the title, URL and visible text of one tab the user shared (see browser_tabs). Refused for any other tab.", + parameters: z.object({ tabId: z.number().int() }), + async execute({ tabId }, ctx) { + const b = needBrowser(ctx) + await ctx.ask(String(tabId), { tabId }) + const page = await b.read(tabId, ctx.signal) + return { title: page.title, output: truncate(`${page.title}\n${page.url}\n\n${page.text}`), metadata: { url: page.url } } + }, +}) + const Todo = z.object({ content: z.string(), status: z.enum(["pending", "in_progress", "completed", "cancelled"]), priority: z.enum(["high", "medium", "low"]).optional() }) const todowrite = defineTool({ name: "todowrite", @@ -228,4 +256,4 @@ const skill = defineTool({ }, }) -export const BUILTIN_TOOLS: ToolDef[] = [read, write, edit, list, glob, grep, bash, webfetch, todowrite, task, skill] +export const BUILTIN_TOOLS: ToolDef[] = [read, write, edit, list, glob, grep, bash, webfetch, todowrite, task, skill, browserTabs, browserRead] diff --git a/packages/core/test/browser.test.ts b/packages/core/test/browser.test.ts new file mode 100644 index 00000000..459e6212 --- /dev/null +++ b/packages/core/test/browser.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest" +import { BrowserBridge, PAIRING_TTL_MS, MAX_PAIRING_ATTEMPTS } from "../src/browser" + +const clock = () => { let t = 1_000; return { now: () => t, advance: (ms: number) => void (t += ms) } } +const paired = () => { const c = clock(), b = new BrowserBridge(c.now), token = b.pair(b.startPairing().code); return { b, c, token } } + +describe("pairing", () => { + it("accepts the code once, tolerating case and dashes, and issues a token", () => { + const b = new BrowserBridge(), { code } = b.startPairing() + const token = b.pair(`${code.slice(0, 4)}-${code.slice(4)}`.toLowerCase()) + expect(b.authenticate(token)).toBe(true) + expect(() => b.pair(code)).toThrow(/No active pairing code/) + }) + it("rejects expired codes", () => { + const c = clock(), b = new BrowserBridge(c.now), { code } = b.startPairing() + c.advance(PAIRING_TTL_MS + 1) + expect(b.status().pairing).toBeUndefined() + expect(() => b.pair(code)).toThrow() + }) + it("burns the code after repeated wrong guesses", () => { + const b = new BrowserBridge(), { code } = b.startPairing() + for (let i = 0; i < MAX_PAIRING_ATTEMPTS; i++) expect(() => b.pair("AAAAAAAA")).toThrow(/Wrong pairing code/) + expect(() => b.pair(code)).toThrow() + }) + it("rejects unknown tokens and drops everything on disconnect", () => { + const { b, token } = paired() + expect(b.authenticate("nope")).toBe(false) + b.share({ id: 1, title: "t", url: "https://a.test/" }) + b.disconnect() + expect(b.authenticate(token)).toBe(false) + expect(b.status().tabs).toEqual([]) + }) + it("a new pairing replaces the old token and consents", () => { + const { b, token } = paired() + b.share({ id: 1, title: "t", url: "https://a.test/" }) + b.pair(b.startPairing().code) + expect(b.authenticate(token)).toBe(false) + expect(b.status().tabs).toEqual([]) + }) +}) + +describe("consent", () => { + it("only shares web pages", () => { + const { b } = paired() + for (const url of ["chrome://settings", "file:///etc/passwd", "javascript:1", "https://u:p@a.test/", "nonsense"]) expect(() => b.share({ id: 1, title: "x", url })).toThrow() + b.share({ id: 2, title: "ok", url: "https://a.test/" }) + expect(b.status().tabs.map((t) => t.id)).toEqual([2]) + }) + it("refuses to read a tab that was not shared, without asking the browser", async () => { + const { b } = paired() + await expect(b.read(9)).rejects.toMatchObject({ code: "permission_denied" }) + expect(await b.poll(0)).toEqual([]) + }) + it("reads a shared tab through the extension", async () => { + const { b } = paired() + b.share({ id: 3, title: "Docs", url: "https://a.test/x" }) + const pending = b.read(3) + const [cmd] = await b.poll(0) + expect(cmd).toMatchObject({ type: "read", tabId: 3 }) + b.complete(cmd!.id, { ok: true, title: "Docs", url: "https://a.test/x", text: "hello" }) + await expect(pending).resolves.toEqual({ title: "Docs", url: "https://a.test/x", text: "hello" }) + }) + it("revoking rejects an in-flight read and tells the extension", async () => { + const { b } = paired() + b.share({ id: 3, title: "Docs", url: "https://a.test/" }) + const pending = b.read(3) + await b.poll(0) + b.revoke(3, true) + await expect(pending).rejects.toMatchObject({ code: "permission_denied" }) + expect(await b.poll(0)).toMatchObject([{ type: "revoke", tabId: 3 }]) + await expect(b.read(3)).rejects.toMatchObject({ code: "permission_denied" }) + }) + it("drops consent when the tab navigated elsewhere", async () => { + const { b } = paired() + b.share({ id: 3, title: "Docs", url: "https://a.test/" }) + const pending = b.read(3) + const [cmd] = await b.poll(0) + b.complete(cmd!.id, { ok: true, title: "Bank", url: "https://bank.test/", text: "secret" }) + await expect(pending).rejects.toMatchObject({ code: "permission_denied" }) + expect(b.status().tabs).toEqual([]) + }) + it("rejects an already-aborted read without queueing it", async () => { + const { b } = paired() + b.share({ id: 3, title: "Docs", url: "https://a.test/" }) + const ctl = new AbortController() + ctl.abort() + await expect(b.read(3, ctl.signal)).rejects.toMatchObject({ code: "aborted" }) + expect(await b.poll(0)).toEqual([]) + }) + it("removes its abort listener once the read settles", async () => { + const { b } = paired() + b.share({ id: 3, title: "Docs", url: "https://a.test/" }) + const ctl = new AbortController(), removed: string[] = [] + const remove = ctl.signal.removeEventListener.bind(ctl.signal) + ctl.signal.removeEventListener = ((type: string, ...rest: [EventListenerOrEventListenerObject]) => (removed.push(type), remove(type, ...rest))) as typeof remove + const pending = b.read(3, ctl.signal) + const [cmd] = await b.poll(0) + b.complete(cmd!.id, { ok: true, title: "Docs", url: "https://a.test/", text: "x" }) + await pending + expect(removed).toContain("abort") + }) + it("refuses reads while the extension is silent", async () => { + const { b, c } = paired() + b.share({ id: 3, title: "Docs", url: "https://a.test/" }) + c.advance(60_000) + await expect(b.read(3)).rejects.toMatchObject({ code: "tool_failed" }) + }) +}) diff --git a/packages/desktop/src/browser-host.ts b/packages/desktop/src/browser-host.ts new file mode 100644 index 00000000..8e280816 --- /dev/null +++ b/packages/desktop/src/browser-host.ts @@ -0,0 +1,95 @@ +// Loopback endpoint the Chrome extension talks to. The Electron main process owns it; the engine only sees the BrowserBridge. +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http" +import type { AddressInfo } from "node:net" +import { CortexError, type BrowserBridge } from "@cortex/core" + +export const BROWSER_PORTS = [47821, 47822, 47823, 47824, 47825] +const MAX_BODY = 2_000_000 +const POLL_MS = 20_000 + +const isExtensionOrigin = (o: string | undefined) => !!o && /^chrome-extension:\/\/[a-p]{32}$/.test(o) + +async function body(req: IncomingMessage): Promise> { + let raw = "" + for await (const chunk of req) { + raw += chunk + if (raw.length > MAX_BODY) throw new CortexError("invalid_request", "Body too large") + } + try { + const v = raw ? JSON.parse(raw) : {} + if (v && typeof v === "object" && !Array.isArray(v)) return v as Record + } catch { + // fall through + } + throw new CortexError("invalid_request", "Invalid JSON") +} + +/** `ports` defaults to the well-known range; tests pass [0] for an ephemeral port. */ +export async function startBrowserHost(bridge: BrowserBridge, ports: number[] = BROWSER_PORTS): Promise<{ port: number; close(): Promise }> { + let port = 0 + const handle = async (req: IncomingMessage, res: ServerResponse) => { + const origin = req.headers.origin + const send = (status: number, data?: unknown) => { + res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", ...(origin ? { "access-control-allow-origin": origin, vary: "Origin" } : {}) }) + res.end(data === undefined ? "" : JSON.stringify(data)) + } + // DNS-rebinding and drive-by web pages: only a literal loopback Host and a browser-extension Origin pass. + const host = req.headers.host + if (host !== `127.0.0.1:${port}` || !isExtensionOrigin(origin)) return send(403, { error: "forbidden" }) + if (req.method === "OPTIONS") { + res.writeHead(204, { "access-control-allow-origin": origin!, "access-control-allow-headers": "authorization, content-type", "access-control-allow-methods": "GET, POST", "access-control-max-age": "600", vary: "Origin" }) + return void res.end() + } + const url = new URL(req.url ?? "/", "http://127.0.0.1") + try { + if (req.method === "POST" && url.pathname === "/pair") return send(200, { token: bridge.pair(String((await body(req)).code ?? "")) }) + const token = /^Bearer (.+)$/.exec(req.headers.authorization ?? "")?.[1] + if (!bridge.authenticate(token)) return send(401, { error: "unauthorized" }) + if (req.method === "GET" && url.pathname === "/poll") { + const abort = new AbortController() + res.on("close", () => abort.abort()) + const commands = await bridge.poll(POLL_MS, abort.signal) + return send(200, { commands }) + } + if (req.method !== "POST") return send(405, { error: "method" }) + const b = await body(req) + if (url.pathname === "/share") { + bridge.share({ id: Number(b.id), title: String(b.title ?? ""), url: String(b.url ?? "") }) + return send(204) + } + if (url.pathname === "/revoke") { + bridge.revoke(Number(b.tabId)) + return send(204) + } + if (url.pathname === "/result") { + bridge.complete(String(b.id), b.ok === true ? { ok: true, title: String(b.title ?? ""), url: String(b.url ?? ""), text: String(b.text ?? "") } : { ok: false, error: String(b.error ?? "failed") }) + return send(204) + } + if (url.pathname === "/disconnect") { + bridge.disconnect() + return send(204) + } + return send(404, { error: "not_found" }) + } catch (e) { + const code = e instanceof CortexError ? e.code : "internal" + return send(code === "permission_denied" ? 403 : code === "invalid_request" ? 400 : 500, { error: code }) + } + } + const server: Server = createServer((req, res) => void handle(req, res).catch(() => res.destroy())) + let lastError: unknown + for (const candidate of ports) { + try { + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(candidate, "127.0.0.1", () => (server.off("error", reject), resolve())) + }) + port = (server.address() as AddressInfo).port + bridge.setEndpoint({ listening: true, port }) + return { port, close: () => new Promise((r) => (bridge.setEndpoint({ listening: false }), server.closeAllConnections(), server.close(() => r()))) } + } catch (e) { + lastError = e + } + } + bridge.setEndpoint({ listening: false }) + throw lastError +} diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index b2baa8e2..a95d5c33 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -12,6 +12,7 @@ import { probeRemote } from "./remote"; import { RemoteSession } from "./remote-session"; import { createUpdater } from "./updater"; import { createCallHost } from "./remote-call"; +import { startBrowserHost } from "./browser-host"; const APP_NAME = "Cortex"; declare const __CORTEX_RELEASE_CHANNEL__: string; @@ -78,6 +79,17 @@ async function boot() { } const server = createServer(core); + // Chrome extension bridge: loopback only, pairing code + per-tab consent; the renderer only sees status. + const browserHost = await startBrowserHost(core.browser).catch(() => undefined); + ipcMain.handle("cortex:browser:status", () => core.browser.status()); + ipcMain.handle("cortex:browser:pair", () => core.browser.startPairing()); + ipcMain.handle("cortex:browser:revoke", (_e, tabId: number) => core.browser.revoke(Number(tabId), true)); + ipcMain.handle("cortex:browser:disconnect", () => core.browser.disconnect()); + ipcMain.handle("cortex:browser:extension-dir", () => path.join(resources, app.isPackaged ? "chrome-extension" : "packages/chrome-extension")); + ipcMain.handle("cortex:browser:reveal-extension", () => shell.openPath(path.join(resources, app.isPackaged ? "chrome-extension" : "packages/chrome-extension"))); + core.browser.onChange(() => { for (const w of BrowserWindow.getAllWindows()) if (!w.isDestroyed()) w.webContents.send("cortex:browser:changed", core.browser.status()); }); + app.on("before-quit", () => void browserHost?.close()); + // Renderer → engine. Requests are rebuilt in main; only /api paths are routed. ipcMain.handle("cortex:fetch", async (_e, req: { url: string; method: string; headers: [string, string][]; body?: string }) => { const u = new URL(req.url, "cortex://local"); diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 52321c81..33bc598f 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -30,6 +30,20 @@ contextBridge.exposeInMainWorld("cortex", { return () => ipcRenderer.removeListener("cortex:update:state", f); }, }, + /** Chrome extension bridge: pairing code and shared tabs. Page text never reaches the renderer. */ + browser: { + status: () => ipcRenderer.invoke("cortex:browser:status"), + pair: () => ipcRenderer.invoke("cortex:browser:pair"), + revoke: (tabId: number) => ipcRenderer.invoke("cortex:browser:revoke", tabId), + disconnect: () => ipcRenderer.invoke("cortex:browser:disconnect"), + extensionDir: (): Promise => ipcRenderer.invoke("cortex:browser:extension-dir"), + revealExtension: (): Promise => ipcRenderer.invoke("cortex:browser:reveal-extension"), + onChange: (cb: (s: unknown) => void) => { + const f = (_: unknown, s: unknown) => cb(s); + ipcRenderer.on("cortex:browser:changed", f); + return () => ipcRenderer.removeListener("cortex:browser:changed", f); + }, + }, /** Bot call: main holds the ticket and socket; this side only moves PCM and controls. */ call: { available: (): Promise<"offer" | "unavailable" | "hidden"> => ipcRenderer.invoke("cortex:call:available"), diff --git a/packages/desktop/test/browser-host.test.ts b/packages/desktop/test/browser-host.test.ts new file mode 100644 index 00000000..95a1d96a --- /dev/null +++ b/packages/desktop/test/browser-host.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it } from "vitest" +import { BrowserBridge } from "@cortex/core" +import { startBrowserHost } from "../src/browser-host" + +const ORIGIN = `chrome-extension://${"a".repeat(32)}` +let close: (() => Promise) | undefined +afterEach(async () => close?.()) + +async function boot() { + const bridge = new BrowserBridge(), host = await startBrowserHost(bridge, [0]) + close = host.close + const base = `http://127.0.0.1:${host.port}` + const req = (path: string, init: { method?: string; token?: string; body?: unknown; origin?: string | null } = {}) => + fetch(base + path, { method: init.method ?? "POST", headers: { ...(init.origin === null ? {} : { origin: init.origin ?? ORIGIN }), ...(init.token ? { authorization: `Bearer ${init.token}` } : {}), "content-type": "application/json" }, body: init.body === undefined ? undefined : JSON.stringify(init.body) }) + return { bridge, req } +} + +describe("browser host", () => { + it("pairs, shares and serves a read end to end", async () => { + const { bridge, req } = await boot() + const { code } = bridge.startPairing() + const { token } = await (await req("/pair", { body: { code } })).json() + expect((await req("/share", { token, body: { id: 5, title: "Docs", url: "https://a.test/" } })).status).toBe(204) + const read = bridge.read(5) + const { commands } = await (await req("/poll", { method: "GET", token })).json() + expect(commands).toMatchObject([{ type: "read", tabId: 5 }]) + await req("/result", { token, body: { id: commands[0].id, ok: true, title: "Docs", url: "https://a.test/", text: "hi" } }) + await expect(read).resolves.toMatchObject({ text: "hi" }) + }) + it("refuses web-page origins, missing origins, bad codes and missing tokens", async () => { + const { bridge, req } = await boot() + const { code } = bridge.startPairing() + expect((await req("/pair", { origin: "https://evil.test", body: { code } })).status).toBe(403) + expect((await req("/pair", { origin: null, body: { code } })).status).toBe(403) + expect((await req("/pair", { body: { code: "WRONGONE" } })).status).toBe(403) + expect((await req("/share", { body: { id: 1, title: "", url: "https://a.test/" } })).status).toBe(401) + expect(bridge.status().connected).toBe(false) + }) + it("rejects a non-loopback Host header", async () => { + const { bridge } = await boot() + const port = bridge.status().port! + const res = await fetch(`http://127.0.0.1:${port}/pair`, { method: "POST", headers: { origin: ORIGIN, host: "evil.test" }, body: "{}" }) + expect(res.status).toBe(403) + }) +}) diff --git a/packages/i18n/locales/de/extras.json b/packages/i18n/locales/de/extras.json index eeefcd0e..ed970d45 100644 --- a/packages/i18n/locales/de/extras.json +++ b/packages/i18n/locales/de/extras.json @@ -42,10 +42,28 @@ "browser.title": "Lokaler Browser", "browser.h1": "Ein Tab, mit deiner Zustimmung", "browser.lead": "Wähle, was Cortex lesen darf. Du kannst deine Erlaubnis jederzeit widerrufen.", - "browser.notice": "Der lokale Chrome-Operator benötigt einen Host für Browsererweiterungen, den diese App noch nicht mitliefert. Es wird auf keinen Tab, kein Cookie und keine Anmeldung zugegriffen.", - "browser.emptyTitle": "Kein lokaler Browser verbunden", - "browser.emptyBody": "Das Öffnen dieser Seite startet nie eine Verbindung.", - "browser.unavailable": "Nicht verfügbar", + "browser.status.connected": "Verbunden", + "browser.status.waiting": "Warten auf die Erweiterung", + "browser.status.off": "Nicht verbunden", + "browser.status.noHost": "Nicht verfügbar", + "browser.connectedBody": "Die Cortex-Erweiterung ist verbunden. Cortex liest einen Tab erst, nachdem du ihn freigegeben hast.", + "browser.offBody": "Installiere die Cortex-Erweiterung in Chrome und koppel sie mit einem Einmalcode.", + "browser.noHostBody": "Cortex konnte seine lokale Verbindung nicht öffnen. Starte die App neu, um es erneut zu versuchen.", + "browser.install": "Erweiterung installieren", + "browser.installHelp": "Öffne in Chrome chrome://extensions, aktiviere den Entwicklermodus, wähle „Entpackte Erweiterung laden“ und wähle den geöffneten Ordner aus.", + "browser.pair": "Kopplungscode abrufen", + "browser.pairNew": "Neuer Code", + "browser.pairCode": "Kopplungscode", + "browser.pairHelp": "Gib diesen Code in der Erweiterung ein. Er gilt nur einmal und läuft in {minutes} Minuten ab.", + "browser.copy": "Code kopieren", + "browser.disconnect": "Trennen", + "browser.tabs": "Freigegebene Tabs", + "browser.revoke": "Freigabe beenden", + "browser.revokeLabel": "Freigabe von {title} beenden", + "browser.emptyTitle": "Kein Tab freigegeben", + "browser.emptyBody": "Öffne in der Erweiterung eine Seite und wähle „Diesen Tab freigeben“. Cortex kann ihn lesen, bis du die Freigabe beendest.", + "browser.offTitle": "Kein Browser verbunden", + "browser.privacy": "Cortex sieht nur Titel, Adresse und sichtbaren Text der Tabs, die du freigibst. Cookies, Passwörter und deine anderen Tabs bleiben privat.", "plan.new": "Neuer Plan", "plan.h1": "Ein Schritt nach dem anderen", "plan.lead": "Deine Arbeitspläne, vom ersten Entwurf bis zur letzten Prüfung.", diff --git a/packages/i18n/locales/en/extras.json b/packages/i18n/locales/en/extras.json index 5c99bb47..54541715 100644 --- a/packages/i18n/locales/en/extras.json +++ b/packages/i18n/locales/en/extras.json @@ -42,10 +42,28 @@ "browser.title": "Local browser", "browser.h1": "One tab, with your consent", "browser.lead": "Choose what Cortex can read. You can withdraw your permission at any time.", - "browser.notice": "The local Chrome operator needs a browser extension host that this app does not ship yet. No tab, cookie or sign-in is accessed.", - "browser.emptyTitle": "No local browser connected", - "browser.emptyBody": "Opening this page never starts a connection.", - "browser.unavailable": "Unavailable", + "browser.status.connected": "Connected", + "browser.status.waiting": "Waiting for the extension", + "browser.status.off": "Not connected", + "browser.status.noHost": "Unavailable", + "browser.connectedBody": "The Cortex extension is connected. Cortex reads a tab only after you share it.", + "browser.offBody": "Install the Cortex extension in Chrome, then pair it with a one-time code.", + "browser.noHostBody": "Cortex could not open its local connection. Restart the app to try again.", + "browser.install": "Install extension", + "browser.installHelp": "In Chrome, open chrome://extensions, turn on Developer mode, choose Load unpacked and select the folder that opens.", + "browser.pair": "Get pairing code", + "browser.pairNew": "New code", + "browser.pairCode": "Pairing code", + "browser.pairHelp": "Enter this code in the extension. It works once and expires in {minutes} minutes.", + "browser.copy": "Copy code", + "browser.disconnect": "Disconnect", + "browser.tabs": "Shared tabs", + "browser.revoke": "Stop sharing", + "browser.revokeLabel": "Stop sharing {title}", + "browser.emptyTitle": "No tab shared", + "browser.emptyBody": "In the extension, open a page and choose Share this tab. Cortex can read it until you stop sharing.", + "browser.offTitle": "No browser connected", + "browser.privacy": "Cortex sees only the title, address and visible text of tabs you share. Cookies, passwords and your other tabs stay private.", "plan.new": "New plan", "plan.h1": "One step after another", "plan.lead": "Your work plans, from the first draft to the last check.", diff --git a/packages/i18n/locales/es/extras.json b/packages/i18n/locales/es/extras.json index 310d64e2..74aea8da 100644 --- a/packages/i18n/locales/es/extras.json +++ b/packages/i18n/locales/es/extras.json @@ -42,10 +42,28 @@ "browser.title": "Navegador local", "browser.h1": "Una pestaña, con tu consentimiento", "browser.lead": "Elige lo que Cortex puede leer. Puedes retirar tu permiso en cualquier momento.", - "browser.notice": "El operador local de Chrome necesita un host de extensión del navegador que esta aplicación aún no incluye. No se accede a ninguna pestaña, cookie ni inicio de sesión.", - "browser.emptyTitle": "No hay ningún navegador local conectado", - "browser.emptyBody": "Abrir esta página nunca inicia una conexión.", - "browser.unavailable": "No disponible", + "browser.status.connected": "Conectado", + "browser.status.waiting": "Esperando la extensión", + "browser.status.off": "No conectado", + "browser.status.noHost": "No disponible", + "browser.connectedBody": "La extensión de Cortex está conectada. Cortex solo lee una pestaña después de que la compartas.", + "browser.offBody": "Instala la extensión de Cortex en Chrome y vincúlala con un código de un solo uso.", + "browser.noHostBody": "Cortex no pudo abrir su conexión local. Reinicia la aplicación para volver a intentarlo.", + "browser.install": "Instalar extensión", + "browser.installHelp": "En Chrome, abre chrome://extensions, activa el modo desarrollador, elige Cargar descomprimida y selecciona la carpeta que se abre.", + "browser.pair": "Obtener código de vinculación", + "browser.pairNew": "Código nuevo", + "browser.pairCode": "Código de vinculación", + "browser.pairHelp": "Introduce este código en la extensión. Solo funciona una vez y caduca en {minutes} minutos.", + "browser.copy": "Copiar código", + "browser.disconnect": "Desconectar", + "browser.tabs": "Pestañas compartidas", + "browser.revoke": "Dejar de compartir", + "browser.revokeLabel": "Dejar de compartir {title}", + "browser.emptyTitle": "Ninguna pestaña compartida", + "browser.emptyBody": "En la extensión, abre una página y elige Compartir esta pestaña. Cortex puede leerla hasta que dejes de compartirla.", + "browser.offTitle": "Ningún navegador conectado", + "browser.privacy": "Cortex solo ve el título, la dirección y el texto visible de las pestañas que compartes. Las cookies, las contraseñas y tus otras pestañas siguen siendo privadas.", "plan.new": "Nuevo plan", "plan.h1": "Un paso tras otro", "plan.lead": "Tus planes de trabajo, desde el primer borrador hasta la última comprobación.", diff --git a/packages/i18n/locales/fr/extras.json b/packages/i18n/locales/fr/extras.json index 85e41e0f..806e8bd4 100644 --- a/packages/i18n/locales/fr/extras.json +++ b/packages/i18n/locales/fr/extras.json @@ -42,10 +42,28 @@ "browser.title": "Navigateur local", "browser.h1": "Un onglet, avec votre consentement", "browser.lead": "Choisissez ce que Cortex peut lire. Vous pouvez retirer votre autorisation à tout moment.", - "browser.notice": "L'opérateur Chrome local nécessite un hôte d'extension de navigateur que cette application ne fournit pas encore. Aucun onglet, cookie ou identifiant n'est consulté.", - "browser.emptyTitle": "Aucun navigateur local connecté", - "browser.emptyBody": "Ouvrir cette page ne démarre jamais de connexion.", - "browser.unavailable": "Indisponible", + "browser.status.connected": "Connecté", + "browser.status.waiting": "En attente de l'extension", + "browser.status.off": "Non connecté", + "browser.status.noHost": "Indisponible", + "browser.connectedBody": "L'extension Cortex est connectée. Cortex ne lit un onglet qu'après que vous l'avez partagé.", + "browser.offBody": "Installez l'extension Cortex dans Chrome, puis associez-la avec un code à usage unique.", + "browser.noHostBody": "Cortex n'a pas pu ouvrir sa connexion locale. Redémarrez l'application pour réessayer.", + "browser.install": "Installer l'extension", + "browser.installHelp": "Dans Chrome, ouvrez chrome://extensions, activez le mode développeur, choisissez Charger l'extension non empaquetée et sélectionnez le dossier qui s'ouvre.", + "browser.pair": "Obtenir un code d'association", + "browser.pairNew": "Nouveau code", + "browser.pairCode": "Code d'association", + "browser.pairHelp": "Saisissez ce code dans l'extension. Il ne fonctionne qu'une fois et expire dans {minutes} minutes.", + "browser.copy": "Copier le code", + "browser.disconnect": "Déconnecter", + "browser.tabs": "Onglets partagés", + "browser.revoke": "Arrêter le partage", + "browser.revokeLabel": "Arrêter le partage de {title}", + "browser.emptyTitle": "Aucun onglet partagé", + "browser.emptyBody": "Dans l'extension, ouvrez une page et choisissez Partager cet onglet. Cortex peut le lire jusqu'à ce que vous arrêtiez le partage.", + "browser.offTitle": "Aucun navigateur connecté", + "browser.privacy": "Cortex voit uniquement le titre, l'adresse et le texte visible des onglets que vous partagez. Cookies, mots de passe et autres onglets restent privés.", "plan.new": "Nouveau plan", "plan.h1": "Une étape après l'autre", "plan.lead": "Vos plans de travail, du premier brouillon à la dernière vérification.", diff --git a/packages/i18n/locales/ja/extras.json b/packages/i18n/locales/ja/extras.json index 07fd141d..c31665a0 100644 --- a/packages/i18n/locales/ja/extras.json +++ b/packages/i18n/locales/ja/extras.json @@ -42,10 +42,28 @@ "browser.title": "ローカルブラウザー", "browser.h1": "同意のもとで、ひとつのタブ", "browser.lead": "Cortex が読み取れる内容を選択します。許可はいつでも取り消せます。", - "browser.notice": "ローカルの Chrome オペレーターには、このアプリにまだ同梱されていないブラウザー拡張機能ホストが必要です。タブ、Cookie、サインイン情報にはアクセスしません。", - "browser.emptyTitle": "ローカルブラウザーが接続されていません", - "browser.emptyBody": "このページを開いても接続が開始されることはありません。", - "browser.unavailable": "利用不可", + "browser.status.connected": "接続済み", + "browser.status.waiting": "拡張機能を待機中", + "browser.status.off": "未接続", + "browser.status.noHost": "利用できません", + "browser.connectedBody": "Cortex拡張機能に接続しています。Cortexは、共有したタブだけを読み取ります。", + "browser.offBody": "ChromeにCortex拡張機能をインストールし、ワンタイムコードでペアリングしてください。", + "browser.noHostBody": "Cortexがローカル接続を開始できませんでした。アプリを再起動してもう一度お試しください。", + "browser.install": "拡張機能をインストール", + "browser.installHelp": "Chromeでchrome://extensionsを開き、デベロッパーモードをオンにして「パッケージ化されていない拡張機能を読み込む」を選び、開いたフォルダを選択します。", + "browser.pair": "ペアリングコードを取得", + "browser.pairNew": "新しいコード", + "browser.pairCode": "ペアリングコード", + "browser.pairHelp": "このコードを拡張機能に入力してください。1回限り有効で、{minutes}分で期限切れになります。", + "browser.copy": "コードをコピー", + "browser.disconnect": "接続を解除", + "browser.tabs": "共有中のタブ", + "browser.revoke": "共有を停止", + "browser.revokeLabel": "{title}の共有を停止", + "browser.emptyTitle": "共有中のタブはありません", + "browser.emptyBody": "拡張機能でページを開き、「このタブを共有」を選びます。共有を停止するまで、Cortexはそのタブを読み取れます。", + "browser.offTitle": "接続されたブラウザはありません", + "browser.privacy": "Cortexが見られるのは、共有したタブのタイトル、アドレス、表示中のテキストだけです。Cookie、パスワード、ほかのタブは非公開のままです。", "plan.new": "新しいプラン", "plan.h1": "一歩ずつ着実に", "plan.lead": "最初の下書きから最終確認まで、あなたの作業プラン。", diff --git a/packages/i18n/locales/ko/extras.json b/packages/i18n/locales/ko/extras.json index c88b257d..d940816e 100644 --- a/packages/i18n/locales/ko/extras.json +++ b/packages/i18n/locales/ko/extras.json @@ -42,10 +42,28 @@ "browser.title": "로컬 브라우저", "browser.h1": "동의 하에 하나의 탭만", "browser.lead": "Cortex가 읽을 수 있는 항목을 선택하세요. 언제든지 권한을 철회할 수 있습니다.", - "browser.notice": "로컬 Chrome 운영자에는 이 앱이 아직 제공하지 않는 브라우저 확장 호스트가 필요합니다. 탭, 쿠키 또는 로그인 정보에 액세스하지 않습니다.", - "browser.emptyTitle": "연결된 로컬 브라우저 없음", - "browser.emptyBody": "이 페이지를 연다고 연결이 시작되지는 않습니다.", - "browser.unavailable": "사용할 수 없음", + "browser.status.connected": "연결됨", + "browser.status.waiting": "확장 프로그램 대기 중", + "browser.status.off": "연결되지 않음", + "browser.status.noHost": "사용할 수 없음", + "browser.connectedBody": "Cortex 확장 프로그램이 연결되었습니다. Cortex는 공유한 탭만 읽습니다.", + "browser.offBody": "Chrome에 Cortex 확장 프로그램을 설치한 뒤 일회용 코드로 페어링하세요.", + "browser.noHostBody": "Cortex가 로컬 연결을 열지 못했습니다. 앱을 다시 시작한 뒤 다시 시도하세요.", + "browser.install": "확장 프로그램 설치", + "browser.installHelp": "Chrome에서 chrome://extensions를 열고 개발자 모드를 켠 다음 '압축해제된 확장 프로그램을 로드합니다'를 선택하고 열린 폴더를 고르세요.", + "browser.pair": "페어링 코드 받기", + "browser.pairNew": "새 코드", + "browser.pairCode": "페어링 코드", + "browser.pairHelp": "확장 프로그램에 이 코드를 입력하세요. 한 번만 사용할 수 있으며 {minutes}분 후 만료됩니다.", + "browser.copy": "코드 복사", + "browser.disconnect": "연결 해제", + "browser.tabs": "공유한 탭", + "browser.revoke": "공유 중지", + "browser.revokeLabel": "{title} 공유 중지", + "browser.emptyTitle": "공유한 탭 없음", + "browser.emptyBody": "확장 프로그램에서 페이지를 열고 '이 탭 공유'를 선택하세요. 공유를 중지하기 전까지 Cortex가 해당 탭을 읽을 수 있습니다.", + "browser.offTitle": "연결된 브라우저 없음", + "browser.privacy": "Cortex는 공유한 탭의 제목, 주소, 보이는 텍스트만 볼 수 있습니다. 쿠키, 비밀번호, 다른 탭은 비공개로 유지됩니다.", "plan.new": "새 계획", "plan.h1": "한 단계씩 차례로", "plan.lead": "첫 초안부터 마지막 확인까지, 작업 계획을 관리하세요.", diff --git a/packages/i18n/locales/pt-BR/extras.json b/packages/i18n/locales/pt-BR/extras.json index 837d4b77..4bde3f0b 100644 --- a/packages/i18n/locales/pt-BR/extras.json +++ b/packages/i18n/locales/pt-BR/extras.json @@ -42,10 +42,28 @@ "browser.title": "Navegador local", "browser.h1": "Uma aba, com o seu consentimento", "browser.lead": "Escolha o que o Cortex pode ler. Você pode retirar sua permissão a qualquer momento.", - "browser.notice": "O operador local do Chrome precisa de um host de extensão de navegador que este app ainda não inclui. Nenhuma aba, cookie ou login é acessado.", - "browser.emptyTitle": "Nenhum navegador local conectado", - "browser.emptyBody": "Abrir esta página nunca inicia uma conexão.", - "browser.unavailable": "Indisponível", + "browser.status.connected": "Conectado", + "browser.status.waiting": "Aguardando a extensão", + "browser.status.off": "Não conectado", + "browser.status.noHost": "Indisponível", + "browser.connectedBody": "A extensão do Cortex está conectada. O Cortex só lê uma aba depois que você a compartilha.", + "browser.offBody": "Instale a extensão do Cortex no Chrome e emparelhe-a com um código de uso único.", + "browser.noHostBody": "O Cortex não conseguiu abrir sua conexão local. Reinicie o app para tentar de novo.", + "browser.install": "Instalar extensão", + "browser.installHelp": "No Chrome, abra chrome://extensions, ative o modo do desenvolvedor, escolha Carregar sem compactação e selecione a pasta que abrir.", + "browser.pair": "Obter código de emparelhamento", + "browser.pairNew": "Novo código", + "browser.pairCode": "Código de emparelhamento", + "browser.pairHelp": "Digite este código na extensão. Ele funciona uma vez e expira em {minutes} minutos.", + "browser.copy": "Copiar código", + "browser.disconnect": "Desconectar", + "browser.tabs": "Abas compartilhadas", + "browser.revoke": "Parar de compartilhar", + "browser.revokeLabel": "Parar de compartilhar {title}", + "browser.emptyTitle": "Nenhuma aba compartilhada", + "browser.emptyBody": "Na extensão, abra uma página e escolha Compartilhar esta aba. O Cortex pode lê-la até você parar de compartilhar.", + "browser.offTitle": "Nenhum navegador conectado", + "browser.privacy": "O Cortex vê apenas o título, o endereço e o texto visível das abas que você compartilha. Cookies, senhas e suas outras abas continuam privados.", "plan.new": "Novo plano", "plan.h1": "Uma etapa depois da outra", "plan.lead": "Seus planos de trabalho, do primeiro rascunho à última verificação.", diff --git a/packages/i18n/locales/zh-Hans/extras.json b/packages/i18n/locales/zh-Hans/extras.json index 7a830031..1776ec3c 100644 --- a/packages/i18n/locales/zh-Hans/extras.json +++ b/packages/i18n/locales/zh-Hans/extras.json @@ -42,10 +42,28 @@ "browser.title": "本地浏览器", "browser.h1": "一个标签页,经你同意", "browser.lead": "选择 Cortex 可以读取的内容。你可以随时撤回授权。", - "browser.notice": "本地 Chrome 操作器需要浏览器扩展宿主,而此应用尚未提供。不会访问任何标签页、Cookie 或登录信息。", - "browser.emptyTitle": "未连接本地浏览器", - "browser.emptyBody": "打开此页面绝不会发起连接。", - "browser.unavailable": "不可用", + "browser.status.connected": "已连接", + "browser.status.waiting": "正在等待扩展程序", + "browser.status.off": "未连接", + "browser.status.noHost": "不可用", + "browser.connectedBody": "Cortex 扩展程序已连接。只有在你共享标签页后,Cortex 才会读取它。", + "browser.offBody": "在 Chrome 中安装 Cortex 扩展程序,然后用一次性代码配对。", + "browser.noHostBody": "Cortex 无法打开本地连接。请重启应用后重试。", + "browser.install": "安装扩展程序", + "browser.installHelp": "在 Chrome 中打开 chrome://extensions,开启开发者模式,选择“加载已解压的扩展程序”,然后选择打开的文件夹。", + "browser.pair": "获取配对码", + "browser.pairNew": "新代码", + "browser.pairCode": "配对码", + "browser.pairHelp": "在扩展程序中输入此代码。它只能使用一次,{minutes} 分钟后过期。", + "browser.copy": "复制代码", + "browser.disconnect": "断开连接", + "browser.tabs": "已共享的标签页", + "browser.revoke": "停止共享", + "browser.revokeLabel": "停止共享 {title}", + "browser.emptyTitle": "没有共享的标签页", + "browser.emptyBody": "在扩展程序中打开一个页面并选择“共享此标签页”。在你停止共享之前,Cortex 可以读取它。", + "browser.offTitle": "没有已连接的浏览器", + "browser.privacy": "Cortex 只能看到你共享的标签页的标题、地址和可见文字。Cookie、密码和你的其他标签页保持私密。", "plan.new": "新建计划", "plan.h1": "一步接一步", "plan.lead": "你的工作计划,从初稿到最后检查。", diff --git a/tests/e2e/browser-connector.spec.ts b/tests/e2e/browser-connector.spec.ts new file mode 100644 index 00000000..f1e7aa00 --- /dev/null +++ b/tests/e2e/browser-connector.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; +import { launch, root } from "./fixtures"; + +const ORIGIN = `chrome-extension://${"a".repeat(32)}`; +const shots = path.join(root, "evidence/browser-connector"); + +for (const theme of ["dark", "light"]) { + test(`local browser page pairs and revokes (${theme})`, async () => { + const { app, page } = await launch({ hash: `#/browser-authorization?theme=${theme}` }); + try { + const status = page.getByTestId("browser-status"); + await expect(status).toHaveAttribute("data-mode", "off"); + await expect(page.getByText("No tab shared")).toHaveCount(0); + await page.getByTestId("browser-pair").click(); + const code = (await page.getByTestId("browser-code").textContent())!.replace(/\W/g, ""); + expect(code).toMatch(/^[A-Z2-9]{8}$/); + await expect(status).toHaveAttribute("data-mode", "waiting"); + fs.mkdirSync(shots, { recursive: true }); + await page.screenshot({ path: path.join(shots, `pairing-${theme}.png`) }); + + // Drive the loopback endpoint exactly as the extension does. + const port = await page.evaluate(async () => (await (window as unknown as { cortex: { browser: { status(): Promise<{ port: number }> } } }).cortex.browser.status()).port); + const call = (p: string, token: string | undefined, body: unknown) => fetch(`http://127.0.0.1:${port}${p}`, { method: "POST", headers: { origin: ORIGIN, "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body) }); + const { token } = await (await call("/pair", undefined, { code })).json(); + await call("/share", token, { id: 7, title: "Quarterly report", url: "https://example.com/reports/q3" }); + await expect(status).toHaveAttribute("data-mode", "connected"); + await expect(page.getByTestId("browser-tabs")).toContainText("Quarterly report"); + await page.screenshot({ path: path.join(shots, `connected-${theme}.png`) }); + + await page.getByTestId("browser-revoke").click(); + await expect(page.getByTestId("browser-tabs")).toHaveCount(0); + await expect(page.getByText("No tab shared")).toBeVisible(); + await page.screenshot({ path: path.join(shots, `empty-${theme}.png`) }); + } finally { await app.close(); } + }); +} diff --git a/tests/unit/chrome-extension.test.ts b/tests/unit/chrome-extension.test.ts new file mode 100644 index 00000000..87bc3963 --- /dev/null +++ b/tests/unit/chrome-extension.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import * as lib from "../../packages/chrome-extension/lib.js"; + +describe("chrome extension helpers", () => { + it("shares only web pages", () => { + expect(lib.shareableUrl("https://a.test/x")).toBe(true); + for (const u of ["chrome://settings", "file:///x", "chrome-extension://abc/p.html", "https://u:p@a.test/", "x"]) expect(lib.shareableUrl(u)).toBe(false); + }); + it("normalizes pairing codes", () => { + expect(lib.normalizeCode("abcd-2345")).toBe("ABCD2345"); + for (const c of ["", "ABCD", "ABCD-234O", "ABCD23456"]) expect(lib.normalizeCode(c)).toBeNull(); + }); + it("pairing treats only a Cortex permission_denied 403 as a wrong code", async () => { + const reply = (status: number, body?: unknown) => new Response(body === undefined ? null : JSON.stringify(body), { status }); + const ports: number[] = []; + const foreign = (async (url: string) => { ports.push(Number(new URL(url).port)); return ports.length === 1 ? reply(403, { error: "nope" }) : reply(200, { token: "t" }); }) as unknown as typeof fetch; + await expect(lib.pairOnPorts("ABCD2345", [1, 2, 3], foreign)).resolves.toEqual({ token: "t", port: 2 }); + const denied = (async () => reply(403, { error: "permission_denied" })) as unknown as typeof fetch; + await expect(lib.pairOnPorts("ABCD2345", [1, 2], denied)).rejects.toThrow("wrong_code"); + const down = (async () => { throw new Error("refused"); }) as unknown as typeof fetch; + await expect(lib.pairOnPorts("ABCD2345", [1], down)).rejects.toThrow("app_not_running"); + }); + it("pairing skips silent or malformed services", async () => { + const hang = ((_u: string, init: RequestInit) => new Promise((_r, reject) => init.signal!.addEventListener("abort", () => reject(new Error("timeout"))))) as unknown as typeof fetch; + const mixed = (async (url: string, init: RequestInit) => { + const port = Number(new URL(url).port); + if (port === 1) return hang(url, init); + if (port === 2) return new Response("", { status: 200 }); + if (port === 3) return new Response(JSON.stringify({ token: 7 }), { status: 200 }); + return new Response(JSON.stringify({ token: "ok" }), { status: 200 }); + }) as unknown as typeof fetch; + await expect(lib.pairOnPorts("ABCD2345", [1, 2, 3, 4], mixed, 20)).resolves.toEqual({ token: "ok", port: 4 }); + }); + it("tracks consent per tab and drops it after navigation", () => { + let s = lib.consentAdd({}, { id: 1, title: "A", url: "https://a.test/" }); + s = lib.consentAdd(s, { id: 2, title: "B", url: "chrome://x" }); + expect(Object.keys(s)).toEqual(["1"]); + expect(lib.consentValid(s, { id: 1, url: "https://a.test/" })).toBe(true); + expect(lib.consentValid(s, { id: 1, url: "https://b.test/" })).toBe(false); + expect(lib.consentHas(lib.consentRemove(s, 1), 1)).toBe(false); + expect(lib.consentHas(s, 1)).toBe(true); + }); +});