diff --git a/package-lock.json b/package-lock.json
index 74ac701..a6e8c12 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "roxy",
- "version": "0.0.84",
+ "version": "0.0.85",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "roxy",
- "version": "0.0.84",
+ "version": "0.0.85",
"license": "MIT",
"dependencies": {
"@ai-sdk/anthropic": "^2.0.85",
diff --git a/package.json b/package.json
index 4de962b..4e17deb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "roxy",
- "version": "0.0.84",
+ "version": "0.0.85",
"description": "Roxy — an open-source AI coding agent for engineers.",
"main": "./out/main/index.js",
"author": "Roxy (https://github.com/roxy-gg/roxy)",
@@ -36,11 +36,13 @@
"icons:providers": "node script/copy-provider-icons.mjs",
"smoke:shared": "esbuild test/shared.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/shared.cjs && node test/.out/shared.cjs",
"smoke:app": "esbuild test/smoke.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/smoke.cjs && electron test/.out/smoke.cjs",
- "smoke": "npm run smoke:shared && npm run smoke:store && npm run smoke:cookies && npm run smoke:app",
+ "smoke": "npm run smoke:shared && npm run smoke:store && npm run smoke:cookies && npm run smoke:relay && npm run smoke:app",
"smoke:cliproxy": "esbuild test/cliproxy.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/cliproxy.cjs && electron test/.out/cliproxy.cjs",
"worktree:setup": "npm ci --prefer-offline --no-audit --no-fund && electron-builder install-app-deps",
"smoke:store": "node test/store-guard.mjs",
- "smoke:cookies": "esbuild test/cookies.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/cookies.cjs && electron test/.out/cookies.cjs"
+ "smoke:cookies": "esbuild test/cookies.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/cookies.cjs && electron test/.out/cookies.cjs",
+ "smoke:relay": "esbuild test/relay.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/relay.cjs && electron test/.out/relay.cjs",
+ "e2e:relay": "esbuild test/relay-e2e.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/relay-e2e.cjs && electron test/.out/relay-e2e.cjs"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.85",
diff --git a/resources/session-relay/background.js b/resources/session-relay/background.js
new file mode 100644
index 0000000..37bdfbf
--- /dev/null
+++ b/resources/session-relay/background.js
@@ -0,0 +1,317 @@
+/**
+ * Roxy Session Relay — background service worker.
+ *
+ * Owns the connection to Roxy: pairing, the bearer token, and the heartbeat.
+ * The popup never talks to the network directly; it messages this worker. That
+ * keeps the token in one place and means a compromised page (which cannot reach
+ * the worker anyway) has no path to it.
+ *
+ * The token lives in `chrome.storage.local`, which is readable only by this
+ * extension's own contexts — not by web pages, not by other extensions.
+ */
+
+/** Must match RELAY_PORT / RELAY_HEADER in Roxy's shared/relay.ts. */
+const RELAY_ORIGIN = 'http://127.0.0.1:4317'
+const RELAY_HEADER = 'x-roxy-relay'
+
+/** Heartbeat cadence. Drives the "Connected" dot in Roxy's Settings. */
+const HEARTBEAT_MINUTES = 1
+
+/**
+ * Roxy's automation prefs, refreshed on every heartbeat.
+ *
+ * Cached so an auto-send decision costs no round trip, but never authoritative:
+ * Roxy re-checks the blocklist server-side on every snapshot, so a stale cache
+ * here cannot leak a blocked site.
+ */
+let prefs = { autoSend: false, trusted: [], blocked: [] }
+
+/** origin -> last auto-send, so a chatty site cannot spam Roxy. */
+const lastSent = new Map()
+const COOLDOWN_MS = 15_000
+
+async function getToken() {
+ const { token } = await chrome.storage.local.get('token')
+ return token || null
+}
+
+/** POST JSON to the relay, attaching the bearer token when we have one. */
+async function post(path, body, token) {
+ const headers = {
+ 'content-type': 'application/json',
+ // Forces a CORS preflight, so this can never be mistaken for a "simple"
+ // request that a web page could also make.
+ [RELAY_HEADER]: '1'
+ }
+ if (token) headers.authorization = `Bearer ${token}`
+ const res = await fetch(`${RELAY_ORIGIN}${path}`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(body)
+ })
+ let json = null
+ try {
+ json = await res.json()
+ } catch {
+ // A non-JSON body means something other than Roxy answered on this port.
+ }
+ return { ok: res.ok, status: res.status, json }
+}
+
+/** Exchange the on-screen code for a long-lived token. */
+async function pair(code) {
+ const manifest = chrome.runtime.getManifest()
+ const r = await post('/pair', {
+ code,
+ extensionId: chrome.runtime.id,
+ browser: detectBrowser(),
+ version: manifest.version
+ })
+ if (r.ok && r.json?.token) {
+ await chrome.storage.local.set({ token: r.json.token })
+ startHeartbeat()
+ // Pull prefs straight away rather than waiting up to a minute for the
+ // first heartbeat — otherwise a freshly trusted site would not auto-send.
+ void heartbeat()
+ return { ok: true }
+ }
+ return { ok: false, error: r.json?.error || `Pairing failed (${r.status}).` }
+}
+
+/**
+ * Best-effort browser name for Roxy's UI ("Chrome wants to send…").
+ *
+ * Chromium forks are mostly indistinguishable from the UA string alone; brand
+ * data from userAgentData is the only reliable signal, and even it falls back
+ * to Chrome. This is cosmetic, never a security decision.
+ */
+function detectBrowser() {
+ const brands = navigator.userAgentData?.brands ?? []
+ for (const { brand } of brands) {
+ if (/edge/i.test(brand)) return 'Edge'
+ if (/brave/i.test(brand)) return 'Brave'
+ if (/opera|opr/i.test(brand)) return 'Opera'
+ if (/vivaldi/i.test(brand)) return 'Vivaldi'
+ }
+ if (brands.some((b) => /chromium/i.test(b.brand))) return 'Chromium'
+ return 'Chrome'
+}
+
+/** Tell Roxy we're alive, so Settings can show a live connection. */
+async function heartbeat() {
+ const token = await getToken()
+ if (!token) return
+ const r = await post('/hello', {}, token)
+ // 401 means Roxy revoked us (the user hit Disconnect). Drop the dead token
+ // so the popup prompts to pair again instead of failing silently forever.
+ if (r.status === 401) await chrome.storage.local.remove('token')
+ else if (r.json?.prefs) prefs = r.json.prefs
+}
+
+/**
+ * Suffix match on a dot boundary — the same rule as Roxy's `isBlockedHost`.
+ *
+ * The two boundaries that matter: `example.com` must not match
+ * `notexample.com` (no dot) nor `example.com.evil.net` (not a suffix).
+ */
+function isBlocked(host) {
+ const h = String(host || '')
+ .toLowerCase()
+ .replace(/\.$/, '')
+ if (!h) return true
+ return prefs.blocked.some((raw) => {
+ const p = String(raw)
+ .trim()
+ .toLowerCase()
+ .replace(/^https?:\/\//, '')
+ .replace(/^\*\./, '')
+ .split('/')[0]
+ .replace(/:\d+$/, '')
+ .replace(/\.$/, '')
+ return p && (h === p || h.endsWith(`.${p}`))
+ })
+}
+
+/** Is this origin cleared for a hands-off transfer? */
+function mayAutoSend(origin) {
+ if (!prefs.autoSend) return false
+ let host
+ try {
+ host = new URL(origin).hostname
+ } catch {
+ return false
+ }
+ if (isBlocked(host)) return false
+ return prefs.trusted.includes(origin)
+}
+
+/**
+ * Capture and send a trusted origin without any UI.
+ *
+ * Runs only for origins the user already granted host access to, so it never
+ * triggers a permission prompt: `permissions.contains` is checked first and a
+ * miss simply means "not ready yet, wait for a manual send".
+ */
+async function autoSend(origin, tabId) {
+ if (!mayAutoSend(origin)) return
+ const last = lastSent.get(origin) ?? 0
+ if (Date.now() - last < COOLDOWN_MS) return
+
+ const allowed = await chrome.permissions.contains({ origins: [`${origin}/*`] }).catch(() => false)
+ if (!allowed) return
+
+ lastSent.set(origin, Date.now())
+ try {
+ const cookies = await chrome.cookies.getAll({ url: `${origin}/` })
+ // Nothing to relay yet (signed out, or cookies not set). Don't send an
+ // empty session — it would overwrite nothing but still churn.
+ if (!cookies.length) return
+
+ const snapshot = {
+ v: 1,
+ origin,
+ capturedAt: Date.now(),
+ cookies: cookies.map(toRow)
+ }
+
+ // localStorage needs the page. Best-effort: a backgrounded or discarded tab
+ // cannot be scripted, and cookies alone are still worth sending.
+ if (tabId != null) {
+ try {
+ const [res] = await chrome.scripting.executeScript({
+ target: { tabId },
+ func: () => {
+ const out = {}
+ for (let i = 0; i < localStorage.length; i++) {
+ const k = localStorage.key(i)
+ if (k != null) out[k] = localStorage.getItem(k) ?? ''
+ }
+ return out
+ }
+ })
+ if (res?.result) snapshot.localStorage = res.result
+ } catch {
+ // Not scriptable right now; cookies still go.
+ }
+ }
+
+ await sendSnapshot(snapshot)
+ } catch {
+ // Auto-send is invisible, so a failure must stay invisible too — the next
+ // cookie change or navigation will try again.
+ }
+}
+
+/** chrome.cookies.Cookie -> the wire shape (see shared/relay.ts). */
+function toRow(c) {
+ const row = {
+ name: c.name,
+ value: c.value,
+ domain: c.domain,
+ path: c.path,
+ secure: c.secure,
+ httpOnly: c.httpOnly,
+ hostOnly: c.hostOnly,
+ session: c.session,
+ sameSite: c.sameSite ?? 'unspecified'
+ }
+ if (typeof c.expirationDate === 'number') row.expirationDate = c.expirationDate
+ if (c.partitionKey) row.partitionKey = c.partitionKey
+ return row
+}
+
+/** The active tab's origin, or null when it isn't a website. */
+async function activeOrigin() {
+ const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
+ if (!tab?.url) return null
+ try {
+ const u = new URL(tab.url)
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null
+ return { origin: u.origin, tabId: tab.id }
+ } catch {
+ return null
+ }
+}
+
+// A cookie changing is the signal that matters: it is what happens when a
+// token rotates mid-session, which is the case auto-send exists for. The
+// cooldown upstream keeps a busy site from turning this into a flood.
+chrome.cookies.onChanged.addListener(async (change) => {
+ if (change.cause === 'evicted' || change.cause === 'expired') return
+ const active = await activeOrigin()
+ if (!active) return
+ // Only react to cookies that actually belong to the tab in front of the
+ // user, so a background tab's analytics cannot drive a transfer.
+ const domain = String(change.cookie?.domain ?? '').replace(/^\./, '')
+ let host
+ try {
+ host = new URL(active.origin).hostname
+ } catch {
+ return
+ }
+ if (host !== domain && !host.endsWith(`.${domain}`)) return
+ autoSend(active.origin, active.tabId)
+})
+
+// Landing on a trusted site should hand its session over without waiting for a
+// cookie to change.
+chrome.tabs.onUpdated.addListener((tabId, info, tab) => {
+ if (info.status !== 'complete' || !tab.active || !tab.url) return
+ try {
+ const u = new URL(tab.url)
+ if (u.protocol === 'http:' || u.protocol === 'https:') autoSend(u.origin, tabId)
+ } catch {
+ // Not a website.
+ }
+})
+
+function startHeartbeat() {
+ chrome.alarms.create('roxy-heartbeat', { periodInMinutes: HEARTBEAT_MINUTES })
+}
+
+chrome.alarms?.onAlarm.addListener((alarm) => {
+ if (alarm.name === 'roxy-heartbeat') heartbeat()
+})
+
+chrome.runtime.onStartup.addListener(startHeartbeat)
+chrome.runtime.onInstalled.addListener(startHeartbeat)
+
+// A service worker is torn down aggressively and respawned on demand, so the
+// cached prefs start empty on every wake. Refresh immediately rather than
+// leaving auto-send dormant until the next alarm fires.
+void heartbeat()
+
+/** Send a captured snapshot. Returns Roxy's verdict for the popup to render. */
+async function sendSnapshot(snapshot) {
+ const token = await getToken()
+ if (!token) return { ok: false, error: 'Not connected to Roxy yet.' }
+ const r = await post('/snapshot', snapshot, token)
+ if (r.ok) return { ok: true }
+ if (r.status === 401) {
+ await chrome.storage.local.remove('token')
+ return { ok: false, error: 'Roxy disconnected this browser. Pair again.' }
+ }
+ if (r.status === 413) return { ok: false, error: 'That session is too large to send.' }
+ return { ok: false, error: r.json?.error || `Roxy rejected the transfer (${r.status}).` }
+}
+
+chrome.runtime.onMessage.addListener((msg, _sender, respond) => {
+ // Only our own popup can reach this; content scripts and pages cannot.
+ if (msg?.type === 'pair') pair(msg.code).then(respond)
+ else if (msg?.type === 'send') sendSnapshot(msg.snapshot).then(respond)
+ else if (msg?.type === 'status') {
+ getToken().then((token) => respond({ paired: Boolean(token) }))
+ } else if (msg?.type === 'unpair') {
+ chrome.storage.local.remove('token').then(() => respond({ ok: true }))
+ } else {
+ return false
+ }
+ // Keep the message channel open for the async respond above.
+ return true
+})
+
+// Exposed for the end-to-end harness (test/relay-e2e.ts), which drives this
+// worker over the DevTools Protocol. `chrome.runtime.sendMessage` cannot be
+// used there: a service worker sending to itself has no receiver. Nothing on a
+// web page can reach this object — it lives in the worker's own global scope.
+globalThis.__roxyRelay = { pair, sendSnapshot, getToken, heartbeat, prefs: () => prefs }
diff --git a/resources/session-relay/manifest.json b/resources/session-relay/manifest.json
new file mode 100644
index 0000000..34fab65
--- /dev/null
+++ b/resources/session-relay/manifest.json
@@ -0,0 +1,28 @@
+{
+ "manifest_version": 3,
+ "name": "Roxy Session Relay",
+ "version": "1.0.0",
+ "description": "Send a site's cookies and storage to the Roxy app, so you can debug a signed-in page without signing in again.",
+
+ "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzJm9L3DATBNEUCb8T0VgC+46UXFa4KtpS9Kzz24MXeeHonZjESbCfBSJzjnMBMSdAWmAY2Spa3v7ozCGibL0Jy05tlPVfE8eJ1n98T2gW72Od3eBi7ToaDHiDPmuYKa1qxbb0CQTpyLvwRSJWyo8J16TMiGcShojtw/o+Ek1xfSaFT5JVy/klid7VSSSk/eCwqppVp0zAouoX/TtYMogaQlcXSNZfL4AuayYi+9Utd3y29A4K97Pe0RucFk6bMGm4Q+ypqA+HTxzDGekHYQMVmZQcjQ4iFsxOYcvNJyqPmrR64+mbqHltYo7Pie1TZ74gOl1ba6dQdmUaDg2vZChhwIDAQAB",
+
+ "permissions": ["activeTab", "alarms", "cookies", "scripting", "storage"],
+
+ "host_permissions": ["http://127.0.0.1/*"],
+
+ "optional_host_permissions": ["http://*/*", "https://*/*"],
+
+ "action": {
+ "default_title": "Roxy Session Relay",
+ "default_popup": "popup.html"
+ },
+
+ "background": {
+ "service_worker": "background.js",
+ "type": "module"
+ },
+
+ "content_security_policy": {
+ "extension_pages": "script-src 'self'; object-src 'self'; connect-src http://127.0.0.1:4317"
+ }
+}
diff --git a/resources/session-relay/popup.html b/resources/session-relay/popup.html
new file mode 100644
index 0000000..4d5d000
--- /dev/null
+++ b/resources/session-relay/popup.html
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/session-relay/popup.js b/resources/session-relay/popup.js
new file mode 100644
index 0000000..eff0af1
--- /dev/null
+++ b/resources/session-relay/popup.js
@@ -0,0 +1,311 @@
+/**
+ * Roxy Session Relay — popup.
+ *
+ * Two states: pair (enter the code Roxy is showing) and send (pick what to
+ * transfer for the current tab). All network I/O goes through the background
+ * worker, which owns the token.
+ *
+ * Nothing is captured until "Send session" is pressed. Site access is requested
+ * at that moment via `chrome.permissions.request`, so installing this extension
+ * does not grant it access to every page you visit.
+ */
+
+const app = document.getElementById('app')
+
+/** Ask the worker whether we already hold a token. */
+async function status() {
+ return chrome.runtime.sendMessage({ type: 'status' })
+}
+
+/** The tab the user is looking at — the only one we ever touch. */
+async function currentTab() {
+ const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
+ return tab ?? null
+}
+
+function originOf(url) {
+ try {
+ const u = new URL(url)
+ return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin : null
+ } catch {
+ return null
+ }
+}
+
+function el(html) {
+ const t = document.createElement('template')
+ t.innerHTML = html.trim()
+ return t.content.firstElementChild
+}
+
+// ---- pairing ---------------------------------------------------------------
+
+function renderPair(error) {
+ app.replaceChildren(
+ el(`
+
+
+
Connect to Roxy
+
Enter the 6-digit code shown in Roxy under Settings › Browser.
+ `)
+ )
+ wireDisconnect()
+
+ const msg = app.querySelector('#msg')
+ const send = app.querySelector('#send')
+
+ send.addEventListener('click', async () => {
+ send.disabled = true
+ send.textContent = 'Capturing…'
+ msg.className = ''
+ msg.textContent = ''
+
+ const want = {
+ cookies: app.querySelector('#c-cookies').checked,
+ localStorage: app.querySelector('#c-local').checked,
+ sessionStorage: app.querySelector('#c-session').checked
+ }
+
+ try {
+ // Site access is requested HERE, from a user gesture, and only for this
+ // origin — not at install time for every site.
+ const granted = await chrome.permissions.request({ origins: [`${origin}/*`] })
+ if (!granted) throw new Error('Access to this site was not granted.')
+
+ // Now that cookies are readable, show the true counts rather than the
+ // dashes that were there a moment ago.
+ void refreshCounts(tab, origin)
+
+ const snapshot = await capture(tab, origin, want)
+ // Sending zero cookies for a site you are signed into looks like success
+ // but is not, so warn instead of silently relaying an empty session.
+ if (want.cookies && snapshot.cookies.length === 0) {
+ throw new Error('No cookies were readable for this site. Try reloading the page.')
+ }
+ send.textContent = 'Sending…'
+ const r = await chrome.runtime.sendMessage({ type: 'send', snapshot })
+ if (!r?.ok) throw new Error(r?.error ?? 'Roxy rejected the transfer.')
+ msg.className = 'msg ok'
+ msg.textContent = 'Sent. Confirm the import in Roxy.'
+ send.textContent = 'Send again'
+ } catch (e) {
+ msg.className = 'msg err'
+ msg.textContent = e?.message ?? String(e)
+ send.textContent = 'Send session'
+ } finally {
+ send.disabled = false
+ }
+ })
+
+ // Show counts up front so the user knows what they're about to hand over.
+ void refreshCounts(tab, origin)
+}
+
+/** Fill in the three counts, distinguishing "none" from "not allowed to look". */
+async function refreshCounts(tab, origin) {
+ const { cookies, local, session } = await preview(tab, origin)
+ const set = (id, v) => {
+ const node = app.querySelector(id)
+ if (node) node.textContent = v == null ? '—' : String(v)
+ }
+ set('#n-cookies', cookies)
+ set('#n-local', local)
+ set('#n-session', session)
+
+ // A dash means "unknown until you grant access", which is not obvious on its
+ // own — say so, or an empty-looking list reads as "there is nothing here".
+ const hint = app.querySelector('#count-hint')
+ if (hint)
+ hint.textContent = cookies == null ? 'Counts appear once you allow access to this site.' : ''
+}
+
+/**
+ * Counts for the checkboxes.
+ *
+ * `null` means UNKNOWN, not zero. The distinction matters: until the user
+ * grants host access for this origin, `chrome.cookies.getAll` returns an empty
+ * array rather than failing — "This method only retrieves cookies for domains
+ * that the extension has host permissions to." Rendering that as `0` would
+ * claim a signed-in site has no cookies, which is exactly backwards.
+ *
+ * Storage is readable earlier because `activeTab` grants a temporary host
+ * permission when the user opens the popup, and `chrome.scripting` honours it.
+ * The cookies API does not, so the two can legitimately disagree.
+ */
+async function preview(tab, origin) {
+ const out = { cookies: null, local: null, session: null }
+
+ const allowed = await chrome.permissions.contains({ origins: [`${origin}/*`] }).catch(() => false)
+ if (allowed) {
+ try {
+ out.cookies = (await chrome.cookies.getAll({ url: `${origin}/` })).length
+ } catch {
+ // Left as null: unknown, not zero.
+ }
+ }
+
+ try {
+ const [res] = await chrome.scripting.executeScript({
+ target: { tabId: tab.id },
+ func: () => [localStorage.length, sessionStorage.length]
+ })
+ if (res?.result) {
+ out.local = res.result[0]
+ out.session = res.result[1]
+ }
+ } catch {
+ // Page blocks injection (a chrome:// page, a PDF viewer): stays unknown.
+ }
+ return out
+}
+
+/** Build the snapshot Roxy expects (see shared/relay.ts). */
+async function capture(tab, origin, want) {
+ const snapshot = {
+ v: 1,
+ origin,
+ title: tab.title ?? undefined,
+ capturedAt: Date.now(),
+ cookies: []
+ }
+
+ if (want.cookies) {
+ // `getAll({ url })` returns every cookie that would be SENT to that URL,
+ // including HttpOnly ones the page itself cannot read — which is the whole
+ // reason this goes through the extension API instead of document.cookie.
+ const raw = await chrome.cookies.getAll({ url: `${origin}/` })
+ snapshot.cookies = raw.map((c) => {
+ const row = {
+ name: c.name,
+ value: c.value,
+ domain: c.domain,
+ path: c.path,
+ secure: c.secure,
+ httpOnly: c.httpOnly,
+ hostOnly: c.hostOnly,
+ session: c.session,
+ sameSite: c.sameSite ?? 'unspecified'
+ }
+ if (typeof c.expirationDate === 'number') row.expirationDate = c.expirationDate
+ // Carried so Roxy can report it was skipped: partitioned cookies cannot
+ // be faithfully recreated there.
+ if (c.partitionKey) row.partitionKey = c.partitionKey
+ return row
+ })
+ }
+
+ if (want.localStorage || want.sessionStorage) {
+ const [res] = await chrome.scripting.executeScript({
+ target: { tabId: tab.id },
+ args: [want.localStorage, want.sessionStorage],
+ func: (wantLocal, wantSession) => {
+ const dump = (store) => {
+ const out = {}
+ for (let i = 0; i < store.length; i++) {
+ const k = store.key(i)
+ if (k != null) out[k] = store.getItem(k) ?? ''
+ }
+ return out
+ }
+ return {
+ local: wantLocal ? dump(localStorage) : undefined,
+ session: wantSession ? dump(sessionStorage) : undefined
+ }
+ }
+ })
+ if (res?.result?.local) snapshot.localStorage = res.result.local
+ if (res?.result?.session) snapshot.sessionStorage = res.result.session
+ }
+
+ return snapshot
+}
+
+function wireDisconnect() {
+ app.querySelector('#disconnect')?.addEventListener('click', async () => {
+ await chrome.runtime.sendMessage({ type: 'unpair' })
+ render()
+ })
+}
+
+function escapeHtml(s) {
+ return String(s).replace(
+ /[&<>"']/g,
+ (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]
+ )
+}
+
+async function render() {
+ const s = await status()
+ if (s?.paired) renderSend()
+ else renderPair()
+}
+
+render()
diff --git a/src/main/db/repo.ts b/src/main/db/repo.ts
index 5b707fb..94dbb8a 100644
--- a/src/main/db/repo.ts
+++ b/src/main/db/repo.ts
@@ -133,6 +133,48 @@ function setSetting(key: string, value: string | null): void {
).run(key, value)
}
+// ---- Session Relay pairing ---------------------------------------------
+// The relay's bearer token, encrypted by the caller (see services/relay.ts)
+// before it gets here. Stored as one JSON blob in `settings` for the same
+// reason the forge host map below is: a single row, no relations, and
+// therefore no migration - which matters because migrations are append-only.
+//
+// NOT in `credentials`: that table is keyed by provider_id with a foreign key
+// into `providers`, and the relay is not a model provider.
+
+const RELAY_PAIRING_KEY = 'session_relay_pairing'
+
+/** The stored relay pairing blob, or null when nothing is paired. */
+export function getRelayPairing(): string | null {
+ const row = getDb().prepare('SELECT value FROM settings WHERE key = ?').get(RELAY_PAIRING_KEY) as
+ | { value: string }
+ | undefined
+ return row?.value ?? null
+}
+
+/** Persist (or clear, with null) the relay pairing blob. */
+export function setRelayPairing(value: string | null): void {
+ setSetting(RELAY_PAIRING_KEY, value)
+}
+
+// The relay's automation prefs (auto-send switch, trusted origins, blocklist).
+// Stored in the clear: these are hostnames, not credentials, and a blocklist
+// you cannot read is a blocklist you cannot fix.
+const RELAY_PREFS_KEY = 'session_relay_prefs'
+
+/** The stored relay automation prefs, or null before anything is set. */
+export function getRelayPrefs(): string | null {
+ const row = getDb().prepare('SELECT value FROM settings WHERE key = ?').get(RELAY_PREFS_KEY) as
+ | { value: string }
+ | undefined
+ return row?.value ?? null
+}
+
+/** Persist (or clear, with null) the relay automation prefs. */
+export function setRelayPrefs(value: string | null): void {
+ setSetting(RELAY_PREFS_KEY, value)
+}
+
// ---- Forge host overrides ----------------------------------------------
// Which software an UNRECOGNISED git host runs (`git.mycorp.com` -> gitlab).
// Only consulted when auto-detection fails, so a stale or mistaken answer can
diff --git a/src/main/index.ts b/src/main/index.ts
index d1e2ce4..0ed55bd 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -6,6 +6,7 @@ import macDockIcon from '../../resources/icon-mac.png?asset'
import { registerIpc } from './ipc'
import { getDb } from './db/database'
import { startLoopScheduler } from './services/loops'
+import { start as startRelay, stop as stopRelay } from './services/relay'
import { listModels } from './services/models'
import { backfillUsageFromHistory } from './services/usage'
import { listConnectedProviders } from './db/repo'
@@ -111,6 +112,9 @@ app.whenReady().then(() => {
// own storage - a failure in it can't touch either.
initTracking()
startLoopScheduler()
+ // The Session Relay listener. Loopback-only and inert until an extension
+ // pairs, so starting it unconditionally costs one idle socket.
+ void startRelay()
// Sweep tool-output spill files older than the retention window (best-effort).
void cleanupToolOutputs()
// One-time: seed the usage/cost table from existing message history so the
@@ -146,6 +150,7 @@ app.on('will-quit', () => {
killAllBackground()
cancelAllBackgroundJobs()
closeAllBrowsers()
+ stopRelay()
shutdownAllLsp()
void shutdownAllMcp()
shutdownRemote()
diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts
index 2498c61..5373fca 100644
--- a/src/main/ipc/index.ts
+++ b/src/main/ipc/index.ts
@@ -30,6 +30,8 @@ import * as copilot from '../services/copilot'
import * as cliproxy from '../services/cliproxy'
import * as browser from '../services/browser'
import * as cookies from '../services/cookies'
+import * as relay from '../services/relay'
+import * as relayInstall from '../services/relay-install'
import { listModels } from '../services/models'
import { pickDefaultModel } from '../../shared/models'
import { CLIPROXY_PROVIDER_IDS, accountsFor, isCliProxyProvider } from '../../shared/cliproxy'
@@ -49,6 +51,7 @@ import { sessionCwd } from '../services/workspace'
import * as git from '../services/git'
import * as forge from '../services/forge'
import type { ForgeKind } from '../../shared/forge'
+import type { RelayImportChoice, RelayPrefs } from '../../shared/relay'
import { pruneWorktrees, removeWorktreeForChat, renameWorkstreamBranch } from '../services/worktree'
import { checkForUpdates, quitAndInstall, getUpdateState } from '../services/updater'
import {
@@ -781,6 +784,26 @@ export function registerIpc(): void {
browser.setChromeHeight(height, keyOf(e))
)
+ // ---- session relay (the paired browser extension) ----
+ // The relay listener itself is NOT reachable from here; these only drive
+ // pairing and the user's approve/reject of a queued snapshot. See
+ // services/relay.ts for why that split is the safety boundary.
+ ipcMain.handle(CHANNELS.relayStatus, () => relay.status())
+ ipcMain.handle(CHANNELS.relayBeginPairing, () => relay.beginPairing())
+ ipcMain.handle(CHANNELS.relayCancelPairing, () => relay.cancelPairing())
+ ipcMain.handle(CHANNELS.relayUnpair, () => relay.unpair())
+ ipcMain.handle(
+ CHANNELS.relayApply,
+ (_e, id: string, choice: RelayImportChoice, trust?: boolean) =>
+ relay.applyPending(id, choice, trust)
+ )
+ ipcMain.handle(CHANNELS.relayReject, (_e, id: string) => relay.rejectPending(id))
+ ipcMain.handle(CHANNELS.relayInstallExtension, () => relayInstall.install())
+ ipcMain.handle(CHANNELS.relayRevealExtension, () => relayInstall.reveal())
+ ipcMain.handle(CHANNELS.relaySetPrefs, (_e, p: Partial) => relay.setPrefs(p))
+ ipcMain.handle(CHANNELS.relayTrustOrigin, (_e, o: string) => relay.trustOrigin(o))
+ ipcMain.handle(CHANNELS.relayUntrustOrigin, (_e, o: string) => relay.untrustOrigin(o))
+
// ---- services (a session's background processes) ----
// Every handler resolves the ROOT session first: a subagent's dev server is
// registered under its parent, and the parent's panel is where it belongs.
diff --git a/src/main/services/browser.ts b/src/main/services/browser.ts
index 9887b21..32360b9 100644
--- a/src/main/services/browser.ts
+++ b/src/main/services/browser.ts
@@ -314,6 +314,32 @@ export function keyForContents(wc: Electron.WebContents): string | null {
return null
}
+/**
+ * An open tab currently sitting on `origin`, across every session's browser.
+ *
+ * Session storage is scoped to one browsing context, so relaying it needs a
+ * LIVE tab on the target origin - there is nothing else to write into.
+ * Prefers a session's ACTIVE tab, since that is the one the user is looking at.
+ */
+export function contentsForOrigin(origin: string): Electron.WebContents | null {
+ let fallback: Electron.WebContents | null = null
+ for (const s of sessions.values()) {
+ for (const t of s.tabs) {
+ if (t.view.webContents.isDestroyed()) continue
+ let tabOrigin: string
+ try {
+ tabOrigin = new URL(t.view.webContents.getURL()).origin
+ } catch {
+ continue
+ }
+ if (tabOrigin !== origin) continue
+ if (t.id === s.activeTabId) return t.view.webContents
+ fallback ??= t.view.webContents
+ }
+ }
+ return fallback
+}
+
/** Label a session's window (usually the project folder), so windows are tellable apart. */
export function setLabel(key: string, label: string): void {
if (key === DEFAULT_KEY) return
diff --git a/src/main/services/relay-install.ts b/src/main/services/relay-install.ts
new file mode 100644
index 0000000..bc6e65c
--- /dev/null
+++ b/src/main/services/relay-install.ts
@@ -0,0 +1,73 @@
+/**
+ * Installing the bundled Session Relay extension where a real browser can load
+ * it.
+ *
+ * Chrome cannot load an unpacked extension from inside an asar archive, and on
+ * a packaged build our files live under `resources/` (kept unpacked via
+ * `asarUnpack` in electron-builder.yml). Rather than point the user at a path
+ * buried in the app bundle — which breaks on every update, and which macOS
+ * hides — we COPY the extension into the user's Documents folder. That path is
+ * stable, greppable, and survives Roxy updating underneath it.
+ *
+ * The copy is re-run on every install click so an app update refreshes the
+ * extension in place; the user just hits Reload in their browser.
+ */
+import { app, shell } from 'electron'
+import { cp, mkdir, readFile, rm } from 'node:fs/promises'
+import { existsSync } from 'node:fs'
+import { join } from 'node:path'
+
+/** Folder name under Documents. Recognisable in a "Load unpacked" dialog. */
+const FOLDER = 'Roxy Session Relay'
+
+/** Where the extension ships inside the app. */
+function sourceDir(): string {
+ // Packaged: resources are unpacked next to the asar. Dev: straight from the
+ // repo, since `resources/` is not copied into out/.
+ return app.isPackaged
+ ? join(process.resourcesPath, 'resources', 'session-relay')
+ : join(app.getAppPath(), 'resources', 'session-relay')
+}
+
+/** Where the user loads it from. */
+export function installDir(): string {
+ return join(app.getPath('documents'), FOLDER)
+}
+
+export interface InstallResult {
+ path: string
+ version: string
+}
+
+/**
+ * Copy the extension to Documents, replacing any previous copy.
+ *
+ * We delete first rather than copy over the top: a stale file from an older
+ * version (a renamed script, a dropped asset) would otherwise linger and could
+ * break the load with a confusing manifest error.
+ */
+export async function install(): Promise {
+ const src = sourceDir()
+ if (!existsSync(src)) {
+ throw new Error(`The bundled extension is missing from this build (${src}).`)
+ }
+ const dest = installDir()
+ await rm(dest, { recursive: true, force: true })
+ await mkdir(dest, { recursive: true })
+ await cp(src, dest, { recursive: true })
+
+ let version = ''
+ try {
+ version = JSON.parse(await readFile(join(dest, 'manifest.json'), 'utf8')).version ?? ''
+ } catch {
+ // Non-fatal: the copy is what matters, the version is only shown in the UI.
+ }
+ return { path: dest, version }
+}
+
+/** Open the folder in the OS file manager, so "Load unpacked" is a short trip. */
+export async function reveal(): Promise {
+ const dir = installDir()
+ if (!existsSync(dir)) throw new Error('Install the extension first.')
+ await shell.openPath(dir)
+}
diff --git a/src/main/services/relay.ts b/src/main/services/relay.ts
new file mode 100644
index 0000000..e5709d8
--- /dev/null
+++ b/src/main/services/relay.ts
@@ -0,0 +1,717 @@
+/**
+ * The Session Relay listener — an authenticated loopback endpoint the bundled
+ * Chrome extension posts a site's session to.
+ *
+ * See ../../shared/relay.ts for the protocol and the threat model. The short
+ * version: a snapshot is live credentials, loopback is reachable by any process
+ * AND (via DNS rebinding) by any web page, so every request must prove it came
+ * from the paired extension. This file is where that is enforced, and it is
+ * deliberately the only place that can accept a snapshot.
+ *
+ * Routes (all POST, all JSON):
+ * /pair { code, extensionId, browser, version } -> { token }
+ * /hello auth -> { ok } .............. heartbeat, drives "Connected"
+ * /snapshot auth + RelaySnapshot -> { queued } ..... needs user approval
+ *
+ * Nothing here ever WRITES to the browser partition. A snapshot is parked in
+ * memory and surfaced to the UI; only an explicit `applyPending` from the
+ * renderer touches cookies or storage. That split is the whole safety story:
+ * compromise of the endpoint alone cannot silently inject a session.
+ */
+import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
+import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
+import { BrowserWindow } from 'electron'
+import {
+ AUTO_SEND_COOLDOWN_MS,
+ DEFAULT_RELAY_PREFS,
+ MAX_SNAPSHOT_BYTES,
+ PAIRING_TTL_MS,
+ RELAY_HEADER,
+ RELAY_PORT,
+ RELAY_PROTOCOL_VERSION,
+ isAutoAllowed,
+ isBlockedOrigin,
+ isImportableOrigin,
+ type PendingSnapshot,
+ type RelayCookie,
+ type RelayImportChoice,
+ type RelayImportResult,
+ type RelayPrefs,
+ type RelaySnapshot,
+ type RelayStatus,
+ type RelayTransfer
+} from '../../shared/relay'
+import { CHANNELS } from '../../shared/ipc'
+import { decryptSecret, encryptSecret } from './secure'
+import * as repo from '../db/repo'
+import * as cookies from './cookies'
+import * as storage from './storage'
+import * as browser from './browser'
+
+/** A completed pairing. Persisted encrypted; see `load`/`persist` below. */
+interface Pairing {
+ token: string
+ extensionId: string
+ browser: string
+ version: string
+}
+
+interface State {
+ server: Server | null
+ port: number
+ pairing: Pairing | null
+ /** The code currently on screen, if the user is mid-pairing. */
+ code: { value: string; expiresAt: number } | null
+ lastSeenAt?: number
+ lastTransferAt?: number
+ /** Snapshots awaiting approval, newest last. Values live ONLY here. */
+ pending: { meta: PendingSnapshot; snapshot: RelaySnapshot }[]
+ /** Automation settings: master switch, trusted origins, blocklist. */
+ prefs: RelayPrefs
+ /** Recent transfers for the Settings activity list. Never includes values. */
+ recent: RelayTransfer[]
+ /** origin -> last auto-apply time, for the per-origin cooldown. */
+ lastAuto: Map
+}
+
+const state: State = {
+ server: null,
+ port: RELAY_PORT,
+ pairing: null,
+ code: null,
+ pending: [],
+ prefs: { ...DEFAULT_RELAY_PREFS },
+ recent: [],
+ lastAuto: new Map()
+}
+
+/** How many transfers to remember for the activity list. */
+const MAX_RECENT = 20
+
+/**
+ * Cap on parked snapshots. A paired-but-malicious extension could otherwise
+ * queue unbounded credential blobs into main's memory.
+ */
+const MAX_PENDING = 5
+
+/** Constant-time compare that also tolerates length mismatch without leaking it. */
+function safeEqual(a: string, b: string): boolean {
+ const ba = Buffer.from(a, 'utf8')
+ const bb = Buffer.from(b, 'utf8')
+ if (ba.length !== bb.length) {
+ // Still burn a comparison so timing doesn't reveal "wrong length" vs
+ // "wrong value" — the branch above is not secret, the contents are.
+ timingSafeEqual(ba, ba)
+ return false
+ }
+ return timingSafeEqual(ba, bb)
+}
+
+/** A 6-digit pairing code. Human-typable; only useful inside its 3-minute TTL. */
+function makeCode(): string {
+ // rejection-free: 3 bytes -> 0..16777215, mod 1e6 bias is ~6e-8, irrelevant
+ // for a 3-minute single-use code guarded by a rate limit.
+ return String(randomBytes(3).readUIntBE(0, 3) % 1_000_000).padStart(6, '0')
+}
+
+/** Wrong-code attempts since the last success, to blunt online guessing. */
+let failedPairAttempts = 0
+const MAX_PAIR_ATTEMPTS = 10
+
+function json(res: ServerResponse, status: number, body: unknown): void {
+ const text = JSON.stringify(body)
+ res.writeHead(status, {
+ 'content-type': 'application/json; charset=utf-8',
+ // Nothing here is cacheable and none of it should ever be stored.
+ 'cache-control': 'no-store',
+ // The relay is not a website; refuse to be framed or sniffed.
+ 'x-content-type-options': 'nosniff'
+ })
+ res.end(text)
+}
+
+/**
+ * The origin we accept, once paired: exactly this extension, nothing else.
+ * Before pairing there is no such origin, so /pair accepts any extension origin
+ * but demands the on-screen code instead.
+ */
+function pairedOrigin(): string | null {
+ return state.pairing ? `chrome-extension://${state.pairing.extensionId}` : null
+}
+
+/**
+ * Reject anything that isn't the paired extension talking to loopback.
+ *
+ * Order matters only for clarity; all four checks must pass:
+ * - Host pins us to 127.0.0.1:, defeating DNS rebinding (a page on
+ * evil.com resolved to 127.0.0.1 would send `Host: evil.com`).
+ * - The custom header forces a preflight, so no page can reach this with a
+ * simple cross-origin POST.
+ * - Origin must be the paired extension. Browsers set this header themselves
+ * and script cannot override it, so this is the load-bearing check.
+ * - The bearer token proves it's our extension and not another one.
+ */
+function authed(req: IncomingMessage): boolean {
+ const p = state.pairing
+ if (!p) return false
+
+ const host = String(req.headers.host ?? '')
+ if (host !== `127.0.0.1:${state.port}` && host !== `localhost:${state.port}`) return false
+
+ if (String(req.headers[RELAY_HEADER] ?? '') !== '1') return false
+
+ const origin = String(req.headers.origin ?? '')
+ if (origin !== pairedOrigin()) return false
+
+ const auth = String(req.headers.authorization ?? '')
+ if (!auth.startsWith('Bearer ')) return false
+ return safeEqual(auth.slice(7), p.token)
+}
+
+/** Read a JSON body, refusing anything oversized before buffering it all. */
+async function readJson(req: IncomingMessage, limit: number): Promise {
+ const declared = Number(req.headers['content-length'] ?? 0)
+ if (declared > limit) throw new Error('payload too large')
+ const chunks: Buffer[] = []
+ let total = 0
+ for await (const chunk of req) {
+ total += (chunk as Buffer).length
+ // Enforce on the stream too: content-length can lie or be absent.
+ if (total > limit) throw new Error('payload too large')
+ chunks.push(chunk as Buffer)
+ }
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
+}
+
+/** Push status to every window so Settings updates without polling. */
+function broadcast(): void {
+ const s = status()
+ for (const w of BrowserWindow.getAllWindows()) {
+ if (!w.isDestroyed()) w.webContents.send(CHANNELS.relayState, s)
+ }
+}
+
+/** Current relay state. Never includes the token or any snapshot VALUES. */
+export function status(): RelayStatus {
+ return {
+ listening: Boolean(state.server?.listening),
+ port: state.port,
+ paired: Boolean(state.pairing),
+ extensionId: state.pairing?.extensionId,
+ browser: state.pairing?.browser,
+ extensionVersion: state.pairing?.version,
+ lastSeenAt: state.lastSeenAt,
+ lastTransferAt: state.lastTransferAt,
+ pairingExpiresAt: state.code?.expiresAt,
+ pending: state.pending.map((p) => p.meta),
+ prefs: state.prefs,
+ recent: state.recent
+ }
+}
+
+/** Automation settings, as the extension needs them. */
+export function prefs(): RelayPrefs {
+ return state.prefs
+}
+
+/**
+ * Replace the automation settings.
+ *
+ * Adding a block also revokes any trust for origins it now covers. Without
+ * that, blocking `example.com` while `https://app.example.com` sat in the
+ * trusted list would leave a contradiction on screen — and although
+ * `isAutoAllowed` checks blocks first (so it would be honoured), a UI that
+ * displays a trusted entry which silently does nothing is a bug waiting to be
+ * misread.
+ */
+export function setPrefs(next: Partial): RelayPrefs {
+ const merged: RelayPrefs = { ...state.prefs, ...next }
+ merged.trusted = merged.trusted.filter((o) => !isBlockedOrigin(o, merged.blocked))
+ state.prefs = merged
+ persistPrefs()
+ broadcast()
+ return state.prefs
+}
+
+/** Mark an origin as needing no further confirmation. */
+export function trustOrigin(origin: string): RelayPrefs {
+ if (!isImportableOrigin(origin)) throw new Error(`Cannot trust "${origin}".`)
+ if (isBlockedOrigin(origin, state.prefs.blocked)) {
+ throw new Error('That site is on the blocklist. Remove it there first.')
+ }
+ if (state.prefs.trusted.includes(origin)) return state.prefs
+ return setPrefs({ trusted: [...state.prefs.trusted, origin] })
+}
+
+export function untrustOrigin(origin: string): RelayPrefs {
+ return setPrefs({ trusted: state.prefs.trusted.filter((o) => o !== origin) })
+}
+
+/** Rough byte size of the credential material, for the confirmation prompt. */
+function sizeOf(snap: RelaySnapshot): number {
+ let n = 0
+ for (const c of snap.cookies) n += c.name.length + c.value.length
+ for (const rec of [snap.localStorage, snap.sessionStorage]) {
+ if (!rec) continue
+ for (const [k, v] of Object.entries(rec)) n += k.length + v.length
+ }
+ return n
+}
+
+/**
+ * Validate an untrusted snapshot into the shape the rest of the app assumes.
+ * Returns null when it's unusable. Everything crossing this boundary is
+ * attacker-controlled, so nothing is taken on trust — not the types, not the
+ * origin, not the array contents.
+ */
+function parseSnapshot(raw: unknown): RelaySnapshot | null {
+ if (!raw || typeof raw !== 'object') return null
+ const o = raw as Record
+ if (o.v !== RELAY_PROTOCOL_VERSION) return null
+ const origin = String(o.origin ?? '')
+ if (!isImportableOrigin(origin)) return null
+
+ const cookies: RelayCookie[] = []
+ if (Array.isArray(o.cookies)) {
+ for (const item of o.cookies) {
+ if (!item || typeof item !== 'object') continue
+ const c = item as Record
+ const name = String(c.name ?? '')
+ const domain = String(c.domain ?? '')
+ if (!name || !domain) continue
+ const row: RelayCookie = {
+ name,
+ value: String(c.value ?? ''),
+ domain,
+ path: String(c.path ?? '/') || '/',
+ secure: Boolean(c.secure),
+ httpOnly: Boolean(c.httpOnly),
+ hostOnly: Boolean(c.hostOnly),
+ session: Boolean(c.session),
+ sameSite: normalizeSameSite(c.sameSite)
+ }
+ if (typeof c.expirationDate === 'number' && Number.isFinite(c.expirationDate)) {
+ row.expirationDate = c.expirationDate
+ }
+ if (c.partitionKey && typeof c.partitionKey === 'object') {
+ const pk = c.partitionKey as Record
+ row.partitionKey = {
+ topLevelSite: pk.topLevelSite ? String(pk.topLevelSite) : undefined,
+ hasCrossSiteAncestor: Boolean(pk.hasCrossSiteAncestor)
+ }
+ }
+ cookies.push(row)
+ }
+ }
+
+ const strings = (v: unknown): Record | undefined => {
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return undefined
+ const out: Record = {}
+ for (const [k, val] of Object.entries(v as Record)) {
+ if (typeof val === 'string') out[k] = val
+ }
+ return out
+ }
+
+ return {
+ v: RELAY_PROTOCOL_VERSION,
+ origin,
+ title: o.title ? String(o.title).slice(0, 200) : undefined,
+ capturedAt: typeof o.capturedAt === 'number' ? o.capturedAt : Date.now(),
+ cookies,
+ localStorage: strings(o.localStorage),
+ sessionStorage: strings(o.sessionStorage)
+ }
+}
+
+function normalizeSameSite(raw: unknown): RelayCookie['sameSite'] {
+ const v = String(raw ?? '')
+ .trim()
+ .toLowerCase()
+ if (v === 'no_restriction' || v === 'none') return 'no_restriction'
+ if (v === 'lax') return 'lax'
+ if (v === 'strict') return 'strict'
+ return 'unspecified'
+}
+
+async function handle(req: IncomingMessage, res: ServerResponse): Promise {
+ const url = new URL(req.url ?? '/', `http://127.0.0.1:${state.port}`)
+
+ // CORS preflight. We answer for the paired extension only; before pairing we
+ // must also allow an extension origin through so /pair itself is reachable.
+ if (req.method === 'OPTIONS') {
+ const origin = String(req.headers.origin ?? '')
+ const allowed = pairedOrigin()
+ const ok = allowed ? origin === allowed : origin.startsWith('chrome-extension://')
+ if (!ok) return void json(res, 403, { error: 'origin not allowed' })
+ res.writeHead(204, {
+ 'access-control-allow-origin': origin,
+ 'access-control-allow-methods': 'POST, OPTIONS',
+ 'access-control-allow-headers': `content-type, authorization, ${RELAY_HEADER}`,
+ 'access-control-max-age': '600',
+ vary: 'origin'
+ })
+ return void res.end()
+ }
+
+ if (req.method !== 'POST') return void json(res, 405, { error: 'method not allowed' })
+
+ // ---- pairing -------------------------------------------------------------
+ if (url.pathname === '/pair') {
+ const origin = String(req.headers.origin ?? '')
+ if (!origin.startsWith('chrome-extension://')) {
+ return void json(res, 403, { error: 'origin not allowed' })
+ }
+ if (!state.code || Date.now() > state.code.expiresAt) {
+ return void json(res, 409, { error: 'no pairing in progress' })
+ }
+ if (failedPairAttempts >= MAX_PAIR_ATTEMPTS) {
+ state.code = null
+ broadcast()
+ return void json(res, 429, { error: 'too many attempts; start pairing again' })
+ }
+ let body: Record
+ try {
+ body = (await readJson(req, 4096)) as Record
+ } catch {
+ return void json(res, 400, { error: 'bad request' })
+ }
+ if (!safeEqual(String(body.code ?? ''), state.code.value)) {
+ failedPairAttempts++
+ return void json(res, 401, { error: 'wrong code' })
+ }
+ // The extension id must match the origin that carried the request, or a
+ // second extension could claim the first one's identity.
+ const extensionId = String(body.extensionId ?? '')
+ if (!extensionId || origin !== `chrome-extension://${extensionId}`) {
+ return void json(res, 400, { error: 'extension id does not match origin' })
+ }
+
+ state.pairing = {
+ token: randomBytes(32).toString('base64url'),
+ extensionId,
+ browser: String(body.browser ?? 'Browser').slice(0, 40),
+ version: String(body.version ?? '').slice(0, 20)
+ }
+ state.code = null
+ failedPairAttempts = 0
+ state.lastSeenAt = Date.now()
+ persist()
+ broadcast()
+ return void json(res, 200, { token: state.pairing.token })
+ }
+
+ // ---- everything below requires a completed pairing -----------------------
+ if (!authed(req)) return void json(res, 401, { error: 'unauthorized' })
+
+ if (url.pathname === '/hello') {
+ state.lastSeenAt = Date.now()
+ broadcast()
+ // The extension caches prefs so it can decide whether to auto-send without
+ // a round trip. Returning them on every heartbeat keeps Roxy the single
+ // source of truth: a block takes effect within one heartbeat, and the
+ // server enforces it independently anyway (see /snapshot).
+ return void json(res, 200, { ok: true, v: RELAY_PROTOCOL_VERSION, prefs: state.prefs })
+ }
+
+ if (url.pathname === '/snapshot') {
+ if (state.pending.length >= MAX_PENDING) {
+ return void json(res, 429, { error: 'too many snapshots awaiting approval' })
+ }
+ let raw: unknown
+ try {
+ raw = await readJson(req, MAX_SNAPSHOT_BYTES)
+ } catch (e) {
+ const tooBig = e instanceof Error && e.message === 'payload too large'
+ return void json(res, tooBig ? 413 : 400, { error: tooBig ? 'too large' : 'bad request' })
+ }
+ const snapshot = parseSnapshot(raw)
+ if (!snapshot) return void json(res, 400, { error: 'unsupported or malformed snapshot' })
+
+ // THE BLOCKLIST IS ENFORCED HERE, not just in the extension. The extension
+ // decides whether to SEND; this decides whether we will even hold it. An
+ // extension running stale prefs — or simply an older build — must not be
+ // able to relay a blocked site.
+ if (isBlockedOrigin(snapshot.origin, state.prefs.blocked)) {
+ return void json(res, 403, { error: 'that site is blocked in Roxy' })
+ }
+
+ state.lastSeenAt = Date.now()
+
+ // Trusted origin: apply now, no prompt. This is the "no clicking" path.
+ if (isAutoAllowed(snapshot.origin, state.prefs)) {
+ const last = state.lastAuto.get(snapshot.origin) ?? 0
+ if (Date.now() - last < AUTO_SEND_COOLDOWN_MS) {
+ // Not an error: the extension is allowed to be chatty, we just decline
+ // to redo the work. Reported so it can back off.
+ return void json(res, 200, { applied: false, throttled: true })
+ }
+ state.lastAuto.set(snapshot.origin, Date.now())
+ const result = await applySnapshot(snapshot, {
+ cookies: true,
+ localStorage: true,
+ sessionStorage: true
+ })
+ record(snapshot, result, true)
+ broadcast()
+ return void json(res, 200, { applied: true })
+ }
+
+ const partitioned = snapshot.cookies.filter((c) => c.partitionKey?.topLevelSite).length
+ const meta: PendingSnapshot = {
+ id: randomUUID(),
+ origin: snapshot.origin,
+ title: snapshot.title,
+ receivedAt: Date.now(),
+ browser: state.pairing?.browser ?? 'Browser',
+ cookieCount: snapshot.cookies.length,
+ partitionedCookieCount: partitioned,
+ localStorageCount: Object.keys(snapshot.localStorage ?? {}).length,
+ sessionStorageCount: Object.keys(snapshot.sessionStorage ?? {}).length,
+ approxBytes: sizeOf(snapshot)
+ }
+ state.pending.push({ meta, snapshot })
+ broadcast()
+ // Bring Roxy forward: the snapshot is useless until the user answers, and
+ // they just clicked "Send" in another app and are looking for the result.
+ const win = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed())
+ win?.show()
+ return void json(res, 202, { queued: true, id: meta.id })
+ }
+
+ return void json(res, 404, { error: 'not found' })
+}
+
+/** Start listening. Idempotent; safe to call on every app start. */
+export async function start(): Promise {
+ if (state.server) return
+ load()
+ const server = createServer((req, res) => {
+ handle(req, res).catch(() => {
+ if (!res.headersSent) json(res, 500, { error: 'internal error' })
+ else res.end()
+ })
+ })
+ // Loopback ONLY. Binding 0.0.0.0 would expose a credential sink to the LAN.
+ await new Promise((resolve) => {
+ server.once('error', () => resolve()) // port busy: stay down, surface via status()
+ server.listen(RELAY_PORT, '127.0.0.1', () => resolve())
+ })
+ state.server = server.listening ? server : null
+ broadcast()
+}
+
+export function stop(): void {
+ state.server?.close()
+ state.server = null
+ // Parked snapshots are credentials; never outlive the process.
+ state.pending = []
+ broadcast()
+}
+
+/** Begin pairing: mint a code for the user to type into the extension. */
+export function beginPairing(): { code: string; expiresAt: number; port: number } {
+ const code = makeCode()
+ state.code = { value: code, expiresAt: Date.now() + PAIRING_TTL_MS }
+ failedPairAttempts = 0
+ broadcast()
+ return { code, expiresAt: state.code.expiresAt, port: state.port }
+}
+
+export function cancelPairing(): void {
+ state.code = null
+ broadcast()
+}
+
+/** Forget the paired extension; its token stops working immediately. */
+export function unpair(): void {
+ state.pairing = null
+ state.code = null
+ state.pending = []
+ persist()
+ broadcast()
+}
+
+/** Take a parked snapshot out of the queue (approve or reject both consume it). */
+export function takePending(id: string): RelaySnapshot | null {
+ const i = state.pending.findIndex((p) => p.meta.id === id)
+ if (i < 0) return null
+ const [entry] = state.pending.splice(i, 1)
+ broadcast()
+ return entry.snapshot
+}
+
+export function markTransferred(): void {
+ state.lastTransferAt = Date.now()
+ broadcast()
+}
+
+/**
+ * Apply a snapshot to the browser partition.
+ *
+ * Split out from `applyPending` so the auto path and the manual path share one
+ * implementation — two copies would drift, and this one writes credentials.
+ *
+ * Partitioned (CHIPS) cookies are counted and skipped, not imported: Electron
+ * 33's cookie API has no `partitionKey`, so the best we could do is write them
+ * into the unpartitioned jar, which puts them in the wrong place. Reporting
+ * "3 skipped" is honest; silently misplacing them is not.
+ */
+async function applySnapshot(
+ snapshot: RelaySnapshot,
+ choice: RelayImportChoice
+): Promise {
+ const result: RelayImportResult = {
+ cookiesImported: 0,
+ cookiesFailed: 0,
+ cookiesSkippedPartitioned: 0,
+ localStorageImported: 0,
+ sessionStorageImported: 0,
+ errors: []
+ }
+
+ if (choice.cookies) {
+ for (const c of snapshot.cookies) {
+ if (c.partitionKey?.topLevelSite) {
+ result.cookiesSkippedPartitioned++
+ continue
+ }
+ const err = await cookies.set(c)
+ if (err) {
+ result.cookiesFailed++
+ if (result.errors.length < 8) result.errors.push(err)
+ } else {
+ result.cookiesImported++
+ }
+ }
+ }
+
+ if (choice.localStorage && snapshot.localStorage) {
+ try {
+ const r = await storage.writeLocalStorage(snapshot.origin, snapshot.localStorage)
+ result.localStorageImported = r.ok
+ if (r.blocked) result.errors.push(`${snapshot.origin} blocked localStorage access.`)
+ else if (r.failed) result.errors.push(`${r.failed} localStorage entries were rejected.`)
+ } catch (e) {
+ result.errors.push(e instanceof Error ? e.message : String(e))
+ }
+ }
+
+ if (choice.sessionStorage && snapshot.sessionStorage) {
+ // Session storage belongs to one tab, so it needs a live tab on that
+ // origin. Without one there is nothing to write into.
+ const contents = browser.contentsForOrigin(snapshot.origin)
+ if (!contents) {
+ result.errors.push(
+ `Session storage needs an open tab on ${snapshot.origin}; open it and send again.`
+ )
+ } else {
+ try {
+ const r = await storage.writeSessionStorage(
+ contents,
+ snapshot.origin,
+ snapshot.sessionStorage
+ )
+ result.sessionStorageImported = r.ok
+ } catch (e) {
+ result.errors.push(e instanceof Error ? e.message : String(e))
+ }
+ }
+ }
+
+ state.lastTransferAt = Date.now()
+ return result
+}
+
+/** Remember a completed transfer for the Settings activity list. */
+function record(snapshot: RelaySnapshot, result: RelayImportResult, auto: boolean): void {
+ state.recent.unshift({
+ origin: snapshot.origin,
+ at: Date.now(),
+ cookies: result.cookiesImported,
+ localStorage: result.localStorageImported,
+ sessionStorage: result.sessionStorageImported,
+ auto,
+ error: result.errors[0]
+ })
+ state.recent.length = Math.min(state.recent.length, MAX_RECENT)
+}
+
+/**
+ * Apply a parked snapshot after the user approved it in the UI.
+ *
+ * `trust` marks the origin so future transfers skip the prompt entirely — the
+ * "approve once, then never again" path.
+ */
+export async function applyPending(
+ id: string,
+ choice: RelayImportChoice,
+ trust = false
+): Promise {
+ const snapshot = takePending(id)
+ if (!snapshot) throw new Error('That transfer is no longer waiting for approval.')
+
+ const result = await applySnapshot(snapshot, choice)
+ record(snapshot, result, false)
+ if (trust) trustOrigin(snapshot.origin)
+ broadcast()
+ return result
+}
+
+/** Discard a parked snapshot without applying any of it. */
+export function rejectPending(id: string): void {
+ takePending(id)
+}
+
+// --- persistence -------------------------------------------------------------
+// The token is a long-lived credential, so it goes through safeStorage like an
+// API key. Pending snapshots are NEVER persisted.
+
+function persist(): void {
+ if (!state.pairing) {
+ repo.setRelayPairing(null)
+ return
+ }
+ const payload = encryptSecret(JSON.stringify(state.pairing))
+ repo.setRelayPairing(JSON.stringify(payload))
+}
+
+/**
+ * Automation prefs are stored in the CLEAR, unlike the token.
+ *
+ * They are a list of hostnames, not a credential — encrypting them would imply
+ * a protection safeStorage does not provide here, and would make the blocklist
+ * unreadable (and so unfixable) if the keychain ever changed.
+ */
+function persistPrefs(): void {
+ repo.setRelayPrefs(JSON.stringify(state.prefs))
+}
+
+function load(): void {
+ const rawPrefs = repo.getRelayPrefs()
+ if (rawPrefs) {
+ try {
+ // Merge over the defaults so a prefs blob written by an older version
+ // (missing a field we since added) cannot produce `undefined` where the
+ // code expects an array.
+ const parsed = JSON.parse(rawPrefs) as Partial
+ state.prefs = {
+ autoSend: parsed.autoSend ?? DEFAULT_RELAY_PREFS.autoSend,
+ trusted: Array.isArray(parsed.trusted) ? parsed.trusted : [],
+ blocked: Array.isArray(parsed.blocked) ? parsed.blocked : []
+ }
+ } catch {
+ state.prefs = { ...DEFAULT_RELAY_PREFS }
+ }
+ }
+
+ const row = repo.getRelayPairing()
+ if (!row) return
+ try {
+ state.pairing = JSON.parse(decryptSecret(JSON.parse(row)))
+ } catch {
+ // Unreadable (keychain changed, DB copied between machines) — drop it and
+ // make the user re-pair rather than leave a half-broken connection.
+ repo.setRelayPairing(null)
+ }
+}
diff --git a/src/main/services/storage.ts b/src/main/services/storage.ts
new file mode 100644
index 0000000..f5d0112
--- /dev/null
+++ b/src/main/services/storage.ts
@@ -0,0 +1,187 @@
+/**
+ * Writing Web Storage into the Roxy browser's partition.
+ *
+ * Cookies have a first-class Electron API (`session.cookies`). localStorage
+ * does NOT: there is no `session.setStorage`, and `clearStorageData` can only
+ * delete. The only supported way to populate an origin's storage is to BE that
+ * origin — load it in a page on the target partition and assign through the
+ * real `window.localStorage`.
+ *
+ * So this opens a hidden, offscreen window on `persist:roxy-browser`, navigates
+ * to the origin, writes, and disposes it. Two consequences worth knowing:
+ *
+ * - It performs a real navigation to the site. We request `about:blank`-like
+ * minimal work by aborting the load as soon as the document exists, but the
+ * origin must be reachable for the browser to grant us a storage context.
+ * - `sessionStorage` is per browsing-context. Writing it in a throwaway window
+ * would be pointless — it dies with the window — so session storage is
+ * applied to a LIVE tab instead (see `applySessionStorage`).
+ *
+ * Everything here is origin-scoped and main-only. The renderer never gets a
+ * `webContents` or an arbitrary `executeJavaScript`, because that would be a
+ * general-purpose code-execution channel wearing a storage costume.
+ */
+import { BrowserWindow } from 'electron'
+import { PARTITION } from './browser'
+import { isImportableOrigin, type RelayStorage } from '../../shared/relay'
+
+/** How long to wait for the origin to produce a document before giving up. */
+const LOAD_TIMEOUT_MS = 15_000
+
+/**
+ * The script that does the writing, built with the payload inlined as JSON.
+ *
+ * Values are injected as a single JSON literal rather than interpolated one by
+ * one: a cookie/token value containing a quote or a backslash would otherwise
+ * break out of the string and run as code in the page's own origin. `JSON
+ * .stringify` of the whole record is exactly one safe literal.
+ *
+ * Storage can throw even when present (Safari-style private mode, quota, a
+ * site's own hardening), so each key is attempted independently and failures
+ * are counted rather than aborting the batch.
+ */
+function writerScript(kind: 'localStorage' | 'sessionStorage', data: RelayStorage): string {
+ return `(() => {
+ const data = ${JSON.stringify(data)};
+ let ok = 0;
+ let failed = 0;
+ try {
+ const store = window.${kind};
+ for (const k of Object.keys(data)) {
+ try { store.setItem(k, data[k]); ok++; } catch { failed++; }
+ }
+ } catch {
+ return { ok: 0, failed: Object.keys(data).length, blocked: true };
+ }
+ return { ok, failed, blocked: false };
+ })()`
+}
+
+export interface StorageWriteResult {
+ ok: number
+ failed: number
+ /** The origin denied storage access outright (third-party blocking, etc). */
+ blocked: boolean
+}
+
+/**
+ * Write `data` into `origin`'s localStorage on the browser partition.
+ *
+ * Throws on an unusable origin or an unreachable site; per-key failures come
+ * back in the result instead, since a partial import is still useful.
+ */
+export async function writeLocalStorage(
+ origin: string,
+ data: RelayStorage
+): Promise {
+ if (!isImportableOrigin(origin)) throw new Error(`Cannot write storage for "${origin}".`)
+ if (!Object.keys(data).length) return { ok: 0, failed: 0, blocked: false }
+
+ const win = new BrowserWindow({
+ show: false,
+ webPreferences: {
+ partition: PARTITION,
+ // This window exists to touch one origin's storage. It must not get
+ // Node, a preload, or any bridge into the app.
+ nodeIntegration: false,
+ contextIsolation: true,
+ sandbox: true,
+ // Never surface this window; it is machinery, not UI.
+ offscreen: false
+ }
+ })
+
+ try {
+ await loadOrigin(win, origin)
+ return (await win.webContents.executeJavaScript(
+ writerScript('localStorage', data),
+ true
+ )) as StorageWriteResult
+ } finally {
+ if (!win.isDestroyed()) win.destroy()
+ }
+}
+
+/**
+ * Navigate to `origin` far enough to have a storage context, then stop.
+ *
+ * We resolve on `dom-ready` rather than `did-finish-load` so a site with slow
+ * or hanging subresources doesn't stall the import — the document (and thus
+ * `window.localStorage`) exists by then. The load is halted immediately after,
+ * so we don't sit there running the site's scripts.
+ */
+function loadOrigin(win: BrowserWindow, origin: string): Promise {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ cleanup()
+ reject(new Error(`Timed out loading ${origin}.`))
+ }, LOAD_TIMEOUT_MS)
+
+ const onReady = (): void => {
+ cleanup()
+ // The document exists; we don't need the rest of the page.
+ if (!win.isDestroyed()) win.webContents.stop()
+ resolve()
+ }
+ const onFail = (
+ _e: Electron.Event,
+ code: number,
+ desc: string,
+ _url: string,
+ isMainFrame: boolean
+ ): void => {
+ // Subresource failures are none of our business; only a main-frame
+ // failure means we never got a document.
+ if (!isMainFrame) return
+ // -3 is ERR_ABORTED, which our own stop() triggers.
+ if (code === -3) return
+ cleanup()
+ reject(new Error(`Could not reach ${origin} (${desc}).`))
+ }
+ function cleanup(): void {
+ clearTimeout(timer)
+ if (win.isDestroyed()) return
+ win.webContents.off('dom-ready', onReady)
+ win.webContents.off('did-fail-load', onFail)
+ }
+
+ win.webContents.once('dom-ready', onReady)
+ win.webContents.on('did-fail-load', onFail)
+ win.loadURL(origin).catch(() => {
+ // loadURL rejects on abort too; did-fail-load is the authority here.
+ })
+ })
+}
+
+/**
+ * Write sessionStorage into a LIVE browsing context.
+ *
+ * Unlike localStorage this cannot be done in a throwaway window: session
+ * storage is scoped to one tab and vanishes with it. The caller passes the
+ * webContents of a tab already on the target origin; if none is open, session
+ * storage simply isn't importable and the caller reports that.
+ */
+export async function writeSessionStorage(
+ contents: Electron.WebContents,
+ origin: string,
+ data: RelayStorage
+): Promise {
+ if (!Object.keys(data).length) return { ok: 0, failed: 0, blocked: false }
+ // Refuse to write one origin's credentials into a page showing another.
+ const current = safeOrigin(contents.getURL())
+ if (current !== origin) {
+ throw new Error(`The open tab is on ${current || 'no page'}, not ${origin}.`)
+ }
+ return (await contents.executeJavaScript(
+ writerScript('sessionStorage', data),
+ true
+ )) as StorageWriteResult
+}
+
+function safeOrigin(url: string): string {
+ try {
+ return new URL(url).origin
+ } catch {
+ return ''
+ }
+}
diff --git a/src/preload/index.ts b/src/preload/index.ts
index e78fe61..416d2d8 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -14,6 +14,7 @@ import type {
UpdateState
} from '../shared/api'
import type { CliProxyState } from '../shared/cliproxy'
+import type { RelayStatus } from '../shared/relay'
/**
* The typed bridge exposed to the renderer as `window.roxy`. Every method maps
@@ -232,6 +233,25 @@ const roxy: RoxyApi = {
clear: (host) => ipcRenderer.invoke(CHANNELS.cookiesClear, host),
importJson: (text) => ipcRenderer.invoke(CHANNELS.cookiesImport, text)
},
+ relay: {
+ status: () => ipcRenderer.invoke(CHANNELS.relayStatus),
+ beginPairing: () => ipcRenderer.invoke(CHANNELS.relayBeginPairing),
+ cancelPairing: () => ipcRenderer.invoke(CHANNELS.relayCancelPairing),
+ unpair: () => ipcRenderer.invoke(CHANNELS.relayUnpair),
+ apply: (id, choice, trust) => ipcRenderer.invoke(CHANNELS.relayApply, id, choice, trust),
+ setPrefs: (prefs) => ipcRenderer.invoke(CHANNELS.relaySetPrefs, prefs),
+ trustOrigin: (origin) => ipcRenderer.invoke(CHANNELS.relayTrustOrigin, origin),
+ untrustOrigin: (origin) => ipcRenderer.invoke(CHANNELS.relayUntrustOrigin, origin),
+ reject: (id) => ipcRenderer.invoke(CHANNELS.relayReject, id),
+ installExtension: () => ipcRenderer.invoke(CHANNELS.relayInstallExtension),
+ revealExtension: () => ipcRenderer.invoke(CHANNELS.relayRevealExtension),
+ onState: (callback) => {
+ const handler = (_event: Electron.IpcRendererEvent, status: RelayStatus): void =>
+ callback(status)
+ ipcRenderer.on(CHANNELS.relayState, handler)
+ return () => ipcRenderer.removeListener(CHANNELS.relayState, handler)
+ }
+ },
services: {
list: (sessionId) => ipcRenderer.invoke(CHANNELS.servicesList, sessionId),
output: (sessionId, id) => ipcRenderer.invoke(CHANNELS.servicesOutput, sessionId, id),
diff --git a/src/renderer/src/components/SessionRelay.tsx b/src/renderer/src/components/SessionRelay.tsx
new file mode 100644
index 0000000..399a3c9
--- /dev/null
+++ b/src/renderer/src/components/SessionRelay.tsx
@@ -0,0 +1,771 @@
+import { useCallback, useEffect, useState } from 'react'
+import { createPortal } from 'react-dom'
+import {
+ Check,
+ Globe,
+ Copy,
+ ExternalLink,
+ FolderOpen,
+ Loader2,
+ RefreshCw,
+ ShieldAlert,
+ X
+} from 'lucide-react'
+import type { PendingSnapshot, RelayImportResult, RelayStatus } from '@shared/relay'
+import { api } from '../lib/api'
+import { cn } from '../lib/cn'
+
+/**
+ * Session Relay — the Settings surface.
+ *
+ * Two jobs: walk the user through installing the browser extension (which is
+ * genuinely four manual steps in a browser we don't control), and be the place
+ * a queued transfer gets approved.
+ *
+ * The approval prompt shows COUNTS, never values. Main deliberately keeps the
+ * credentials until the user says yes, so there is nothing here to leak.
+ */
+export function SessionRelay(): JSX.Element {
+ const [status, setStatus] = useState(null)
+ const [open, setOpen] = useState(false)
+
+ const refresh = useCallback(async () => setStatus(await api.relay.status()), [])
+
+ useEffect(() => {
+ void refresh()
+ // Main pushes on every state change (pairing, heartbeat, queued snapshot),
+ // so the panel is live without polling.
+ return api.relay.onState(setStatus)
+ }, [refresh])
+
+ const pending = status?.pending ?? []
+
+ return (
+
+ Send a site's live session from Chrome, Edge or Brave into Roxy's browser, so
+ you can debug a signed-in page without signing in again. Nothing transfers until you
+ click send in the extension, and nothing is applied until you approve it here.
+
+ {result.cookiesSkippedPartitioned} partitioned{' '}
+ {result.cookiesSkippedPartitioned === 1 ? 'cookie was' : 'cookies were'} skipped —
+ Electron cannot store these in the right partition, so importing them would put them in
+ the wrong one.
+
+ )}
+ {result.errors.map((e, i) => (
+
+ {e}
+
+ ))}
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ {snapshot.browser} wants to send a session to Roxy
+
diff --git a/src/shared/api.ts b/src/shared/api.ts
index 2bca98d..fdc3f68 100644
--- a/src/shared/api.ts
+++ b/src/shared/api.ts
@@ -2,6 +2,7 @@
* The typed contract exposed to the renderer as `window.roxy`.
* Implemented in src/preload/index.ts, handled in src/main/ipc/*.
*/
+import type { RelayImportChoice, RelayImportResult, RelayPrefs, RelayStatus } from './relay'
import type {
AddMessageInput,
AppSettings,
@@ -898,6 +899,36 @@ export interface RoxyApi {
/** Import a Cookie-Editor / EditThisCookie JSON blob. Rejects only on malformed JSON. */
importJson(text: string): Promise
}
+ /**
+ * Session Relay - a paired browser extension that hands a site's live
+ * session (cookies + storage) to Roxy. Nothing is applied without the user
+ * confirming the specific transfer; see shared/relay.ts for the threat model.
+ */
+ relay: {
+ status(): Promise
+ /** Mint a pairing code for the user to type into the extension. */
+ beginPairing(): Promise<{ code: string; expiresAt: number; port: number }>
+ cancelPairing(): Promise
+ /** Revoke the paired extension's token. */
+ unpair(): Promise
+ /**
+ * Apply a queued snapshot. This is the only path that writes it.
+ * `trust` also marks the origin, so future transfers skip the prompt.
+ */
+ apply(id: string, choice: RelayImportChoice, trust?: boolean): Promise
+ /** Update automation settings (auto-send switch, trusted list, blocklist). */
+ setPrefs(prefs: Partial): Promise
+ /** Mark an origin as needing no further confirmation. */
+ trustOrigin(origin: string): Promise
+ untrustOrigin(origin: string): Promise
+ /** Discard a queued snapshot untouched. */
+ reject(id: string): Promise
+ /** Copy the bundled extension to Documents; returns where it landed. */
+ installExtension(): Promise<{ path: string; version: string }>
+ revealExtension(): Promise
+ /** Subscribe to relay status; returns an unsubscribe fn. */
+ onState(callback: (status: RelayStatus) => void): () => void
+ }
services: {
/** Background processes owned by a session (includes its subagents'). */
list(sessionId: string): Promise
diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts
index 743e775..218f783 100644
--- a/src/shared/ipc.ts
+++ b/src/shared/ipc.ts
@@ -212,6 +212,24 @@ export const CHANNELS = {
*/
browserChromeHeight: 'browser:chrome-height',
+ /**
+ * Session Relay - the paired browser extension that transfers a site's
+ * cookies/storage into Roxy. See shared/relay.ts for the protocol.
+ */
+ relayStatus: 'relay:status',
+ relayBeginPairing: 'relay:begin-pairing',
+ relayCancelPairing: 'relay:cancel-pairing',
+ relayUnpair: 'relay:unpair',
+ relayApply: 'relay:apply',
+ relayReject: 'relay:reject',
+ relayInstallExtension: 'relay:install-extension',
+ relayRevealExtension: 'relay:reveal-extension',
+ relaySetPrefs: 'relay:set-prefs',
+ relayTrustOrigin: 'relay:trust-origin',
+ relayUntrustOrigin: 'relay:untrust-origin',
+ /** main -> renderer: relay status changed (paired, snapshot queued, ...) */
+ relayState: 'relay:state',
+
/** renderer -> main: a session's background processes (the Services panel) */
servicesList: 'services:list',
/** renderer -> main: full buffered output of one service, for the log view */
diff --git a/src/shared/relay.ts b/src/shared/relay.ts
new file mode 100644
index 0000000..5684bb4
--- /dev/null
+++ b/src/shared/relay.ts
@@ -0,0 +1,316 @@
+/**
+ * The Session Relay wire protocol — shared by the Electron main process, the
+ * renderer, and the bundled Chrome extension.
+ *
+ * The relay moves a site's session (cookies + origin storage) from a real
+ * browser into Roxy's browser partition, so you can debug a signed-in site
+ * without signing in again. It is deliberately ONE-WAY (browser -> Roxy).
+ *
+ * CONSENT MODEL. A site starts at zero access: the extension holds no host
+ * permission for it, so it cannot read its cookies at all. The first transfer
+ * is fully manual (click in the extension, approve in Roxy). At that point the
+ * user may mark the origin TRUSTED, after which sends and imports for THAT
+ * ORIGIN happen with no further clicks.
+ *
+ * The lists are deliberately asymmetric, because they fail in opposite
+ * directions:
+ *
+ * - TRUST is exact-origin and opt-in. `https://app.example.com` never
+ * implies `https://admin.example.com`. Broad trust would be a footgun, and
+ * Chrome enforces the same shape anyway: auto-send can only ever reach an
+ * origin the user separately granted host access to.
+ * - BLOCKS match broadly and win over everything. `example.com` blocks the
+ * apex and every subdomain, is checked before trust, and is enforced in
+ * BOTH the extension and Roxy — so a stale unpacked extension cannot
+ * relay a blocked site even if its cached prefs are out of date.
+ *
+ * THREAT MODEL. A snapshot is live credentials. The relay listens on loopback,
+ * which any process on the machine — and, via a form POST or an tag, any
+ * WEBSITE the user visits — can also reach. So the endpoint is not "local
+ * therefore trusted". Every request must prove three things:
+ *
+ * 1. WHO: a bearer token issued during an explicit pairing handshake, held in
+ * the extension's own storage. Random web pages don't have it.
+ * 2. WHERE FROM: an `Origin` of exactly `chrome-extension://`.
+ * Browsers set `Origin` themselves and forbid pages from forging it, so
+ * this alone rejects every `https://evil.com` request even if the token
+ * somehow leaked.
+ * 3. WHICH HOST: a `Host` header of `127.0.0.1:`. Without this, an
+ * attacker who controls DNS can point `evil.com` at 127.0.0.1 and have the
+ * browser treat the relay as same-origin (DNS rebinding).
+ *
+ * Requests are also JSON-only with a required custom header, so they can never
+ * be a simple/no-preflight cross-origin request from a page.
+ *
+ * WHY NOT NATIVE MESSAGING (yet). `chrome.runtime.connectNative` is a stronger
+ * transport — no listening socket at all — but it needs a registered host
+ * manifest plus a Windows registry key or a per-browser profile path on
+ * macOS/Linux, and a separate stdio executable we'd have to ship and sign. The
+ * loopback endpoint below reaches the same place with checks a reviewer can
+ * read in one file. The extension talks to a small module boundary, so the
+ * transport can be swapped later without touching the popup or this schema.
+ */
+
+/** Bumped when the snapshot shape changes incompatibly. */
+export const RELAY_PROTOCOL_VERSION = 1
+
+/**
+ * The loopback port. Fixed so the extension can find Roxy without discovery
+ * (an extension cannot read a file to learn a random port). Chosen well above
+ * the dev-server range in `ports.ts` (3100-3999) so it never collides with a
+ * session's own server.
+ */
+export const RELAY_PORT = 4317
+
+/**
+ * Required on every relay request. Custom headers force a CORS preflight, so a
+ * web page cannot reach the relay with a "simple" request that skips one — and
+ * our preflight only ever approves the paired extension's origin.
+ */
+export const RELAY_HEADER = 'x-roxy-relay'
+
+/** How long a pairing code is valid. Short: it's read off a screen and typed. */
+export const PAIRING_TTL_MS = 3 * 60 * 1000
+
+/** Hard cap on a snapshot body. Generous for cookies, far below a memory risk. */
+export const MAX_SNAPSHOT_BYTES = 4 * 1024 * 1024
+
+/**
+ * The bundled extension's ID.
+ *
+ * Fixed by the `key` field in its manifest.json, which pins the ID even when
+ * the extension is loaded unpacked — without it Chrome derives an ID from the
+ * install path, so every reinstall would produce a different origin and break
+ * the pairing. Roxy authorizes exactly this one `chrome-extension://` origin.
+ *
+ * Derived as Chrome does: sha256 of the DER public key, first 16 bytes, each
+ * nibble mapped 0-15 -> a-p. If the manifest key ever changes, this must too.
+ */
+export const RELAY_EXTENSION_ID = 'bekpajpbgjeloofgicpnkgahfllakeao'
+
+/** One cookie as the extension sees it (`chrome.cookies.Cookie`). */
+export interface RelayCookie {
+ name: string
+ value: string
+ domain: string
+ path: string
+ secure: boolean
+ httpOnly: boolean
+ hostOnly: boolean
+ session: boolean
+ sameSite: 'no_restriction' | 'lax' | 'strict' | 'unspecified'
+ /** Seconds since epoch; absent for session cookies. */
+ expirationDate?: number
+ /**
+ * Set when the cookie is partitioned (CHIPS). Carried so we can TELL the user
+ * it was skipped — Electron 33's cookie API has no `partitionKey` field, so
+ * such a cookie cannot be reproduced faithfully. Importing it unpartitioned
+ * would put it in the wrong jar, which is worse than not importing it.
+ */
+ partitionKey?: { topLevelSite?: string; hasCrossSiteAncestor?: boolean }
+}
+
+/** Key/value pairs lifted from one origin's Web Storage. */
+export type RelayStorage = Record
+
+/** What the extension sends after the user presses "Send session". */
+export interface RelaySnapshot {
+ v: typeof RELAY_PROTOCOL_VERSION
+ /** The exact origin the storage belongs to, e.g. `https://app.example.com`. */
+ origin: string
+ /** Page title at capture time, purely to make the Roxy prompt legible. */
+ title?: string
+ capturedAt: number
+ cookies: RelayCookie[]
+ /** Absent when the user unticked it, or the page blocked access. */
+ localStorage?: RelayStorage
+ sessionStorage?: RelayStorage
+}
+
+/** A snapshot held in main, awaiting the user's confirmation in the UI. */
+export interface PendingSnapshot {
+ id: string
+ origin: string
+ title?: string
+ receivedAt: number
+ /** Which browser sent it, for the prompt ("Chrome wants to send…"). */
+ browser: string
+ cookieCount: number
+ /** Partitioned cookies, counted separately: they are reported, not applied. */
+ partitionedCookieCount: number
+ localStorageCount: number
+ sessionStorageCount: number
+ /**
+ * Byte size of the values, so the UI can say "12 cookies, 4 KB" without ever
+ * shipping the values themselves to the renderer. Credentials stay in main
+ * until the user approves the import.
+ */
+ approxBytes: number
+}
+
+/** Connection state, as the Settings UI renders it. */
+export interface RelayStatus {
+ /** The loopback listener is up. */
+ listening: boolean
+ port: number
+ /** An extension has completed pairing and holds a live token. */
+ paired: boolean
+ /** The paired extension id, shown in the manage screen. */
+ extensionId?: string
+ /** Which browser paired, self-reported at pairing time. */
+ browser?: string
+ extensionVersion?: string
+ lastSeenAt?: number
+ lastTransferAt?: number
+ /** A pairing code is on screen right now, and this is when it expires. */
+ pairingExpiresAt?: number
+ /** Snapshots waiting on the user's yes/no. */
+ pending: PendingSnapshot[]
+ /** Automation settings (trusted origins, blocklist, master switch). */
+ prefs: RelayPrefs
+ /** The most recent transfers, newest first, for the activity list. */
+ recent: RelayTransfer[]
+}
+
+/** What the user chose to apply from a pending snapshot. */
+export interface RelayImportChoice {
+ cookies: boolean
+ localStorage: boolean
+ sessionStorage: boolean
+}
+
+/** Outcome of applying a snapshot. */
+export interface RelayImportResult {
+ cookiesImported: number
+ cookiesFailed: number
+ /** Partitioned cookies deliberately not applied (see `RelayCookie`). */
+ cookiesSkippedPartitioned: number
+ localStorageImported: number
+ sessionStorageImported: number
+ errors: string[]
+}
+
+/**
+ * Whether an origin can receive storage. Storage is written by loading the
+ * origin in a hidden page and touching `window.localStorage`, which only exists
+ * for http/https — `file:`, `data:` and extension pages have no usable,
+ * addressable Web Storage for our purposes.
+ */
+export function isImportableOrigin(origin: string): boolean {
+ try {
+ const u = new URL(origin)
+ return (u.protocol === 'https:' || u.protocol === 'http:') && u.origin === origin
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Automation preferences. Owned by Roxy; the extension caches a copy and
+ * refreshes it on every heartbeat, so Roxy stays the single source of truth.
+ */
+export interface RelayPrefs {
+ /** Master switch. Off means every transfer is manual, as before. */
+ autoSend: boolean
+ /**
+ * Origins that send and import without prompting. EXACT origins only —
+ * scheme, host and port must all match.
+ */
+ trusted: string[]
+ /**
+ * Host patterns that may never be relayed, whatever else is set. Matches the
+ * apex and all subdomains; see `isBlockedHost`.
+ */
+ blocked: string[]
+}
+
+export const DEFAULT_RELAY_PREFS: RelayPrefs = {
+ autoSend: true,
+ trusted: [],
+ // Empty by default. A shipped list would be both presumptuous and dangerously
+ // incomplete — it reads as "we protected you", when the real protection is
+ // that nothing is trusted until you say so.
+ blocked: []
+}
+
+/**
+ * Minimum gap between automatic sends for one origin.
+ *
+ * Auto-send is driven partly by `chrome.cookies.onChanged`, which fires
+ * constantly on a busy site — every analytics ping can rewrite a cookie. This
+ * both stops a redundant flood and is what makes the token-refresh case work:
+ * the new token arrives shortly after it rotates, not 200 times a minute.
+ */
+export const AUTO_SEND_COOLDOWN_MS = 15_000
+
+/**
+ * Normalize a blocklist entry to a bare host.
+ *
+ * Users paste whatever is in their address bar, so tolerate a scheme, a port,
+ * a path, a `*.` prefix and a trailing dot. Everything reduces to the host,
+ * which is the only part matching operates on.
+ */
+function normalizePattern(raw: string): string {
+ return raw
+ .trim()
+ .toLowerCase()
+ .replace(/^https?:\/\//, '')
+ .replace(/^\*\./, '')
+ .split('/')[0]
+ .replace(/:\d+$/, '')
+ .replace(/\.$/, '')
+}
+
+/**
+ * Does `host` fall under any blocklist pattern?
+ *
+ * Matching is suffix-anchored on a DOT BOUNDARY, which is what stops the two
+ * classic bypasses: `example.com` must not match `notexample.com` (no boundary)
+ * and must not match `example.com.evil.net` (not a suffix). Blocking is
+ * deliberately broad — an entry covers the apex and every subdomain — because
+ * for a safety net over-matching is the safe direction to err.
+ */
+export function isBlockedHost(host: string, patterns: string[]): boolean {
+ const h = host.trim().toLowerCase().replace(/\.$/, '')
+ if (!h) return true // unparseable host: refuse rather than guess
+ for (const raw of patterns) {
+ const p = normalizePattern(raw)
+ if (!p) continue
+ if (h === p || h.endsWith(`.${p}`)) return true
+ }
+ return false
+}
+
+/** Convenience: block check straight from an origin string. */
+export function isBlockedOrigin(origin: string, patterns: string[]): boolean {
+ try {
+ return isBlockedHost(new URL(origin).hostname, patterns)
+ } catch {
+ return true // if we cannot parse it, we cannot clear it
+ }
+}
+
+/**
+ * May this origin be relayed without asking?
+ *
+ * Blocks are evaluated FIRST and cannot be overridden by trust — adding a
+ * domain to the blocklist must be sufficient on its own, without also hunting
+ * through the trusted list to revoke it.
+ */
+export function isAutoAllowed(origin: string, prefs: RelayPrefs): boolean {
+ if (!prefs.autoSend) return false
+ if (isBlockedOrigin(origin, prefs.blocked)) return false
+ return prefs.trusted.includes(origin)
+}
+
+/** One completed automatic transfer, for the activity list in Settings. */
+export interface RelayTransfer {
+ origin: string
+ at: number
+ cookies: number
+ localStorage: number
+ sessionStorage: number
+ /** Set when the transfer was applied without prompting. */
+ auto: boolean
+ error?: string
+}
diff --git a/test/relay-e2e.ts b/test/relay-e2e.ts
new file mode 100644
index 0000000..4210f6c
--- /dev/null
+++ b/test/relay-e2e.ts
@@ -0,0 +1,294 @@
+/**
+ * Session Relay — end-to-end check against a REAL Chrome.
+ *
+ * The in-process suite (test/relay.ts) proves the server refuses the attacks.
+ * This proves the other half: that the actual bundled extension loads in a real
+ * Chrome, pairs over the real protocol, and lands a snapshot in Roxy's queue.
+ * Between them, both sides of the wire are covered by real code.
+ *
+ * It runs the relay in THIS process (so it can mint a pairing code directly,
+ * exactly as Settings does) and drives Chrome over the DevTools Protocol.
+ *
+ * WHY CDP AND NOT `--load-extension`: Chrome 137+ removed that flag from
+ * branded builds because malware abused it, and the escape-hatch feature flag
+ * has since been removed too. `Extensions.loadUnpacked` is the documented
+ * replacement. The USER flow is unaffected — "Load unpacked" on
+ * chrome://extensions with Developer mode on, which is what the setup wizard
+ * walks through, still works. This is a harness limitation, not a product one.
+ *
+ * Not part of `npm run smoke`: it needs a Chrome install and drives a real
+ * browser, which is too environment-dependent for CI.
+ *
+ * Run: npm run e2e:relay
+ */
+import { app } from 'electron'
+import { spawn } from 'node:child_process'
+import { existsSync, mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import * as relay from '../src/main/services/relay'
+import { DEFAULT_RELAY_PREFS, RELAY_EXTENSION_ID } from '../src/shared/relay'
+
+const CDP_PORT = 9333
+const EXT = resolve('resources/session-relay')
+
+const CHROME = [
+ 'C:/Program Files/Google/Chrome/Application/chrome.exe',
+ 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
+ '/usr/bin/google-chrome'
+].find((p) => existsSync(p))
+
+let failures = 0
+function check(name: string, cond: boolean, detail: unknown = ''): void {
+ const line = cond ? ` ok ${name}` : ` FAIL ${name} ${detail === '' ? '' : String(detail)}`
+ if (!cond) failures++
+ process.stderr.write(line + '\n')
+}
+
+const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms))
+
+/**
+ * Minimal CDP client, over the `ws` package (already a direct dependency).
+ *
+ * Not the platform WebSocket: Electron's MAIN process has no global one, and
+ * routing through a hidden renderer does not work either — an `about:blank`
+ * page has an opaque origin, and Chrome refuses CDP socket upgrades from it.
+ */
+async function cdp(wsUrl: string): Promise<{
+ send: (method: string, params?: unknown, sessionId?: string) => Promise>
+ close: () => void
+}> {
+ const { WebSocket } = await import('ws')
+ const ws = new WebSocket(wsUrl, { maxPayload: 256 * 1024 * 1024 })
+ await new Promise((res, rej) => {
+ ws.once('open', res)
+ ws.once('error', rej)
+ })
+
+ let id = 0
+ const waiting = new Map void; rej: (e: Error) => void }>()
+ ws.on('message', (data) => {
+ const msg = JSON.parse(String(data))
+ const w = waiting.get(msg.id)
+ if (!w) return
+ waiting.delete(msg.id)
+ if (msg.error) w.rej(new Error(msg.error.message))
+ else w.res((msg.result ?? {}) as never)
+ })
+
+ return {
+ send: (method, params = {}, sessionId) =>
+ new Promise((res, rej) => {
+ const n = ++id
+ waiting.set(n, { res: res as (v: never) => void, rej })
+ const frame: Record = { id: n, method, params }
+ if (sessionId) frame.sessionId = sessionId
+ ws.send(JSON.stringify(frame))
+ // loadUnpacked installs an extension; give it room, but never hang.
+ setTimeout(() => {
+ if (waiting.delete(n)) rej(new Error(`CDP ${method} timed out`))
+ }, 60_000)
+ }),
+ close: () => ws.close()
+ }
+}
+
+async function main(): Promise {
+ await app.whenReady()
+ process.stderr.write('session relay e2e:\n')
+
+ if (!CHROME) {
+ process.stderr.write(' no Chrome installed; skipping.\n')
+ app.exit(0)
+ return
+ }
+
+ await relay.start()
+ check('relay is listening', relay.status().listening)
+ // Start from a clean slate so a previous run's pairing can't mask a failure.
+ relay.unpair()
+
+ const profile = mkdtempSync(join(tmpdir(), 'roxy-relay-'))
+ const chrome = spawn(
+ CHROME,
+ [
+ `--user-data-dir=${profile}`,
+ `--remote-debugging-port=${CDP_PORT}`,
+ '--no-first-run',
+ '--no-default-browser-check',
+ 'about:blank'
+ ],
+ { stdio: 'ignore' }
+ )
+
+ try {
+ let version: { Browser: string; webSocketDebuggerUrl: string } | null = null
+ for (let i = 0; i < 40 && !version; i++) {
+ await sleep(500)
+ version = await fetch(`http://127.0.0.1:${CDP_PORT}/json/version`)
+ .then((r) => r.json())
+ .catch(() => null)
+ }
+ if (!version) throw new Error('Chrome DevTools never came up.')
+ process.stderr.write(` (${version.Browser})\n`)
+
+ const client = await cdp(version.webSocketDebuggerUrl)
+ const loaded = (await client.send('Extensions.loadUnpacked', { path: EXT })) as unknown as {
+ id?: string
+ }
+ check('the bundled extension loads in real Chrome', Boolean(loaded?.id), JSON.stringify(loaded))
+ check(' and takes the ID we authorize', loaded?.id === RELAY_EXTENSION_ID, loaded?.id)
+
+ await sleep(2000)
+ const targets = (await fetch(`http://127.0.0.1:${CDP_PORT}/json/list`).then((r) =>
+ r.json()
+ )) as { id: string; type: string; url: string }[]
+ const sw = targets.find(
+ (t) => t.type === 'service_worker' && t.url.includes(RELAY_EXTENSION_ID)
+ )
+ check('its service worker starts', Boolean(sw))
+ if (!sw) throw new Error('no service worker to drive')
+
+ const sessionId = (
+ (await client.send('Target.attachToTarget', {
+ targetId: sw.id,
+ flatten: true
+ })) as unknown as { sessionId: string }
+ ).sessionId
+
+ /** Run an expression inside the extension's service worker. */
+ const inSw = async (expression: string): Promise => {
+ const r = (await client.send(
+ 'Runtime.evaluate',
+ { expression, awaitPromise: true, returnByValue: true },
+ sessionId
+ )) as unknown as { exceptionDetails?: { text: string }; result: { value: unknown } }
+ if (r.exceptionDetails) throw new Error(r.exceptionDetails.text)
+ return r.result.value
+ }
+
+ // Everything below goes through the extension's OWN message handlers, so
+ // this exercises the shipped background.js, not a re-implementation.
+ const bad = (await inSw(`__roxyRelay.pair('000000')`)) as {
+ ok?: boolean
+ }
+ check('a wrong pairing code is refused', bad?.ok === false, JSON.stringify(bad))
+ check(' and nothing is paired', !relay.status().paired)
+
+ const { code } = relay.beginPairing()
+ const paired = (await inSw(`__roxyRelay.pair('${code}')`)) as { ok?: boolean }
+ check('the real code pairs', paired?.ok === true, JSON.stringify(paired))
+ check(' Roxy now reports paired', relay.status().paired)
+ check(' as the extension we pinned', relay.status().extensionId === RELAY_EXTENSION_ID)
+
+ const status = (await inSw(`__roxyRelay.getToken().then(t => ({ paired: Boolean(t) }))`)) as {
+ paired?: boolean
+ }
+ check(' and the extension agrees', status?.paired === true)
+
+ const sent = (await inSw(`__roxyRelay.sendSnapshot({
+ v: 1,
+ origin: 'https://example.com',
+ capturedAt: Date.now(),
+ cookies: [{
+ name: 'e2e', value: 'from-chrome', domain: '.example.com', path: '/',
+ secure: true, httpOnly: false, hostOnly: false, session: true, sameSite: 'lax'
+ }]
+ })`)) as { ok?: boolean; error?: string }
+ check('a snapshot reaches Roxy', sent?.ok === true, JSON.stringify(sent))
+
+ const pending = relay.status().pending
+ check(' it is QUEUED, not applied', pending.length === 1, pending.length)
+ check(' with the right origin', pending[0]?.origin === 'https://example.com')
+ check(
+ ' and its value never reaches status',
+ !JSON.stringify(relay.status()).includes('from-chrome')
+ )
+
+ // Disconnecting must cut the extension off immediately.
+ relay.unpair()
+ const after = (await inSw(
+ `__roxyRelay.sendSnapshot({ v: 1, origin: 'https://example.com', capturedAt: Date.now(), cookies: [] })`
+ )) as { ok?: boolean }
+ check('after Disconnect the extension is refused', after?.ok === false, JSON.stringify(after))
+
+ // ---- the no-clicks path ------------------------------------------------
+ // Re-pair, trust the origin, and assert a snapshot is APPLIED on arrival
+ // rather than queued. This is the behaviour the feature exists for.
+ const { code: code2 } = relay.beginPairing()
+ await inSw(`__roxyRelay.pair('${code2}')`)
+ relay.setPrefs({ autoSend: true, trusted: ['https://example.com'], blocked: [] })
+
+ const auto = (await inSw(`__roxyRelay.sendSnapshot({
+ v: 1,
+ origin: 'https://example.com',
+ capturedAt: Date.now(),
+ cookies: [{
+ name: 'auto', value: 'no-clicks', domain: '.example.com', path: '/',
+ secure: true, httpOnly: false, hostOnly: false, session: true, sameSite: 'lax'
+ }]
+ })`)) as { ok?: boolean }
+ check('a trusted origin transfers with no prompt', auto?.ok === true, JSON.stringify(auto))
+ check(' nothing was queued for approval', relay.status().pending.length === 0)
+ check(' and it is recorded as automatic', relay.status().recent[0]?.auto === true)
+ check(
+ ' with the cookie actually applied',
+ relay.status().recent[0]?.cookies === 1,
+ JSON.stringify(relay.status().recent[0])
+ )
+
+ // The cooldown must stop a chatty site from re-sending in a loop.
+ const again = (await inSw(`__roxyRelay.sendSnapshot({
+ v: 1, origin: 'https://example.com', capturedAt: Date.now(),
+ cookies: [{
+ name: 'auto', value: 'again', domain: '.example.com', path: '/',
+ secure: true, httpOnly: false, hostOnly: false, session: true, sameSite: 'lax'
+ }]
+ })`)) as { ok?: boolean }
+ check('an immediate repeat is throttled, not reapplied', again?.ok === true)
+ check(' still only one recorded transfer', relay.status().recent.length === 1)
+
+ // A blocked domain must be refused even though the origin is trusted.
+ relay.setPrefs({ blocked: ['example.com'] })
+ const blocked = (await inSw(`__roxyRelay.sendSnapshot({
+ v: 1, origin: 'https://example.com', capturedAt: Date.now(), cookies: []
+ })`)) as { ok?: boolean; error?: string }
+ check('the blocklist refuses a trusted origin', blocked?.ok === false, JSON.stringify(blocked))
+ check(' and trust was revoked by the block', relay.status().prefs.trusted.length === 0)
+
+ // The extension must learn about the block from the heartbeat, so it stops
+ // even trying — the server check is a backstop, not the only guard.
+ await inSw(`__roxyRelay.heartbeat()`)
+ const cached = (await inSw(`JSON.stringify(__roxyRelay.prefs())`)) as string
+ check('the extension picked up the blocklist', cached.includes('example.com'), cached)
+
+ relay.setPrefs({ ...DEFAULT_RELAY_PREFS })
+
+ client.close()
+ } finally {
+ chrome.kill()
+ relay.stop()
+ try {
+ rmSync(profile, { recursive: true, force: true })
+ } catch {
+ // Chrome holds the profile briefly after kill; harmless in a temp dir.
+ }
+ }
+
+ process.stderr.write(
+ failures ? `\nE2E FAILED — ${failures} failing\n` : '\nAll relay e2e checks passed.\n'
+ )
+ app.exit(failures ? 1 : 0)
+}
+
+process.on('uncaughtException', (e) => {
+ process.stderr.write(`CRASH: ${e?.stack ?? e}\n`)
+ app.exit(1)
+})
+process.on('unhandledRejection', (e) => {
+ process.stderr.write(`REJECT: ${e instanceof Error ? e.stack : String(e)}\n`)
+ app.exit(1)
+})
+
+void main()
diff --git a/test/relay.ts b/test/relay.ts
new file mode 100644
index 0000000..0d116ac
--- /dev/null
+++ b/test/relay.ts
@@ -0,0 +1,397 @@
+/**
+ * Session Relay security checks.
+ *
+ * The relay is a loopback endpoint that hands out and accepts live credentials,
+ * so its ACCESS CONTROL is the thing worth testing — not the happy path. Each
+ * case below is an attack the endpoint must refuse:
+ *
+ * - a web page POSTing to 127.0.0.1 (wrong Origin)
+ * - a DNS-rebinding attack (right Origin, wrong Host)
+ * - a second extension guessing the token
+ * - brute-forcing the 6-digit pairing code
+ * - a paired client flooding memory with queued snapshots
+ *
+ * Runs against the REAL server in a real Electron main process; the module is
+ * driven exactly as the app drives it. Nothing here touches the browser
+ * partition: every test stops at the queue, which is precisely the boundary
+ * that makes the design safe (receiving != applying).
+ *
+ * Run: npm run smoke:relay
+ */
+import { app } from 'electron'
+import { connect } from 'node:net'
+import {
+ DEFAULT_RELAY_PREFS,
+ RELAY_HEADER,
+ RELAY_PORT,
+ isAutoAllowed,
+ isBlockedHost,
+ type RelayPrefs
+} from '../src/shared/relay'
+import * as relay from '../src/main/services/relay'
+
+let failures = 0
+
+function check(name: string, cond: boolean, detail: unknown = ''): void {
+ const line = cond ? ` ok ${name}` : ` FAIL ${name} ${detail === '' ? '' : String(detail)}`
+ if (!cond) failures++
+ // stderr: Electron on Windows does not reliably deliver stdout to a
+ // redirected parent shell, so a failure would otherwise vanish in CI.
+ process.stderr.write(line + '\n')
+}
+
+const BASE = `http://127.0.0.1:${RELAY_PORT}`
+
+/** A raw request with full control over the headers an attacker would forge. */
+async function call(
+ path: string,
+ opts: { origin?: string; host?: string; token?: string; header?: boolean; body?: unknown } = {}
+): Promise<{ status: number; json: Record | null }> {
+ const headers: Record = { 'content-type': 'application/json' }
+ if (opts.origin !== undefined) headers.origin = opts.origin
+ if (opts.host !== undefined) headers.host = opts.host
+ if (opts.token) headers.authorization = `Bearer ${opts.token}`
+ if (opts.header !== false) headers[RELAY_HEADER] = '1'
+ const res = await fetch(`${BASE}${path}`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(opts.body ?? {})
+ })
+ let json: Record | null = null
+ try {
+ json = (await res.json()) as Record
+ } catch {
+ /* some responses have no body */
+ }
+ return { status: res.status, json }
+}
+
+/**
+ * A raw-socket request, so we can forge headers `fetch` refuses to send.
+ *
+ * This matters for the DNS-rebinding case specifically: undici silently
+ * replaces a user-supplied `Host` with the real authority, which would make
+ * that test pass without proving anything. A real attacker writes bytes to a
+ * socket, so the test does too.
+ */
+function raw(
+ path: string,
+ headers: Record,
+ body: unknown
+): Promise<{ status: number; text: string }> {
+ return new Promise((resolve, reject) => {
+ const payload = Buffer.from(JSON.stringify(body ?? {}), 'utf8')
+ const lines = [
+ `POST ${path} HTTP/1.1`,
+ ...Object.entries(headers).map(([k, v]) => `${k}: ${v}`),
+ `content-length: ${payload.length}`,
+ 'connection: close',
+ '',
+ ''
+ ].join('\r\n')
+ const sock = connect(RELAY_PORT, '127.0.0.1', () => {
+ sock.write(lines)
+ sock.write(payload)
+ })
+ const chunks: Buffer[] = []
+ sock.on('data', (d) => chunks.push(d))
+ sock.on('error', reject)
+ sock.on('end', () => {
+ const text = Buffer.concat(chunks).toString('utf8')
+ const status = Number(text.match(/^HTTP\/1\.1 (\d{3})/)?.[1] ?? 0)
+ resolve({ status, text })
+ })
+ })
+}
+
+/** A syntactically valid snapshot, so rejections are about AUTH, not shape. */
+function snapshot(origin: string = 'https://example.com'): unknown {
+ return {
+ v: 1,
+ origin,
+ capturedAt: Date.now(),
+ cookies: [
+ {
+ name: 'sid',
+ value: 'secret',
+ domain: '.example.com',
+ path: '/',
+ secure: true,
+ httpOnly: true,
+ hostOnly: false,
+ session: true,
+ sameSite: 'lax'
+ }
+ ]
+ }
+}
+
+const EXT = 'bekpajpbgjeloofgicpnkgahfllakeao'
+const EXT_ORIGIN = `chrome-extension://${EXT}`
+const HOST = `127.0.0.1:${RELAY_PORT}`
+
+async function main(): Promise {
+ await app.whenReady()
+ process.stderr.write('session relay:\n')
+
+ await relay.start()
+ check('listener is up on loopback', relay.status().listening)
+ check(' and starts unpaired', !relay.status().paired)
+
+ // --- pairing is required before anything works ---------------------------
+ let r = await call('/snapshot', { origin: EXT_ORIGIN, host: HOST, body: snapshot() })
+ check('snapshot without pairing is rejected', r.status === 401, r.status)
+
+ r = await call('/pair', { origin: EXT_ORIGIN, host: HOST, body: { code: '123456' } })
+ check('pairing without a code on screen is rejected', r.status === 409, r.status)
+
+ // --- a web page cannot pair ----------------------------------------------
+ const { code } = relay.beginPairing()
+ check('beginPairing issues a 6-digit code', /^\d{6}$/.test(code), code)
+
+ r = await call('/pair', {
+ origin: 'https://evil.com',
+ host: HOST,
+ body: { code, extensionId: EXT }
+ })
+ check('a website cannot pair even with the right code', r.status === 403, r.status)
+
+ // --- an extension cannot claim another extension's id --------------------
+ r = await call('/pair', {
+ origin: 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+ host: HOST,
+ body: { code, extensionId: EXT }
+ })
+ check('extension id must match the request origin', r.status === 400, r.status)
+
+ // --- wrong code ----------------------------------------------------------
+ r = await call('/pair', {
+ origin: EXT_ORIGIN,
+ host: HOST,
+ body: { code: '000000', extensionId: EXT }
+ })
+ check('a wrong code is rejected', r.status === 401, r.status)
+
+ // --- the real pairing ----------------------------------------------------
+ r = await call('/pair', { origin: EXT_ORIGIN, host: HOST, body: { code, extensionId: EXT } })
+ check('the paired extension gets a token', r.status === 200 && typeof r.json?.token === 'string')
+ const token = String(r.json?.token ?? '')
+ check(' token is long enough to not be guessable', token.length >= 32, token.length)
+ check(' status reports paired', relay.status().paired)
+ check(' status never leaks the token', !JSON.stringify(relay.status()).includes(token))
+
+ // --- authenticated requests now work -------------------------------------
+ r = await call('/hello', { origin: EXT_ORIGIN, host: HOST, token })
+ check('paired heartbeat succeeds', r.status === 200, r.status)
+
+ // --- and every forged variant still fails --------------------------------
+ r = await call('/hello', { origin: 'https://evil.com', host: HOST, token })
+ check('a website with a STOLEN token is still rejected (Origin)', r.status === 401, r.status)
+
+ // A forged Host is the DNS-rebinding case: a page on evil.com resolved to
+ // 127.0.0.1 reaches our socket, but the browser sends `Host: evil.com`.
+ // Must go over a raw socket — `fetch` would rewrite the header (see `raw`).
+ const rebind = await raw(
+ '/hello',
+ {
+ host: 'evil.com',
+ origin: EXT_ORIGIN,
+ authorization: `Bearer ${token}`,
+ [RELAY_HEADER]: '1',
+ 'content-type': 'application/json'
+ },
+ {}
+ )
+ check('DNS-rebinding is rejected (forged Host)', rebind.status === 401, rebind.status)
+
+ // Control: the same raw request with the correct Host must succeed, or the
+ // check above would pass for the wrong reason (e.g. a malformed request).
+ const rawOk = await raw(
+ '/hello',
+ {
+ host: HOST,
+ origin: EXT_ORIGIN,
+ authorization: `Bearer ${token}`,
+ [RELAY_HEADER]: '1',
+ 'content-type': 'application/json'
+ },
+ {}
+ )
+ check(
+ ' (control: the same request with the real Host succeeds)',
+ rawOk.status === 200,
+ rawOk.status
+ )
+
+ r = await call('/hello', {
+ origin: EXT_ORIGIN,
+ host: HOST,
+ token: 'wrong-token-wrong-token-wrong'
+ })
+ check('a bad token is rejected', r.status === 401, r.status)
+
+ r = await call('/hello', { origin: EXT_ORIGIN, host: HOST })
+ check('a missing token is rejected', r.status === 401, r.status)
+
+ r = await call('/hello', { origin: EXT_ORIGIN, host: HOST, token, header: false })
+ check('a request without the custom header is rejected', r.status === 401, r.status)
+
+ // --- CORS preflight only ever names the paired extension -----------------
+ const pre = await fetch(`${BASE}/snapshot`, {
+ method: 'OPTIONS',
+ headers: { origin: 'https://evil.com', host: HOST }
+ })
+ check('preflight refuses a website origin', pre.status === 403, pre.status)
+
+ // --- the blocklist and trust rules ---------------------------------------
+ // These decide whether credentials move without asking, so the matching
+ // edge cases are asserted directly rather than inferred from behaviour.
+ const B = (host: string, patterns: string[]): boolean => isBlockedHost(host, patterns)
+
+ check('blocks an exact host', B('example.com', ['example.com']))
+ check('blocks a subdomain', B('app.example.com', ['example.com']))
+ check('blocks a deep subdomain', B('a.b.example.com', ['example.com']))
+ check('does NOT block a lookalike suffix', !B('notexample.com', ['example.com']))
+ check(
+ 'does NOT block a domain that merely contains it',
+ !B('example.com.evil.net', ['example.com'])
+ )
+ check('does NOT block an unrelated host', !B('other.com', ['example.com']))
+ check('ignores case', B('APP.Example.COM', ['example.com']))
+ check('tolerates a scheme in the pattern', B('app.example.com', ['https://example.com']))
+ check('tolerates a *. prefix', B('app.example.com', ['*.example.com']))
+ check('tolerates a path and port', B('app.example.com', ['example.com:8443/foo']))
+ check('tolerates a trailing dot', B('example.com.', ['example.com']))
+ check('an empty host is refused, not allowed', B('', ['example.com']))
+ check('an empty pattern matches nothing', !B('example.com', ['']))
+
+ const prefsFor = (trusted: string[], blocked: string[]): RelayPrefs => ({
+ autoSend: true,
+ trusted,
+ blocked
+ })
+
+ check(
+ 'a trusted origin is auto-allowed',
+ isAutoAllowed('https://app.example.com', prefsFor(['https://app.example.com'], []))
+ )
+ check(
+ 'trust is EXACT: a sibling subdomain is not covered',
+ !isAutoAllowed('https://admin.example.com', prefsFor(['https://app.example.com'], []))
+ )
+ check(
+ 'trust is EXACT: a different scheme is not covered',
+ !isAutoAllowed('http://app.example.com', prefsFor(['https://app.example.com'], []))
+ )
+ check(
+ 'a block BEATS trust',
+ !isAutoAllowed(
+ 'https://app.example.com',
+ prefsFor(['https://app.example.com'], ['example.com'])
+ )
+ )
+ check(
+ 'the master switch disables everything',
+ !isAutoAllowed('https://app.example.com', {
+ autoSend: false,
+ trusted: ['https://app.example.com'],
+ blocked: []
+ })
+ )
+ check(
+ 'an untrusted origin is not auto-allowed',
+ !isAutoAllowed('https://x.com', prefsFor([], []))
+ )
+
+ // Blocking must also REVOKE trust, so the UI can never show a trusted entry
+ // that silently does nothing.
+ relay.setPrefs({ autoSend: true, trusted: ['https://app.example.com'], blocked: [] })
+ check('trust is stored', relay.status().prefs.trusted.length === 1)
+ relay.setPrefs({ blocked: ['example.com'] })
+ check(
+ 'adding a block revokes trust it now covers',
+ relay.status().prefs.trusted.length === 0,
+ JSON.stringify(relay.status().prefs)
+ )
+ let threw = false
+ try {
+ relay.trustOrigin('https://app.example.com')
+ } catch {
+ threw = true
+ }
+ check('trusting a blocked origin is refused', threw)
+
+ // A blocked origin must be refused at the WIRE, not merely un-automated —
+ // a stale extension must not be able to relay it.
+ const blockedSend = await call('/snapshot', {
+ origin: EXT_ORIGIN,
+ host: HOST,
+ token,
+ body: snapshot('https://app.example.com')
+ })
+ check('a blocked origin is rejected on arrival', blockedSend.status === 403, blockedSend.status)
+ check(' and nothing was queued', relay.status().pending.length === 0)
+
+ relay.setPrefs({ ...DEFAULT_RELAY_PREFS })
+
+ // --- snapshots queue, they do NOT apply ----------------------------------
+ r = await call('/snapshot', { origin: EXT_ORIGIN, host: HOST, token, body: snapshot() })
+ check('a valid snapshot is queued', r.status === 202, r.status)
+ check(' it appears as pending', relay.status().pending.length === 1)
+ const pending = relay.status().pending[0]
+ check(' pending reports counts, not values', pending.cookieCount === 1)
+ check(' no cookie VALUE crosses into status', !JSON.stringify(relay.status()).includes('secret'))
+
+ // --- malformed input is rejected without throwing ------------------------
+ r = await call('/snapshot', { origin: EXT_ORIGIN, host: HOST, token, body: { v: 99 } })
+ check('an unknown protocol version is rejected', r.status === 400, r.status)
+
+ r = await call('/snapshot', {
+ origin: EXT_ORIGIN,
+ host: HOST,
+ token,
+ body: { v: 1, origin: 'file:///etc/passwd', cookies: [] }
+ })
+ check('a non-http origin is rejected', r.status === 400, r.status)
+
+ // --- a paired client cannot flood memory ---------------------------------
+ for (let i = 0; i < 6; i++) {
+ await call('/snapshot', { origin: EXT_ORIGIN, host: HOST, token, body: snapshot() })
+ }
+ check(
+ 'the pending queue is capped',
+ relay.status().pending.length <= 5,
+ relay.status().pending.length
+ )
+
+ // --- rejecting drops it untouched ----------------------------------------
+ const before = relay.status().pending.length
+ relay.rejectPending(relay.status().pending[0].id)
+ check('reject removes the snapshot', relay.status().pending.length === before - 1)
+
+ // --- unpair revokes immediately ------------------------------------------
+ relay.unpair()
+ check('unpair clears the pairing', !relay.status().paired)
+ check(' and drops queued snapshots', relay.status().pending.length === 0)
+ r = await call('/hello', { origin: EXT_ORIGIN, host: HOST, token })
+ check('the old token stops working at once', r.status === 401, r.status)
+
+ relay.stop()
+ check('stop closes the listener', !relay.status().listening)
+
+ process.stderr.write(
+ failures ? `\nRELAY FAILED — ${failures} failing\n` : '\nAll relay checks passed.\n'
+ )
+ app.exit(failures ? 1 : 0)
+}
+
+process.on('uncaughtException', (e) => {
+ process.stderr.write(`CRASH: ${e?.stack ?? e}\n`)
+ app.exit(1)
+})
+process.on('unhandledRejection', (e) => {
+ process.stderr.write(`REJECT: ${e instanceof Error ? e.stack : String(e)}\n`)
+ app.exit(1)
+})
+
+void main()