From f14475caa645f3c62acae448c4bbd01455626fa4 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:13:54 -0700 Subject: [PATCH] fix(browser): label popups from committed document origins (#30540) * fix(browser): label popup chrome from committed documents * fix(browser): keep opaque popup documents neutral * test(browser): prepare real popup identity validation * test(browser): apply background policy in popup probes * test(browser): fix popup title and view-lifetime checks * fix(browser): ignore subframe failures in popup chrome * rm draft docs * fix(browser): finish popup probe teardown after signal exit * test(browser): use typed popup probe window access * fix(browser): prevent automatic popup title updates * commit * fix test --- .github/workflows/pr.yml | 2 + config/scripts/ci-unit-files.mjs | 2 + config/scripts/pr-package-test-paths.mjs | 2 + config/scripts/vitest-node-runtime-files.mjs | 2 + docs/review-evidence/pr-19217/README.md | 44 --- ...r-link-materialization-electron-fixture.ts | 62 ++++ ...wser-link-materialization.electron.test.ts | 222 +++++++++++++++ .../browser-manager-guest-popup-policy.ts | 1 - .../browser-popup-adapter-probe-fixture.ts | 1 - .../browser-popup-background-cdp-probe.ts | 2 +- ...opup-native-compatibility.electron.test.ts | 2 +- .../browser-popup-native-window.test.ts | 80 ++++-- .../browser/browser-popup-native-window.ts | 23 +- .../browser/browser-route-guest-popups.ts | 1 - src/main/browser/popup-committed-origin.ts | 19 ++ .../popup-identity-electron-probe.test.ts | 53 ++++ .../browser/popup-identity-electron-probe.ts | 107 +++++++ .../browser/popup-origin-bar-window.test.ts | 152 +++++++++- src/main/browser/popup-origin-bar-window.ts | 81 +++--- .../popup-origin-chrome-electron-fixture.ts | 199 +++++++++++++ .../popup-origin-chrome.electron.test.ts | 265 ++++++++++++++++++ src/main/browser/popup-origin-description.ts | 30 ++ ...wser-link-materialization.test-fixture.tsx | 166 +++++++++++ .../browser-state-open-link-profile.test.ts | 6 +- 24 files changed, 1397 insertions(+), 127 deletions(-) delete mode 100644 docs/review-evidence/pr-19217/README.md create mode 100644 src/main/browser/browser-link-materialization-electron-fixture.ts create mode 100644 src/main/browser/browser-link-materialization.electron.test.ts create mode 100644 src/main/browser/popup-committed-origin.ts create mode 100644 src/main/browser/popup-identity-electron-probe.test.ts create mode 100644 src/main/browser/popup-identity-electron-probe.ts create mode 100644 src/main/browser/popup-origin-chrome-electron-fixture.ts create mode 100644 src/main/browser/popup-origin-chrome.electron.test.ts create mode 100644 src/main/browser/popup-origin-description.ts create mode 100644 src/renderer/src/hooks/ipc-events/browser-link-materialization.test-fixture.tsx diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 02a5ce5974d..0a0fe7a3b2a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1182,6 +1182,8 @@ jobs: src/main/browser/browser-route-h3-egress.electron.test.ts src/main/browser/browser-route-dns-prefetch.electron.test.ts src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts + src/main/browser/popup-origin-chrome.electron.test.ts + src/main/browser/browser-link-materialization.electron.test.ts src/main/browser/browser-popup-compatibility.electron.test.ts src/main/browser/browser-popup-adapter-compatibility.electron.test.ts src/main/browser/browser-popup-native-compatibility.electron.test.ts diff --git a/config/scripts/ci-unit-files.mjs b/config/scripts/ci-unit-files.mjs index 610a0ea177b..e1c265dbcd6 100644 --- a/config/scripts/ci-unit-files.mjs +++ b/config/scripts/ci-unit-files.mjs @@ -36,6 +36,8 @@ export const UNIT_EXCLUDE = [ 'tests/e2e/relay-region-compatibility.unit.test.ts', 'tests/e2e/relay-region-correction.unit.test.ts', // Electron probes with in-process deadlines; the package job runs them serially. + 'src/main/browser/popup-origin-chrome.electron.test.ts', + 'src/main/browser/browser-link-materialization.electron.test.ts', 'src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts', 'src/main/browser/browser-route-tcp-egress.electron.test.ts', 'src/main/browser/browser-route-webrtc-egress.electron.test.ts', diff --git a/config/scripts/pr-package-test-paths.mjs b/config/scripts/pr-package-test-paths.mjs index 81d74ee3c31..05cdc5a7e31 100644 --- a/config/scripts/pr-package-test-paths.mjs +++ b/config/scripts/pr-package-test-paths.mjs @@ -1,4 +1,6 @@ export const LINUX_PACKAGE_TESTS = [ + 'src/main/browser/popup-origin-chrome.electron.test.ts', + 'src/main/browser/browser-link-materialization.electron.test.ts', 'src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts', 'src/main/browser/browser-route-tcp-egress.electron.test.ts', 'src/main/browser/browser-route-webrtc-egress.electron.test.ts', diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 06634db16dc..61e9c531448 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -65,6 +65,8 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/main/browser/*cookie*.test.ts', // Playwright's CDP transport needs Node's WebSocket implementation. 'src/main/browser/browser-popup-adapter-compatibility.electron.test.ts', + 'src/main/browser/popup-origin-chrome.electron.test.ts', + 'src/main/browser/browser-link-materialization.electron.test.ts', 'src/main/browser/browser-popup-compatibility.electron.test.ts', 'src/main/browser/browser-popup-native-compatibility.electron.test.ts', 'src/main/browser/browser-popup-background-compatibility.electron.test.ts', diff --git a/docs/review-evidence/pr-19217/README.md b/docs/review-evidence/pr-19217/README.md deleted file mode 100644 index 2eaecc5d4c2..00000000000 --- a/docs/review-evidence/pr-19217/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# Structured worktree status validation - -Validated on September 7, 2026 in a background Electron dev instance of -`pr19217-review-r2`, based on `ce1024096b` with the source-adapter refactor. -CDP app identity confirmed the checkout; CDP screenshots of the full hidden renderer -were reviewed at the time and are not retained here. -The command output is the real `orca worktree ps --json` response reduced to status, -agent state, provider, and pane key for readability. - -## Functional correctness - -A real Codex structured session appeared as `working` in `worktree.ps` while the -sidebar showed working. Closing its chat tab removed that exact session's row and -returned the worktree to `active`. A different completed chat remained present, -confirming that closure removed only the selected session. - -The disappearing session is `codex_40677067_f492_4d7d_86dd_ec566ede04c3`. -The host's held-session roster controls eligibility; its retained broadcast cache -is history, not a roster. Failed eviction intentionally keeps an entry for retry. - -## Architecture - -PTY reconciliation and process admission belong to the PTY source adapter. -Structured input comes from the current host's held-session projections. One -admitted collection feeds row shaping and worktree aggregation, with no structured -boolean bypass. PTY hooks and retained reports still arrive independently, so their -precedence and conservative remote evidence rules remain necessary. No second -persistent status store or provider polling was introduced. - -## Validation and limits - -Independent final review found no proven issues. Runtime, host lifecycle, status -feed and source-admission suites passed: 1,344 tests, one skipped. Node typecheck, -targeted lint and diff checks passed. Ablating the runtime call to enumerate -retained history caused the executable call-site test to fail with two rows where -one was expected; restoring the live accessor passed both call-site tests. - -Codex working and closure were observed live on macOS. Claude provider turns, -approval/input states, live Windows/Linux/WSL/SSH/relay/mobile scenarios and -release-scale latency/heap measurements remain unverified. Existing tests cover -remote/WSL evidence, monitoring precedence and lifecycle cases. The existing -30-minute freshness rule and CLI activity timestamps are preserved; complete -CLI/sidebar timing parity is not claimed. The wire keeps its existing row shape -and status vocabulary; mobile receives the new rows without a new opcode. diff --git a/src/main/browser/browser-link-materialization-electron-fixture.ts b/src/main/browser/browser-link-materialization-electron-fixture.ts new file mode 100644 index 00000000000..7b573aa641e --- /dev/null +++ b/src/main/browser/browser-link-materialization-electron-fixture.ts @@ -0,0 +1,62 @@ +import { app, BrowserWindow, ipcMain, session, webContents } from 'electron' +import { join } from 'node:path' +import { applyBackgroundActivationPolicy } from '../window/foreground-activation-policy' + +if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') { + throw new Error('Popup identity probes require a background launch') +} +applyBackgroundActivationPolicy() + +const root = process.env.ORCA_POPUP_IDENTITY_ROOT +if (!root) { + throw new Error('Missing probe root') +} + +app.whenReady().then(async () => { + const window = new BrowserWindow({ + show: false, + width: 1000, + height: 700, + webPreferences: { + preload: join(root, 'preload.cjs'), + sandbox: false, + contextIsolation: true, + nodeIntegration: false, + webviewTag: true + } + }) + ipcMain.handle('browser:setGrabMode', () => undefined) + ipcMain.handle('link-probe:open', (_event, event) => + window.webContents.send('browser:open-link-in-orca-tab', event) + ) + ipcMain.handle('link-probe:register', (_event, args) => { + const guest = webContents.fromId(args.webContentsId) + return guest?.hostWebContents === window.webContents + }) + ipcMain.handle('link-probe:guests', () => + webContents + .getAllWebContents() + .filter((guest) => guest.hostWebContents === window.webContents) + .map((guest) => ({ + id: guest.id, + url: guest.mainFrame.url, + isolated: guest.session === session.fromPartition('persist:link-probe-isolated'), + other: guest.session === session.fromPartition('persist:link-probe-default') + })) + ) + ipcMain.handle('link-probe:seed', async (_event, origin) => { + await session + .fromPartition('persist:link-probe-isolated') + .cookies.set({ url: origin, name: 'synthetic_profile', value: 'isolated' }) + await session + .fromPartition('persist:link-probe-default') + .cookies.set({ url: origin, name: 'synthetic_profile', value: 'other' }) + }) + window.webContents.on('will-attach-webview', (_event, preferences) => { + preferences.nodeIntegration = false + preferences.contextIsolation = true + preferences.sandbox = true + }) + await window.loadFile(join(root, 'index.html')) +}) +setTimeout(() => app.exit(1), 120_000).unref() diff --git a/src/main/browser/browser-link-materialization.electron.test.ts b/src/main/browser/browser-link-materialization.electron.test.ts new file mode 100644 index 00000000000..3b8c135324e --- /dev/null +++ b/src/main/browser/browser-link-materialization.electron.test.ts @@ -0,0 +1,222 @@ +import { createServer } from 'node:http' +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' +import type { Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { + buildPopupIdentityProbe, + createPopupIdentityProbeRoot, + launchPopupIdentityProbe +} from './popup-identity-electron-probe' + +const root = createPopupIdentityProbeRoot() +let probe: Awaited> +let page: Page +let origin: string +const rendererErrors: string[] = [] +type LinkSnapshot = { + tabs: { id: string; sessionProfileId: string | null; sessionPartition: string | null }[] + sourceWorkspaceId: string + selected: string | null + selectedUnified: string[] + unified: { id: string; entityId: string }[] +} +const receipts: { path: string; cookie: string }[] = [] +const server = createServer((request, response) => { + receipts.push({ path: request.url ?? '', cookie: request.headers.cookie ?? '' }) + response + .writeHead(200, { 'content-type': 'text/html' }) + .end('Synthetic destination

One document, no subresources

') +}) + +beforeAll(async () => { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Missing HTTP port') + } + origin = `http://127.0.0.1:${address.port}` + writeFileSync( + join(root, 'preload.ts'), + ` +import { contextBridge, ipcRenderer } from 'electron' +import { browserPageInteractionAndSessionsApi } from ${JSON.stringify(join(process.cwd(), 'src/preload/api/browser-bridge-page-interaction-and-sessions.ts'))} +let heldOpen = null +contextBridge.exposeInMainWorld('probeApi', { + browser: { ...browserPageInteractionAndSessionsApi, onOpenLinkInOrcaTab: (callback) => browserPageInteractionAndSessionsApi.onOpenLinkInOrcaTab((event) => { heldOpen = () => callback(event) }), registerGuest: (args) => ipcRenderer.invoke('link-probe:register', args) }, + fixture: { + open: (event) => ipcRenderer.invoke('link-probe:open', event), + pending: () => heldOpen !== null, + release: () => { const consume = heldOpen; heldOpen = null; consume?.() }, + guests: () => ipcRenderer.invoke('link-probe:guests'), + seed: (origin) => ipcRenderer.invoke('link-probe:seed', origin) + } +}) +` + ) + writeFileSync( + join(root, 'bootstrap.ts'), + ` +import { installBrowserLinkMaterializationProbe } from ${JSON.stringify(join(process.cwd(), 'src/renderer/src/hooks/ipc-events/browser-link-materialization.test-fixture.tsx'))} +try { window.linkProbe = installBrowserLinkMaterializationProbe() } catch (error) { window.linkProbeError = String(error.stack || error) } +` + ) + writeFileSync( + join(root, 'index.html'), + `Real link materialization probe` + ) + await buildPopupIdentityProbe(root, join(root, 'preload.ts'), 'preload.cjs') + await buildPopupIdentityProbe(root, join(root, 'bootstrap.ts'), 'renderer.js', true) + await buildPopupIdentityProbe( + root, + join(process.cwd(), 'src/main/browser/browser-link-materialization-electron-fixture.ts'), + 'main.cjs' + ) + probe = await launchPopupIdentityProbe(root) + await vi.waitFor( + () => + expect( + probe.browser + .contexts() + .flatMap((context) => context.pages()) + .some((candidate) => candidate.url().endsWith('/index.html')) + ).toBe(true), + { timeout: 10_000 } + ) + const found = probe.browser + .contexts() + .flatMap((context) => context.pages()) + .find((candidate) => candidate.url().endsWith('/index.html')) + if (!found) { + throw new Error('Missing host renderer') + } + page = found + page.on('pageerror', (error) => rendererErrors.push(String(error))) +}, 90_000) + +afterAll(async () => { + await probe?.stop() + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) +}) + +async function ready(): Promise { + await expect + .poll( + () => + page.evaluate('window.linkProbeError || Boolean(window.linkProbe)').then((ready) => { + if (rendererErrors.length) { + throw new Error(rendererErrors.join('\n')) + } + return ready + }), + { timeout: 10_000 } + ) + .toBe(true) + await page.evaluate(`window.probeApi.fixture.seed(${JSON.stringify(origin)})`) +} + +async function open(activate: boolean, path: string, hold = false): Promise { + await page.evaluate( + `window.probeApi.fixture.open({ browserPageId: window.linkProbe.sourcePageId, url: ${JSON.stringify(origin + path)}, activate: ${activate} })` + ) + await expect.poll(() => page.evaluate('window.probeApi.fixture.pending()')).toBe(true) + if (!hold) { + await page.evaluate('window.probeApi.fixture.release()') + } +} + +describe('production link bridge, store and mounted guest', () => { + it.each([false, true])( + 'materializes an isolated profile with activation=%s', + async (activate) => { + rendererErrors.length = 0 + await page.reload() + await ready() + const path = `/control-${activate}` + await open(activate, path) + const state = await page.evaluate('window.linkProbe.snapshot()') + const created = state.tabs.find((tab) => tab.id !== state.sourceWorkspaceId) + if (!created) { + throw new Error('No replacement tab materialized') + } + expect(created).toMatchObject({ + sessionProfileId: 'isolated-profile', + sessionPartition: 'persist:link-probe-isolated' + }) + expect(state.selected).toBe(activate ? created.id : state.sourceWorkspaceId) + const selectedEntity = activate ? created.id : state.sourceWorkspaceId + const selectedUnified = state.unified.find((tab) => tab.entityId === selectedEntity) + expect(state.selectedUnified).toContain(selectedUnified?.id) + await page.evaluate('window.linkProbe.mountTabs()') + await expect + .poll(() => receipts.filter((receipt) => receipt.path === path), { timeout: 10_000 }) + .toEqual([{ path, cookie: 'synthetic_profile=isolated' }]) + await expect + .poll(() => page.evaluate('window.probeApi.fixture.guests()'), { timeout: 10_000 }) + .toEqual([ + expect.objectContaining({ url: `${origin}${path}`, isolated: true, other: false }) + ]) + }, + 25_000 + ) + + it('rejects a removed source page before real store materialization', async () => { + rendererErrors.length = 0 + await page.reload() + await ready() + await open(true, '/removed-page', true) + await page.evaluate("window.linkProbe.mutate('remove-page')") + await page.evaluate('window.probeApi.fixture.release()') + await page.evaluate('window.linkProbe.mountTabs()') + expect((await page.evaluate('window.linkProbe.snapshot()')).tabs).toHaveLength(1) + expect(await page.evaluate('window.probeApi.fixture.guests()')).toEqual([]) + expect(receipts.some((receipt) => receipt.path === '/removed-page')).toBe(false) + }) + + // Known defects until the claim handoff repair; these assertions deliberately describe baseline evidence. + it.each(['remove-tab', 'profile-switch', 'delete-profile'])( + 'records unsafe stale-source baseline through the real mount: %s', + async (transition) => { + rendererErrors.length = 0 + await page.reload() + await ready() + const path = `/baseline-${transition}` + await open(false, path, true) + await page.evaluate(`window.linkProbe.mutate(${JSON.stringify(transition)})`) + await page.evaluate('window.probeApi.fixture.release()') + await page.evaluate('window.linkProbe.mountTabs()') + await expect + .poll(() => receipts.some((receipt) => receipt.path === path), { timeout: 10_000 }) + .toBe(true) + const state = await page.evaluate('window.linkProbe.snapshot()') + const replacement = state.tabs.find((tab) => tab.id !== state.sourceWorkspaceId) + expect(replacement).toBeDefined() + const expectedProfile = transition === 'delete-profile' ? 'isolated' : 'other' + expect(receipts.filter((receipt) => receipt.path === path)).toEqual([ + { path, cookie: `synthetic_profile=${expectedProfile}` } + ]) + await expect + .poll(() => page.evaluate('window.probeApi.fixture.guests()'), { timeout: 10_000 }) + .toEqual([ + expect.objectContaining({ + url: `${origin}${path}`, + isolated: expectedProfile === 'isolated', + other: expectedProfile === 'other' + }) + ]) + }, + 25_000 + ) + + it.todo( + 'rejects removed source tabs, profile switches and deleted profiles without a tab, mount, default fallback or destination request' + ) + it.todo( + 'validates main-owned document/registration claims during delayed IPC consumption, async validation and deferred mount' + ) +}) diff --git a/src/main/browser/browser-manager-guest-popup-policy.ts b/src/main/browser/browser-manager-guest-popup-policy.ts index 8348e027b62..31032eb71f5 100644 --- a/src/main/browser/browser-manager-guest-popup-policy.ts +++ b/src/main/browser/browser-manager-guest-popup-policy.ts @@ -87,7 +87,6 @@ export abstract class BrowserManagerGuestPopupPolicy extends BrowserManagerNavig if (isNativeBrowserPopupRequest({ disposition, features, frameName })) { return allowNativeBrowserPopup({ opener: guest, - targetUrl: url, options: SAFE_POPUP_WINDOW_OPTIONS, background: disposition === 'background-tab', canPresent: () => Boolean(this.resolvePopupOwnerContext(guest.id)), diff --git a/src/main/browser/browser-popup-adapter-probe-fixture.ts b/src/main/browser/browser-popup-adapter-probe-fixture.ts index 52a47943ae2..3bad6d57d17 100644 --- a/src/main/browser/browser-popup-adapter-probe-fixture.ts +++ b/src/main/browser/browser-popup-adapter-probe-fixture.ts @@ -98,7 +98,6 @@ export class BrowserPopupAdapterProbeFixture { unrelatedDuringAdmission = false const response = allowNativeBrowserPopup({ opener: guest, - targetUrl: details.url, options: { show: false }, onCreated: ({ nativeWindow }) => { if (!nativeWindow) { diff --git a/src/main/browser/browser-popup-background-cdp-probe.ts b/src/main/browser/browser-popup-background-cdp-probe.ts index 4552730417e..7fca368da63 100644 --- a/src/main/browser/browser-popup-background-cdp-probe.ts +++ b/src/main/browser/browser-popup-background-cdp-probe.ts @@ -186,7 +186,7 @@ export async function observeBackgroundPopupProbe( expect(status().native.creation.at(-1)).toMatchObject({ firstNavigationPolicy: 'disable_non_proxied_udp', firstNavigationOwner: expect.any(Number), - firstNavigationTitle: targetOrigin + firstNavigationTitle: 'about:blank' }) expect(await child.evaluate('window.open("about:blank")===null')).toBe(true) } diff --git a/src/main/browser/browser-popup-native-compatibility.electron.test.ts b/src/main/browser/browser-popup-native-compatibility.electron.test.ts index 1f076f3d966..94b00d601a8 100644 --- a/src/main/browser/browser-popup-native-compatibility.electron.test.ts +++ b/src/main/browser/browser-popup-native-compatibility.electron.test.ts @@ -109,7 +109,7 @@ describe('hidden Chromium native popup request compatibility', () => { if (name === 'route') { expect(status().native.creation.at(-1)).toMatchObject({ firstNavigationPolicy: 'disable_non_proxied_udp', - firstNavigationTitle: origin, + firstNavigationTitle: 'about:blank', firstNavigationOwner: expect.any(Number) }) } diff --git a/src/main/browser/browser-popup-native-window.test.ts b/src/main/browser/browser-popup-native-window.test.ts index 6f143e0d814..cda517158fc 100644 --- a/src/main/browser/browser-popup-native-window.test.ts +++ b/src/main/browser/browser-popup-native-window.test.ts @@ -21,6 +21,7 @@ afterEach(() => { function nativeWindow() { const contents = Object.assign(new EventEmitter(), { session: {}, + mainFrame: { url: '', origin: '' }, isDestroyed: vi.fn(() => false), close: vi.fn(), setWindowOpenHandler: vi.fn() @@ -64,7 +65,6 @@ describe('native popup admission', () => { const onCreated = vi.fn() const response = allowNativeBrowserPopup({ opener, - targetUrl: 'https://example.com/post?code=synthetic', options: { show: true, frame: true }, onCreated }) @@ -72,7 +72,7 @@ describe('native popup admission', () => { expect(response.overrideBrowserWindowOptions).toMatchObject({ show: false, fullscreenable: false, - title: 'https://example.com', + title: 'about:blank', useContentSize: true, minWidth: 360, minHeight: 200, @@ -91,13 +91,12 @@ describe('native popup admission', () => { linked.contents.session = opener.contents.session scripted.contents.session = opener.contents.session const onScripted = vi.fn(() => { - prepareNativeBrowserPopup(scripted.browserWindow, 'https://example.com/scripted', () => true) + prepareNativeBrowserPopup(scripted.browserWindow, () => true) return scripted.browserWindow.webContents }) const admit = (onCreated: () => Electron.WebContents) => allowNativeBrowserPopup({ opener: opener.browserWindow.webContents, - targetUrl: 'https://example.com/scripted', options: {}, onCreated }) @@ -107,7 +106,6 @@ describe('native popup admission', () => { const onLinked = vi.fn(() => linked.browserWindow.webContents) allowNativeBrowserPopup({ opener: opener.browserWindow.webContents, - targetUrl: 'https://example.com/linked', options: {}, onCreated: onLinked }) @@ -134,7 +132,6 @@ describe('native popup admission', () => { const onCreated = vi.fn(() => first.browserWindow.webContents) allowNativeBrowserPopup({ opener: opener.browserWindow.webContents, - targetUrl: 'https://example.com/scripted', options: {}, onCreated }) @@ -159,7 +156,6 @@ describe('native popup admission', () => { for (const onCreated of [earlier, later]) { allowNativeBrowserPopup({ opener: opener.browserWindow.webContents, - targetUrl: 'https://example.com/identical', options: { show: false }, onCreated }) @@ -184,7 +180,6 @@ describe('native popup admission', () => { for (const onCreated of [onA, onB]) { allowNativeBrowserPopup({ opener: opener.browserWindow.webContents, - targetUrl: 'https://example.com/identical', options: { show: false }, onCreated }) @@ -208,7 +203,6 @@ describe('native popup admission', () => { ] as const) { allowNativeBrowserPopup({ opener, - targetUrl: 'https://example.com/identical', options: { show: false }, onCreated }) @@ -229,7 +223,6 @@ describe('native popup admission', () => { const onCreated = vi.fn(() => unrelated.browserWindow.webContents) allowNativeBrowserPopup({ opener: opener.browserWindow.webContents, - targetUrl: 'https://example.com/identical', options: { show: false }, onCreated }) @@ -248,7 +241,6 @@ describe('native popup admission', () => { const onCreated = vi.fn(() => child.browserWindow.webContents) allowNativeBrowserPopup({ opener: opener.browserWindow.webContents, - targetUrl: 'https://example.com/identical', options: { show: false }, onCreated }) @@ -279,13 +271,11 @@ describe('native popup admission', () => { const options = { show: state !== 'explicit-hidden' } const prepare = vi.fn(() => state === 'preparation-rejected' - ? prepareNativeBrowserPopup(browserWindow, 'https://example.com/post', () => false) - .contentWebContents + ? prepareNativeBrowserPopup(browserWindow, () => false).contentWebContents : browserWindow.webContents ) const response = allowNativeBrowserPopup({ opener: opener.browserWindow.webContents, - targetUrl: 'https://example.com/post', options, background: true, canPresent: () => { @@ -326,7 +316,7 @@ describe('native popup admission', () => { } ])('blocks native navigation and descendants when preparation fails', (prepare) => { const { browserWindow, contents, window } = nativeWindow() - const popup = prepareNativeBrowserPopup(browserWindow, 'https://example.com/', prepare) + const popup = prepareNativeBrowserPopup(browserWindow, prepare) for (const event of ['will-navigate', 'will-frame-navigate', 'will-redirect']) { const preventDefault = vi.fn() contents.emit(event, { preventDefault }) @@ -340,9 +330,66 @@ describe('native popup admission', () => { expect(closed).toHaveBeenCalledOnce() }) + it('initializes only from the committed frame and retains it through failed redirects', () => { + const { browserWindow, contents, window } = nativeWindow() + contents.mainFrame.url = 'https://committed.example/private?secret=synthetic' + prepareNativeBrowserPopup(browserWindow) + expect(window.setTitle).toHaveBeenLastCalledWith('https://committed.example') + contents.emit('did-start-navigation', {}, 'https://requested.example/auth') + contents.emit('will-redirect', {}, 'https://redirect.example/auth') + contents.emit('did-fail-load', {}, -3, 'ERR_ABORTED', 'https://redirect.example/auth', true) + contents.emit('did-finish-load') + expect(window.setTitle).toHaveBeenLastCalledWith('https://committed.example') + contents.mainFrame.url = 'chrome-error://chromewebdata/' + contents.emit( + 'did-fail-load', + {}, + -105, + 'ERR_NAME_NOT_RESOLVED', + 'https://failed.example/', + true + ) + expect(window.setTitle).toHaveBeenLastCalledWith('chrome-error:') + }) + + it.each([1, 1000])( + 'ignores %i subframe failures but still updates main-frame errors', + (count) => { + const { browserWindow, contents, window } = nativeWindow() + contents.mainFrame.url = 'https://committed.example/' + contents.mainFrame.origin = 'https://committed.example' + prepareNativeBrowserPopup(browserWindow) + window.setTitle.mockClear() + + for (let index = 0; index < count; index += 1) { + contents.emit('did-fail-load', {}, -2, 'ERR_FAILED', 'https://iframe.example/', false) + } + expect(window.setTitle).not.toHaveBeenCalled() + + contents.mainFrame.url = 'https://failed.example/' + contents.mainFrame.origin = 'null' + contents.emit('did-fail-load', {}, -2, 'ERR_FAILED', contents.mainFrame.url, true) + expect(window.setTitle).toHaveBeenCalledExactlyOnceWith('unknown') + } + ) + + it('uses a neutral title for an opaque network document and retains a surviving origin on abort', () => { + const { browserWindow, contents, window } = nativeWindow() + contents.mainFrame.url = 'https://committed.example/' + contents.mainFrame.origin = 'https://committed.example' + prepareNativeBrowserPopup(browserWindow) + contents.emit('did-fail-load', {}, -3, 'ERR_ABORTED', 'https://failed.example/', true) + expect(window.setTitle).toHaveBeenLastCalledWith('https://committed.example') + contents.mainFrame.url = 'https://failed.example/' + contents.mainFrame.origin = 'null' + contents.emit('did-fail-load', {}, -2, 'ERR_FAILED', 'https://failed.example/', true) + expect(window.setTitle).toHaveBeenLastCalledWith('unknown') + }) + it('protects the title and updates only main-frame origins, including redirects', () => { const { browserWindow, contents, window } = nativeWindow() - prepareNativeBrowserPopup(browserWindow, 'https://example.com/post?code=synthetic', () => true) + prepareNativeBrowserPopup(browserWindow, () => true) + expect(window.setTitle).toHaveBeenLastCalledWith('about:blank') const preventDefault = vi.fn() window.emit('page-title-updated', { preventDefault }, 'Spoofed title') expect(preventDefault).toHaveBeenCalledOnce() @@ -356,6 +403,7 @@ describe('native popup admission', () => { true ) expect(window.setTitle).toHaveBeenCalledTimes(1) + contents.mainFrame.url = 'https://destination.example.net/other' contents.emit('did-navigate', {}, 'https://destination.example.net/other') expect(window.setTitle).toHaveBeenLastCalledWith('https://destination.example.net') }) diff --git a/src/main/browser/browser-popup-native-window.ts b/src/main/browser/browser-popup-native-window.ts index 5e5274fc340..d9c9cbbfe44 100644 --- a/src/main/browser/browser-popup-native-window.ts +++ b/src/main/browser/browser-popup-native-window.ts @@ -1,12 +1,12 @@ import { app, type BrowserWindow, type WebContents } from 'electron' import { isBackgroundLaunch } from '../window/foreground-activation-policy' -import { safeOrigin } from './browser-manager-types' import type { PopupChildWindowOptions, PopupOriginBarWindow } from './popup-origin-bar-window' import { isNativeBrowserPopupWindow, markNativeBrowserPopupWindow } from './native-browser-popup-windows' import { MIN_POPUP_CONTENT_HEIGHT, MIN_POPUP_CONTENT_WIDTH } from './popup-window-minimum-size' +import { describeCommittedPopupOrigin } from './popup-committed-origin' const rejectedNativePopups = new WeakSet() // Why: like Electron, an opener's latest script admission answers the next child it shows. @@ -34,7 +34,6 @@ export function isNativeBrowserPopupRequest(input: { */ export function allowNativeBrowserPopup(input: { opener: WebContents - targetUrl: string options: Electron.BrowserWindowConstructorOptions background?: boolean canPresent?: () => boolean @@ -90,7 +89,7 @@ export function allowNativeBrowserPopup(input: { action: 'allow', overrideBrowserWindowOptions: { ...input.options, - title: safeOrigin(input.targetUrl), + title: 'about:blank', fullscreenable: false, show: mayPresent && !input.background, // Content-sized like origin-bar popups, so resizeTo cannot hide the origin title. @@ -159,7 +158,6 @@ function rejectNativeBrowserPopup(window: BrowserWindow): void { /** The native frame is the trust surface; public APIs cannot reparent its primary contents. */ export function prepareNativeBrowserPopup( window: BrowserWindow, - initialUrl: string, prepareContent?: (contents: WebContents) => boolean ): PopupOriginBarWindow { const contents = window.webContents @@ -195,19 +193,24 @@ export function prepareNativeBrowserPopup( rejectNativeBrowserPopup(window) return popup } - const updateOrigin = (url: string): void => { + const updateOrigin = (): void => { if (!window.isDestroyed()) { - window.setTitle(safeOrigin(url)) + window.setTitle(describeCommittedPopupOrigin(contents).label) } } window.on('page-title-updated', (event) => event.preventDefault()) // A blocked/uncommitted redirect must never lend its origin to the old document. - contents.on('did-navigate', (_event, url) => updateOrigin(url)) - contents.on('did-navigate-in-page', (_event, url, isMainFrame) => { + contents.on('did-navigate', updateOrigin) + contents.on('did-navigate-in-page', (_event, _url, isMainFrame) => { if (isMainFrame) { - updateOrigin(url) + updateOrigin() } }) - updateOrigin(initialUrl) + contents.on('did-fail-load', (_event, _errorCode, _errorDescription, _url, isMainFrame) => { + if (isMainFrame) { + updateOrigin() + } + }) + updateOrigin() return popup } diff --git a/src/main/browser/browser-route-guest-popups.ts b/src/main/browser/browser-route-guest-popups.ts index 56520c3241f..86cd74e1565 100644 --- a/src/main/browser/browser-route-guest-popups.ts +++ b/src/main/browser/browser-route-guest-popups.ts @@ -203,7 +203,6 @@ export function createBrowserRouteGuestPopupController(input: { return allowNativeBrowserPopup({ // Electron reports a script-opened child to the contents whose handler answered. opener: source, - targetUrl: details?.url ?? normalized, options, background: details?.disposition === 'background-tab', canPresent: () => !disposed && navigationAllowed(ORCA_BROWSER_BLANK_URL), diff --git a/src/main/browser/popup-committed-origin.ts b/src/main/browser/popup-committed-origin.ts new file mode 100644 index 00000000000..852adb5a7b7 --- /dev/null +++ b/src/main/browser/popup-committed-origin.ts @@ -0,0 +1,19 @@ +import { describePopupOrigin } from './popup-origin-description' + +/** RenderFrameHost's last committed URL excludes pending destinations and page titles. */ +export function describeCommittedPopupOrigin(contents: Electron.WebContents): { + label: string + insecure: boolean +} { + try { + const frame = contents.mainFrame + const url = frame.url || 'about:blank' + // Error and sandboxed documents can retain a network URL with an opaque security origin. + if (frame.origin === 'null' && URL.canParse(url) && new URL(url).origin !== 'null') { + return { label: 'unknown', insecure: true } + } + return describePopupOrigin(url) + } catch { + return { label: 'about:blank', insecure: false } + } +} diff --git a/src/main/browser/popup-identity-electron-probe.test.ts b/src/main/browser/popup-identity-electron-probe.test.ts new file mode 100644 index 00000000000..340da15d66f --- /dev/null +++ b/src/main/browser/popup-identity-electron-probe.test.ts @@ -0,0 +1,53 @@ +import { EventEmitter } from 'node:events' +import { existsSync, rmSync } from 'node:fs' +import { afterEach, expect, it, vi } from 'vitest' +import { + createPopupIdentityProbeRoot, + launchPopupIdentityProbe +} from './popup-identity-electron-probe' + +const mocks = vi.hoisted(() => ({ spawn: vi.fn(), closeBrowser: vi.fn() })) + +vi.mock('@orca/process-host', () => ({ spawnProcess: mocks.spawn })) +vi.mock('playwright', () => ({ + chromium: { connectOverCDP: vi.fn(async () => ({ close: mocks.closeBrowser })) } +})) +vi.mock('./browser-session-ua-cdp-collector', () => ({ + waitForBrowserCdpEndpoint: vi.fn(async () => undefined) +})) + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + vi.clearAllMocks() +}) + +it.each([ + { exitCode: 0, signalCode: null }, + { exitCode: null, signalCode: 'SIGTERM' } +] as const)( + 'cleans up a probe whose child already exited with $exitCode / $signalCode', + async ({ exitCode, signalCode }) => { + const child = Object.assign(new EventEmitter(), { + stderr: new EventEmitter(), + exitCode, + signalCode, + kill: vi.fn() + }) + mocks.spawn.mockReturnValue(child) + const root = createPopupIdentityProbeRoot() + roots.push(root) + const probe = await launchPopupIdentityProbe(root) + child.emit('exit', exitCode, signalCode) + + await probe.stop() + + expect(mocks.closeBrowser).toHaveBeenCalledOnce() + expect(child.listenerCount('exit')).toBe(0) + expect(existsSync(root)).toBe(false) + }, + 2_000 +) diff --git a/src/main/browser/popup-identity-electron-probe.ts b/src/main/browser/popup-identity-electron-probe.ts new file mode 100644 index 00000000000..badc1c4400f --- /dev/null +++ b/src/main/browser/popup-identity-electron-probe.ts @@ -0,0 +1,107 @@ +import { spawnProcess } from '@orca/process-host' +import { mkdtempSync, rmSync } from 'node:fs' +import { createServer } from 'node:net' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { chromium } from 'playwright' +import { build } from 'vite' +import { waitForBrowserCdpEndpoint } from './browser-session-ua-cdp-collector' +import { resolveElectronProbeLaunch } from './electron-probe-display-launch' + +export function createPopupIdentityProbeRoot(): string { + return mkdtempSync(join(tmpdir(), 'orca-popup-identity-')) +} + +export async function buildPopupIdentityProbe( + root: string, + entry: string, + fileName: string, + renderer = false +): Promise { + await build({ + configFile: false, + logLevel: 'silent', + oxc: { jsx: { development: false } }, + resolve: { alias: { '@': join(process.cwd(), 'src/renderer/src') } }, + define: { + ORCA_FEATURE_WALL_ENABLED: 'true', + ORCA_BUILD_COMMIT: JSON.stringify('0'.repeat(40)), + 'import.meta.env.DEV': 'false', + 'process.env.NODE_ENV': JSON.stringify('production') + }, + build: { + emptyOutDir: false, + lib: { + entry, + formats: [renderer ? 'iife' : 'cjs'], + name: 'PopupIdentityProbe', + fileName: () => fileName + }, + outDir: root, + target: renderer ? 'chrome140' : 'node22', + rollupOptions: { + external: renderer ? [] : ['electron', /^node:/], + output: { inlineDynamicImports: true } + } + } + }) +} + +export async function launchPopupIdentityProbe(root: string, electronArgs: readonly string[] = []) { + const portServer = createServer() + await new Promise((resolve) => portServer.listen(0, '127.0.0.1', resolve)) + const address = portServer.address() + if (!address || typeof address === 'string') { + throw new Error('Missing probe port') + } + const port = address.port + await new Promise((resolve) => portServer.close(() => resolve())) + const electronBinary: unknown = createRequire(import.meta.url)('electron') + if (typeof electronBinary !== 'string') { + throw new Error('Missing Electron executable') + } + const { ELECTRON_RUN_AS_NODE: _runAsNode, ...env } = process.env + const { executable, args } = resolveElectronProbeLaunch({ + electronBinary, + electronArgs: [ + ...electronArgs, + join(root, 'main.cjs'), + `--user-data-dir=${join(root, 'profile')}`, + `--remote-debugging-port=${port}` + ], + platform: process.platform, + display: env.DISPLAY + }) + const child = spawnProcess({ + program: executable, + args, + env: { ...env, ORCA_BACKGROUND_LAUNCH: '1', ORCA_POPUP_IDENTITY_ROOT: root }, + stdio: ['ignore', 'pipe', 'pipe'] + }) + const diagnostics: string[] = [] + child.stderr?.on('data', (data: Buffer) => diagnostics.push(data.toString())) + try { + await waitForBrowserCdpEndpoint(port) + const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`) + return { + browser, + diagnostics, + stop: async (): Promise => { + await browser.close() + child.kill() + await new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve() + } else { + child.once('exit', () => resolve()) + } + }) + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + } + } + } catch (error) { + child.kill() + throw new Error(`${String(error)}\n${diagnostics.join('')}`) + } +} diff --git a/src/main/browser/popup-origin-bar-window.test.ts b/src/main/browser/popup-origin-bar-window.test.ts index 0f7e15b5f55..1f9c7518391 100644 --- a/src/main/browser/popup-origin-bar-window.test.ts +++ b/src/main/browser/popup-origin-bar-window.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' type Handler = (...args: unknown[]) => void type FakeWebContents = { + mainFrame: { url: string; origin: string } on: ReturnType once: ReturnType off: ReturnType @@ -21,7 +22,9 @@ const { fakeElectron } = vi.hoisted(() => { const add = (event: string, handler: Handler): void => { handlers.set(event, [...(handlers.get(event) ?? []), handler]) } + const mainFrame = { url: '', origin: '' } return { + mainFrame, on: vi.fn(add), once: vi.fn(add), off: vi.fn((event: string, handler: Handler) => { @@ -38,6 +41,9 @@ const { fakeElectron } = vi.hoisted(() => { // 'destroyed' handler, which is what the double-close guard relies on. isDestroyed: vi.fn(() => destroyed), emit: (event: string, ...args: unknown[]) => { + if (event === 'did-navigate' || (event === 'did-navigate-in-page' && args[2])) { + mainFrame.url = String(args[1]) + } if (event === 'destroyed') { destroyed = true } @@ -304,20 +310,22 @@ describe('openPopupWithOriginBar', () => { }) }) - it('renders only the origin in the bar, never path or query', () => { + it('renders only the committed origin in the bar, never path or query', () => { + const adopted = createFakeWebContents() + adopted.mainFrame.url = 'https://accounts.example.com/oauth?code=SECRET' openPopupWithOriginBar( - { webContents: createFakeWebContents() as never }, + { webContents: adopted as never }, 'https://accounts.example.com/oauth?code=SECRET' ) const { bar } = lastViews() bar.webContents.emit('did-finish-load') - expect(bar.webContents.executeJavaScript).toHaveBeenCalledTimes(1) + expect(bar.webContents.executeJavaScript).toHaveBeenCalledTimes(2) const script = bar.webContents.executeJavaScript.mock.calls[0][0] as string expect(script).toContain('"https://accounts.example.com"') expect(script).not.toContain('SECRET') expect(script).not.toContain('/oauth') - expect(lastWindow().options.title).toBe('https://accounts.example.com') + expect(lastWindow().options.title).toBe('about:blank') }) it('updates the origin bar and title when the popup navigates', () => { @@ -342,10 +350,16 @@ describe('openPopupWithOriginBar', () => { { webContents: adopted as never }, 'https://accounts.example.com/oauth?code=SECRET' ) - // Until the page supplies a title, the window title is the origin. - expect(lastWindow().options.title).toBe('https://accounts.example.com') + expect(lastWindow().options.title).toBe('about:blank') + const blankTitleEvent = { preventDefault: vi.fn() } + adopted.emit('page-title-updated', blankTitleEvent, 'Uncommitted destination') + expect(blankTitleEvent.preventDefault).toHaveBeenCalledOnce() + expect(lastWindow().setTitle).toHaveBeenLastCalledWith('about:blank') + adopted.emit('did-navigate', {}, 'https://accounts.example.com/oauth') - adopted.emit('page-title-updated', {}, 'Sign in to Example') + const committedTitleEvent = { preventDefault: vi.fn() } + adopted.emit('page-title-updated', committedTitleEvent, 'Sign in to Example') + expect(committedTitleEvent.preventDefault).toHaveBeenCalledOnce() expect(lastWindow().setTitle).toHaveBeenLastCalledWith('Sign in to Example') // A stale title must not survive a cross-origin navigation. @@ -353,6 +367,128 @@ describe('openPopupWithOriginBar', () => { expect(lastWindow().setTitle).toHaveBeenLastCalledWith('https://evil.example.net') }) + it.each(['about:blank', 'about:blank#state', 'about:blank?state=1'])( + 'keeps opener-written %s content neutral across failed navigation and delayed bar loading', + (url) => { + const adopted = createFakeWebContents() + adopted.mainFrame.url = url + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements the presenter's WebContents surface. + openPopupWithOriginBar({ webContents: adopted as never }, 'https://requested.example/auth') + const { bar } = lastViews() + lastWindow().setTitle.mockClear() + adopted.emit('page-title-updated', { preventDefault: vi.fn() }, 'https://requested.example') + expect(lastWindow().setTitle).not.toHaveBeenCalled() + adopted.emit('did-start-navigation', {}, 'https://requested.example/auth') + adopted.emit('will-redirect', {}, 'https://redirect.example/auth') + adopted.emit('did-fail-load', {}, -3, 'ERR_ABORTED', 'https://redirect.example/auth', true) + adopted.emit('did-finish-load') + bar.webContents.emit('did-finish-load') + expect(lastWindow().setTitle).toHaveBeenLastCalledWith('about:blank') + expect(bar.webContents.executeJavaScript.mock.calls.at(-1)?.[0]).toContain('"about:blank"') + expect(bar.webContents.executeJavaScript.mock.calls.flat().join('')).not.toContain( + 'requested.example' + ) + } + ) + + it('checks the committed document when a title arrives before its navigation event', () => { + const adopted = createFakeWebContents() + adopted.mainFrame.url = 'https://committed.example/' + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements the presenter's WebContents surface. + openPopupWithOriginBar({ webContents: adopted as never }, 'https://requested.example/') + lastWindow().setTitle.mockClear() + adopted.mainFrame.url = 'about:blank#state' + adopted.emit( + 'page-title-updated', + { preventDefault: vi.fn() }, + 'https://accounts.trusted.example' + ) + expect(lastWindow().setTitle).not.toHaveBeenCalled() + lastViews().bar.webContents.emit('did-finish-load') + expect(lastWindow().setTitle).toHaveBeenLastCalledWith('about:blank') + }) + + it('retains the committed origin through pending redirects and renders the latest commit when the bar loads', () => { + const adopted = createFakeWebContents() + adopted.mainFrame.url = 'https://committed.example/private' + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements the presenter's WebContents surface. + openPopupWithOriginBar({ webContents: adopted as never }, 'https://requested.example/') + const { bar } = lastViews() + adopted.emit('will-redirect', {}, 'https://pending.example/') + adopted.emit('did-fail-load', {}, -3, 'ERR_ABORTED', 'https://pending.example/', true) + adopted.emit('did-finish-load') + expect(bar.webContents.executeJavaScript.mock.calls.at(-1)?.[0]).toContain( + '"https://committed.example"' + ) + adopted.emit('did-navigate', {}, 'http://next.example/path?secret=synthetic') + bar.webContents.emit('did-finish-load') + expect(bar.webContents.executeJavaScript.mock.calls.at(-1)?.[0]).toContain( + '"http://next.example"' + ) + adopted.mainFrame.url = 'chrome-error://chromewebdata/' + adopted.emit( + 'did-fail-load', + {}, + -105, + 'ERR_NAME_NOT_RESOLVED', + 'https://failed.example/', + true + ) + expect(bar.webContents.executeJavaScript.mock.calls.at(-1)?.[0]).toContain('"chrome-error:"') + }) + + it.each([1, 1000])('preserves the page title through %i subframe failures', (count) => { + const adopted = createFakeWebContents() + adopted.mainFrame.url = 'https://committed.example/' + adopted.mainFrame.origin = 'https://committed.example' + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements the presenter's WebContents surface. + openPopupWithOriginBar({ webContents: adopted as never }, adopted.mainFrame.url) + const { bar } = lastViews() + bar.webContents.emit('did-finish-load') + adopted.emit('page-title-updated', { preventDefault: vi.fn() }, 'Stable popup title') + bar.webContents.executeJavaScript.mockClear() + const titleUpdates = lastWindow().setTitle.mock.calls.length + + for (let index = 0; index < count; index += 1) { + adopted.emit('did-fail-load', {}, -2, 'ERR_FAILED', 'https://iframe.example/', false) + } + expect(lastWindow().setTitle).toHaveBeenCalledTimes(titleUpdates) + expect(lastWindow().setTitle).toHaveBeenLastCalledWith('Stable popup title') + expect(bar.webContents.executeJavaScript).not.toHaveBeenCalled() + + adopted.mainFrame.url = 'https://failed.example/' + adopted.mainFrame.origin = 'null' + adopted.emit('did-fail-load', {}, -2, 'ERR_FAILED', adopted.mainFrame.url, true) + expect(lastWindow().setTitle).toHaveBeenCalledTimes(titleUpdates + 1) + expect(lastWindow().setTitle).toHaveBeenLastCalledWith('unknown') + expect(bar.webContents.executeJavaScript).toHaveBeenCalledTimes(1) + expect(bar.webContents.executeJavaScript.mock.calls[0][0]).toContain('"unknown"') + }) + + it('never labels an opaque committed error or sandboxed document with its network URL', () => { + const adopted = createFakeWebContents() + adopted.mainFrame.url = 'https://opaque.example/document' + adopted.mainFrame.origin = 'null' + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements the presenter's WebContents surface. + openPopupWithOriginBar({ webContents: adopted as never }, 'https://requested.example/') + const { bar } = lastViews() + bar.webContents.emit('did-finish-load') + expect(bar.webContents.executeJavaScript.mock.calls.at(-1)?.[0]).toContain('"unknown"') + adopted.mainFrame.origin = 'https://committed.example' + adopted.emit('did-navigate', {}, 'https://committed.example/') + adopted.emit('did-fail-load', {}, -3, 'ERR_ABORTED', 'https://failed.example/', true) + expect(bar.webContents.executeJavaScript.mock.calls.at(-1)?.[0]).toContain( + '"https://committed.example"' + ) + adopted.mainFrame.url = 'https://failed.example/' + adopted.mainFrame.origin = 'null' + adopted.emit('did-fail-load', {}, -2, 'ERR_FAILED', 'https://failed.example/', true) + expect(bar.webContents.executeJavaScript.mock.calls.at(-1)?.[0]).toContain('"unknown"') + expect(bar.webContents.executeJavaScript.mock.calls.at(-1)?.[0]).toContain( + "classList.toggle('insecure', true)" + ) + }) + it('closes the window when the popup content is destroyed, without re-closing the contents', () => { const adopted = createFakeWebContents() openPopupWithOriginBar({ webContents: adopted as never }, 'https://example.com/') @@ -374,6 +510,8 @@ describe('openPopupWithOriginBar', () => { bar.webContents.emit('did-finish-load') bar.webContents.executeJavaScript.mockClear() + adopted.emit('did-navigate', {}, 'https://example.com/login') + bar.webContents.executeJavaScript.mockClear() adopted.emit('did-finish-load') expect(bar.webContents.executeJavaScript).toHaveBeenCalledTimes(1) diff --git a/src/main/browser/popup-origin-bar-window.ts b/src/main/browser/popup-origin-bar-window.ts index aaf8c668a48..c0dd4d22b22 100644 --- a/src/main/browser/popup-origin-bar-window.ts +++ b/src/main/browser/popup-origin-bar-window.ts @@ -5,6 +5,8 @@ import { prepareNativeBrowserPopup } from './browser-popup-native-window' import { MIN_POPUP_CONTENT_HEIGHT, MIN_POPUP_CONTENT_WIDTH } from './popup-window-minimum-size' +import { describeCommittedPopupOrigin } from './popup-committed-origin' +export { describePopupOrigin } from './popup-origin-description' // Why: Electron passes the pre-created popup WebContents through the // createWindow options (present at runtime, absent from the published d.ts). @@ -28,37 +30,6 @@ export const POPUP_ORIGIN_BAR_HEIGHT = 34 const DEFAULT_POPUP_CONTENT_WIDTH = 800 const DEFAULT_POPUP_CONTENT_HEIGHT = 600 -// Why: http on loopback is a secure context (local OAuth callback servers are -// common); only flag plain http to a real remote host. -function isLoopbackHost(hostname: string): boolean { - return ( - hostname === 'localhost' || - hostname.endsWith('.localhost') || - hostname === '127.0.0.1' || - hostname === '[::1]' - ) -} - -// Why: the bar must show only the origin — popup URLs routinely carry OAuth -// codes and one-time tokens in path/query that must never reach UI surfaces. -export function describePopupOrigin(rawUrl: string): { label: string; insecure: boolean } { - try { - const parsed = new URL(rawUrl) - if (parsed.origin !== 'null') { - return { - label: parsed.origin, - insecure: parsed.protocol === 'http:' && !isLoopbackHost(parsed.hostname) - } - } - if (parsed.protocol === 'about:') { - return { label: 'about:blank', insecure: false } - } - return { label: parsed.protocol, insecure: false } - } catch { - return { label: 'unknown', insecure: true } - } -} - // Colors mirror the canonical tokens in src/renderer/src/assets/main.css // (--background/--foreground/--border/--destructive); a data: URL page cannot // import that stylesheet, so the values are inlined per theme here. @@ -149,10 +120,9 @@ export function openPopupWithOriginBar( prepareContent?: (contents: Electron.WebContents) => boolean ): PopupOriginBarWindow { if (options.nativeWindow) { - return prepareNativeBrowserPopup(options.nativeWindow, initialUrl, prepareContent) + return prepareNativeBrowserPopup(options.nativeWindow, prepareContent) } const { width, height } = clampPopupContentSize(options) - const initialOrigin = describePopupOrigin(initialUrl) const window = new BaseWindow({ width, height: height + POPUP_ORIGIN_BAR_HEIGHT, @@ -164,7 +134,7 @@ export function openPopupWithOriginBar( : {}), minWidth: MIN_POPUP_CONTENT_WIDTH, minHeight: MIN_POPUP_CONTENT_HEIGHT + POPUP_ORIGIN_BAR_HEIGHT, - title: initialOrigin.label, + title: 'about:blank', // Why: Windows/Linux attach Orca's app menu to every window, including web popups. autoHideMenuBar: true, show: @@ -215,9 +185,8 @@ export function openPopupWithOriginBar( ) { return closeUnpreparedPopup(window, contentWebContents, originBarWebContents) } - let currentUrl = initialUrl const renderOrigin = (): void => { - const { label, insecure } = describePopupOrigin(currentUrl) + const { label, insecure } = describeCommittedPopupOrigin(contentWebContents) // Why: origin is the title only until the page supplies one — the bar // below stays the trust surface, so the native title bar can show the // page title (Chrome popup behavior) instead of doubling the origin. @@ -239,21 +208,47 @@ export function openPopupWithOriginBar( `data:text/html;charset=utf-8,${encodeURIComponent(ORIGIN_BAR_HTML)}` ) - const handleDidNavigate = (_event: Electron.Event, url: string): void => { - currentUrl = url + contentWebContents.on('did-navigate', renderOrigin) + const handleInPageNavigate = ( + _event: Electron.Event, + _url: string, + isMainFrame: boolean + ): void => { + if (isMainFrame) { + renderOrigin() + } + } + contentWebContents.on('did-navigate-in-page', handleInPageNavigate) + const handleLoadFailure = ( + _event: Electron.Event, + _errorCode: number, + _errorDescription: string, + _url: string, + isMainFrame: boolean + ): void => { + if (!isMainFrame) { + return + } + // Error documents omit did-navigate; read the committed frame, never the failed target. renderOrigin() } - contentWebContents.on('did-navigate', handleDidNavigate) + contentWebContents.on('did-fail-load', handleLoadFailure) // Why: origin writes fail silently if the bar is mid-load; re-asserting at // load completion means a dropped write can never leave a stale origin up // for the lifetime of the page. contentWebContents.on('did-finish-load', renderOrigin) - const handlePageTitleUpdated = (_event: Electron.Event, title: string): void => { - if (!window.isDestroyed() && title) { + const handlePageTitleUpdated = (event: Electron.Event, title: string): void => { + event.preventDefault() + if ( + !window.isDestroyed() && + title && + describeCommittedPopupOrigin(contentWebContents).label !== 'about:blank' + ) { window.setTitle(title) } } contentWebContents.on('page-title-updated', handlePageTitleUpdated) + renderOrigin() if (options.loadInitialUrl && !options.webContents) { void contentWebContents.loadURL(initialUrl).catch(() => {}) @@ -269,7 +264,9 @@ export function openPopupWithOriginBar( window.once('closed', () => { if (!contentWebContents.isDestroyed()) { contentWebContents.off('destroyed', handleContentDestroyed) - contentWebContents.off('did-navigate', handleDidNavigate) + contentWebContents.off('did-navigate', renderOrigin) + contentWebContents.off('did-navigate-in-page', handleInPageNavigate) + contentWebContents.off('did-fail-load', handleLoadFailure) contentWebContents.off('did-finish-load', renderOrigin) contentWebContents.off('page-title-updated', handlePageTitleUpdated) // Why: close() (not destroy) so the page's unload handlers run — OAuth diff --git a/src/main/browser/popup-origin-chrome-electron-fixture.ts b/src/main/browser/popup-origin-chrome-electron-fixture.ts new file mode 100644 index 00000000000..8d54d8f5c0f --- /dev/null +++ b/src/main/browser/popup-origin-chrome-electron-fixture.ts @@ -0,0 +1,199 @@ +import { app, BaseWindow, BrowserWindow, WebContentsView } from 'electron' +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { openPopupWithOriginBar } from './popup-origin-bar-window' +import { prepareNativeBrowserPopup } from './browser-popup-native-window' +import { applyBackgroundActivationPolicy } from '../window/foreground-activation-policy' + +if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') { + throw new Error('Popup identity probes require a background launch') +} +applyBackgroundActivationPolicy() + +app.commandLine.appendSwitch('host-resolver-rules', 'MAP popup-error.invalid 127.0.0.1') +app.commandLine.appendSwitch('no-proxy-server') + +const root = process.env.ORCA_POPUP_IDENTITY_ROOT +if (!root) { + throw new Error('Missing probe root') +} +const statusPath = join(root, 'status.json') +const attachedSourceViews = new Set() +let active: Electron.WebContents | undefined +let presenter: BaseWindow | undefined +let parent: BrowserWindow +let kind = 'adopted' +let pageTitle: string | undefined + +function trackPageTitle(contents: Electron.WebContents): void { + pageTitle = undefined + contents.on('page-title-updated', (_event, title) => { + pageTitle = title + }) +} + +function adoptedWindow(contents: Electron.WebContents): BaseWindow | undefined { + return BaseWindow.getAllWindows().find((window) => + window.contentView.children.some( + (view) => view instanceof WebContentsView && view.webContents === contents + ) + ) +} + +async function command(input: { + id: number + kind?: string + url?: string + action: string + stage?: string +}) { + if (input.action === 'collect-garbage') { + if (typeof globalThis.gc !== 'function') { + throw new Error('Popup lifetime checks require --js-flags=--expose-gc') + } + globalThis.gc() + await new Promise((resolve) => setImmediate(resolve)) + globalThis.gc() + await new Promise((resolve) => setImmediate(resolve)) + } + if (input.action === 'kind') { + kind = input.kind ?? 'adopted' + } + if (input.action === 'attach') { + if (input.kind === 'native') { + const window = new BrowserWindow({ + show: false, + webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false } + }) + await window.loadURL(input.url ?? 'about:blank') + prepareNativeBrowserPopup(window) + active = window.webContents + trackPageTitle(active) + presenter = window + } else { + const sourceView = new WebContentsView({ + webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false } + }) + // Collecting the source view would destroy its contents even after adoption. + attachedSourceViews.add(sourceView) + const contents = sourceView.webContents + contents.once('destroyed', () => attachedSourceViews.delete(sourceView)) + await contents.loadURL(input.url ?? 'about:blank') + openPopupWithOriginBar( + { + webContents: contents, + webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false }, + show: false + }, + 'https://uncommitted.invalid/' + ) + active = contents + trackPageTitle(active) + presenter = adoptedWindow(contents) + } + } + if (input.action === 'block') { + active?.on('will-navigate', (event, url) => { + if (url.endsWith('/blocked')) { + event.preventDefault() + } + }) + } + if (input.action === 'navigate') { + void active?.loadURL(input.url ?? 'about:blank').catch(() => {}) + } + if (input.action === 'stop') { + active?.stop() + } + if (input.action === 'close') { + presenter?.close() + active = undefined + presenter = undefined + } + const bar = presenter?.contentView.children.find( + (view) => view instanceof WebContentsView && view.webContents !== active + ) + if (input.action === 'capture' && bar instanceof WebContentsView) { + const artifacts = process.env.ORCA_POPUP_IDENTITY_ARTIFACT_DIR + if ( + artifacts && + (input.stage === 'pending' || input.stage === 'committed' || input.stage === 'opaque-error') + ) { + mkdirSync(artifacts, { recursive: true }) + bar.webContents.debugger.attach('1.3') + try { + await bar.webContents.debugger.sendCommand('Page.enable') + const screenshot = await bar.webContents.debugger.sendCommand('Page.captureScreenshot') + writeFileSync( + join(artifacts, `adopted-origin-${input.stage}.png`), + Buffer.from(screenshot.data, 'base64') + ) + } finally { + bar.webContents.debugger.detach() + } + } + } + let barState: unknown = null + if (bar instanceof WebContentsView) { + barState = await bar.webContents + .executeJavaScript( + "({label: document.querySelector('#origin')?.textContent, insecure: document.body.classList.contains('insecure')})" + ) + .catch(() => null) + } + writeFileSync( + statusPath, + JSON.stringify({ + id: input.id, + electron: process.versions.electron, + kind, + title: presenter?.getTitle(), + pageTitle, + committedUrl: active?.mainFrame.url, + committedOrigin: active?.mainFrame.origin, + bar: barState, + windowsHidden: BaseWindow.getAllWindows().every((window) => !window.isVisible()) + }) + ) +} + +app.whenReady().then(async () => { + parent = new BrowserWindow({ + show: false, + webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false } + }) + parent.webContents.setWindowOpenHandler(() => { + if (kind === 'native') { + return { action: 'allow', overrideBrowserWindowOptions: { show: false } } + } + return { + action: 'allow', + createWindow: (options) => { + const popup = openPopupWithOriginBar( + { ...options, show: false }, + 'https://uncommitted.invalid/' + ) + active = popup.contentWebContents + trackPageTitle(active) + presenter = adoptedWindow(active) + return active + } + } + }) + parent.webContents.on('did-create-window', (window) => { + active = window.webContents + trackPageTitle(active) + presenter = window + prepareNativeBrowserPopup(window) + }) + parent.webContents.on('console-message', (_event, _level, message) => { + if (!message.startsWith('origin-probe:')) { + return + } + void command(JSON.parse(message.slice('origin-probe:'.length))).catch((error) => + writeFileSync(statusPath, JSON.stringify({ error: String(error) })) + ) + }) + await parent.loadURL('data:text/html,Origin presenter probe') +}) +setTimeout(() => app.exit(1), 90_000).unref() diff --git a/src/main/browser/popup-origin-chrome.electron.test.ts b/src/main/browser/popup-origin-chrome.electron.test.ts new file mode 100644 index 00000000000..566d0cb7173 --- /dev/null +++ b/src/main/browser/popup-origin-chrome.electron.test.ts @@ -0,0 +1,265 @@ +import { createServer, type ServerResponse } from 'node:http' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import type { Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { + buildPopupIdentityProbe, + createPopupIdentityProbeRoot, + launchPopupIdentityProbe +} from './popup-identity-electron-probe' +import { startHiddenRendererFrameCapture } from './hidden-renderer-frame-capture-fixture' + +type OriginProbeWindow = Window & { originProbeChild?: Window } + +type Snapshot = { + id: number + error?: string + electron: string + title?: string + pageTitle?: string + committedUrl?: string + committedOrigin?: string + windowsHidden: boolean + bar: { label: string; insecure: boolean } | null +} +const held: ServerResponse[] = [] +const requestedPaths: string[] = [] +const root = createPopupIdentityProbeRoot() +const server = createServer((request, response) => { + requestedPaths.push(request.url ?? '') + if (request.url?.startsWith('/hold')) { + held.push(response) + return + } + if (request.url === '/redirect') { + setTimeout(() => response.writeHead(302, { location: '/hold-redirect' }).end(), 100) + return + } + if (request.url === '/failure') { + response.destroy() + return + } + response + .writeHead(200, { 'content-type': 'text/html' }) + .end('Page controlled title

Committed document

') +}) +let origin: string +let probe: Awaited> +let page: Page +let id = 0 +const observations: Snapshot[] = [] +let stopCapture: (() => Promise) | undefined + +async function command( + action: string, + options: { url?: string; kind?: string; stage?: string } = {} +): Promise { + const commandId = ++id + await page.evaluate((input) => console.log(`origin-probe:${JSON.stringify(input)}`), { + id: commandId, + action, + ...options + }) + const path = join(root, 'status.json') + await expect + .poll( + () => { + if (!existsSync(path)) { + return false + } + const result = JSON.parse(readFileSync(path, 'utf8')) + if (result.error) { + throw new Error(result.error) + } + return result.id + }, + { timeout: 10_000 } + ) + .toBe(commandId) + const result: Snapshot = JSON.parse(readFileSync(path, 'utf8')) + observations.push(result) + return result +} + +async function expectOrigin(label: string, kind: string): Promise { + let snapshot: Snapshot | undefined + await expect + .poll( + async () => { + snapshot = await command('snapshot') + return kind === 'native' ? snapshot.title : snapshot.bar?.label + }, + { timeout: 10_000 } + ) + .toBe(label) + if (!snapshot) { + throw new Error('Missing snapshot') + } + expect(snapshot.windowsHidden).toBe(true) + return snapshot +} + +async function captureBar(stage: string): Promise { + if (process.env.ORCA_POPUP_IDENTITY_ARTIFACT_DIR) { + await command('capture', { stage }) + } +} + +beforeAll(async () => { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Missing HTTP port') + } + origin = `http://127.0.0.1:${address.port}` + await buildPopupIdentityProbe( + root, + join(process.cwd(), 'src/main/browser/popup-origin-chrome-electron-fixture.ts'), + 'main.cjs' + ) + probe = await launchPopupIdentityProbe(root, ['--js-flags=--expose-gc']) + await vi.waitFor( + () => + expect( + probe.browser + .contexts() + .flatMap((context) => context.pages()) + .some((candidate) => candidate.url().startsWith('data:text/html,')) + ).toBe(true), + { timeout: 10_000 } + ) + const found = probe.browser + .contexts() + .flatMap((context) => context.pages()) + .find((candidate) => candidate.url().startsWith('data:text/html,')) + if (!found) { + throw new Error('Missing parent renderer') + } + page = found + stopCapture = await startHiddenRendererFrameCapture(page, (hasFrame) => + vi.waitFor(() => expect(hasFrame()).toBe(true), { timeout: 10_000 }) + ) +}, 60_000) + +afterAll(async () => { + for (const response of held) { + response.destroy() + } + const artifacts = process.env.ORCA_POPUP_IDENTITY_ARTIFACT_DIR + if (artifacts) { + mkdirSync(artifacts, { recursive: true }) + writeFileSync( + join(artifacts, 'origin-observations.json'), + JSON.stringify(observations, null, 2) + ) + } + await stopCapture?.() + await probe?.stop() + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) +}) + +describe('committed popup origin chrome under Electron', () => { + it.each(['adopted', 'native'])( + 'keeps %s chrome neutral for written blank content and retained through pending, canceled and failed redirects', + async (kind) => { + await command('kind', { kind }) + await page.evaluate((url) => { + const child = window.open('about:blank#state', '', 'width=600,height=400') + if (!child) { + throw new Error('No child handle') + } + const probeWindow: OriginProbeWindow = window + probeWindow.originProbeChild = child + child.document.write('https://requested.invalid

Written by opener

') + child.location.href = url + }, `${origin}/hold-initial`) + const initial = await expectOrigin('about:blank', kind) + expect(initial.committedUrl).toBe('about:blank#state') + expect(initial.title).toBe('about:blank') + expect(initial.bar?.insecure ?? false).toBe(false) + await page.evaluate(() => { + const probeWindow: OriginProbeWindow = window + const child = probeWindow.originProbeChild + if (!child) { + throw new Error('No child handle') + } + child.document.title = 'https://accounts.trusted.example' + }) + await expect + .poll(async () => (await command('snapshot')).pageTitle, { timeout: 10_000 }) + .toBe('https://accounts.trusted.example') + expect((await command('snapshot')).title).toBe('about:blank') + if (kind === 'adopted') { + await captureBar('pending') + } + await command('stop') + await expectOrigin('about:blank', kind) + await command('block') + await page.evaluate((url) => { + const probeWindow: OriginProbeWindow = window + const child = probeWindow.originProbeChild + if (!child) { + throw new Error('No child handle') + } + child.location.href = url + }, `${origin}/blocked`) + await expectOrigin('about:blank', kind) + expect(requestedPaths).not.toContain('/blocked') + await command('navigate', { url: `${origin}/committed` }) + await expectOrigin(origin, kind) + const otherOrigin = origin.replace('127.0.0.1', 'localhost') + const redirectStart = requestedPaths.length + await command('navigate', { url: `${otherOrigin}/redirect` }) + await expect + .poll(() => requestedPaths.slice(redirectStart), { timeout: 10_000 }) + .toContain('/hold-redirect') + const redirect = await expectOrigin(origin, kind) + expect(redirect.committedUrl).toBe(`${origin}/committed`) + await command('stop') + const canceled = await expectOrigin(origin, kind) + expect(canceled.committedUrl).toBe(`${origin}/committed`) + await command('navigate', { url: `${otherOrigin}/committed` }) + await expectOrigin(otherOrigin, kind) + if (kind === 'adopted') { + await captureBar('committed') + } + await command('navigate', { url: `${otherOrigin}/failure` }) + await expect + .poll(async () => (await command('snapshot')).committedUrl, { timeout: 10_000 }) + .not.toBe(`${otherOrigin}/committed`) + const failure = await expectOrigin('unknown', kind) + expect(failure.committedOrigin).toBe('null') + const httpsFailure = `${origin.replace('http://127.0.0.1', 'https://popup-error.invalid')}/tls-failure` + await command('navigate', { url: `${origin}/recovered` }) + await expectOrigin(origin, kind) + await command('navigate', { url: httpsFailure }) + const opaque = await expectOrigin('unknown', kind) + expect(opaque.committedUrl).toBe(httpsFailure) + expect(opaque.committedOrigin).toBe('null') + expect(opaque.bar?.insecure ?? true).toBe(true) + if (kind === 'adopted') { + await captureBar('opaque-error') + } + await command('close') + }, + 30_000 + ) + + it.each(['adopted', 'native'])( + 'retains a %s presenter attached to an already committed frame through garbage collection', + async (kind) => { + const url = `${origin}/already-committed` + await command('attach', { kind, url }) + await command('collect-garbage') + const attached = await expectOrigin(origin, kind) + expect(attached.committedUrl).toBe(url) + const nextUrl = `${origin.replace('127.0.0.1', 'localhost')}/after-collection` + await command('navigate', { url: nextUrl }) + const navigated = await expectOrigin(new URL(nextUrl).origin, kind) + expect(navigated.committedUrl).toBe(nextUrl) + await command('close') + } + ) +}) diff --git a/src/main/browser/popup-origin-description.ts b/src/main/browser/popup-origin-description.ts new file mode 100644 index 00000000000..c1b41d32090 --- /dev/null +++ b/src/main/browser/popup-origin-description.ts @@ -0,0 +1,30 @@ +// Why: http on loopback is a secure context (local OAuth callback servers are +// common); only flag plain http to a real remote host. +function isLoopbackHost(hostname: string): boolean { + return ( + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname === '127.0.0.1' || + hostname === '[::1]' + ) +} + +// Why: the bar must show only the origin — popup URLs routinely carry OAuth +// codes and one-time tokens in path/query that must never reach UI surfaces. +export function describePopupOrigin(rawUrl: string): { label: string; insecure: boolean } { + try { + const parsed = new URL(rawUrl) + if (parsed.origin !== 'null') { + return { + label: parsed.origin, + insecure: parsed.protocol === 'http:' && !isLoopbackHost(parsed.hostname) + } + } + if (parsed.protocol === 'about:') { + return { label: 'about:blank', insecure: false } + } + return { label: parsed.protocol, insecure: false } + } catch { + return { label: 'unknown', insecure: true } + } +} diff --git a/src/renderer/src/hooks/ipc-events/browser-link-materialization.test-fixture.tsx b/src/renderer/src/hooks/ipc-events/browser-link-materialization.test-fixture.tsx new file mode 100644 index 00000000000..c4fbe4de6b1 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/browser-link-materialization.test-fixture.tsx @@ -0,0 +1,166 @@ +import { buildDefaultSettings } from '../../../../shared/default-global-settings' +import { createRoot } from 'react-dom/client' +import { useAppStore } from '@/store' +import { TooltipProvider } from '@/components/ui/tooltip' +import { BrowserPagePane } from '@/components/browser-pane/assemble-chrome/browser-page-pane' +import { registerBrowserOverlaySlotViewport } from '@/components/browser-pane/host-guest/browser-page-viewport' +import { registerBrowserStateIpcBridge } from './browser-state-ipc-bridge' + +const worktreeId = 'link-probe-folder' +const profilePartition = 'persist:link-probe-isolated' +const defaultPartition = 'persist:link-probe-default' +let sourceWorkspaceId = '' +let sourcePageId = '' +const mounted = new Map>() + +function mountTabs(): void { + const state = useAppStore.getState() + for (const tab of state.browserTabsByWorktree[worktreeId] ?? []) { + if (mounted.has(tab.id) || tab.id === sourceWorkspaceId) { + continue + } + const page = state.browserPagesByWorkspace[tab.id]?.[0] + if (!page) { + throw new Error('Created tab has no page') + } + const slot = document.createElement('div') + slot.style.cssText = 'width:800px;height:500px;position:relative' + document.body.appendChild(slot) + registerBrowserOverlaySlotViewport(tab.id, slot) + const root = createRoot(slot) + mounted.set(tab.id, root) + root.render( + + + + ) + } +} + +export function installBrowserLinkMaterializationProbe() { + const initial = useAppStore.getState() + const settings = + initial.settings ?? + buildDefaultSettings({ + workspaceDir: '', + appFontFamily: 'sans-serif', + editorAutoSaveDelayMs: 1000, + primarySelectionMiddleClickPaste: false, + primarySelectionDefaultedForLinux: false, + terminalFontFamily: 'monospace', + terminalInactivePaneOpacity: 1, + terminalRightClickToPaste: false, + notifications: { + enabled: false, + agentTaskComplete: false, + terminalBell: false, + suppressWhenFocused: true, + customSoundId: 'system', + customSoundPath: null, + customSoundVolume: 0, + mutedNotificationSourceIds: [], + notifyHiddenWorkspaces: false + }, + voice: { + enabled: false, + sttModel: '', + modelsDir: '', + language: 'en', + dictationMode: 'hold', + terminalConfirmBeforeInsert: true, + userModels: [], + openAiApiKeyConfigured: false, + microphoneDeviceId: null, + microphoneDeviceLabel: null + } + }) + useAppStore.setState({ + activeWorktreeId: worktreeId, + settings: { ...settings, activeRuntimeEnvironmentId: null }, + defaultBrowserSessionProfileId: 'other-profile', + browserSessionProfiles: [ + { + id: 'isolated-profile', + label: 'Isolated synthetic profile', + partition: profilePartition, + scope: 'isolated', + source: null + }, + { + id: 'other-profile', + label: 'Other synthetic profile', + partition: defaultPartition, + scope: 'isolated', + source: null + } + ] + }) + const source = useAppStore.getState().createBrowserTab(worktreeId, 'about:blank', { + sessionProfileId: 'isolated-profile', + sessionPartition: profilePartition, + activate: true + }) + sourceWorkspaceId = source.id + if (!source.activePageId) { + throw new Error('Source has no active page') + } + sourcePageId = source.activePageId + registerBrowserStateIpcBridge([], () => false) + return { + sourcePageId, + snapshot: () => { + const state = useAppStore.getState() + return { + tabs: state.browserTabsByWorktree[worktreeId], + pages: state.browserPagesByWorkspace, + selected: state.activeBrowserTabIdByWorktree[worktreeId], + selectedUnified: state.groupsByWorktree[worktreeId]?.map((group) => group.activeTabId), + unified: state.unifiedTabsByWorktree[worktreeId], + sourceWorkspaceId + } + }, + mountTabs, + mutate: (transition: string) => { + const state = useAppStore.getState() + const tabs = state.browserTabsByWorktree[worktreeId] ?? [] + if (transition === 'remove-tab') { + useAppStore.setState({ + browserTabsByWorktree: { + [worktreeId]: tabs.filter((tab) => tab.id !== sourceWorkspaceId) + } + }) + } + if (transition === 'remove-page') { + useAppStore.setState({ browserPagesByWorkspace: {} }) + } + if (transition === 'profile-switch') { + useAppStore.setState({ + browserTabsByWorktree: { + [worktreeId]: tabs.map((tab) => + tab.id === sourceWorkspaceId + ? { ...tab, sessionProfileId: 'other-profile', sessionPartition: defaultPartition } + : tab + ) + } + }) + } + if (transition === 'delete-profile') { + useAppStore.setState({ browserSessionProfiles: [] }) + } + } + } +} diff --git a/src/renderer/src/hooks/ipc-events/browser-state-open-link-profile.test.ts b/src/renderer/src/hooks/ipc-events/browser-state-open-link-profile.test.ts index 8e237b910c2..75094ad2854 100644 --- a/src/renderer/src/hooks/ipc-events/browser-state-open-link-profile.test.ts +++ b/src/renderer/src/hooks/ipc-events/browser-state-open-link-profile.test.ts @@ -102,7 +102,8 @@ describe('link-opened Orca tabs', () => { ) }) - it('leaves the profile unset when the opener tab is gone, so the user default still applies', () => { + // Baseline defect: remove .fails when the captured-claim handoff rejects this event. + it.fails('rejects a missing source tab without default-profile fallback', () => { storeState.value = { browserPagesByWorkspace: { 'workspace-1': [{ id: 'page-1', workspaceId: 'missing', worktreeId: 'worktree-1' }] @@ -113,8 +114,7 @@ describe('link-opened Orca tabs', () => { captureOpenLinkHandler()({ browserPageId: 'page-1', url: 'https://docs.example.com/guide' }) - const options = createBrowserTabMock.mock.calls[0][2] as Record - expect('sessionProfileId' in options).toBe(false) + expect(createBrowserTabMock).not.toHaveBeenCalled() }) it('creates modifier-click links in the background', () => {