mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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<ReturnType<typeof launchPopupIdentityProbe>>
|
||||
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('<title>Synthetic destination</title><p>One document, no subresources</p>')
|
||||
})
|
||||
|
||||
beforeAll(async () => {
|
||||
await new Promise<void>((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'),
|
||||
`<!doctype html><meta charset="utf-8"><title>Real link materialization probe</title><body><script>
|
||||
const noSubscribe = () => () => {}
|
||||
const category = (base = {}) => new Proxy(base, { get: (target, property) => property in target ? target[property] : String(property).startsWith('on') ? noSubscribe : async () => null })
|
||||
Object.defineProperty(window, 'api', { value: new Proxy({ browser: category(window.probeApi.browser), e2e: undefined, ui: category(), app: category() }, { get: (target, property) => property in target ? target[property] : category() }) })
|
||||
</script><script src="./renderer.js"></script></body>`
|
||||
)
|
||||
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<void>((resolve) => server.close(() => resolve()))
|
||||
})
|
||||
|
||||
async function ready(): Promise<void> {
|
||||
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<void> {
|
||||
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<LinkSnapshot>('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<LinkSnapshot>('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<LinkSnapshot>('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'
|
||||
)
|
||||
})
|
||||
@@ -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)),
|
||||
|
||||
@@ -98,7 +98,6 @@ export class BrowserPopupAdapterProbeFixture {
|
||||
unrelatedDuringAdmission = false
|
||||
const response = allowNativeBrowserPopup({
|
||||
opener: guest,
|
||||
targetUrl: details.url,
|
||||
options: { show: false },
|
||||
onCreated: ({ nativeWindow }) => {
|
||||
if (!nativeWindow) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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<BrowserWindow>()
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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<void> {
|
||||
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<void>((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<void>((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<void> => {
|
||||
await browser.close()
|
||||
child.kill()
|
||||
await new Promise<void>((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('')}`)
|
||||
}
|
||||
}
|
||||
@@ -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<typeof vi.fn>
|
||||
once: ReturnType<typeof vi.fn>
|
||||
off: ReturnType<typeof vi.fn>
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<WebContentsView>()
|
||||
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<void>((resolve) => setImmediate(resolve))
|
||||
globalThis.gc()
|
||||
await new Promise<void>((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,<title>Origin presenter probe</title>')
|
||||
})
|
||||
setTimeout(() => app.exit(1), 90_000).unref()
|
||||
@@ -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('<title>Page controlled title</title><p>Committed document</p>')
|
||||
})
|
||||
let origin: string
|
||||
let probe: Awaited<ReturnType<typeof launchPopupIdentityProbe>>
|
||||
let page: Page
|
||||
let id = 0
|
||||
const observations: Snapshot[] = []
|
||||
let stopCapture: (() => Promise<void>) | undefined
|
||||
|
||||
async function command(
|
||||
action: string,
|
||||
options: { url?: string; kind?: string; stage?: string } = {}
|
||||
): Promise<Snapshot> {
|
||||
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<Snapshot> {
|
||||
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<void> {
|
||||
if (process.env.ORCA_POPUP_IDENTITY_ARTIFACT_DIR) {
|
||||
await command('capture', { stage })
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await new Promise<void>((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<void>((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('<title>https://requested.invalid</title><p>Written by opener</p>')
|
||||
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')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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<string, ReturnType<typeof createRoot>>()
|
||||
|
||||
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(
|
||||
<TooltipProvider>
|
||||
<BrowserPagePane
|
||||
browserTab={page}
|
||||
workspaceId={tab.id}
|
||||
worktreeId={worktreeId}
|
||||
sessionProfileId={tab.sessionProfileId ?? null}
|
||||
sessionPartition={tab.sessionPartition ?? null}
|
||||
isActive={state.activeBrowserTabIdByWorktree[worktreeId] === tab.id}
|
||||
chromeShortcutScope="inactive"
|
||||
isAutomationVisible={false}
|
||||
isMobileDriven={false}
|
||||
isRemotelyViewed={false}
|
||||
inputLocked={false}
|
||||
onUpdatePageState={state.updateBrowserPageState}
|
||||
onSetUrl={state.setBrowserPageUrl}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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: [] })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
expect('sessionProfileId' in options).toBe(false)
|
||||
expect(createBrowserTabMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates modifier-click links in the background', () => {
|
||||
|
||||
Reference in New Issue
Block a user