feat(browser-preview): render remote HTML docs locally over an orca-preview scheme (STA-5557) (#16679)

This commit is contained in:
Jinwoo Hong
2026-08-27 14:34:21 -07:00
committed by GitHub
parent 808fc3563d
commit 249d93bc5d
119 changed files with 12735 additions and 1726 deletions
File diff suppressed because one or more lines are too long
+1
View File
@@ -209,6 +209,7 @@ export const electronViteConfig: UserConfig = {
index: resolve('src/main/index.ts'),
// Why: sandboxed webview preloads cannot load Rollup helper chunks.
'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'),
'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'),
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
@@ -0,0 +1,210 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const browserMocks = vi.hoisted(() => ({
appGetPathMock: vi.fn(() => '/downloads'),
shellOpenExternalMock: vi.fn(),
browserWindowFromWebContentsMock: vi.fn(),
menuBuildFromTemplateMock: vi.fn(),
guestOffMock: vi.fn(),
guestOnMock: vi.fn(),
guestSetBackgroundThrottlingMock: vi.fn(),
guestSetWindowOpenHandlerMock: vi.fn(),
guestOpenDevToolsMock: vi.fn(),
webContentsFromIdMock: vi.fn(),
screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })),
openPopupWithOriginBarMock: vi.fn()
}))
vi.mock('electron', () => ({
app: { getPath: browserMocks.appGetPathMock },
BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock },
clipboard: { writeText: vi.fn() },
shell: { openExternal: browserMocks.shellOpenExternalMock },
Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock },
screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock },
webContents: { fromId: browserMocks.webContentsFromIdMock }
}))
vi.mock('./popup-origin-bar-window', () => ({
openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock
}))
import { browserManager } from './browser-manager'
import type { BrowserAnnotationViewportBridgeOptions } from '../../shared/browser-annotation-viewport-bridge'
import {
rendererWebContentsId,
resetBrowserManagerMocks,
resetBrowserManagerState
} from './browser-manager-test-harness'
import {
createViewportGuestFactory,
flushViewportOps
} from './browser-manager-viewport-test-fixtures'
const { webContentsFromIdMock } = browserMocks
const makeGuest = createViewportGuestFactory(browserMocks)
const BRIDGE_OPTIONS: BrowserAnnotationViewportBridgeOptions = {
emitViewport: false,
enabled: true,
markers: [],
token: 'annotationviewporttoken'
}
function registerPage(pageId: string, guest: Record<string, unknown>): void {
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: pageId,
webContentsId: guest.id as number,
rendererWebContentsId
})
}
/** The production resolver's shape: read the registry now, not when the request was made. */
function resolveFromRegistry(pageId: string): () => Electron.WebContents | null {
return () => browserManager.getAuthorizedGuest(pageId, rendererWebContentsId)
}
describe('browserManager.setAnnotationViewportBridge', () => {
beforeEach(() => {
resetBrowserManagerMocks(browserMocks)
resetBrowserManagerState()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('installs the bridge in an isolated world', async () => {
const { guest } = makeGuest(4646)
registerPage('tab-annotations', guest)
const ok = await browserManager.setAnnotationViewportBridge(
'tab-annotations',
BRIDGE_OPTIONS,
resolveFromRegistry('tab-annotations')
)
expect(ok).toBe(true)
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith(
expect.any(Number),
[
expect.objectContaining({
code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge')
})
],
false
)
})
// Why this is the case that matters: a cross-process navigation re-registers the same page id
// with a new WebContents and leaves the retired one alive, so a bridge op that resolved its
// guest when the request arrived would inject into a page nobody is looking at — and the
// annotation badges would stop tracking the document on screen.
it('injects into the guest the page has when a queued op finally runs, not the one it was asked with', async () => {
const { guest: firstGuest } = makeGuest(5101)
let releaseFirstInjection = (): void => {}
// One shared gate for every call, so a wrongly-routed second injection still settles and the
// test fails on where it landed rather than on a timeout.
const firstInjectionGate = new Promise<void>((resolve) => {
releaseFirstInjection = () => resolve()
})
firstGuest.executeJavaScriptInIsolatedWorld = vi.fn(() => firstInjectionGate)
registerPage('tab-swap', firstGuest)
const firstDone = browserManager.setAnnotationViewportBridge(
'tab-swap',
BRIDGE_OPTIONS,
resolveFromRegistry('tab-swap')
)
await flushViewportOps()
// A second request arrives while the first still holds the chain — at this moment the page is
// still on the first guest, which is the guest a request-time resolution would capture.
const secondDone = browserManager.setAnnotationViewportBridge(
'tab-swap',
BRIDGE_OPTIONS,
resolveFromRegistry('tab-swap')
)
await flushViewportOps()
// Only now does the page swap renderer processes, while the second op is still queued.
const { guest: secondGuest } = makeGuest(5102)
registerPage('tab-swap', secondGuest)
releaseFirstInjection()
await expect(firstDone).resolves.toBe(true)
await expect(secondDone).resolves.toBe(true)
expect(secondGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1)
expect(firstGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1)
})
// Why this is the resolver's cleanup and not the bridge's: the authority that reads the registry
// is the one that can see the guest is gone. The bridge only reports the refusal.
it('refuses when its guest died while the op was queued, and the resolver drops the registration', async () => {
const { guest } = makeGuest(5103)
registerPage('tab-dies', guest)
expect(browserManager.getGuestWebContentsId('tab-dies')).toBe(5103)
webContentsFromIdMock.mockReturnValue(null)
await expect(
browserManager.setAnnotationViewportBridge(
'tab-dies',
BRIDGE_OPTIONS,
resolveFromRegistry('tab-dies')
)
).resolves.toBe(false)
// Why assert the registry and not just the answer: a stale guest has to clear every per-tab
// entry, or the page keeps a dead WebContents id that later ops resolve against.
expect(browserManager.getGuestWebContentsId('tab-dies')).toBeNull()
})
// Why this one exists: an unresolved guest is not the same as a dead one. A request addressed by
// the wrong renderer names a page that is alive and on screen, and answering it with teardown
// would cancel that page's in-flight downloads and grabs over a misaddressed message.
it('refuses a request from the wrong renderer without tearing down the healthy page it named', async () => {
const { guest } = makeGuest(5105)
registerPage('tab-mismatch', guest)
const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest')
await expect(
browserManager.setAnnotationViewportBridge('tab-mismatch', BRIDGE_OPTIONS, () =>
browserManager.getAuthorizedGuest('tab-mismatch', rendererWebContentsId + 1)
)
).resolves.toBe(false)
expect(unregisterGuest).not.toHaveBeenCalled()
expect(browserManager.getGuestWebContentsId('tab-mismatch')).toBe(5105)
expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
})
it('refuses a destroyed guest without injecting into it', async () => {
const { guest } = makeGuest(5104)
registerPage('tab-destroyed', guest)
;(guest.isDestroyed as ReturnType<typeof vi.fn>).mockReturnValue(true)
await expect(
browserManager.setAnnotationViewportBridge(
'tab-destroyed',
BRIDGE_OPTIONS,
() => guest as never
)
).resolves.toBe(false)
expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
})
// Why: a document page owns no browsing state, so treating its refusal as a stale page would run
// teardown against an id the browsing registry never held.
it('does not run page teardown when a document page resolves to nothing', async () => {
const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest')
await expect(
browserManager.setAnnotationViewportBridge('doc-page-1', BRIDGE_OPTIONS, () => null)
).resolves.toBe(false)
expect(unregisterGuest).not.toHaveBeenCalled()
})
})
@@ -45,6 +45,32 @@ import {
resetBrowserManagerMocks,
resetBrowserManagerState
} from './browser-manager-test-harness'
import { installDocPreviewGuestPolicy } from './doc-preview-guest-policy'
import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry'
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
/**
* A page the document half of the registry really holds. Built rather than named: membership is
* what both doors refuse on now, so an id that merely looks like a preview's would be admitted.
*/
function registerWorkspaceDocPage(browserPageId: string): void {
const grant = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId
})
const guest = {
isFocused: () => false,
isDestroyed: () => false,
getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath),
on: vi.fn(),
once: vi.fn(),
setWindowOpenHandler: vi.fn(),
setWebRTCIPHandlingPolicy: vi.fn()
}
installDocPreviewGuestPolicy(guest as never, { id: rendererWebContentsId, send: vi.fn() })
}
const {
guestOffMock,
@@ -60,6 +86,7 @@ describe('browserManager', () => {
beforeEach(() => {
resetBrowserManagerMocks(browserMocks)
resetBrowserManagerState()
revokeAllDocPreviewGrants()
})
afterEach(() => {
@@ -92,6 +119,77 @@ describe('browserManager', () => {
expect(browserManager.getSessionProfileIdForTab('browser-1')).toBe('work')
})
// Why both doors: one id in both halves of the registry would make the tool door answer with a
// document guest for a page the reader is browsing in.
it.each(['registerGuest', 'registerOffscreenGuest'] as const)(
'refuses %s for a page the document registry already holds',
(entryPoint) => {
const guest = {
id: 129,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
off: guestOffMock,
openDevTools: guestOpenDevToolsMock
}
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
const browserPageId = 'doc-page-1'
registerWorkspaceDocPage(browserPageId)
if (entryPoint === 'registerGuest') {
expect(
browserManager.registerGuest({
browserPageId,
webContentsId: guest.id,
rendererWebContentsId
})
).toBe(false)
} else {
expect(
browserManager.registerOffscreenGuest({ browserPageId, webContentsId: guest.id })
).toBe(false)
}
expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull()
}
)
// Why this answer is load-bearing: the headless backend destroys its window on false, so a true
// for a guest that is already gone would leave a page id registered onto nothing.
it.each(['missing', 'destroyed'] as const)(
'refuses registerOffscreenGuest when the named guest is %s',
(guestState) => {
webContentsFromIdMock.mockReturnValue(
guestState === 'missing' ? null : { id: 137, isDestroyed: vi.fn(() => true) }
)
expect(
browserManager.registerOffscreenGuest({ browserPageId: 'offscreen-1', webContentsId: 137 })
).toBe(false)
expect(browserManager.getGuestWebContentsId('offscreen-1')).toBeNull()
}
)
// Why the exit door needs the same check: a document page withdraws by revoking its grant, so its
// id here is misaddressed — and unregistering opens by evicting whatever grab that id names.
it('refuses unregisterGuest for a page the document registry holds', () => {
registerWorkspaceDocPage('doc-page-2')
const cancelGrabOp = vi.spyOn(browserManager, 'cancelGrabOp')
browserManager.unregisterGuest('doc-page-2')
expect(cancelGrabOp).not.toHaveBeenCalled()
// The presence half: the same door does evict a browsing page's grab.
browserManager.unregisterGuest('browser-page-1')
expect(cancelGrabOp).toHaveBeenCalledWith('browser-page-1', 'evicted')
cancelGrabOp.mockRestore()
})
it('blocks non-web guest navigations after attach', () => {
const guest = {
isDestroyed: vi.fn(() => false),
@@ -0,0 +1,250 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const browserMocks = vi.hoisted(() => ({
appGetPathMock: vi.fn(() => '/downloads'),
shellOpenExternalMock: vi.fn(),
browserWindowFromWebContentsMock: vi.fn(),
menuBuildFromTemplateMock: vi.fn(),
guestOffMock: vi.fn(),
guestOnMock: vi.fn(),
guestSetBackgroundThrottlingMock: vi.fn(),
guestSetWindowOpenHandlerMock: vi.fn(),
guestOpenDevToolsMock: vi.fn(),
webContentsFromIdMock: vi.fn(),
screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })),
openPopupWithOriginBarMock: vi.fn()
}))
vi.mock('electron', () => ({
app: { getPath: browserMocks.appGetPathMock },
BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock },
clipboard: { writeText: vi.fn() },
shell: { openExternal: browserMocks.shellOpenExternalMock },
Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock },
screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock },
webContents: { fromId: browserMocks.webContentsFromIdMock }
}))
vi.mock('./popup-origin-bar-window', () => ({
openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock
}))
import { browserManager } from './browser-manager'
import { resetBrowserManagerMocks, resetBrowserManagerState } from './browser-manager-test-harness'
import { getWorkspaceDocPageGuest } from './doc-preview-guest-policy'
import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry'
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
type GuestFake = {
id: number
url: string
listeners: Map<string, ((...args: never[]) => void)[]>
windowOpenHandler: ((details: { url: string; frameName: string }) => unknown) | null
webRtcPolicy: string | null
isDestroyed: () => boolean
isFocused: () => boolean
getURL: () => string
getType: () => string
setBackgroundThrottling: (value: boolean) => void
setWebRTCIPHandlingPolicy: (policy: string) => void
setWindowOpenHandler: (handler: (details: { url: string; frameName: string }) => unknown) => void
executeJavaScriptInIsolatedWorld: ReturnType<typeof vi.fn>
debugger: {
isAttached: () => boolean
attach: ReturnType<typeof vi.fn>
sendCommand: ReturnType<typeof vi.fn>
}
on: (event: string, listener: (...args: never[]) => void) => void
once: (event: string, listener: (...args: never[]) => void) => void
off: (event: string, listener: (...args: never[]) => void) => void
}
function createGuest(id: number, url: string): GuestFake {
const listeners = new Map<string, ((...args: never[]) => void)[]>()
const guest: GuestFake = {
id,
url,
listeners,
windowOpenHandler: null,
webRtcPolicy: null,
isDestroyed: () => false,
isFocused: () => true,
getURL: () => guest.url,
getType: () => 'webview',
setBackgroundThrottling: vi.fn(),
setWebRTCIPHandlingPolicy: (policy: string) => {
guest.webRtcPolicy = policy
},
setWindowOpenHandler: (handler) => {
guest.windowOpenHandler = handler
},
executeJavaScriptInIsolatedWorld: vi.fn(async () => undefined),
debugger: {
isAttached: () => true,
attach: vi.fn(),
sendCommand: vi.fn(async () => undefined)
},
on: (event, listener) => {
listeners.set(event, [...(listeners.get(event) ?? []), listener])
},
once: (event, listener) => {
listeners.set(event, [...(listeners.get(event) ?? []), listener])
},
off: (event, listener) => {
listeners.set(
event,
(listeners.get(event) ?? []).filter((entry) => entry !== listener)
)
}
}
return guest
}
function listenerCount(guest: GuestFake, event: string): number {
return guest.listeners.get(event)?.length ?? 0
}
/** Drives the guest's own will-navigate listeners and reports whether they refused. */
function navigateTo(guest: GuestFake, url: string): boolean {
let prevented = false
const event = { preventDefault: () => (prevented = true) } as never
for (const listener of guest.listeners.get('will-navigate') ?? []) {
;(listener as (event: unknown, url: string) => void)(event, url)
}
return prevented
}
const host = { id: 5001, send: vi.fn() } as unknown as Electron.WebContents
beforeEach(() => {
resetBrowserManagerMocks(browserMocks)
resetBrowserManagerState()
revokeAllDocPreviewGrants()
browserMocks.guestSetBackgroundThrottlingMock.mockReturnValue(undefined)
})
describe('guest policy profiles', () => {
function attachPreviewGuest(id = 301): {
guest: GuestFake
grantId: string
browserPageId: string
} {
const browserPageId = `doc-page-${id}`
const grant = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId
})
const guest = createGuest(id, buildDocPreviewUrl(grant.id, 'index.html'))
browserManager.attachGuestPolicies(guest as never, null, { profile: 'workspace-doc', host })
return { guest, grantId: grant.id, browserPageId }
}
// The presence half of every absence below: a browsing guest observably takes all of it through
// the same method, so a profile that fenced nothing — or an attach path that stopped installing
// anything at all — cannot pass these by being uniformly empty.
it('gives a browsing guest link routing, popups and anti-detection', () => {
const guest = createGuest(300, 'https://example.com/')
browserManager.attachGuestPolicies(guest as never)
expect(listenerCount(guest, 'dom-ready')).toBe(1)
expect(listenerCount(guest, 'frame-created')).toBe(1)
expect(listenerCount(guest, 'did-create-window')).toBe(1)
expect(guest.debugger.sendCommand).toHaveBeenCalled()
expect(navigateTo(guest, 'https://elsewhere.example/')).toBe(false)
})
it('gives a workspace-document guest none of it', () => {
const { guest } = attachPreviewGuest()
expect(listenerCount(guest, 'dom-ready')).toBe(0)
expect(listenerCount(guest, 'frame-created')).toBe(0)
expect(listenerCount(guest, 'did-create-window')).toBe(0)
expect(guest.debugger.sendCommand).not.toHaveBeenCalled()
expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
})
it('holds a workspace-document guest inside the grant it is showing', () => {
const { guest, grantId } = attachPreviewGuest()
expect(navigateTo(guest, buildDocPreviewUrl(grantId, 'other.html'))).toBe(false)
expect(navigateTo(guest, 'https://example.com/')).toBe(true)
expect(guest.webRtcPolicy).toBe('disable_non_proxied_udp')
})
it('denies every window a workspace-document guest asks for', () => {
const { guest } = attachPreviewGuest()
expect(guest.windowOpenHandler?.({ url: 'https://example.com/', frameName: '' })).toEqual({
action: 'deny'
})
})
// Why teardown and not just install: registration and teardown both key on the guest having been
// policy-attached, so a profile that installs outside that bookkeeping leaves the id marked
// attached forever and stays answerable to tools after the surface is gone.
it('tears a workspace-document guest down through the same policy cleanup', () => {
const { guest, browserPageId } = attachPreviewGuest(302)
expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBe(guest as never)
for (const listener of guest.listeners.get('destroyed') ?? []) {
listener()
}
expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBeNull()
// The manager's own bookkeeping was reached too: a second attach is refused while the id is
// still marked policy-attached, and accepted once its teardown has run.
browserManager.attachGuestPolicies(guest as never)
expect(listenerCount(guest, 'dom-ready')).toBe(1)
})
// The seam the whole split rests on: one public door answers for both halves, and each page id
// resolves in exactly one of them. Asserted against the real manager, because the IPC census
// test has to mock it.
describe('the one door across both halves of the registry', () => {
const BROWSING_PAGE_ID = 'browser-page-1'
function registerBrowsingGuest(id = 400): GuestFake {
const guest = createGuest(id, 'https://example.com/')
browserMocks.webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: BROWSING_PAGE_ID,
workspaceId: 'workspace-1',
worktreeId: 'wt-1',
webContentsId: id,
rendererWebContentsId: host.id
})
return guest
}
it('answers each page with the guest of its own half', () => {
const browsing = registerBrowsingGuest()
const { guest: document, browserPageId } = attachPreviewGuest()
expect(browserManager.getAuthorizedGuest(BROWSING_PAGE_ID, host.id)).toBe(browsing as never)
expect(browserManager.getAuthorizedGuest(browserPageId, host.id)).toBe(document as never)
})
// Why this is the containment claim and not a lookup detail: page management, agent commands,
// download routing and certificate attribution all read the browsing map directly, so a
// document page being absent from it is what fences them without a guard of their own.
it('keeps a document page out of the browsing map entirely', () => {
registerBrowsingGuest()
const { browserPageId } = attachPreviewGuest()
expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull()
expect([...browserManager.getWebContentsIdByTabId().keys()]).toEqual([BROWSING_PAGE_ID])
})
})
it('attaches a guest once whatever profile it was asked for', () => {
const { guest } = attachPreviewGuest(303)
browserManager.attachGuestPolicies(guest as never)
expect(listenerCount(guest, 'dom-ready')).toBe(0)
expect(listenerCount(guest, 'will-navigate')).toBe(1)
})
})
@@ -882,34 +882,5 @@ describe('browserManager', () => {
expect(debuggerAttach).toHaveBeenCalledWith('1.3')
expect(debuggerSendCommand).not.toHaveBeenCalled()
})
it('installs annotation viewport bridge in an isolated world', async () => {
const { guest } = makeGuest(4646)
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'tab-annotations',
webContentsId: guest.id as number,
rendererWebContentsId
})
const ok = await browserManager.setAnnotationViewportBridge('tab-annotations', {
emitViewport: false,
enabled: true,
markers: [],
token: 'annotationviewporttoken'
})
expect(ok).toBe(true)
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith(
expect.any(Number),
[
expect.objectContaining({
code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge')
})
],
false
)
})
})
})
+91 -15
View File
@@ -63,6 +63,11 @@ import {
BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID,
buildBrowserAnnotationViewportBridgeScript
} from '../../shared/browser-annotation-viewport-bridge'
import {
getWorkspaceDocPageGuest,
installDocPreviewGuestPolicy,
isWorkspaceDocPageId
} from './doc-preview-guest-policy'
import type { KeybindingOverrides } from '../../shared/keybindings'
import {
BrowserCertificateTrustController,
@@ -157,6 +162,15 @@ type PopupOwnerContext = {
browserTabId: string
rootGuestWebContentsId: number
}
/**
* What a guest is allowed to be. A browsing guest is the web — popups, clicked-link routing and
* anti-detection all apply. A workspace-document guest renders one granted document and gets none
* of that; `host` is the renderer that minted its grant, and the only sink for what it reports.
*/
export type BrowserGuestPolicy =
| { profile: 'browsing' }
| { profile: 'workspace-doc'; host: Electron.WebContents }
const BROWSING_GUEST_POLICY: BrowserGuestPolicy = { profile: 'browsing' }
type PendingMainFrameNavigation = {
currentUrl: string
supersededUrls: string[]
@@ -671,12 +685,20 @@ export class BrowserManager {
attachGuestPolicies(
guest: Electron.WebContents,
inheritedOwnerContext: PopupOwnerContext | null = null
inheritedOwnerContext: PopupOwnerContext | null = null,
policy: BrowserGuestPolicy = BROWSING_GUEST_POLICY
): void {
if (this.policyAttachedGuestIds.has(guest.id)) {
return
}
this.policyAttachedGuestIds.add(guest.id)
// Why one door with a profile rather than a second installer beside it: whether a guest was
// policy-attached at all is what registration and teardown both key on, so a guest that took
// another path into the app is invisible to both.
if (policy.profile === 'workspace-doc') {
this.attachWorkspaceDocGuestPolicies(guest, policy.host)
return
}
if (inheritedOwnerContext) {
this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext)
}
@@ -1016,6 +1038,30 @@ export class BrowserManager {
})
}
/**
* A workspace document is not the web: no popups, no link routing, no anti-detection, and no
* navigation bookkeeping for chrome it does not have. What it does share with a browsing guest is
* this method's teardown, so a retired preview drops its listeners on the same path.
*/
private attachWorkspaceDocGuestPolicies(
guest: Electron.WebContents,
host: Electron.WebContents
): void {
const disposeDocPolicy = installDocPreviewGuestPolicy(guest, host)
const handleDestroyed = (): void => {
this.cleanupGuestPolicyAttachment(guest.id)
}
guest.on('destroyed', handleDestroyed)
this.policyCleanupByGuestId.set(guest.id, () => {
disposeDocPolicy()
try {
guest.off('destroyed', handleDestroyed)
} catch {
// guest may already be destroyed
}
})
}
// Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA,
// not the request header, so the header-level Firefox switch in setupClientHintsOverride
// must be matched here per navigation or the two layers disagree — itself a bot tell.
@@ -1318,7 +1364,9 @@ export class BrowserManager {
rendererWebContentsId
}: BrowserGuestRegistration): boolean {
const browserTabId = browserPageId ?? legacyBrowserTabId
if (!browserTabId) {
// Why refuse rather than overwrite: the two halves of the registry must stay disjoint, or one
// id resolves in both and the tool door silently prefers the document guest over the page.
if (!browserTabId || isWorkspaceDocPageId(browserTabId)) {
return false
}
// Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents.
@@ -1377,6 +1425,12 @@ export class BrowserManager {
}
unregisterGuest(browserTabId: string): void {
// Why the check on the exit door too: a document page withdraws by revoking its grant, never
// through here, so its id arriving is misaddressed — and the cancel below would evict that
// preview's live grab on the strength of it.
if (isWorkspaceDocPageId(browserTabId)) {
return
}
// Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise.
this.cancelGrabOp(browserTabId, 'evicted')
@@ -1444,10 +1498,15 @@ export class BrowserManager {
sessionProfileId?: string | null
userAgentMode?: BrowserSessionUserAgentMode
webContentsId: number
}): void {
}): boolean {
// Why the same check on both registration doors: one id resolving in both halves is the exact
// confusion the split registries exist to prevent.
if (isWorkspaceDocPageId(browserPageId)) {
return false
}
const guest = webContents.fromId(webContentsId)
if (!guest || guest.isDestroyed()) {
return
return false
}
// Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle.
this.offscreenGuestIds.add(webContentsId)
@@ -1468,6 +1527,7 @@ export class BrowserManager {
this.worktreeIdByTabId.set(browserPageId, worktreeId)
}
this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId)
return true
}
unregisterAll(): void {
@@ -1843,12 +1903,13 @@ export class BrowserManager {
async setAnnotationViewportBridge(
browserTabId: string,
options: BrowserAnnotationViewportBridgeOptions
options: BrowserAnnotationViewportBridgeOptions,
resolveGuest: () => Electron.WebContents | null
): Promise<boolean> {
const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve()
const next = prev
.catch(() => {})
.then(() => this.doSetAnnotationViewportBridgeImpl(browserTabId, options))
.then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest))
this.annotationViewportBridgeOpsByTabId.set(browserTabId, next)
try {
return await next
@@ -1859,18 +1920,22 @@ export class BrowserManager {
}
}
// Why the caller resolves the guest: the same bridge serves browsing pages and workspace
// documents, which live in different halves of the page registry.
// Why a resolver and not the guest itself: this op may have waited behind another one, and a
// cross-process navigation meanwhile swaps the tab's contents without destroying the old one —
// injecting into the guest the request named would bridge a page nobody is looking at.
// Why no tab id: with teardown gone this reaches only the guest the resolver hands back, and
// taking an id it cannot act on would invite the next reader to act on it.
private async doSetAnnotationViewportBridgeImpl(
browserTabId: string,
options: BrowserAnnotationViewportBridgeOptions
options: BrowserAnnotationViewportBridgeOptions,
resolveGuest: () => Electron.WebContents | null
): Promise<boolean> {
const webContentsId = this.webContentsIdByTabId.get(browserTabId)
if (!webContentsId) {
return false
}
const guest = webContents.fromId(webContentsId)
// Why no teardown here: the resolver already unregisters a page whose guest died, and the only
// case it uniquely leaves is an ownership mismatch on a healthy page — where tearing down would
// cancel that page's in-flight downloads and grabs over a request that was merely misaddressed.
const guest = resolveGuest()
if (!guest || guest.isDestroyed()) {
// Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps.
this.unregisterGuest(browserTabId)
return false
}
@@ -1977,10 +2042,21 @@ export class BrowserManager {
// --- Browser Context Grab — main-owned operations ---
/** Validate that the sender owns browserTabId; returns the guest WebContents or null. */
/**
* The guest a request from `senderWebContentsId` may act on, across both halves of the page
* registry. This is the only door taught about workspace-document guests: they are kept out of
* the browsing maps entirely, so page management, agent commands, download routing and
* certificate attribution all miss them without a guard of their own — and a reader who opens a
* tool on the document in front of them still gets an answer.
*/
getAuthorizedGuest(
browserTabId: string,
senderWebContentsId: number
): Electron.WebContents | null {
const docGuest = getWorkspaceDocPageGuest(browserTabId, senderWebContentsId)
if (docGuest) {
return docGuest
}
const registeredRenderer = this.rendererWebContentsIdByTabId.get(browserTabId)
if (registeredRenderer == null || registeredRenderer !== senderWebContentsId) {
return null
@@ -0,0 +1,185 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BrowserSessionProfile } from '../../shared/browser-workspace-types'
const mocks = vi.hoisted(() => ({
handleGuestWillDownload: vi.fn(),
noticeDocPreviewDownloadBlocked: vi.fn()
}))
type WillDownloadListener = (
event: { preventDefault: () => void },
item: { id: string },
webContents: { id: number }
) => void
type FakeSession = {
listeners: WillDownloadListener[]
on: ReturnType<typeof vi.fn>
removeListener: ReturnType<typeof vi.fn>
getUserAgent: () => string
setUserAgent: ReturnType<typeof vi.fn>
setPermissionRequestHandler: ReturnType<typeof vi.fn>
setPermissionCheckHandler: ReturnType<typeof vi.fn>
setDisplayMediaRequestHandler: ReturnType<typeof vi.fn>
}
const sessionsByPartition = new Map<string, FakeSession>()
function fakeSession(): FakeSession {
const listeners: WillDownloadListener[] = []
return {
listeners,
on: vi.fn((event: string, listener: WillDownloadListener) => {
if (event === 'will-download') {
listeners.push(listener)
}
}),
removeListener: vi.fn((event: string, listener: WillDownloadListener) => {
if (event !== 'will-download') {
return
}
const index = listeners.indexOf(listener)
if (index !== -1) {
listeners.splice(index, 1)
}
}),
getUserAgent: () => 'Mozilla/5.0 Orca',
setUserAgent: vi.fn(),
setPermissionRequestHandler: vi.fn(),
setPermissionCheckHandler: vi.fn(),
setDisplayMediaRequestHandler: vi.fn()
}
}
vi.mock('electron', () => ({
session: {
fromPartition: (partition: string) => {
const existing = sessionsByPartition.get(partition)
if (existing) {
return existing
}
const created = fakeSession()
sessionsByPartition.set(partition, created)
return created
}
}
}))
vi.mock('./browser-manager', () => ({
browserManager: {
handleGuestWillDownload: mocks.handleGuestWillDownload,
installCertificateRequestGuard: vi.fn(),
removeCertificateRequestGuard: vi.fn(),
notifyPermissionDenied: vi.fn()
}
}))
vi.mock('./doc-preview-download-block-notice', () => ({
noticeDocPreviewDownloadBlocked: mocks.noticeDocPreviewDownloadBlocked
}))
vi.mock('./browser-media-access', () => ({
hasSystemMediaAccess: () => false,
requestSystemMediaAccess: async () => false
}))
vi.mock('./browser-session-ua', () => ({
cleanElectronUserAgent: (userAgent: string) => userAgent,
setupClientHintsOverride: vi.fn()
}))
vi.mock('./browser-session-user-agent-mode', () => ({
setBrowserSessionUserAgentMode: vi.fn()
}))
vi.mock('./browser-webauthn-access', () => ({
allowsBrowserWebAuthnPermission: () => false,
clearBrowserWebAuthnAccessHandlers: vi.fn(),
installBrowserWebAuthnAccessHandlers: vi.fn()
}))
type PartitionPolicyInstaller = (
profile: BrowserSessionProfile,
options?: { downloads?: 'route' | 'deny' }
) => void
// Why imported per test rather than at the top: the installer remembers which partitions it has
// already configured in module state, so a shared import would make the second test's install a
// no-op and leave it reading the first test's listener.
async function loadInstaller(): Promise<PartitionPolicyInstaller> {
const module = await import('./browser-session-partition-policies')
return module.installBrowserSessionPartitionPolicies
}
function profileFor(partition: string): BrowserSessionProfile {
return {
id: partition,
scope: 'isolated',
partition,
label: partition,
source: null,
userAgentMode: 'clean'
}
}
/** Fires the partition's real `will-download` listener and reports what it decided. */
function fireWillDownload(partition: string): { cancelled: boolean } {
const sess = sessionsByPartition.get(partition)
if (!sess || sess.listeners.length !== 1) {
throw new Error(`expected exactly one will-download listener on ${partition}`)
}
let cancelled = false
sess.listeners[0]({ preventDefault: () => (cancelled = true) }, { id: 'item-1' }, { id: 42 })
return { cancelled }
}
beforeEach(() => {
vi.clearAllMocks()
sessionsByPartition.clear()
vi.resetModules()
})
describe('partition download policy', () => {
// The presence half: without it, a deny assertion passes for a partition that installed no
// listener at all, and would keep passing if the whole download path were removed.
it('routes a download on a partition that did not ask for the deny', async () => {
const install = await loadInstaller()
install(profileFor('persist:browsing-1'))
expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false)
expect(mocks.handleGuestWillDownload).toHaveBeenCalledWith(
expect.objectContaining({ guestWebContentsId: 42 })
)
})
it('cancels a download on a partition that asked for the deny, routing nothing', async () => {
const install = await loadInstaller()
install(profileFor('orca-doc-preview'), { downloads: 'deny' })
expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true)
expect(mocks.handleGuestWillDownload).not.toHaveBeenCalled()
})
// Why in the same run as the routing test above: a refusal the reader cannot see is a pressed
// button that does nothing, and a notice on the routing partition would announce a download that
// is about to arrive normally.
it('tells the reader about the refusal, and only on the partition that refused', async () => {
const install = await loadInstaller()
install(profileFor('orca-doc-preview'), { downloads: 'deny' })
install(profileFor('persist:browsing-1'))
fireWillDownload('persist:browsing-1')
expect(mocks.noticeDocPreviewDownloadBlocked).not.toHaveBeenCalled()
fireWillDownload('orca-doc-preview')
expect(mocks.noticeDocPreviewDownloadBlocked).toHaveBeenCalledWith(
expect.objectContaining({ id: 42 })
)
})
// Why both partitions in one run: the listener is module state shared across sessions, so a deny
// installed for one partition must not follow the next partition that installs after it.
it('keeps each partition on its own decision', async () => {
const install = await loadInstaller()
install(profileFor('orca-doc-preview'), { downloads: 'deny' })
install(profileFor('persist:browsing-1'))
expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true)
expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false)
expect(mocks.handleGuestWillDownload).toHaveBeenCalledTimes(1)
})
})
@@ -11,6 +11,7 @@ import {
clearBrowserWebAuthnAccessHandlers,
installBrowserWebAuthnAccessHandlers
} from './browser-webauthn-access'
import { noticeDocPreviewDownloadBlocked } from './doc-preview-download-block-notice'
// Why: one shared installer keeps every partition's deny-by-default permission/download policies from drifting apart.
const configuredPartitions = new Set<string>()
@@ -22,6 +23,23 @@ const handleWillDownload = (
browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item })
}
/**
* Why a second listener instead of a branch inside the shared one: `will-download` is a session
* event that names no partition, so the only place the decision can be keyed by partition is which
* listener that partition's session got. A workspace-document guest has no page of its own to
* attribute a download to, so routing one lands it in this desktop's Downloads folder under a
* remote-authored name that nothing in the UI accounts for.
*/
const handleDeniedWillDownload = (
event: Electron.Event,
_item: Electron.DownloadItem,
webContents: Electron.WebContents
): void => {
event.preventDefault()
// The page gets nothing back; the reader gets a sentence, or a pressed button just does nothing.
noticeDocPreviewDownloadBlocked(webContents)
}
function resolvePermissionNoticeUrl(
webContents: Electron.WebContents,
details: Electron.PermissionRequest | undefined
@@ -37,7 +55,13 @@ function resolvePermissionNoticeUrl(
}
}
export function installBrowserSessionPartitionPolicies(profile: BrowserSessionProfile): void {
/** `route` hands the item to the owning page's download flow; `deny` cancels it before it starts. */
export type BrowserPartitionDownloadPolicy = 'route' | 'deny'
export function installBrowserSessionPartitionPolicies(
profile: BrowserSessionProfile,
options?: { downloads?: BrowserPartitionDownloadPolicy }
): void {
const { partition } = profile
const sess = session.fromPartition(partition)
setBrowserSessionUserAgentMode(sess, profile.userAgentMode ?? 'clean')
@@ -106,7 +130,11 @@ export function installBrowserSessionPartitionPolicies(profile: BrowserSessionPr
callback({ video: undefined, audio: undefined })
})
sess.removeListener('will-download', handleWillDownload)
sess.on('will-download', handleWillDownload)
sess.removeListener('will-download', handleDeniedWillDownload)
sess.on(
'will-download',
options?.downloads === 'deny' ? handleDeniedWillDownload : handleWillDownload
)
configuredPartitions.add(partition)
}
@@ -115,6 +143,7 @@ export function clearBrowserSessionPartitionPolicies(partition: string, sess: Se
configuredPartitions.delete(partition)
browserManager.removeCertificateRequestGuard(sess)
sess.removeListener('will-download', handleWillDownload)
sess.removeListener('will-download', handleDeniedWillDownload)
clearBrowserWebAuthnAccessHandlers(sess)
sess.setPermissionRequestHandler(null)
sess.setPermissionCheckHandler(null)
@@ -0,0 +1,107 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
publishDocPreviewFailure: vi.fn(),
boundGrantIdByGuest: new Map<object, string>()
}))
vi.mock('./doc-preview-failure-notice', () => ({
publishDocPreviewFailure: mocks.publishDocPreviewFailure
}))
vi.mock('./doc-preview-guest-policy', () => ({
readDocPreviewGuestBoundGrantId: (guest: object) => mocks.boundGrantIdByGuest.get(guest) ?? null
}))
const GRANT_ID = 'a'.repeat(32)
const OTHER_GRANT_ID = 'b'.repeat(32)
/** Only the identity matters: the module asks the guest registry what grant this contents holds. */
function guestBoundTo(grantId: string | null): Electron.WebContents {
const guest = {} as Electron.WebContents
if (grantId !== null) {
mocks.boundGrantIdByGuest.set(guest, grantId)
}
return guest
}
async function loadNotifier(): Promise<(guest: Electron.WebContents) => void> {
const module = await import('./doc-preview-download-block-notice')
return module.noticeDocPreviewDownloadBlocked
}
beforeEach(() => {
vi.clearAllMocks()
mocks.boundGrantIdByGuest.clear()
// Why per test: the module remembers which grants it has already told the reader about.
vi.resetModules()
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-27T00:00:00.000Z'))
})
afterEach(() => {
vi.useRealTimers()
})
describe('noticeDocPreviewDownloadBlocked', () => {
it('tells the shell which preview had a download refused', async () => {
const notice = await loadNotifier()
notice(guestBoundTo(GRANT_ID))
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledWith({
grantId: GRANT_ID,
reason: 'download-blocked'
})
})
// Why: a document can ask in a loop. The reader learns nothing from the second notice, and every
// attempt would otherwise cross the IPC boundary and re-render the strip.
it('says it once however often the document asks', async () => {
const notice = await loadNotifier()
const guest = guestBoundTo(GRANT_ID)
notice(guest)
notice(guest)
vi.advanceTimersByTime(1_500)
notice(guest)
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(1)
})
it('says it again for an attempt long after the last one', async () => {
const notice = await loadNotifier()
const guest = guestBoundTo(GRANT_ID)
notice(guest)
vi.advanceTimersByTime(2_500)
notice(guest)
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2)
})
// Why not one throttle for the whole app: two previews are two readers, and silencing the second
// because the first just refused something leaves that press unexplained.
it('throttles each preview on its own', async () => {
const notice = await loadNotifier()
notice(guestBoundTo(GRANT_ID))
notice(guestBoundTo(OTHER_GRANT_ID))
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2)
expect(mocks.publishDocPreviewFailure).toHaveBeenLastCalledWith({
grantId: OTHER_GRANT_ID,
reason: 'download-blocked'
})
})
// The absence half of the first test: no shell is showing this contents, so there is no preview
// to put a notice on. Without the presence tests above, this would pass on a module that never
// published anything at all.
it('says nothing for a contents no preview is bound to', async () => {
const notice = await loadNotifier()
notice(guestBoundTo(null))
expect(mocks.publishDocPreviewFailure).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,34 @@
import { publishDocPreviewFailure } from './doc-preview-failure-notice'
import { readDocPreviewGuestBoundGrantId } from './doc-preview-guest-policy'
/**
* Why the reader is told at all, when the point of the fence is that nothing happens: a press that
* produces no file and no explanation reads as Orca being broken. The notice is chrome the document
* cannot see or read back, so the refusal stays as silent to the page as it was.
*
* Why a floor between notices: a document can ask in a loop, and every attempt would otherwise
* cross the IPC boundary and re-render the strip. The reader learns nothing from the thousandth.
*/
const NOTICE_MIN_INTERVAL_MS = 2_000
const noticedAtByGrantId = new Map<string, number>()
export function noticeDocPreviewDownloadBlocked(guest: Electron.WebContents): void {
const grantId = readDocPreviewGuestBoundGrantId(guest)
// Nothing to route a notice to: no shell is showing this contents as a preview.
if (grantId === null) {
return
}
const now = Date.now()
// Doubles as the expiry check, so what survives is exactly "noticed inside the window", and a
// grant that stops downloading stops being remembered.
for (const [noticedGrantId, noticedAt] of noticedAtByGrantId) {
if (now - noticedAt >= NOTICE_MIN_INTERVAL_MS) {
noticedAtByGrantId.delete(noticedGrantId)
}
}
if (noticedAtByGrantId.has(grantId)) {
return
}
noticedAtByGrantId.set(grantId, now)
publishDocPreviewFailure({ grantId, reason: 'download-blocked' })
}
@@ -0,0 +1,70 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DOC_PREVIEW_LOAD_FAILURE_CHANNEL } from '../../shared/doc-preview-scheme'
import { publishDocPreviewFailure, setDocPreviewFailureSink } from './doc-preview-failure-notice'
afterEach(() => {
setDocPreviewFailureSink(null)
})
describe('publishDocPreviewFailure', () => {
it('sends the grant, path, and reason on the failure channel', () => {
const send = vi.fn()
setDocPreviewFailureSink({ send })
publishDocPreviewFailure({
grantId: 'a'.repeat(32),
relativePath: 'index.html',
reason: 'too-large'
})
expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, {
grantId: 'a'.repeat(32),
relativePath: 'index.html',
reason: 'too-large'
})
})
// Why: reads can outlive the window that asked for them; a missing sink must not throw inside
// the protocol handler.
it('is a no-op with no sink registered', () => {
expect(() =>
publishDocPreviewFailure({
grantId: 'b'.repeat(32),
relativePath: 'index.html',
reason: 'unreadable'
})
).not.toThrow()
})
it('sends nothing into a destroyed window', () => {
const send = vi.fn()
setDocPreviewFailureSink({ send, isDestroyed: () => true })
publishDocPreviewFailure({
grantId: 'c'.repeat(32),
relativePath: 'index.html',
reason: 'unreadable'
})
expect(send).not.toHaveBeenCalled()
})
// Why: a WebContents can be torn down between the liveness check and the send.
it('swallows a throwing sink and stops using it', () => {
const send = vi.fn(() => {
throw new Error('Object has been destroyed')
})
setDocPreviewFailureSink({ send })
const failure = {
grantId: 'd'.repeat(32),
relativePath: 'index.html',
reason: 'unreadable' as const
}
expect(() => publishDocPreviewFailure(failure)).not.toThrow()
publishDocPreviewFailure(failure)
expect(send).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,34 @@
import {
DOC_PREVIEW_LOAD_FAILURE_CHANNEL,
type DocPreviewFailure
} from '../../shared/doc-preview-scheme'
type DocPreviewFailureSink = {
send: (channel: string, payload: DocPreviewFailure) => void
isDestroyed?: () => boolean
}
let failureSink: DocPreviewFailureSink | null = null
export function setDocPreviewFailureSink(sink: DocPreviewFailureSink | null): void {
failureSink = sink
}
/**
* The preview shell cannot read the guest's HTTP status, and a 4xx body renders as
* if it were the document. Pushing the reason lets the shell replace that with a
* localized notice for the failure the user actually hit.
*/
export function publishDocPreviewFailure(failure: DocPreviewFailure): void {
const sink = failureSink
// Why: a read can outlive the window that asked for it, and sending into torn-down
// WebContents throws — an unreadable asset must not take the protocol handler with it.
if (!sink || sink.isDestroyed?.()) {
return
}
try {
sink.send(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, failure)
} catch {
failureSink = null
}
}
@@ -0,0 +1,333 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
callRuntimeEnvironment: vi.fn(),
readFile: vi.fn(),
realpath: vi.fn(),
requireSshFilesystemProvider: vi.fn()
}))
vi.mock('../ipc/runtime-environment-transport-routing', () => ({
callRuntimeEnvironment: mocks.callRuntimeEnvironment
}))
vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/user-data' }))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
requireSshFilesystemProvider: mocks.requireSshFilesystemProvider
}))
import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader'
import { docPreviewContentType, readDocPreviewFile } from './doc-preview-file-reader'
import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry'
function sshGrant(): ReturnType<typeof mintDocPreviewGrant> {
return mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
}
function runtimeGrant(root = '/srv/repo/docs'): ReturnType<typeof mintDocPreviewGrant> {
return mintDocPreviewGrant({
owner: {
kind: 'runtime',
environmentId: 'env-1',
worktreeSelector: 'id:wt-1',
worktreeRoot: '/srv/repo'
},
root,
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
}
beforeEach(() => {
vi.clearAllMocks()
revokeAllDocPreviewGrants()
// Why: an unsymlinked host canonicalizes to the path it was given.
mocks.realpath.mockImplementation((path: string) => Promise.resolve(path))
mocks.requireSshFilesystemProvider.mockReturnValue({
readFile: mocks.readFile,
realpath: mocks.realpath
})
})
describe('docPreviewContentType', () => {
it('maps document and asset extensions, defaulting to octet-stream', () => {
expect(docPreviewContentType('index.html')).toBe('text/html; charset=utf-8')
expect(docPreviewContentType('assets/app.CSS')).toBe('text/css; charset=utf-8')
expect(docPreviewContentType('assets/logo.png')).toBe('image/png')
expect(docPreviewContentType('data.bin')).toBe('application/octet-stream')
})
})
describe('readDocPreviewFile — ssh owner', () => {
it('reads text through the SSH filesystem provider', async () => {
mocks.readFile.mockResolvedValue({ content: '<h1>hi</h1>', isBinary: false })
const outcome = await readDocPreviewFile(sshGrant(), 'index.html')
expect(mocks.requireSshFilesystemProvider).toHaveBeenCalledWith('ssh-1')
expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/docs/index.html')
expect(outcome).toEqual({
ok: true,
bytes: Buffer.from('<h1>hi</h1>', 'utf8'),
contentType: 'text/html; charset=utf-8'
})
})
it('decodes a base64 binary asset', async () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47])
mocks.readFile.mockResolvedValue({ content: png.toString('base64'), isBinary: true })
const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png')
expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' })
})
// Why: the SSH reader rejects an over-cap file rather than clamping it, so a completed read is
// always whole and needs no truncation flag.
it('serves a whole SSH read that carries no truncation flag', async () => {
mocks.readFile.mockResolvedValue({ content: '<h1>whole</h1>', isBinary: false })
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true })
})
// Why: the SSH read path only serves images and PDFs as bytes, so a font is refused there by
// design — the failure must name the file type, not a stale server.
it('reports a file type the host will not send as unsupported-asset', async () => {
mocks.readFile.mockResolvedValue({ content: '', isBinary: true })
const outcome = await readDocPreviewFile(sshGrant(), 'assets/font.woff2')
expect(outcome).toMatchObject({ ok: false, status: 415, reason: 'unsupported-asset' })
})
// Why: a host that still named the type read a 0-byte file, so 0 bytes is the honest answer —
// reporting it as a refused format would be a failure the workspace never reported.
it('serves an empty file the host still typed instead of calling it unsupported', async () => {
mocks.readFile.mockResolvedValue({ content: '', isBinary: true, mimeType: 'image/png' })
const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png')
expect(outcome).toEqual({ ok: true, bytes: Buffer.alloc(0), contentType: 'image/png' })
})
// Why: containment above is lexical, and the SSH read RPC enforces no root of its own, so a
// symlink inside the grant would otherwise read anything the account can reach.
it('404s a path that canonicalizes outside the grant root', async () => {
mocks.realpath.mockImplementation((path: string) =>
Promise.resolve(path === '/home/alice/docs/escape.html' ? '/etc/shadow' : path)
)
const outcome = await readDocPreviewFile(sshGrant(), 'escape.html')
expect(outcome).toMatchObject({ ok: false, status: 404 })
expect(mocks.readFile).not.toHaveBeenCalled()
})
it('reads the canonical path once containment holds', async () => {
mocks.realpath.mockImplementation((path: string) =>
Promise.resolve(path === '/home/alice/docs/link.html' ? '/home/alice/docs/real.html' : path)
)
mocks.readFile.mockResolvedValue({ content: '<h1>real</h1>', isBinary: false })
expect(await readDocPreviewFile(sshGrant(), 'link.html')).toMatchObject({ ok: true })
expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/docs/real.html')
})
// Why: a symlinked root is legitimate; containment must be judged on what both sides resolve to.
it('keeps serving a grant whose own root is a symlink', async () => {
mocks.realpath.mockImplementation((path: string) =>
Promise.resolve(path.replace('/home/alice/docs', '/mnt/data/docs'))
)
mocks.readFile.mockResolvedValue({ content: '<h1>hi</h1>', isBinary: false })
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true })
expect(mocks.readFile).toHaveBeenCalledWith('/mnt/data/docs/index.html')
})
it('404s when the host cannot canonicalize the path at all', async () => {
mocks.realpath.mockRejectedValue(new Error('no such file'))
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({
ok: false,
status: 404
})
expect(mocks.readFile).not.toHaveBeenCalled()
})
it('404s a path outside the grant root without touching the provider', async () => {
const outcome = await readDocPreviewFile(sshGrant(), '../../etc/passwd')
expect(outcome).toMatchObject({ ok: false, status: 404 })
expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled()
})
it('reports an over-cap SSH file as too large rather than unreadable', async () => {
mocks.readFile.mockRejectedValue(new FileReadCapExceededError('exceeds client cap'))
expect(await readDocPreviewFile(sshGrant(), 'huge.html')).toMatchObject({
ok: false,
status: 413
})
})
it('404s when the provider read fails', async () => {
mocks.readFile.mockRejectedValue(new Error('no such file'))
expect(await readDocPreviewFile(sshGrant(), 'missing.html')).toMatchObject({
ok: false,
status: 404
})
})
})
describe('readDocPreviewFile — paired runtime owner', () => {
it('reads text over worktree-relative files.read', async () => {
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: true,
result: { content: '<h1>remote</h1>', truncated: false, byteLength: 15 }
})
const outcome = await readDocPreviewFile(runtimeGrant(), 'index.html')
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
'/user-data',
'env-1',
'files.read',
{ worktree: 'id:wt-1', relativePath: 'docs/index.html' },
15_000
)
expect(outcome).toEqual({
ok: true,
bytes: Buffer.from('<h1>remote</h1>', 'utf8'),
contentType: 'text/html; charset=utf-8'
})
})
it('falls back to the base64 preview RPC for a binary asset', async () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47])
mocks.callRuntimeEnvironment
.mockResolvedValueOnce({
ok: false,
error: { code: 'runtime_error', message: 'binary_file' }
})
.mockResolvedValueOnce({
ok: true,
result: {
content: png.toString('base64'),
isBinary: true,
isImage: true,
mimeType: 'image/png'
}
})
const outcome = await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')
expect(mocks.callRuntimeEnvironment).toHaveBeenNthCalledWith(
2,
'/user-data',
'env-1',
'files.readPreview',
{ worktree: 'id:wt-1', relativePath: 'docs/assets/logo.png' },
15_000
)
expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' })
})
it('degrades on an old server whose empty binary preview carries no metadata', async () => {
mocks.callRuntimeEnvironment
.mockResolvedValueOnce({
ok: false,
error: { code: 'runtime_error', message: 'binary_file' }
})
.mockResolvedValueOnce({ ok: true, result: { content: '', isBinary: true } })
expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toMatchObject({
ok: false,
status: 415,
reason: 'unsupported-asset'
})
})
it('serves an empty paired asset the host typed rather than reporting a refusal', async () => {
mocks.callRuntimeEnvironment
.mockResolvedValueOnce({
ok: false,
error: { code: 'runtime_error', message: 'binary_file' }
})
.mockResolvedValueOnce({
ok: true,
result: { content: '', isBinary: true, isImage: true, mimeType: 'image/png' }
})
expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toEqual({
ok: true,
bytes: Buffer.alloc(0),
contentType: 'image/png'
})
})
// Why: files.read clamps text at the host cap and only says so in `truncated`; serving the
// clamped bytes renders a document that silently stops halfway.
it('refuses a truncated text read instead of serving the clamped bytes', async () => {
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: true,
result: { content: '<h1>half of', truncated: true, byteLength: 40_000_000 }
})
const outcome = await readDocPreviewFile(runtimeGrant(), 'index.html')
expect(outcome).toMatchObject({ ok: false, status: 413 })
expect(outcome).not.toMatchObject({ ok: true })
})
it('serves a read the host reports as complete', async () => {
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: true,
result: { content: '<h1>all</h1>', truncated: false, byteLength: 12 }
})
expect(await readDocPreviewFile(runtimeGrant(), 'index.html')).toMatchObject({ ok: true })
})
// Why: the binary RPC has no `truncated` field — it rejects an over-cap asset with this error.
it('reports the host rejecting an over-cap binary as too large', async () => {
mocks.callRuntimeEnvironment
.mockResolvedValueOnce({
ok: false,
error: { code: 'runtime_error', message: 'binary_file' }
})
.mockResolvedValueOnce({
ok: false,
error: { code: 'runtime_error', message: 'file_too_large' }
})
expect(await readDocPreviewFile(runtimeGrant(), 'assets/huge.png')).toMatchObject({
ok: false,
status: 413
})
})
it('does not treat an unrelated RPC failure as a binary fallback', async () => {
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: false,
error: { code: 'runtime_error', message: 'permission_denied' }
})
expect(await readDocPreviewFile(runtimeGrant(), 'index.html')).toMatchObject({
ok: false,
status: 404
})
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledOnce()
})
it('404s a document outside the worktree, which files.read cannot address', async () => {
const outcome = await readDocPreviewFile(runtimeGrant('/tmp/agent-docs'), 'index.html')
expect(outcome).toMatchObject({ ok: false, status: 404 })
expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled()
})
})
+198
View File
@@ -0,0 +1,198 @@
import { extname } from 'node:path'
import type {
RuntimeFilePreviewResult,
RuntimeFileReadResult
} from '../../shared/runtime-file-contracts'
import type { DocPreviewFileFailureReason } from '../../shared/doc-preview-scheme'
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader'
import { getCanonicalUserDataPath } from '../persistence'
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import {
resolveCanonicalDocPreviewPath,
resolveDocPreviewTargetPath,
toRuntimeWorktreeRelativePath,
type DocPreviewGrant
} from './doc-preview-grant-registry'
const DOC_PREVIEW_READ_TIMEOUT_MS = 15_000
/** Why not "needs a newer server": the SSH read path only ever serves images and PDFs as bytes, so
* a font is refused there by design, not by version. Name the file type, not the host's age. */
const UNSERVABLE_ASSET_PREVIEW_MESSAGE = 'This workspace cannot send this file type to a preview.'
/** `files.read` clamps text at the host's cap and reports it; serving the clamped bytes would
* render a silently half-finished document. */
const TRUNCATED_PREVIEW_MESSAGE = 'This document is too large for the server to send in full.'
/** The paired host rejects an over-cap asset outright instead of clamping it. */
const RUNTIME_TOO_LARGE_ERROR = 'file_too_large'
/** Both owners refuse an over-cap file; only their error shapes differ. */
function isTooLargeReadError(error: unknown): boolean {
return (
error instanceof FileReadCapExceededError ||
(error instanceof Error && error.message === RUNTIME_TOO_LARGE_ERROR)
)
}
export type DocPreviewReadOutcome =
| { ok: true; bytes: Buffer; contentType: string }
| { ok: false; status: number; reason: DocPreviewFileFailureReason; message: string }
const DOC_PREVIEW_CONTENT_TYPES: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.htm': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.md': 'text/plain; charset=utf-8',
'.csv': 'text/plain; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
'.pdf': 'application/pdf',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.otf': 'font/otf'
}
export function docPreviewContentType(relativePath: string): string {
return (
DOC_PREVIEW_CONTENT_TYPES[extname(relativePath).toLowerCase()] ?? 'application/octet-stream'
)
}
type PreviewFileBytes = {
content: string
isBinary: boolean
truncated?: boolean
/** Set by every owner that agreed to serve the bytes, so it also survives a 0-byte file. */
mimeType?: string
}
function toOutcome(source: PreviewFileBytes, contentType: string): DocPreviewReadOutcome {
if (source.truncated) {
return { ok: false, status: 413, reason: 'too-large', message: TRUNCATED_PREVIEW_MESSAGE }
}
if (!source.isBinary) {
return { ok: true, bytes: Buffer.from(source.content, 'utf8'), contentType }
}
if (source.content) {
return { ok: true, bytes: Buffer.from(source.content, 'base64'), contentType }
}
// Why: an empty binary body is two different answers. A host that still named the file's type
// read a 0-byte file, and 0 bytes is what it should serve; a host that named no type declined
// the format outright and has nothing to send.
return source.mimeType
? { ok: true, bytes: Buffer.alloc(0), contentType }
: {
ok: false,
status: 415,
reason: 'unsupported-asset',
message: UNSERVABLE_ASSET_PREVIEW_MESSAGE
}
}
async function readRuntimeDocPreviewFile(
environmentId: string,
worktreeSelector: string,
relativePath: string
): Promise<PreviewFileBytes> {
const userDataPath = getCanonicalUserDataPath()
const response = await callRuntimeEnvironment(
userDataPath,
environmentId,
'files.read',
{ worktree: worktreeSelector, relativePath },
DOC_PREVIEW_READ_TIMEOUT_MS
)
if (response.ok) {
const result = response.result as RuntimeFileReadResult
return { content: result.content, isBinary: false, truncated: result.truncated === true }
}
// Why: files.read rejects binaries with a typed error; the base64 preview RPC serves
// images and fonts the same way it does for markdown previews. Match the exact
// message so an unrelated failure can't spoof the fallback.
if (response.error.message !== 'binary_file') {
throw new Error(response.error.message)
}
const previewResponse = await callRuntimeEnvironment(
userDataPath,
environmentId,
'files.readPreview',
{ worktree: worktreeSelector, relativePath },
DOC_PREVIEW_READ_TIMEOUT_MS
)
if (!previewResponse.ok) {
throw new Error(previewResponse.error.message)
}
const preview = previewResponse.result as RuntimeFilePreviewResult
// Why: readPreview never clamps — it rejects an over-cap asset — so its body is whole or absent.
return {
content: preview.content,
isBinary: preview.isBinary,
...(preview.mimeType ? { mimeType: preview.mimeType } : {})
}
}
function notFoundOutcome(message = 'Not found'): DocPreviewReadOutcome {
return { ok: false, status: 404, reason: 'unreadable', message }
}
/** Reads one in-grant path over the same channel the editor uses for that owner. */
export async function readDocPreviewFile(
grant: DocPreviewGrant,
relativePath: string
): Promise<DocPreviewReadOutcome> {
const absolutePath = resolveDocPreviewTargetPath(grant, relativePath)
if (!absolutePath) {
return notFoundOutcome()
}
const contentType = docPreviewContentType(relativePath)
try {
if (grant.owner.kind === 'ssh') {
const provider = requireSshFilesystemProvider(grant.owner.connectionId)
// Why: the SSH read RPC enforces no root of its own, so containment has to survive a symlink
// before the read — the lexical check above only proves the requested path looked contained.
const canonicalPath = await resolveCanonicalDocPreviewPath(grant, absolutePath, (path) =>
provider.realpath(path)
)
if (!canonicalPath) {
return notFoundOutcome()
}
// Why: the SSH reader rejects an over-cap file outright, so its result is never partial.
return toOutcome(await provider.readFile(canonicalPath), contentType)
}
const worktreeRelativePath = toRuntimeWorktreeRelativePath(
grant.owner.worktreeRoot,
absolutePath
)
if (!worktreeRelativePath) {
// Why: files.read is worktree-scoped, so a doc outside the worktree has no client-side channel.
return notFoundOutcome()
}
// Why no realpath pass here: the host resolves this path through resolveAuthorizedPath, which
// canonicalizes and re-checks the worktree root server-side before reading.
return toOutcome(
await readRuntimeDocPreviewFile(
grant.owner.environmentId,
grant.owner.worktreeSelector,
worktreeRelativePath
),
contentType
)
} catch (error) {
return isTooLargeReadError(error)
? { ok: false, status: 413, reason: 'too-large', message: TRUNCATED_PREVIEW_MESSAGE }
: notFoundOutcome(error instanceof Error ? error.message : undefined)
}
}
@@ -0,0 +1,164 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
getDocPreviewGrant,
mintDocPreviewGrant,
resolveCanonicalDocPreviewPath,
resolveDocPreviewTargetPath,
revokeAllDocPreviewGrants,
revokeDocPreviewGrant,
toRuntimeWorktreeRelativePath,
type DocPreviewGrant
} from './doc-preview-grant-registry'
const sshOwner = { kind: 'ssh', connectionId: 'ssh-1' } as const
function mintPosixGrant(root = '/srv/repo/docs'): DocPreviewGrant {
return mintDocPreviewGrant({
owner: sshOwner,
root,
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
}
beforeEach(() => {
revokeAllDocPreviewGrants()
})
describe('doc preview grants', () => {
it('mints unguessable ids and looks them up', () => {
const first = mintPosixGrant()
const second = mintPosixGrant()
expect(first.id).toMatch(/^[0-9a-f]{32}$/)
expect(first.id).not.toBe(second.id)
expect(getDocPreviewGrant(first.id)).toBe(first)
})
it('returns nothing for an unknown or revoked grant', () => {
const grant = mintPosixGrant()
expect(getDocPreviewGrant('0'.repeat(32))).toBeNull()
expect(revokeDocPreviewGrant(grant.id)).toBe(true)
expect(getDocPreviewGrant(grant.id)).toBeNull()
expect(revokeDocPreviewGrant(grant.id)).toBe(false)
})
})
describe('resolveDocPreviewTargetPath', () => {
it('resolves paths inside the grant root', () => {
const grant = mintPosixGrant()
expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html')
expect(resolveDocPreviewTargetPath(grant, 'assets/logo.png')).toBe(
'/srv/repo/docs/assets/logo.png'
)
})
it('refuses parent traversal, absolute escapes and empty paths', () => {
const grant = mintPosixGrant()
expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull()
expect(resolveDocPreviewTargetPath(grant, 'assets/../../secret.env')).toBeNull()
expect(resolveDocPreviewTargetPath(grant, '..')).toBeNull()
expect(resolveDocPreviewTargetPath(grant, '')).toBeNull()
expect(resolveDocPreviewTargetPath(grant, 'a//b')).toBeNull()
})
it('refuses backslash and NUL segments that could re-split on the owning host', () => {
const grant = mintPosixGrant()
expect(resolveDocPreviewTargetPath(grant, '..\\secret.env')).toBeNull()
expect(resolveDocPreviewTargetPath(grant, 'index.html\0.png')).toBeNull()
})
// Why this is a traversal test and not a containment test: every request path that names a
// sibling directory has to climb out of the root first, so the `..` segment guard answers it
// before the prefix check runs. Sibling containment is exercised where it is reachable —
// against a canonicalized path, below.
it('refuses a sibling directory by refusing the traversal that reaches it', () => {
const grant = mintPosixGrant()
expect(resolveDocPreviewTargetPath(grant, '../docs-private/secret.html')).toBeNull()
})
it('keeps a Windows drive root addressable instead of turning it drive-relative', () => {
const grant = mintDocPreviewGrant({
owner: sshOwner,
root: 'C:\\',
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('C:\\index.html')
})
it('follows the owning host path flavor rather than this process platform', () => {
const windowsGrant = mintDocPreviewGrant({
owner: sshOwner,
root: 'C:\\srv\\repo\\docs',
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
expect(resolveDocPreviewTargetPath(windowsGrant, 'assets/logo.png')).toBe(
'C:\\srv\\repo\\docs\\assets\\logo.png'
)
expect(resolveDocPreviewTargetPath(windowsGrant, '../secret.env')).toBeNull()
})
it('normalizes a trailing separator on the root', () => {
const grant = mintDocPreviewGrant({
owner: sshOwner,
root: '/srv/repo/docs/',
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html')
expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull()
})
})
describe('resolveCanonicalDocPreviewPath', () => {
// Why here and not above: a canonical path is the one input that can name a sibling directory
// without traversing — the host resolved a symlink to it — so this is where the prefix check
// is the only thing standing between the grant and `/srv/repo/docs-private`.
it('refuses a canonical path in a sibling directory that shares the root prefix', async () => {
const grant = mintPosixGrant()
await expect(
resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) =>
path === grant.root ? path : '/srv/repo/docs-private/secret.html'
)
).resolves.toBeNull()
})
it('answers the canonical path when it stays inside the canonical root', async () => {
const grant = mintPosixGrant()
await expect(
resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) => path)
).resolves.toBe('/srv/repo/docs/report.html')
})
})
describe('toRuntimeWorktreeRelativePath', () => {
it('produces a worktree-relative path for files inside the worktree', () => {
expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo/docs/index.html')).toBe(
'docs/index.html'
)
})
it('rejects paths outside the worktree, which files.read cannot address', () => {
expect(toRuntimeWorktreeRelativePath('/srv/repo', '/tmp/agent/report.html')).toBeNull()
expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo')).toBeNull()
})
it('uses Windows semantics for a Windows worktree root', () => {
expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'C:\\srv\\repo\\docs\\index.html')).toBe(
'docs/index.html'
)
expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'D:\\other\\index.html')).toBeNull()
})
})
@@ -0,0 +1,208 @@
import { randomBytes } from 'node:crypto'
import { posix, win32 } from 'node:path'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
/**
* A preview grant is the only authority that turns an `orca-preview://` request
* into bytes: it names the host that owns the file and the single directory
* subtree requests may resolve inside. No grant, no bytes.
*/
export type DocPreviewOwner =
| { kind: 'ssh'; connectionId: string }
| {
kind: 'runtime'
environmentId: string
/** Selector the runtime resolves `files.read` against. */
worktreeSelector: string
/** Worktree root on the runtime host; `files.read` only accepts paths inside it. */
worktreeRoot: string
}
export type DocPreviewGrant = {
id: string
owner: DocPreviewOwner
/** Containing directory of the opened document, on the owning host. */
root: string
/** Path of the opened document relative to `root`. */
entryRelativePath: string
/**
* Browser page the reader opened this document in. Main registers the guest under it once the
* guest commits to the grant, so the surface a tool names is the page the reader is looking at
* and not the grant, which a re-mint replaces underneath the same page.
*/
browserPageId: string
}
const grantsById = new Map<string, DocPreviewGrant>()
function pathFlavorFor(root: string): typeof posix | typeof win32 {
return isWindowsAbsolutePathLike(root) ? win32 : posix
}
function normalizeRootPath(root: string): string {
const flavor = pathFlavorFor(root)
const normalized =
flavor === win32 ? flavor.normalize(root.replace(/\//g, '\\')) : flavor.normalize(root)
// Why: `C:\` is the whole root, and trimming its separator would make win32.join answer the
// drive-relative `C:x`, which resolves against the host's cwd instead of inside the grant.
if (flavor === win32 && /^[a-zA-Z]:\\$/.test(normalized)) {
return normalized
}
// Why: a trailing separator would make the containment prefix check accept a sibling directory.
return normalized.length > 1 && normalized.endsWith(flavor.sep)
? normalized.slice(0, -1)
: normalized
}
export function mintDocPreviewGrant(params: {
owner: DocPreviewOwner
root: string
entryRelativePath: string
browserPageId: string
}): DocPreviewGrant {
const grant: DocPreviewGrant = {
id: randomBytes(16).toString('hex'),
owner: params.owner,
root: normalizeRootPath(params.root),
entryRelativePath: params.entryRelativePath.replace(/\\/g, '/'),
browserPageId: params.browserPageId
}
grantsById.set(grant.id, grant)
return grant
}
export function getDocPreviewGrant(grantId: string): DocPreviewGrant | null {
return grantsById.get(grantId) ?? null
}
/**
* Why anything listens at all: a grant is the only thing that names a preview's lifetime. State
* elsewhere in main keyed by a preview's tool target — grab intent, a queued grab chain — has no
* other signal telling it the surface is gone, and would otherwise accrete one entry per grant
* for the life of the process.
*/
const revocationListeners = new Set<(grant: DocPreviewGrant) => void>()
/** Why the whole grant and not its id: it is already gone from the registry when listeners run. */
export function onDocPreviewGrantRevoked(listener: (grant: DocPreviewGrant) => void): () => void {
revocationListeners.add(listener)
return () => revocationListeners.delete(listener)
}
function notifyRevoked(grant: DocPreviewGrant): void {
for (const listener of revocationListeners) {
listener(grant)
}
}
export function revokeDocPreviewGrant(grantId: string): boolean {
canonicalRootByGrantId.delete(grantId)
const grant = grantsById.get(grantId)
if (!grant) {
return false
}
grantsById.delete(grantId)
notifyRevoked(grant)
return true
}
export function revokeAllDocPreviewGrants(): void {
canonicalRootByGrantId.clear()
const revoked = [...grantsById.values()]
grantsById.clear()
for (const grant of revoked) {
notifyRevoked(grant)
}
}
function hasUnsafeSegment(segments: string[]): boolean {
return segments.some(
(segment) =>
segment.length === 0 ||
segment === '.' ||
segment === '..' ||
segment.includes('\0') ||
segment.includes('\\')
)
}
/**
* Resolves a request path to an absolute path on the owning host, or null when
* it would escape the grant's root. Path flavor follows the root (the owning
* host may be Windows while this client is not), never `process.platform`.
*/
export function resolveDocPreviewTargetPath(
grant: DocPreviewGrant,
relativePath: string
): string | null {
const segments = relativePath.split('/').filter((segment, index, all) => {
// Why: keep empty segments visible to the safety check except a single trailing one from `dir/`.
return !(segment === '' && index === all.length - 1)
})
if (segments.length === 0 || hasUnsafeSegment(segments)) {
return null
}
const flavor = pathFlavorFor(grant.root)
const resolved = flavor.normalize(flavor.join(grant.root, ...segments))
return isInsideRoot(grant.root, resolved, flavor) ? resolved : null
}
function isInsideRoot(
root: string,
candidate: string,
flavor: typeof posix | typeof win32
): boolean {
const rootPrefix = root.endsWith(flavor.sep) ? root : `${root}${flavor.sep}`
return candidate.startsWith(rootPrefix)
}
/** Why: realpath is a host round-trip, and a grant's root is fixed for its lifetime. */
const canonicalRootByGrantId = new Map<string, Promise<string>>()
/**
* Second containment pass for hosts where the lexical one is not enough: a symlink
* inside the root can point anywhere, and the SSH read RPC applies no root of its
* own. Both sides are canonicalized on the owning host before the prefix re-check;
* a host that cannot canonicalize a path answers nothing.
*/
export async function resolveCanonicalDocPreviewPath(
grant: DocPreviewGrant,
absolutePath: string,
realpath: (path: string) => Promise<string>
): Promise<string | null> {
try {
let canonicalRoot = canonicalRootByGrantId.get(grant.id)
if (!canonicalRoot) {
canonicalRoot = realpath(grant.root).then(normalizeRootPath)
canonicalRootByGrantId.set(grant.id, canonicalRoot)
}
const [root, canonicalPath] = await Promise.all([canonicalRoot, realpath(absolutePath)])
const flavor = pathFlavorFor(root)
return isInsideRoot(root, canonicalPath, flavor) ? canonicalPath : null
} catch {
// Why: a root that no longer canonicalizes must not fall back to the lexical answer.
canonicalRootByGrantId.delete(grant.id)
return null
}
}
/**
* Path a runtime `files.read` can address, i.e. relative to the worktree root.
* Returns null when the grant root sits outside the worktree — the runtime file
* RPCs are worktree-scoped, so those documents are unreadable client-side.
*/
export function toRuntimeWorktreeRelativePath(
worktreeRoot: string,
absolutePath: string
): string | null {
const flavor = pathFlavorFor(worktreeRoot)
const normalizedRoot = normalizeRootPath(worktreeRoot)
const relative = flavor.relative(normalizedRoot, absolutePath)
if (!relative || relative === '..' || relative.startsWith(`..${flavor.sep}`)) {
return null
}
if (flavor === win32 && /^[a-zA-Z]:/.test(relative)) {
return null
}
return relative.replace(/\\/g, '/')
}
@@ -0,0 +1,550 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
getWorkspaceDocPageGuest,
installDocPreviewGuestPolicy,
readDocPreviewGuestBoundGrantId,
reportDocPreviewLinkClick
} from './doc-preview-guest-policy'
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
import {
mintDocPreviewGrant,
revokeAllDocPreviewGrants,
revokeDocPreviewGrant
} from './doc-preview-grant-registry'
type GuestHandlers = Record<string, (...args: never[]) => void>
const HOST_RENDERER_ID = 42
function installOnFakeGuest(
hostId: number = HOST_RENDERER_ID,
/** What the guest is already showing when the embedder hands it over, as a real one usually is. */
initialUrl = ''
): {
contents: object
handlers: GuestHandlers
hostId: number
isFocused: ReturnType<typeof vi.fn>
send: ReturnType<typeof vi.fn>
setWebRTCIPHandlingPolicy: ReturnType<typeof vi.fn>
windowOpenHandler: (details: { url: string }) => { action: string }
/** Why without the `destroyed` event: Chromium tears the contents down before main runs it. */
markContentsDestroyed: () => void
} {
const handlers: GuestHandlers = {}
let contentsDestroyed = false
const send = vi.fn()
const isFocused = vi.fn(() => true)
const setWebRTCIPHandlingPolicy = vi.fn()
let windowOpenHandler: (details: { url: string }) => { action: string } = () => ({
action: 'deny'
})
const register = (event: string, handler: (...args: never[]) => void): void => {
handlers[event] = handler
}
const guest = {
isFocused,
isDestroyed: () => contentsDestroyed,
getURL: () => initialUrl,
on: vi.fn(register),
once: vi.fn(register),
setWindowOpenHandler: vi.fn((handler: (details: { url: string }) => { action: string }) => {
windowOpenHandler = handler
}),
setWebRTCIPHandlingPolicy
}
installDocPreviewGuestPolicy(guest as never, { id: hostId, send })
return {
contents: guest,
handlers,
hostId,
isFocused,
send,
setWebRTCIPHandlingPolicy,
windowOpenHandler: (details) => windowOpenHandler(details),
markContentsDestroyed: () => {
contentsDestroyed = true
}
}
}
type FakeGuest = ReturnType<typeof installOnFakeGuest>
function startMainFrameNavigation(guest: FakeGuest, url: string): void {
guest.handlers['did-start-navigation']?.({ url, isMainFrame: true } as never)
}
/** A guest already showing a document, which is the only state a link can be pressed in. */
function boundGuest(): { grant: ReturnType<typeof mintGrant>; guest: FakeGuest } {
const grant = mintGrant()
const guest = installOnFakeGuest()
startMainFrameNavigation(guest, buildDocPreviewUrl(grant.id, 'index.html'))
return { grant, guest }
}
function reportClick(guest: FakeGuest, url: string): void {
reportDocPreviewLinkClick(guest.contents as never, url)
}
// Why a fresh page per grant: the registry is keyed by the page now, so two grants sharing one
// would have the second silently replace the first rather than stand beside it.
let nextDocPageOrdinal = 0
function mintGrant(): ReturnType<typeof mintDocPreviewGrant> {
nextDocPageOrdinal += 1
return mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: `doc-page-${nextDocPageOrdinal}`
})
}
beforeEach(() => {
vi.clearAllMocks()
revokeAllDocPreviewGrants()
})
describe('doc preview guest policy', () => {
it('allows relative navigation within the bound grant', () => {
const { grant, guest } = boundGuest()
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.(
{ preventDefault } as never,
buildDocPreviewUrl(grant.id, 'guide.html') as never
)
expect(preventDefault).not.toHaveBeenCalled()
expect(guest.send).not.toHaveBeenCalled()
})
// Why an unlatched guest is refused rather than trusted: the renderer-set src is
// browser-initiated and never reaches will-navigate, so a navigation arriving before the latch is
// one the guest started for itself.
it('blocks a navigation the guest starts before a document has bound it', () => {
const grant = mintGrant()
const guest = installOnFakeGuest()
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.(
{ preventDefault } as never,
buildDocPreviewUrl(grant.id, 'index.html') as never
)
expect(preventDefault).toHaveBeenCalledOnce()
expect(guest.send).not.toHaveBeenCalled()
})
it('blocks navigation into a different live grant once bound', () => {
const { guest } = boundGuest()
const otherGrant = mintGrant()
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.(
{ preventDefault } as never,
buildDocPreviewUrl(otherGrant.id, 'index.html') as never
)
expect(preventDefault).toHaveBeenCalledOnce()
})
it('blocks a revoked grant even when it matches the bound id', () => {
const { grant, guest } = boundGuest()
revokeAllDocPreviewGrants()
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.(
{ preventDefault } as never,
buildDocPreviewUrl(grant.id, 'guide.html') as never
)
expect(preventDefault).toHaveBeenCalledOnce()
})
it('blocks an external navigation without offering it to the renderer', () => {
const { guest } = boundGuest()
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.({ preventDefault } as never, 'https://example.com/' as never)
expect(preventDefault).toHaveBeenCalledOnce()
expect(guest.send).not.toHaveBeenCalled()
})
it('blocks a file: navigation', () => {
const { guest } = boundGuest()
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.({ preventDefault } as never, 'file:///etc/passwd' as never)
expect(preventDefault).toHaveBeenCalledOnce()
expect(guest.send).not.toHaveBeenCalled()
})
it('applies the same rule to redirects', () => {
const { guest } = boundGuest()
const preventDefault = vi.fn()
guest.handlers['will-redirect']?.({ preventDefault } as never, 'https://evil.test/' as never)
expect(preventDefault).toHaveBeenCalledOnce()
expect(guest.send).not.toHaveBeenCalled()
})
it('denies every popup and routes none of them', () => {
const { guest } = boundGuest()
expect(guest.windowOpenHandler({ url: 'https://example.com/docs' })).toEqual({
action: 'deny'
})
expect(guest.windowOpenHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' })
expect(guest.send).not.toHaveBeenCalled()
})
// Why: will-navigate never fires for a subframe, so an <iframe src="https://…"> would load
// off-machine even though the top frame cannot.
it('blocks a subframe navigating outside the grant, without opening a tab for it', () => {
const { guest } = boundGuest()
const preventDefault = vi.fn()
guest.handlers['will-frame-navigate']?.({
preventDefault,
url: 'https://tracker.test/pixel',
isMainFrame: false
} as never)
expect(preventDefault).toHaveBeenCalledOnce()
expect(guest.send).not.toHaveBeenCalled()
})
it('lets a subframe load an in-grant asset', () => {
const { grant, guest } = boundGuest()
const preventDefault = vi.fn()
guest.handlers['will-frame-navigate']?.({
preventDefault,
url: buildDocPreviewUrl(grant.id, 'chart.html'),
isMainFrame: false
} as never)
expect(preventDefault).not.toHaveBeenCalled()
})
// Why this group exists: the external-link route ends in a real browser tab with full network, so
// a document that can read its grant and reach that route can exfiltrate it. Only a reader's own
// press on a link may, and only the guest's preload can tell that a press was one.
describe('the trusted-click route out of the preview', () => {
// The headline. The earlier design read a recent-input timestamp, so a script that navigated
// shortly after any genuine press was routed out as if the press had asked for it. Here the
// navigation sinks route nothing at all, so when the input happened cannot matter.
it('routes nothing for a scripted location change or window.open, whenever input happened', () => {
const { guest } = boundGuest()
const preventDefault = vi.fn()
// Genuine input the guest would report, delivered immediately before the document acts.
guest.handlers['input-event']?.({} as never, { type: 'mouseDown' } as never)
guest.handlers['before-input-event']?.({} as never, { type: 'keyDown' } as never)
guest.handlers['will-navigate']?.(
{ preventDefault } as never,
'https://attacker.test/?d=secret' as never
)
const popup = guest.windowOpenHandler({ url: 'https://attacker.test/?d=secret' })
expect(preventDefault).toHaveBeenCalledOnce()
expect(popup).toEqual({ action: 'deny' })
expect(guest.send).not.toHaveBeenCalled()
// Why assert the absence of the listeners too: with them gone there is no timing a document
// could hit, rather than a window it merely failed to hit in this test.
expect(guest.handlers['input-event']).toBeUndefined()
expect(guest.handlers['before-input-event']).toBeUndefined()
})
it('routes a reported click on an external link exactly once', () => {
const { guest } = boundGuest()
reportClick(guest, 'https://example.com/docs')
expect(guest.send).toHaveBeenCalledExactlyOnceWith('docPreview:externalLink', {
url: 'https://example.com/docs'
})
})
it('drops a click reported while the guest is not the contents the reader is looking at', () => {
const { guest } = boundGuest()
guest.isFocused.mockReturnValue(false)
reportClick(guest, 'https://example.com/docs')
expect(guest.send).not.toHaveBeenCalled()
})
it('drops a click reported by a sender that is not a preview guest', () => {
const { guest } = boundGuest()
reportDocPreviewLinkClick({ isFocused: () => true } as never, 'https://example.com/docs')
expect(guest.send).not.toHaveBeenCalled()
})
it('drops a click reported by a guest that never bound a grant', () => {
mintGrant()
const guest = installOnFakeGuest()
reportClick(guest, 'https://example.com/docs')
expect(guest.send).not.toHaveBeenCalled()
})
it("drops a click once the guest's bound grant is revoked", () => {
const { guest } = boundGuest()
revokeAllDocPreviewGrants()
reportClick(guest, 'https://example.com/docs')
expect(guest.send).not.toHaveBeenCalled()
})
it.each([
'file:///etc/passwd',
'javascript:fetch("https://attacker.test")',
'orca-preview://a/b',
'/Users/alice/secrets.txt',
''
])('drops a reported click on %s, which is not the web', (url) => {
const { guest } = boundGuest()
reportClick(guest, url)
expect(guest.send).not.toHaveBeenCalled()
})
it('stops routing for a guest that has been destroyed', () => {
const { guest } = boundGuest()
guest.handlers['destroyed']?.()
reportClick(guest, 'https://example.com/docs')
expect(guest.send).not.toHaveBeenCalled()
})
})
// Why: a peer connection is UDP straight off the network stack — the response CSP and the
// session's request filter both miss it, so this is the only place it can be refused.
it('denies the guest non-proxied WebRTC UDP at attach', () => {
const guest = installOnFakeGuest()
expect(guest.setWebRTCIPHandlingPolicy).toHaveBeenCalledWith('disable_non_proxied_udp')
})
// Why: latching from a subframe would let an in-document iframe decide which grant the guest
// belongs to, and every later main-frame check would be measured against that.
it('binds the guest from the main frame only', () => {
const grant = mintGrant()
const otherGrant = mintGrant()
const guest = installOnFakeGuest()
guest.handlers['did-start-navigation']?.({
url: buildDocPreviewUrl(otherGrant.id, 'index.html'),
isMainFrame: false
} as never)
startMainFrameNavigation(guest, buildDocPreviewUrl(grant.id, 'index.html'))
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.(
{ preventDefault } as never,
buildDocPreviewUrl(otherGrant.id, 'index.html') as never
)
expect(preventDefault).toHaveBeenCalledOnce()
})
describe('tool authorization', () => {
it('answers the hosting renderer for a guest bound to a live grant', () => {
const { grant, guest } = boundGuest()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
})
it('refuses a renderer that does not host the preview', () => {
const { grant } = boundGuest()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID + 1)).toBeNull()
})
// Why this is the ordinary case and not an edge one: the embedder hands the guest over after
// it has already started loading its src, so the navigation that binds most previews to their
// grant happens before any listener here exists. Missing it leaves the tools unable to name
// the guest for as long as the reader stays on the page they opened.
it('binds a guest that is already showing its document when the policy installs', () => {
const grant = mintGrant()
const guest = installOnFakeGuest(HOST_RENDERER_ID, buildDocPreviewUrl(grant.id, 'index.html'))
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
})
// The same miss, one event later: the load started before the listener and commits after it.
it('binds a guest whose first navigation only reaches the commit', () => {
const grant = mintGrant()
const guest = installOnFakeGuest()
guest.handlers['did-navigate']?.(
{} as never,
buildDocPreviewUrl(grant.id, 'index.html') as never
)
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
})
// Why: the src commit is what proves this guest is showing that grant. Before it, the id names
// a document nothing has been asked to render.
it('refuses a guest that has not committed a document yet', () => {
const grant = mintGrant()
installOnFakeGuest()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
})
// Why: revoking is how a closed tab withdraws its preview, and it happens while the guest is
// still being torn down — a tool must stop answering at revoke, not at destroy.
it('stops answering once the grant is revoked', () => {
const { grant } = boundGuest()
revokeDocPreviewGrant(grant.id)
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
})
it('drops the guest when it is destroyed', () => {
const { grant, guest } = boundGuest()
guest.handlers['destroyed']?.()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
})
// Why this is separate from the destroy event: the contents die before main runs that listener,
// so in that window the registration still looks live and only this check refuses it.
it('refuses a guest whose contents died before the destroy event arrived', () => {
const { grant, guest } = boundGuest()
guest.markContentsDestroyed()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
})
it('answers nothing for a grant no preview ever rendered', () => {
const grant = mintGrant()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
})
// Why this is what makes three latch call sites safe: install, did-start-navigation and
// did-navigate all feed the same latch, so without the no-rebind guard the second and third
// would let a later navigation move a bound guest onto another grant — and a tool asking for
// that grant would be handed a guest showing someone else's document.
it('never rebinds a bound guest onto a second grant', () => {
const { grant, guest } = boundGuest()
const other = mintGrant()
startMainFrameNavigation(guest, buildDocPreviewUrl(other.id, 'index.html'))
guest.handlers['did-navigate']?.(
{} as never,
buildDocPreviewUrl(other.id, 'index.html') as never
)
expect(getWorkspaceDocPageGuest(other.browserPageId, HOST_RENDERER_ID)).toBeNull()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
})
// Why the replacement is registered before the old guest's teardown runs: a re-mint attaches
// the new guest first, and Chromium runs the outgoing guest's destroyed listener afterwards. A
// teardown that deleted by page alone would unregister the guest the reader is now looking at.
it('leaves a re-minted preview registered when the guest it replaced tears down', () => {
const first = mintGrant()
const outgoing = installOnFakeGuest(
HOST_RENDERER_ID,
buildDocPreviewUrl(first.id, 'index.html')
)
const remint = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: first.browserPageId
})
const incoming = installOnFakeGuest(
HOST_RENDERER_ID,
buildDocPreviewUrl(remint.id, 'index.html')
)
expect(getWorkspaceDocPageGuest(first.browserPageId, HOST_RENDERER_ID)).toBe(
incoming.contents
)
outgoing.handlers['destroyed']?.()
expect(getWorkspaceDocPageGuest(first.browserPageId, HOST_RENDERER_ID)).toBe(
incoming.contents
)
})
// Why the grant has to be live at the latch and not only at each later request: registering the
// page a dead grant names would put a guest that can read nothing into the registry the tool
// door answers from, under a page a live grant may later want.
it('registers nothing for a guest showing a grant that is already revoked', () => {
const grant = mintGrant()
revokeDocPreviewGrant(grant.id)
const guest = installOnFakeGuest(HOST_RENDERER_ID, buildDocPreviewUrl(grant.id, 'index.html'))
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
// The presence half: the same install does register when the grant is still live.
const live = mintGrant()
installOnFakeGuest(HOST_RENDERER_ID, buildDocPreviewUrl(live.id, 'index.html'))
expect(getWorkspaceDocPageGuest(live.browserPageId, HOST_RENDERER_ID)).not.toBeNull()
expect(guest.contents).toBeDefined()
})
// Why both directions: two previews open at once must not be able to drive each other's guest.
it('keeps two live previews on their own guests', () => {
const first = boundGuest()
const second = boundGuest()
expect(getWorkspaceDocPageGuest(first.grant.browserPageId, HOST_RENDERER_ID)).toBe(
first.guest.contents
)
expect(getWorkspaceDocPageGuest(second.grant.browserPageId, HOST_RENDERER_ID)).toBe(
second.guest.contents
)
})
})
// Session events name the contents and nothing else, so this is how a fence firing on the whole
// partition works out which preview — if any — the reader should be told about.
describe('naming the grant a contents is showing', () => {
it('answers the bound grant for a live preview guest', () => {
const { grant, guest } = boundGuest()
expect(readDocPreviewGuestBoundGrantId(guest.contents as never)).toBe(grant.id)
})
it('answers nothing for a contents that is not a preview guest', () => {
boundGuest()
expect(readDocPreviewGuestBoundGrantId({} as never)).toBeNull()
})
it('answers nothing for a preview guest that has committed no document', () => {
const guest = installOnFakeGuest()
expect(readDocPreviewGuestBoundGrantId(guest.contents as never)).toBeNull()
})
it('answers nothing once the guest is gone', () => {
const { guest } = boundGuest()
guest.handlers['destroyed']?.()
expect(readDocPreviewGuestBoundGrantId(guest.contents as never)).toBeNull()
})
})
})
@@ -0,0 +1,247 @@
import {
DOC_PREVIEW_EXTERNAL_LINK_CHANNEL,
parseDocPreviewUrl
} from '../../shared/doc-preview-scheme'
import { normalizeExternalBrowserUrl } from '../../shared/browser-url'
import { enforceBrowserRouteWebRtcPolicy } from './browser-route-webrtc-policy'
import { getDocPreviewGrant } from './doc-preview-grant-registry'
/** The trusted renderer hosting the preview: the sink for link reports and the only sender allowed to drive tools on it. */
type PreviewHostRenderer = {
id: number
send: (channel: string, payload: { url: string }) => void
}
type PreviewGuestRegistration = {
host: PreviewHostRenderer
readBoundGrantId: () => string | null
isFocused: () => boolean
}
/**
* Why a registry: the click report arrives on an IPC channel, where the only thing main holds is
* the sender. Without this, "is this a live preview guest, and which grant is it bound to" has no
* answer, and any WebContents that learned the channel name would be routed out.
*/
const previewGuests = new WeakMap<object, PreviewGuestRegistration>()
/**
* The workspace-document half of the browser page registry, keyed by the page the reader opened
* the document in. It is deliberately a separate map from the browsing one: page management,
* agent commands, download routing and certificate attribution all read that map directly, in
* more places than a guard could be remembered in, so a document guest is simply not in it. The
* one door taught about both is `browserManager.getAuthorizedGuest`, because acting on the guest
* the reader is looking at is the single operation that legitimately spans them.
*/
const docGuestsByPageId = new Map<
string,
{ guest: Electron.WebContents; hostId: number; grantId: string }
>()
const registrationListeners = new Set<(browserPageId: string) => void>()
/** Why anything listens: a tool request can beat the guest's own attach, and the wait it parks in lives in the IPC layer. */
export function onWorkspaceDocGuestRegistered(
listener: (browserPageId: string) => void
): () => void {
registrationListeners.add(listener)
return () => registrationListeners.delete(listener)
}
/**
* Resolve the document guest a browser tool should act on. Grants the guest nothing — it answers
* only whether the trusted renderer asking is the one hosting a live, grant-bound preview on that
* page. Reached only through `browserManager.getAuthorizedGuest`.
*/
export function getWorkspaceDocPageGuest(
browserPageId: string,
senderWebContentsId: number
): Electron.WebContents | null {
const registration = docGuestsByPageId.get(browserPageId)
if (!registration || registration.hostId !== senderWebContentsId) {
return null
}
// Why: revocation is how a closed tab withdraws its preview, and it happens before the guest is torn down.
if (!getDocPreviewGrant(registration.grantId)) {
return null
}
if (registration.guest.isDestroyed()) {
docGuestsByPageId.delete(browserPageId)
return null
}
return registration.guest
}
/** True when the page renders a workspace document, so the browsing registry must never hold it. */
export function isWorkspaceDocPageId(browserPageId: string): boolean {
return docGuestsByPageId.has(browserPageId)
}
/**
* The grant a live preview guest is bound to, or null for any other WebContents — the same identity
* question `reportDocPreviewLinkClick` asks, for a session event that hands over only the contents.
*/
export function readDocPreviewGuestBoundGrantId(guest: Electron.WebContents): string | null {
return previewGuests.get(guest)?.readBoundGrantId() ?? null
}
/**
* The preview guest renders a workspace document, not the web: it may only move within its own
* grant, and nothing it does by itself leaves the preview. The one route out is
* `reportDocPreviewLinkClick`, which answers for a click the reader really made.
*
* Reached only through `browserManager.attachGuestPolicies` under the workspace-doc profile, so
* every guest in the app is policy-attached by one door whatever it renders. Returns the disposal
* that door stores: without it a retired guest keeps its listeners and stays answerable through the
* grant-keyed authority until the WebContents itself is collected.
*/
export function installDocPreviewGuestPolicy(
guest: Electron.WebContents,
host: PreviewHostRenderer
): () => void {
// Why: the first commit is the renderer-set src, already admitted by will-attach-webview;
// latching it pins every later navigation to that one grant.
let boundGrantId: string | null = null
let boundPageId: string | null = null
const latchGrantFromUrl = (rawUrl: string): void => {
if (boundGrantId !== null) {
return
}
const grantId = parseDocPreviewUrl(rawUrl)?.grantId ?? null
// Why the grant must still be live: registering under the page a dead grant names would put a
// guest nothing can read into the registry the tool door answers from.
const grant = grantId === null ? null : getDocPreviewGrant(grantId)
if (!grant) {
return
}
boundGrantId = grant.id
boundPageId = grant.browserPageId
docGuestsByPageId.set(boundPageId, { guest, hostId: host.id, grantId: grant.id })
for (const listener of registrationListeners) {
listener(boundPageId)
}
}
previewGuests.set(guest, {
host,
readBoundGrantId: () => boundGrantId,
isFocused: () => guest.isFocused()
})
const forgetGuest = (): void => {
previewGuests.delete(guest)
// Why the identity check: a re-mint registers the replacement under the same page before this
// guest's own teardown runs, and deleting by key alone would unregister the live one.
if (boundPageId !== null && docGuestsByPageId.get(boundPageId)?.guest === guest) {
docGuestsByPageId.delete(boundPageId)
}
}
guest.once('destroyed', forgetGuest)
const isAllowedPreviewNavigation = (rawUrl: string): boolean => {
const target = parseDocPreviewUrl(rawUrl)
if (!target || !getDocPreviewGrant(target.grantId)) {
return false
}
// Why the latch is required and not just consistent: the renderer-set src is browser-initiated,
// so will-navigate never fires for it. Anything reaching here before the latch is the guest
// moving itself, which no grant has admitted yet.
return boundGrantId !== null && target.grantId === boundGrantId
}
/**
* Why deny-only, with nothing routed: a navigation the guest starts cannot be attributed to the
* reader. The document may read its whole grant over `connect-src 'self'`, so routing an
* unattributable URL to a real browser tab with full network is how those bytes would get out.
*/
const navigationGuard = (event: Electron.Event, url: string): void => {
if (isAllowedPreviewNavigation(url)) {
return
}
event.preventDefault()
}
// Why here and not only on navigation events: the guest is already loading its src by the time
// the embedder hands it to us, so the only navigation most previews ever make has already
// started. Latching what it is on now is what binds the usual preview to its grant at all.
latchGrantFromUrl(guest.getURL())
const latchFromMainFrameNavigation = (details: { isMainFrame: boolean; url: string }): void => {
// Why: only the top document defines which grant this guest belongs to. Latching from a
// subframe would let an in-document iframe rebind the guest to another grant.
if (!details.isMainFrame) {
return
}
latchGrantFromUrl(details.url)
}
// Why a committed URL too: a navigation that started before this listener existed still commits
// after it, and a preview that never navigates again would otherwise stay bound to nothing.
const latchFromCommittedUrl = (_event: Electron.Event, url: string): void =>
latchGrantFromUrl(url)
// Why: will-navigate never fires for a subframe, so without this an <iframe src="https://…">
// inside a previewed document would load off-machine even though the top frame cannot.
const frameNavigationGuard = (details: {
isMainFrame: boolean
url: string
preventDefault: () => void
}): void => {
if (details.isMainFrame || isAllowedPreviewNavigation(details.url)) {
return
}
details.preventDefault()
}
guest.on('did-start-navigation', latchFromMainFrameNavigation)
guest.on('did-navigate', latchFromCommittedUrl)
guest.on('will-navigate', navigationGuard)
guest.on('will-redirect', navigationGuard)
guest.on('will-frame-navigate', frameNavigationGuard)
// Why deny with nothing routed: previews own no native child windows, and a popup the document
// asked for is the document asking, not the reader. A link the reader presses is intercepted
// before Chromium ever considers a popup.
guest.setWindowOpenHandler(() => ({ action: 'deny' }))
// Why a second fence for one API: WebRTC opens UDP straight from the network stack, so neither the
// response CSP nor the session's request filter ever sees it. This is the only layer that can.
enforceBrowserRouteWebRtcPolicy(guest, () => {})
return () => {
forgetGuest()
if (guest.isDestroyed()) {
return
}
guest.off('destroyed', forgetGuest)
guest.off('did-start-navigation', latchFromMainFrameNavigation)
guest.off('did-navigate', latchFromCommittedUrl)
guest.off('will-navigate', navigationGuard)
guest.off('will-redirect', navigationGuard)
guest.off('will-frame-navigate', frameNavigationGuard)
}
}
function isWebUrl(url: string): boolean {
return url.startsWith('http://') || url.startsWith('https://')
}
/**
* The only way a URL leaves a preview. Every condition is load-bearing: the sender must be a live
* preview guest still bound to a grant, it must be the contents the reader is looking at, and the
* target must be the web. Anything else is dropped without a trace the document could observe.
*/
export function reportDocPreviewLinkClick(sender: Electron.WebContents, rawUrl: string): void {
const registration = previewGuests.get(sender)
if (!registration) {
return
}
const boundGrantId = registration.readBoundGrantId()
if (boundGrantId === null || !getDocPreviewGrant(boundGrantId)) {
return
}
// Why focus and not just the preload's trusted-click check: that check runs inside the guest, so
// it holds only while the guest renderer does. Focus is the half main can verify for itself.
if (!registration.isFocused()) {
return
}
const externalUrl = normalizeExternalBrowserUrl(rawUrl)
if (!externalUrl || !isWebUrl(externalUrl)) {
return
}
registration.host.send(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, { url: externalUrl })
}
@@ -0,0 +1,336 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
readDocPreviewFile: vi.fn(),
installBrowserSessionPartitionPolicies: vi.fn()
}))
function createFakeSession(): {
protocol: { isProtocolHandled: () => boolean; handle: ReturnType<typeof vi.fn> }
webRequest: { onBeforeRequest: ReturnType<typeof vi.fn> }
} {
return {
protocol: { isProtocolHandled: () => false, handle: vi.fn() },
webRequest: { onBeforeRequest: vi.fn() }
}
}
// Why one session per partition and a distinct default: installing the handler on the default
// session instead of the preview session is otherwise invisible — every read would come back
// from the same object.
const previewSession = createFakeSession()
const defaultSession = createFakeSession()
vi.mock('electron', () => ({
protocol: { registerSchemesAsPrivileged: vi.fn() },
session: {
get defaultSession() {
return defaultSession
},
fromPartition: (partition: string) => {
if (partition !== 'orca-doc-preview') {
throw new Error(`unexpected partition ${partition}`)
}
return previewSession
}
}
}))
vi.mock('./doc-preview-file-reader', () => ({ readDocPreviewFile: mocks.readDocPreviewFile }))
vi.mock('./browser-session-partition-policies', () => ({
installBrowserSessionPartitionPolicies: mocks.installBrowserSessionPartitionPolicies
}))
import { protocol } from 'electron'
import {
getDocPreviewSession,
handleDocPreviewRequest,
installDocPreviewProtocolHandler,
isAllowedDocPreviewRequestUrl,
isDocPreviewSession,
registerDocPreviewSchemePrivileges
} from './doc-preview-protocol'
import {
mintDocPreviewGrant,
revokeAllDocPreviewGrants,
revokeDocPreviewGrant
} from './doc-preview-grant-registry'
import {
buildDocPreviewUrl,
DOC_PREVIEW_LOAD_FAILURE_CHANNEL
} from '../../shared/doc-preview-scheme'
import { setDocPreviewFailureSink } from './doc-preview-failure-notice'
function mintGrant(): ReturnType<typeof mintDocPreviewGrant> {
return mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
}
beforeEach(() => {
vi.clearAllMocks()
revokeAllDocPreviewGrants()
setDocPreviewFailureSink(null)
mocks.readDocPreviewFile.mockResolvedValue({
ok: true,
bytes: Buffer.from('<h1>hi</h1>', 'utf8'),
contentType: 'text/html; charset=utf-8'
})
})
describe('handleDocPreviewRequest', () => {
it('serves an in-grant document with no-store so reload re-reads the workspace', async () => {
const grant = mintGrant()
const response = await handleDocPreviewRequest(
new Request(buildDocPreviewUrl(grant.id, 'index.html'))
)
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('text/html; charset=utf-8')
expect(response.headers.get('Cache-Control')).toBe('no-store')
expect(await response.text()).toBe('<h1>hi</h1>')
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith(grant, 'index.html')
})
it('falls back to the granted entry document for a root request', async () => {
const grant = mintGrant()
await handleDocPreviewRequest(new Request(`orca-preview://${grant.id}/`))
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith(grant, 'index.html')
})
it('decodes percent-encoded segments before resolving', async () => {
const grant = mintGrant()
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'a b/c#d.html')))
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith(grant, 'a b/c#d.html')
})
it('404s an unknown grant without reading anything', async () => {
const response = await handleDocPreviewRequest(
new Request(`orca-preview://${'0'.repeat(32)}/index.html`)
)
expect(response.status).toBe(404)
expect(mocks.readDocPreviewFile).not.toHaveBeenCalled()
})
it('404s a revoked grant', async () => {
const grant = mintGrant()
revokeDocPreviewGrant(grant.id)
const response = await handleDocPreviewRequest(
new Request(buildDocPreviewUrl(grant.id, 'index.html'))
)
expect(response.status).toBe(404)
expect(mocks.readDocPreviewFile).not.toHaveBeenCalled()
})
it('404s a malformed grant id', async () => {
const response = await handleDocPreviewRequest(new Request('orca-preview://not-a-grant/x.html'))
expect(response.status).toBe(404)
expect(mocks.readDocPreviewFile).not.toHaveBeenCalled()
})
it('propagates the reader status for an unservable asset', async () => {
const grant = mintGrant()
mocks.readDocPreviewFile.mockResolvedValue({
ok: false,
status: 415,
reason: 'unsupported-asset',
message: 'cannot send this file type'
})
const response = await handleDocPreviewRequest(
new Request(buildDocPreviewUrl(grant.id, 'a.woff2'))
)
expect(response.status).toBe(415)
expect(await response.text()).toBe('cannot send this file type')
})
// Why: previewed documents are agent-authored, so an outbound request is an exfiltration channel
// for everything else the grant can read.
it('serves documents under a self-only content security policy', async () => {
const grant = mintGrant()
const response = await handleDocPreviewRequest(
new Request(buildDocPreviewUrl(grant.id, 'index.html'))
)
const policy = response.headers.get('Content-Security-Policy') ?? ''
expect(policy).toContain("default-src 'self'")
expect(policy).toContain("connect-src 'self'")
expect(policy).toContain("object-src 'none'")
expect(policy).toContain("img-src 'self' data:")
expect(policy).not.toContain('https:')
// Why assert an absence: `webrtc 'block'` is the obvious directive to reach for here and this
// Chromium does not implement it — it logs "Unrecognized Content-Security-Policy directive
// 'webrtc'" and gathers candidates regardless. Adding it back would document a fence that is
// not there; the guest's IP-handling policy is what actually refuses.
expect(policy).not.toContain('webrtc')
})
// Why: the guest paints a 4xx body as if it were the document, so the shell only learns the
// reason from this push.
it('pushes the failure reason for the requested path', async () => {
const send = vi.fn()
setDocPreviewFailureSink({ send })
const grant = mintGrant()
mocks.readDocPreviewFile.mockResolvedValue({
ok: false,
status: 413,
reason: 'too-large',
message: 'too large'
})
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'index.html')))
expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, {
grantId: grant.id,
relativePath: 'index.html',
reason: 'too-large'
})
})
it('pushes nothing when the document is served', async () => {
const send = vi.fn()
setDocPreviewFailureSink({ send })
const grant = mintGrant()
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'index.html')))
expect(send).not.toHaveBeenCalled()
})
// Why: a grant revoked underneath a live guest 404s like a missing file, and without this push
// the shell leaves the handler's "Not found" body on screen as if it were the document.
it('pushes a failure for a revoked grant so the shell can replace the 404 body', async () => {
const send = vi.fn()
const grant = mintGrant()
revokeDocPreviewGrant(grant.id)
setDocPreviewFailureSink({ send })
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'index.html')))
expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, {
grantId: grant.id,
relativePath: 'index.html',
reason: 'unreadable'
})
})
})
describe('installDocPreviewProtocolHandler', () => {
// Why the default session is named here: it is the session every other Electron API reaches for
// by default, and handling the scheme there would serve preview bytes to ordinary browsing.
it('handles the scheme on the preview session and nowhere else', () => {
installDocPreviewProtocolHandler()
expect(previewSession.protocol.handle).toHaveBeenCalledWith(
'orca-preview',
handleDocPreviewRequest
)
expect(previewSession.webRequest.onBeforeRequest).toHaveBeenCalled()
expect(defaultSession.protocol.handle).not.toHaveBeenCalled()
expect(defaultSession.webRequest.onBeforeRequest).not.toHaveBeenCalled()
})
it('cancels every request the preview session should never carry', () => {
installDocPreviewProtocolHandler()
const filter = previewSession.webRequest.onBeforeRequest.mock.calls.at(-1)?.[0] as (
details: { url: string },
callback: (response: { cancel: boolean }) => void
) => void
const cancelled = (url: string): boolean => {
let response: { cancel: boolean } | null = null
filter({ url }, (value) => {
response = value
})
return (response as { cancel: boolean } | null)?.cancel === true
}
expect(cancelled('https://cdn.example.com/tracker.js')).toBe(true)
expect(cancelled(`orca-preview://${'a'.repeat(32)}/index.html`)).toBe(false)
})
// Why: preview guests are webviews like any other, so they must not skip the deny-by-default
// permission and display-media policy every browser partition gets.
it('applies the shared browser partition policies to the preview session', () => {
installDocPreviewProtocolHandler()
expect(mocks.installBrowserSessionPartitionPolicies).toHaveBeenCalledWith(
expect.objectContaining({ partition: 'orca-doc-preview', userAgentMode: 'clean' }),
expect.anything()
)
})
// Why downloads and nothing else: the browser download flow attributes a file to the page that
// asked for it, and a previewed document is no page. Routed, it would write remote-authored bytes
// into this desktop's Downloads folder with no prompt and no tab to name as the source.
it('asks for downloads to be denied on the preview partition', () => {
installDocPreviewProtocolHandler()
expect(mocks.installBrowserSessionPartitionPolicies).toHaveBeenCalledWith(expect.anything(), {
downloads: 'deny'
})
})
})
describe('registerDocPreviewSchemePrivileges', () => {
// Why the whole literal and not a subset: every privilege this scheme does not claim is one the
// document cannot use to escape it. `allowServiceWorkers` would outlive the tab that was granted
// the read, and `bypassCSP` would undo the self-only policy the handler serves with.
it('claims exactly the privileges the preview document needs', () => {
registerDocPreviewSchemePrivileges()
expect(protocol.registerSchemesAsPrivileged).toHaveBeenCalledWith([
{
scheme: 'orca-preview',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
stream: true
}
}
])
})
})
describe('isAllowedDocPreviewRequestUrl', () => {
// Why: the session refuses to carry the request at all, so a CSP bypass in one element type
// still reaches nothing off-machine.
it('admits in-document schemes and refuses everything that leaves the machine', () => {
expect(isAllowedDocPreviewRequestUrl(`orca-preview://${'a'.repeat(32)}/index.html`)).toBe(true)
expect(isAllowedDocPreviewRequestUrl('devtools://devtools/bundled/inspector.html')).toBe(true)
expect(isAllowedDocPreviewRequestUrl('data:image/png;base64,AAA')).toBe(true)
expect(isAllowedDocPreviewRequestUrl('blob:orca-preview://abc/123')).toBe(true)
expect(isAllowedDocPreviewRequestUrl('https://cdn.example.com/app.css')).toBe(false)
expect(isAllowedDocPreviewRequestUrl('http://127.0.0.1:9999/exfil')).toBe(false)
expect(isAllowedDocPreviewRequestUrl('ws://evil.example.com/socket')).toBe(false)
expect(isAllowedDocPreviewRequestUrl('file:///etc/passwd')).toBe(false)
})
})
describe('isDocPreviewSession', () => {
it('claims no session until the preview session has been materialized', () => {
expect(isDocPreviewSession({} as never)).toBe(false)
})
it('matches only the memoized preview session', () => {
const created = getDocPreviewSession()
expect(isDocPreviewSession(created)).toBe(true)
expect(isDocPreviewSession({} as never)).toBe(false)
expect(getDocPreviewSession()).toBe(created)
})
})
+144
View File
@@ -0,0 +1,144 @@
import { protocol, session } from 'electron'
import {
DOC_PREVIEW_PARTITION,
DOC_PREVIEW_SCHEME,
parseDocPreviewUrl
} from '../../shared/doc-preview-scheme'
import { installBrowserSessionPartitionPolicies } from './browser-session-partition-policies'
import { readDocPreviewFile } from './doc-preview-file-reader'
import { publishDocPreviewFailure } from './doc-preview-failure-notice'
import { getDocPreviewGrant } from './doc-preview-grant-registry'
/** Must run before `app.whenReady()`; Electron freezes the privileged scheme table at ready. */
export function registerDocPreviewSchemePrivileges(): void {
protocol.registerSchemesAsPrivileged([
{
scheme: DOC_PREVIEW_SCHEME,
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
stream: true
}
}
])
}
let docPreviewSession: Electron.Session | null = null
/** Non-persistent session, so preview bytes never land in a browsing profile's storage. */
export function getDocPreviewSession(): Electron.Session {
docPreviewSession ??= session.fromPartition(DOC_PREVIEW_PARTITION)
return docPreviewSession
}
/** Pure identity check: every guest attach consults it, and none should materialize a session. */
export function isDocPreviewSession(candidate: Electron.Session): boolean {
return docPreviewSession !== null && candidate === docPreviewSession
}
/**
* Product decision, not a hardening default: previewed documents are agent-authored, so any
* outbound request they can make is an exfiltration channel for whatever else the page can read.
* Self-contained documents — inline CSS/JS/SVG and in-grant assets — render in full; a CDN
* stylesheet, font, script, or analytics beacon deliberately does not load.
*/
const DOC_PREVIEW_CONTENT_SECURITY_POLICY = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self' data:",
"connect-src 'self'",
"frame-src 'self'",
"object-src 'none'"
// Why no `webrtc 'block'` here, though a peer connection is exactly the outbound channel this
// policy is meant to close: Chromium 43-era answers that directive with "Unrecognized
// Content-Security-Policy directive 'webrtc'" and gathers candidates anyway, so listing it would
// read as a fence while fencing nothing. The guest's IP-handling policy is the one that holds —
// see `installDocPreviewGuestPolicy`.
].join('; ')
function notFound(message: string): Response {
return new Response(message, {
status: 404,
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
})
}
export async function handleDocPreviewRequest(request: Request): Promise<Response> {
const target = parseDocPreviewUrl(request.url)
if (!target) {
return notFound('Not found')
}
const grant = getDocPreviewGrant(target.grantId)
if (!grant) {
// Why: a revoked or unknown grant is indistinguishable from a missing file by design — but the
// shell still needs to know, or the guest paints this body where the document should be.
publishDocPreviewFailure({
grantId: target.grantId,
relativePath: target.relativePath,
reason: 'unreadable'
})
return notFound('Not found')
}
const relativePath = target.relativePath || grant.entryRelativePath
const outcome = await readDocPreviewFile(grant, relativePath)
if (!outcome.ok) {
publishDocPreviewFailure({ grantId: target.grantId, relativePath, reason: outcome.reason })
return new Response(outcome.message, {
status: outcome.status,
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
})
}
return new Response(new Uint8Array(outcome.bytes), {
status: 200,
headers: {
'Content-Type': outcome.contentType,
'Content-Security-Policy': DOC_PREVIEW_CONTENT_SECURITY_POLICY,
// Why: reload must re-read the workspace disk, so nothing may be cached.
'Cache-Control': 'no-store'
}
})
}
/** `data:`/`blob:` never leave the document; every other scheme would reach off-machine. */
export function isAllowedDocPreviewRequestUrl(url: string): boolean {
return (
url.startsWith(`${DOC_PREVIEW_SCHEME}://`) ||
url.startsWith('devtools://') ||
url.startsWith('data:') ||
url.startsWith('blob:')
)
}
export function installDocPreviewProtocolHandler(): void {
const previewSession = getDocPreviewSession()
if (previewSession.protocol.isProtocolHandled(DOC_PREVIEW_SCHEME)) {
return
}
previewSession.protocol.handle(DOC_PREVIEW_SCHEME, handleDocPreviewRequest)
// Why: the response CSP is the document's own promise to obey; this is the session refusing to
// carry the request at all, so a CSP bypass in one element type still reaches nothing.
previewSession.webRequest.onBeforeRequest((details, callback) => {
callback({ cancel: !isAllowedDocPreviewRequestUrl(details.url) })
})
// Why: preview guests are webviews like any other, so they inherit the same deny-by-default
// permission, display-media and user-agent policy every browser partition gets.
installBrowserSessionPartitionPolicies(
{
id: DOC_PREVIEW_PARTITION,
scope: 'isolated',
partition: DOC_PREVIEW_PARTITION,
label: 'Document preview',
source: null,
userAgentMode: 'clean'
},
// Why downloads are the one policy that does not carry over: the browser download flow needs a
// page to attribute the file to, and a previewed document is not one. Routed here it would
// reserve a name in this desktop's Downloads folder and write remote-authored bytes into it
// with nothing in the UI naming the tab that asked, and no prompt in front of it.
{ downloads: 'deny' }
)
}
@@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
windows: [] as { webContents: MockWebContents }[],
windows: [] as MockBrowserWindow[],
BrowserWindow: vi.fn(),
finishLoads: true
}))
@@ -50,6 +50,41 @@ vi.mock('./browser-session-registry', () => ({
}))
import { OffscreenBrowserBackend } from './offscreen-browser-backend'
import { installDocPreviewGuestPolicy, isWorkspaceDocPageId } from './doc-preview-guest-policy'
import { mintDocPreviewGrant } from './doc-preview-grant-registry'
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
/** The real door's answer, so the backend is tested against the refusal it will actually get. */
function registerOffscreenGuestLikeBrowserManager({
browserPageId
}: {
browserPageId: string
}): boolean {
return !isWorkspaceDocPageId(browserPageId)
}
/**
* A page the document half of the registry really owns. Built rather than named, because the door
* refuses on registry membership now — a made-up id would be admitted and prove nothing.
*/
function registerWorkspaceDocPage(browserPageId: string): void {
const grant = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId
})
const guest = {
isFocused: () => false,
isDestroyed: () => false,
getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath),
on: vi.fn(),
once: vi.fn(),
setWindowOpenHandler: vi.fn(),
setWebRTCIPHandlingPolicy: vi.fn()
}
installDocPreviewGuestPolicy(guest as never, { id: 1, send: vi.fn() })
}
describe('OffscreenBrowserBackend lifecycle', () => {
beforeEach(() => {
@@ -73,7 +108,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
vi.useFakeTimers()
mocks.finishLoads = false
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
const backend = new OffscreenBrowserBackend(browserManager as never)
@@ -94,9 +129,36 @@ describe('OffscreenBrowserBackend lifecycle', () => {
vi.useRealTimers()
})
// Why the unregister assertion and not just the destroy: a refused id is one the document half
// of the registry owns, and the teardown hook would cancel that preview's work on the way out.
it('destroys a window whose registration was refused without unregistering the id', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
const backend = new OffscreenBrowserBackend(browserManager as never)
registerWorkspaceDocPage('doc-page-1')
await expect(
backend.createTab({
browserPageId: 'doc-page-1',
url: 'https://example.com',
worktreeId: 'wt'
})
).rejects.toThrow('was refused')
expect(mocks.windows[0].isDestroyed()).toBe(true)
expect(browserManager.unregisterGuest).not.toHaveBeenCalled()
// Why shutdown and not the map: a refused id left behind is invisible until teardown walks it
// and hands that id to the other authority after all.
await backend.destroyAll()
expect(browserManager.unregisterGuest).not.toHaveBeenCalled()
})
it('unregisters a closing page before awaiting owner retirement', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
let releaseOwnerRetirement!: () => void
@@ -126,7 +188,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('preserves a replacement page when the old window finishes closing', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
let releaseOwnerRetirement!: () => void
@@ -152,7 +214,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('retires the helper when an offscreen renderer is destroyed unexpectedly', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
const onPageClosed = vi.fn(async () => {})
@@ -167,7 +229,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('cleans every helper owner during backend shutdown', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
const onPageClosed = vi.fn(async () => {})
@@ -186,7 +248,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('rejects a concurrent create while shutdown is draining owned pages', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
let releaseOwnerRetirement!: () => void
@@ -215,7 +277,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('joins owner retirement started by an unexpected renderer destroy', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
let releaseOwnerRetirement!: () => void
@@ -244,7 +306,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('bounds concurrent helper retirements during shutdown', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
let activeRetirements = 0
@@ -284,7 +346,10 @@ describe('OffscreenBrowserBackend lifecycle', () => {
})
it('closes the page even when daemon retirement throws', async () => {
const browserManager = { registerOffscreenGuest: vi.fn(), unregisterGuest: vi.fn() }
const browserManager = {
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
const backend = new OffscreenBrowserBackend(browserManager as never, {
getAgentBrowserBridge: () => ({
onPageClosed: vi.fn(async () => {
+27 -17
View File
@@ -66,9 +66,33 @@ export class OffscreenBrowserBackend implements BrowserBackend {
this.windowsByPageId.set(browserPageId, win)
// Why: if the offscreen window is destroyed out from under us (crash, app
// teardown), drop the registry entry so commands fail cleanly instead of
// resolving a dead WebContents.
// Why: register the guest and return immediately so the new tab appears
// without waiting for the page to finish loading. Previously createTab
// awaited the full navigation, so clicking "New Browser Tab" did nothing for
// up to a second on real URLs. The page loads asynchronously and streams
// once it paints; a failed load leaves the (usable) tab open, matching how a
// normal browser tab survives a failed navigation.
const registered = this.browserManager.registerOffscreenGuest({
browserPageId,
worktreeId: params.worktreeId,
sessionProfileId: profile?.id ?? null,
userAgentMode: profile?.userAgentMode,
webContentsId: win.webContents.id
})
if (!registered) {
// Why destroy rather than carry on: the window already exists but carries none of the guest
// policies registration installs, so leaving it would navigate an unvalidated URL with no
// policy on it and hand back a page id nothing can drive. The renderer door aborts its mount
// the same way; this is that abort.
this.windowsByPageId.delete(browserPageId)
win.destroy()
throw new Error(`Browser page ${browserPageId} was refused`)
}
// Why only once registration took: this teardown unregisters the page id, and a refused id is
// one the other authority may own — cancelling its work would be the confusion we just refused.
// Why at all: if the window dies out from under us (crash, app teardown), drop the registry
// entry so commands fail cleanly instead of resolving a dead WebContents.
win.webContents.once('destroyed', () => {
// Explicit close removes the page first and performs awaited cleanup;
// only an unexpected destruction still owns the bridge retirement here.
@@ -80,20 +104,6 @@ export class OffscreenBrowserBackend implements BrowserBackend {
this.browserManager.unregisterGuest(browserPageId)
})
// Why: register the guest and return immediately so the new tab appears
// without waiting for the page to finish loading. Previously createTab
// awaited the full navigation, so clicking "New Browser Tab" did nothing for
// up to a second on real URLs. The page loads asynchronously and streams
// once it paints; a failed load leaves the (usable) tab open, matching how a
// normal browser tab survives a failed navigation.
this.browserManager.registerOffscreenGuest({
browserPageId,
worktreeId: params.worktreeId,
sessionProfileId: profile?.id ?? null,
userAgentMode: profile?.userAgentMode,
webContentsId: win.webContents.id
})
const url = params.url || 'about:blank'
void this.loadUrl(win, url).catch((error) => {
console.warn(
+11
View File
@@ -319,6 +319,11 @@ import { browserCertificateTrustController, browserManager } from './browser/bro
import { RpcDispatcher } from './runtime/rpc/dispatcher'
import { OffscreenBrowserBackend } from './browser/offscreen-browser-backend'
import { initializeBrowserSessionsForApp } from './browser/browser-session-startup'
import {
installDocPreviewProtocolHandler,
registerDocPreviewSchemePrivileges
} from './browser/doc-preview-protocol'
import { registerDocPreviewGrantHandlers } from './ipc/doc-preview-grant-ipc'
import { initializeBrowserClientHostId } from './browser/browser-client-host-id'
import { setUnreadDockBadgeCount } from './dock/unread-badge'
import { AutomationService } from './automations/service'
@@ -962,6 +967,9 @@ if (hasSingleInstanceLock) {
if (shouldApplyPreReadyAppName(devInstanceIdentity)) {
app.setName(devInstanceIdentity.appName)
}
// Why: Electron freezes the privileged scheme table at ready, so the doc-preview
// scheme must be declared here or its webview loses fetch/secure-origin privileges.
registerDocPreviewSchemePrivileges()
// Why: must precede app.whenReady() so Crashpad is installed before the
// first renderer spawns; a CHECK before this point is still exit-code-only.
startCrashpadCapture()
@@ -2437,6 +2445,9 @@ void app.whenReady().then(async () => {
} catch {
console.warn('[proxy] Failed to apply network proxy settings')
}
// Why: the preview session is protocol-scoped, so the handler must exist before any preview webview attaches.
installDocPreviewProtocolHandler()
registerDocPreviewGrantHandlers()
// Why: browser sessions serve desktop webviews and runtime profile commands, so init at app startup rather than via a renderer IPC path.
initializeBrowserSessionsForApp({
orcaProfileId: activeOrcaProfile.profile.id,
+19
View File
@@ -1,5 +1,6 @@
import { ipcMain } from 'electron'
import { browserManager } from '../browser/browser-manager'
import { onDocPreviewGrantRevoked } from '../browser/doc-preview-grant-registry'
import { isTrustedBrowserRenderer } from './browser-renderer-trust'
import { waitForNextTabRegistration } from './browser-tab-registration-wait'
import type {
@@ -48,7 +49,25 @@ export function disposeGrabModeStateForPage(browserPageId: string): void {
grabModeOperationByPageId.delete(browserPageId)
}
/**
* Why previews need their own disposal path: a browser page announces its own death on
* `browser:unregisterGuest`, and a preview never does — it withdraws by revoking its grant, which
* is also what a re-mint does. Without this, every grant leaves an intent entry behind forever.
*/
let disposePreviewGrantSubscription: (() => void) | null = null
function subscribeToPreviewGrantRevocation(): void {
disposePreviewGrantSubscription?.()
disposePreviewGrantSubscription = onDocPreviewGrantRevoked((grant) => {
// Why cancel first: a grab still armed on that guest would otherwise leave the renderer's
// await hanging on a surface the reader has already closed.
browserManager.cancelGrabOp(grant.browserPageId, 'evicted')
disposeGrabModeStateForPage(grant.browserPageId)
})
}
export function registerBrowserGrabHandlers(): void {
subscribeToPreviewGrantRevocation()
ipcMain.removeHandler('browser:setGrabMode')
ipcMain.removeHandler('browser:awaitGrabSelection')
ipcMain.removeHandler('browser:cancelGrab')
+20 -6
View File
@@ -85,12 +85,26 @@ export function registerBrowserGuestViewHandlers(): void {
) {
return false
}
return browserManager.setAnnotationViewportBridge(args.browserPageId, {
enabled: args.enabled,
emitViewport: args.emitViewport,
markers: args.markers,
token: args.token
})
// Why resolve here: this is a tool acting on a guest the reader is looking at, so it answers
// for a workspace document too — and routing through the authority also pins the request to
// the renderer that owns the target, which page-id-only resolution never checked.
const resolveGuest = (): Electron.WebContents | null =>
browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id)
if (!resolveGuest()) {
return false
}
// Why hand over the resolver rather than that guest: the op is serialized per tab, and the
// one it finally runs against must be the one on screen then, not the one this request saw.
return browserManager.setAnnotationViewportBridge(
args.browserPageId,
{
enabled: args.enabled,
emitViewport: args.emitViewport,
markers: args.markers,
token: args.token
},
resolveGuest
)
}
)
@@ -0,0 +1,510 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as DocPreviewGuestPolicyModule from '../browser/doc-preview-guest-policy'
import type * as TabRegistrationWaitModule from './browser-tab-registration-wait'
const {
handleMock,
removeHandlerMock,
getAuthorizedGuestMock,
setGrabModeMock,
awaitGrabSelectionMock,
cancelGrabOpMock,
captureSelectionScreenshotMock,
extractHoverPayloadMock,
setAnnotationViewportBridgeMock,
openDevToolsMock,
setViewportOverrideMock,
previewAuthoritySpy,
registrationWaitSpy
} = vi.hoisted(() => ({
handleMock: vi.fn(),
removeHandlerMock: vi.fn(),
getAuthorizedGuestMock: vi.fn(),
setGrabModeMock: vi.fn().mockResolvedValue(true),
awaitGrabSelectionMock: vi.fn().mockResolvedValue({ opId: 'op', kind: 'cancelled' }),
cancelGrabOpMock: vi.fn(),
captureSelectionScreenshotMock: vi.fn().mockResolvedValue(null),
extractHoverPayloadMock: vi.fn().mockResolvedValue(null),
setAnnotationViewportBridgeMock: vi.fn().mockResolvedValue(true),
openDevToolsMock: vi.fn().mockResolvedValue(true),
setViewportOverrideMock: vi.fn().mockResolvedValue(true),
previewAuthoritySpy: vi.fn(),
registrationWaitSpy: vi.fn()
}))
vi.mock('electron', () => ({
BrowserWindow: { fromWebContents: vi.fn() },
ipcMain: { removeHandler: removeHandlerMock, handle: handleMock },
webContents: { fromId: vi.fn(() => ({ isDestroyed: () => false })) }
}))
vi.mock('../browser/browser-manager', () => ({
browserCertificateTrustController: { proceed: vi.fn(() => ({ ok: true })) },
browserManager: {
registerGuest: vi.fn(() => true),
attachGuestPolicies: vi.fn(),
unregisterGuest: vi.fn(),
getGuestWebContentsId: vi.fn(),
getWebContentsIdByTabId: vi.fn(() => new Map()),
getWorktreeIdForTab: vi.fn(),
getAuthorizedGuest: getAuthorizedGuestMock,
setGrabMode: setGrabModeMock,
awaitGrabSelection: awaitGrabSelectionMock,
cancelGrabOp: cancelGrabOpMock,
captureSelectionScreenshot: captureSelectionScreenshotMock,
extractHoverPayload: extractHoverPayloadMock,
setAnnotationViewportBridge: setAnnotationViewportBridgeMock,
openDevTools: openDevToolsMock,
setViewportOverride: setViewportOverrideMock,
cancelDownload: vi.fn()
}
}))
// Why the real policy module behind a spy: the point of these tests is which half of the page
// registry a channel reads, so a stub of it would prove nothing about what was consulted.
vi.mock('../browser/doc-preview-guest-policy', async (importOriginal) => {
const actual = await importOriginal<typeof DocPreviewGuestPolicyModule>()
return {
...actual,
getWorkspaceDocPageGuest: (browserPageId: string, senderWebContentsId: number) => {
previewAuthoritySpy(browserPageId, senderWebContentsId)
return actual.getWorkspaceDocPageGuest(browserPageId, senderWebContentsId)
}
}
})
// Why spy rather than stub: the wait is real machinery the browser-page path still depends on;
// only whether a preview target enters it is under test.
vi.mock('./browser-tab-registration-wait', async (importOriginal) => {
const actual = await importOriginal<typeof TabRegistrationWaitModule>()
return {
...actual,
waitForNextTabRegistration: (...args: Parameters<typeof actual.waitForNextTabRegistration>) => {
registrationWaitSpy(args[0])
return actual.waitForNextTabRegistration(...args)
}
}
})
import { registerBrowserHandlers } from './browser'
import { browserManager } from '../browser/browser-manager'
import {
getWorkspaceDocPageGuest,
installDocPreviewGuestPolicy
} from '../browser/doc-preview-guest-policy'
import {
mintDocPreviewGrant,
revokeAllDocPreviewGrants,
revokeDocPreviewGrant
} from '../browser/doc-preview-grant-registry'
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
const HOST_RENDERER_ID = 91
const OTHER_RENDERER_ID = 92
/** Mirrors browser-grab-ipc's own wait, so elapsing it here settles the same parked request. */
const GRAB_REGISTRATION_WAIT_MS = 1_000
/**
* Every `browser:*` invoke channel, split by whether it acts on a guest the reader is looking at
* (a tool) or manages browser-page, session and profile state. The split is asserted to be total,
* so a new channel cannot be added without deciding which side of the preview seam it belongs on.
*/
const TOOL_CHANNELS = [
'browser:setGrabMode',
'browser:awaitGrabSelection',
'browser:cancelGrab',
'browser:captureSelectionScreenshot',
'browser:extractHoverPayload',
'browser:setAnnotationViewportBridge'
]
const BROWSER_PAGE_CHANNELS = [
'browser:registerGuest',
'browser:prepareSshWorkspacePartition',
'browser:repairGuestRegistration',
'browser:isGuestRegistered',
'browser:unregisterGuest',
'browser:respondWebAuthnAccount',
'browser:proceedCertificate',
'browser:activeTabChanged',
'browser:openDevTools',
'browser:setViewportOverride',
'browser:publishClientPageMetadata',
'browser:cancelDownload',
'browser:session:listProfiles',
'browser:session:createProfile',
'browser:session:deleteProfile',
'browser:session:importCookies',
'browser:session:resolvePartition',
'browser:session:clearDefaultCookies',
'browser:session:importFromBrowserForClientHost',
'browser:session:clientRouteImportSources',
'browser:session:detectBrowsers',
'browser:session:detectBrowsersForClientHost',
'browser:session:importFromBrowser'
]
type Handler = (event: { sender: Electron.WebContents }, args: unknown) => unknown
function registeredHandlers(): Map<string, Handler> {
const handlers = new Map<string, Handler>()
for (const [channel, handler] of handleMock.mock.calls as [string, Handler][]) {
handlers.set(channel, handler)
}
return handlers
}
function trustedSender(id: number): { sender: Electron.WebContents } {
return {
sender: {
id,
isDestroyed: () => false,
getType: () => 'window',
getURL: () => 'file:///renderer/index.html'
} as unknown as Electron.WebContents
}
}
let nextDocPageOrdinal = 0
function grantForNewDocPage(): { id: string; browserPageId: string } {
nextDocPageOrdinal += 1
const browserPageId = `doc-page-${nextDocPageOrdinal}`
const grant = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId
})
return { id: grant.id, browserPageId }
}
/** A preview guest already showing its document, which is the only state a tool can act in. */
function renderPreviewForGrant(
grant: { id: string; browserPageId: string },
hostId: number = HOST_RENDERER_ID
): {
grantId: string
browserPageId: string
contents: object
markContentsDestroyed: () => void
} {
const browserPageId = grant.browserPageId
const handlers: Record<string, (...args: never[]) => void> = {}
const register = (event: string, handler: (...args: never[]) => void): void => {
handlers[event] = handler
}
// Why the guest already reports its URL: the embedder hands a preview over mid-load, so this is
// the state the policy really installs into.
const documentUrl = buildDocPreviewUrl(grant.id, 'index.html')
let contentsDestroyed = false
const guest = {
isFocused: () => true,
isDestroyed: () => contentsDestroyed,
getURL: () => documentUrl,
on: vi.fn(register),
once: vi.fn(register),
setWindowOpenHandler: vi.fn(),
setWebRTCIPHandlingPolicy: vi.fn()
}
installDocPreviewGuestPolicy(guest as never, { id: hostId, send: vi.fn() })
handlers['did-start-navigation']?.({ url: documentUrl, isMainFrame: true } as never)
return {
grantId: grant.id,
browserPageId,
contents: guest,
// Why without the `destroyed` event: Chromium tears the contents down before main runs that
// listener, so this is the window the authority has to answer for on its own.
markContentsDestroyed: () => {
contentsDestroyed = true
}
}
}
function liveRenderedPreview(
hostId: number = HOST_RENDERER_ID
): ReturnType<typeof renderPreviewForGrant> {
return renderPreviewForGrant(grantForNewDocPage(), hostId)
}
/** Minimal well-formed args per channel, so a refusal is authorization and not shape validation. */
function toolArgs(channel: string, browserPageId: string): Record<string, unknown> {
switch (channel) {
case 'browser:setGrabMode':
return { browserPageId, enabled: true }
case 'browser:awaitGrabSelection':
return { browserPageId, opId: 'op-1' }
case 'browser:captureSelectionScreenshot':
return { browserPageId, rect: { x: 0, y: 0, width: 10, height: 10 } }
case 'browser:setAnnotationViewportBridge':
return {
browserPageId,
enabled: true,
emitViewport: true,
markers: [],
token: 'annotation-bridge-token-1'
}
default:
return { browserPageId }
}
}
/** The viewport bridge is handed a resolver rather than the contents, so unwrap one call argument. */
function resolvesToGuest(argument: unknown, guest: object): boolean {
return argument === guest || (typeof argument === 'function' && argument() === guest)
}
const GUEST_RECEIVING_MOCKS = [
setGrabModeMock,
awaitGrabSelectionMock,
captureSelectionScreenshotMock,
extractHoverPayloadMock,
setAnnotationViewportBridgeMock
]
beforeEach(() => {
vi.stubEnv('ELECTRON_RENDERER_URL', '')
// Why before clearing: revoking the previous test's grants disposes their page state through the
// mocked manager, and those calls belong to that test, not this one.
revokeAllDocPreviewGrants()
vi.clearAllMocks()
// Why the mocked manager still answers for documents: this file's subject is which channels may
// reach a document guest, not how the manager splits its registry — that lives in
// browser-manager-guest-policy-profile.test.ts, against the real manager.
getAuthorizedGuestMock.mockImplementation((browserPageId: string, senderWebContentsId: number) =>
getWorkspaceDocPageGuest(browserPageId, senderWebContentsId)
)
setGrabModeMock.mockResolvedValue(true)
awaitGrabSelectionMock.mockResolvedValue({ opId: 'op-1', kind: 'cancelled' })
captureSelectionScreenshotMock.mockResolvedValue(null)
extractHoverPayloadMock.mockResolvedValue(null)
setAnnotationViewportBridgeMock.mockResolvedValue(true)
registerBrowserHandlers()
// Why fake timers for the whole file: a tool asking for a page whose guest has not attached parks
// in the registration wait, and several cases here are exactly the ones that never attach. Real
// time would spend that wait for each of them.
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
/** Settles a tool request, elapsing the registration wait it may be parked in. */
async function settle<T>(pending: Promise<T> | T | undefined): Promise<T | undefined> {
await vi.advanceTimersByTimeAsync(GRAB_REGISTRATION_WAIT_MS)
return pending
}
describe('doc preview tool authorization', () => {
it('classifies every registered browser channel as a tool or a browser-page channel', () => {
const registered = [...registeredHandlers().keys()].sort()
const classified = [...TOOL_CHANNELS, ...BROWSER_PAGE_CHANNELS].sort()
expect(registered).toEqual(classified)
})
it.each(TOOL_CHANNELS)('drives the preview guest from %s', async (channel) => {
const preview = liveRenderedPreview()
const handler = registeredHandlers().get(channel)
await handler?.(trustedSender(HOST_RENDERER_ID), toolArgs(channel, preview.browserPageId))
// Why assert on the guest and not on a return value: the whole point of the seam is which
// WebContents the tool ends up acting on.
const receivedGuest = GUEST_RECEIVING_MOCKS.some((mock) =>
mock.mock.calls.some((args) => args.some((arg) => resolvesToGuest(arg, preview.contents)))
)
expect(receivedGuest || cancelGrabOpMock.mock.calls.length > 0).toBe(true)
})
// The load-bearing containment claim: page and session management never reads the document half
// of the registry, so a document page can never be resolved as, or managed like, a browsing one.
// Nothing here is a per-channel guard — a document guest is simply not in the map these read.
it.each(BROWSER_PAGE_CHANNELS)('never reads the document registry from %s', async (channel) => {
const preview = liveRenderedPreview()
const handler = registeredHandlers().get(channel)
try {
await handler?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: preview.browserPageId,
profileId: preview.browserPageId,
environmentId: preview.browserPageId
})
} catch {
// A malformed-for-this-channel payload may throw; the claim is about which registry was read.
}
expect(previewAuthoritySpy).not.toHaveBeenCalled()
})
it.each(TOOL_CHANNELS)(
'refuses %s from a renderer that does not host the preview',
async (channel) => {
const preview = liveRenderedPreview()
const handler = registeredHandlers().get(channel)
await settle(
handler?.(trustedSender(OTHER_RENDERER_ID), toolArgs(channel, preview.browserPageId))
)
for (const mock of [...GUEST_RECEIVING_MOCKS, cancelGrabOpMock]) {
expect(mock).not.toHaveBeenCalled()
}
}
)
it.each(TOOL_CHANNELS)('refuses %s for a page no preview rendered', async (channel) => {
const handler = registeredHandlers().get(channel)
await settle(
handler?.(trustedSender(HOST_RENDERER_ID), toolArgs(channel, 'doc-page-unrendered'))
)
for (const mock of [...GUEST_RECEIVING_MOCKS, cancelGrabOpMock]) {
expect(mock).not.toHaveBeenCalled()
}
})
// Why the contents check has to be its own condition: the grant is still live and the sender is
// still the host, so nothing else in the authority notices that the guest is gone.
it.each(TOOL_CHANNELS)('refuses %s once the preview contents are destroyed', async (channel) => {
const preview = liveRenderedPreview()
preview.markContentsDestroyed()
const handler = registeredHandlers().get(channel)
await settle(
handler?.(trustedSender(HOST_RENDERER_ID), toolArgs(channel, preview.browserPageId))
)
for (const mock of [...GUEST_RECEIVING_MOCKS, cancelGrabOpMock]) {
expect(mock).not.toHaveBeenCalled()
}
})
// Why this inverts what the split registries used to assert: a document page now registers under
// its own id, so the wait is reachable and useful — a tool opened while the guest is still
// attaching parks here instead of answering not-ready at the reader.
it('waits for a document page whose guest has not attached yet, and arms when it does', async () => {
const grant = grantForNewDocPage()
const handler = registeredHandlers().get('browser:setGrabMode')
const pending = handler?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: grant.browserPageId,
enabled: true
})
await vi.advanceTimersByTimeAsync(0)
expect(registrationWaitSpy).toHaveBeenCalledWith(grant.browserPageId)
const preview = renderPreviewForGrant(grant)
await expect(pending).resolves.toEqual({ ok: true })
expect(setGrabModeMock).toHaveBeenCalledWith(grant.browserPageId, true, preview.contents)
})
// The absence half, with the same presence precondition: a page nothing ever renders still gives
// the reader an answer rather than hanging on the full timeout.
it('answers not-ready once the wait for an unrendered page elapses', async () => {
const handler = registeredHandlers().get('browser:setGrabMode')
await expect(
settle(
handler?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: 'doc-page-never-rendered',
enabled: true
})
)
).resolves.toEqual({ ok: false, reason: 'not-ready' })
})
it('still waits for a browser page whose registration may be in flight', async () => {
const handler = registeredHandlers().get('browser:setGrabMode')
await settle(
handler?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: 'browser-page-1',
enabled: true
})
)
expect(registrationWaitSpy).toHaveBeenCalledWith('browser-page-1')
})
// Why the grant and not the guest: a preview withdraws by revoking, which is also what a
// re-mint does, and nothing else tells main that this tool target will never be used again.
it('disposes the grab state a preview target accumulated when its grant is revoked', async () => {
const preview = liveRenderedPreview()
const handler = registeredHandlers().get('browser:setGrabMode')
await handler?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: preview.browserPageId,
enabled: true
})
cancelGrabOpMock.mockClear()
revokeDocPreviewGrant(preview.grantId)
expect(cancelGrabOpMock).toHaveBeenCalledWith(preview.browserPageId, 'evicted')
})
// Why both halves of this door: the manager refuses a preview id on its own, but the grab
// disposal beside it takes a renderer-supplied id, and the intent it drops is compared by
// identity — dropping it makes the grab settle ok without ever arming the guest.
it('leaves an in-flight preview grab armed when unregisterGuest names its target', async () => {
const preview = liveRenderedPreview()
const handlers = registeredHandlers()
const pending = handlers.get('browser:setGrabMode')?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: preview.browserPageId,
enabled: true
})
// Why synchronously here: the intent is recorded before the queued operation runs, so this is
// the exact window in which a disposal at this door would be invisible to the caller.
const unregistered = handlers.get('browser:unregisterGuest')?.(
trustedSender(HOST_RENDERER_ID),
{ browserPageId: preview.browserPageId }
)
// Why the guest and not the result: a dropped intent settles as ok either way, so only the
// guest actually being driven separates an armed grab from a silent no-op.
await expect(pending).resolves.toEqual({ ok: true })
expect(setGrabModeMock).toHaveBeenCalledWith(preview.browserPageId, true, preview.contents)
expect(unregistered).toBe(false)
expect(vi.mocked(browserManager.unregisterGuest)).not.toHaveBeenCalled()
})
// The converse, so the guard above cannot be widened into a door that stops closing tabs.
it('still disposes a browser page grab through the same door', async () => {
getAuthorizedGuestMock.mockReturnValue({ isDestroyed: () => false })
const handlers = registeredHandlers()
const pending = handlers.get('browser:setGrabMode')?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: 'browser-page-1',
enabled: true
})
const unregistered = handlers.get('browser:unregisterGuest')?.(
trustedSender(HOST_RENDERER_ID),
{ browserPageId: 'browser-page-1' }
)
expect(unregistered).toBe(true)
await expect(pending).resolves.toEqual({ ok: true })
expect(setGrabModeMock).not.toHaveBeenCalled()
expect(vi.mocked(browserManager.unregisterGuest)).toHaveBeenCalledWith('browser-page-1')
})
// Why with a live preview standing beside it: the halves are looked up in one door now, so the
// thing to prove is that a browsing id is not answered by whatever document happens to be open.
it('never hands a browsing page id the guest of a document that is open', async () => {
const preview = liveRenderedPreview()
const handler = registeredHandlers().get('browser:extractHoverPayload')
await handler?.(trustedSender(HOST_RENDERER_ID), { browserPageId: 'browser-page-1' })
expect(getAuthorizedGuestMock).toHaveBeenCalledWith('browser-page-1', HOST_RENDERER_ID)
expect(previewAuthoritySpy).toHaveBeenCalledWith('browser-page-1', HOST_RENDERER_ID)
expect(extractHoverPayloadMock).not.toHaveBeenCalled()
// The presence half: the same channel does reach that guest under the page it really renders.
await handler?.(trustedSender(HOST_RENDERER_ID), { browserPageId: preview.browserPageId })
expect(extractHoverPayloadMock).toHaveBeenCalledWith(preview.browserPageId, preview.contents)
})
})
@@ -0,0 +1,108 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
getWebContentsIdByTabId: vi.fn(() => new Map<string, number>()),
getWorktreeIdForTab: vi.fn(() => undefined as string | undefined),
getGuestWebContentsId: vi.fn(() => null as number | null),
webContentsFromId: vi.fn(() => null as unknown)
}))
vi.mock('electron', () => ({ webContents: { fromId: mocks.webContentsFromId } }))
vi.mock('../browser/browser-manager', () => ({
browserManager: {
getWebContentsIdByTabId: mocks.getWebContentsIdByTabId,
getWorktreeIdForTab: mocks.getWorktreeIdForTab,
getGuestWebContentsId: mocks.getGuestWebContentsId
}
}))
import {
waitForAnyTabRegistration,
waitForNextTabRegistration,
waitForWorktreeTabRegistration
} from './browser-tab-registration-wait'
import { installDocPreviewGuestPolicy } from '../browser/doc-preview-guest-policy'
import {
mintDocPreviewGrant,
revokeAllDocPreviewGrants
} from '../browser/doc-preview-grant-registry'
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
const WAIT_MS = 1_000
/** Registers a document guest the way the attach door does, which is what notifies the waiters. */
function attachDocumentGuest(browserPageId: string): void {
const grant = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId
})
const guest = {
isFocused: () => false,
isDestroyed: () => false,
getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath),
on: vi.fn(),
once: vi.fn(),
setWindowOpenHandler: vi.fn(),
setWebRTCIPHandlingPolicy: vi.fn()
}
installDocPreviewGuestPolicy(guest as never, { id: 91, send: vi.fn() })
}
/** Whether a wait has settled, without letting its rejection escape as an unhandled one. */
function track(pending: Promise<void>): () => 'pending' | 'resolved' | 'rejected' {
let state: 'pending' | 'resolved' | 'rejected' = 'pending'
pending.then(
() => {
state = 'resolved'
},
() => {
state = 'rejected'
}
)
return () => state
}
beforeEach(() => {
revokeAllDocPreviewGrants()
vi.clearAllMocks()
mocks.getWebContentsIdByTabId.mockReturnValue(new Map())
mocks.getGuestWebContentsId.mockReturnValue(null)
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('a workspace document registering', () => {
it('settles a wait that already names its page', async () => {
const settled = track(waitForNextTabRegistration('doc-page-1', WAIT_MS))
attachDocumentGuest('doc-page-1')
await vi.advanceTimersByTimeAsync(0)
expect(settled()).toBe('resolved')
})
// Why these two stay pending: they are how the CLI and agents ask for a browser tab to drive, and
// a preview is neither drivable by them nor visible to them. Satisfying either would hand the
// caller a surface that answers nothing, instead of letting it keep waiting for a real tab.
it('leaves the waits that ask for any browser tab still waiting', async () => {
const worktreeWait = track(waitForWorktreeTabRegistration('wt-1', WAIT_MS))
const anyWait = track(waitForAnyTabRegistration(WAIT_MS))
attachDocumentGuest('doc-page-2')
await vi.advanceTimersByTimeAsync(0)
expect(worktreeWait()).toBe('pending')
expect(anyWait()).toBe('pending')
// The presence half: those waits do settle, so a mutant that never resolves them would pass the
// assertions above by being uniformly stuck.
await vi.advanceTimersByTimeAsync(WAIT_MS)
expect(worktreeWait()).toBe('rejected')
expect(anyWait()).toBe('rejected')
})
})
@@ -1,5 +1,6 @@
import { webContents } from 'electron'
import { browserManager } from '../browser/browser-manager'
import { onWorkspaceDocGuestRegistered } from '../browser/doc-preview-guest-policy'
// Why: CLI-driven tab creation must wait until the renderer mounts the webview
// and calls registerGuest, so the tab has a webContentsId and is operable by
@@ -109,6 +110,18 @@ export function waitForAnyTabRegistration(timeoutMs = 8_000): Promise<void> {
return waitForRegistrationSet(pendingAnyTabRegistrations, timeoutMs, () => {})
}
/**
* Why a document page resolves only its own waiters: the worktree-wide and any-tab waits are how
* the CLI and agents ask for a browser tab to drive, and a preview is neither drivable by them nor
* visible to them. Only a request already naming this page — a tool the reader opened on the
* document — is waiting for this.
*/
onWorkspaceDocGuestRegistered((browserPageId) => {
const pendingResolves = pendingTabRegistrations.get(browserPageId)
pendingTabRegistrations.delete(browserPageId)
resolvePendingRegistrations(pendingResolves)
})
export function resolveTabRegistrationWaiters(browserPageId: string, worktreeId: string): void {
const pendingResolves = pendingTabRegistrations.get(browserPageId)
pendingTabRegistrations.delete(browserPageId)
+48 -6
View File
@@ -794,6 +794,8 @@ describe('registerBrowserHandlers', () => {
it('validates annotation viewport bridge requests before syncing to the guest', async () => {
registerBrowserHandlers()
const guest = { isDestroyed: () => false } as Electron.WebContents
getAuthorizedGuestMock.mockReturnValue(guest)
const syncHandler = handleMock.mock.calls.find(
([channel]) => channel === 'browser:setAnnotationViewportBridge'
@@ -818,12 +820,52 @@ describe('registerBrowserHandlers', () => {
)
expect(result).toBe(true)
expect(setAnnotationViewportBridgeMock).toHaveBeenCalledWith('page-1', {
emitViewport: false,
enabled: true,
markers: [],
token: 'annotationviewporttoken'
})
expect(setAnnotationViewportBridgeMock).toHaveBeenCalledWith(
'page-1',
{
emitViewport: false,
enabled: true,
markers: [],
token: 'annotationviewporttoken'
},
// Why a resolver and not the guest: the op is serialized per page, so it has to read the
// registry when it runs — a navigation while it waited may have swapped the contents.
// Which guest it then resolves is pinned in browser-manager-annotation-bridge.test.ts.
expect.any(Function)
)
})
// Why this is new: the channel used to hand a page id straight to the manager, so any trusted
// renderer could drive any page's guest. It now resolves through the same authority the grab
// channels use, which pins the request to the renderer that registered the page.
it('refuses an annotation viewport bridge request from a renderer that does not own the page', async () => {
registerBrowserHandlers()
getAuthorizedGuestMock.mockReturnValue(null)
const syncHandler = handleMock.mock.calls.find(
([channel]) => channel === 'browser:setAnnotationViewportBridge'
)?.[1] as (event: { sender: Electron.WebContents }, args: unknown) => Promise<boolean> | boolean
const result = await syncHandler(
{
sender: {
id: 91,
isDestroyed: () => false,
getType: () => 'window',
getURL: () => 'file:///renderer/index.html'
} as Electron.WebContents
},
{
browserPageId: 'page-1',
emitViewport: false,
enabled: true,
markers: [],
token: 'annotationviewporttoken'
}
)
expect(result).toBe(false)
expect(setAnnotationViewportBridgeMock).not.toHaveBeenCalled()
})
it('rejects invalid annotation viewport bridge requests', async () => {
+8
View File
@@ -2,6 +2,7 @@ import { ipcMain, webContents } from 'electron'
import { browserCertificateTrustController, browserManager } from '../browser/browser-manager'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import { browserSessionRegistry } from '../browser/browser-session-registry'
import { isWorkspaceDocPageId } from '../browser/doc-preview-guest-policy'
import { isTrustedBrowserRenderer } from './browser-renderer-trust'
import {
isLiveBrowserWebContentsId,
@@ -160,6 +161,13 @@ export function registerBrowserHandlers(): void {
if (!isTrustedBrowserRenderer(event.sender)) {
return false
}
// Why the whole door and not just the manager call: a document page shares this renderer, and
// the grab disposal below drops the intent an in-flight preview grab compares by identity —
// that grab would then answer ok without ever arming. A document page withdraws by revoking
// its grant, so its id arriving here is misaddressed however it got here.
if (typeof args?.browserPageId !== 'string' || isWorkspaceDocPageId(args.browserPageId)) {
return false
}
// Why: notify bridge before unregistering so it can destroy the session
// process and proxy. Must happen before unregisterGuest clears the mapping.
const wcId = browserManager.getGuestWebContentsId(args.browserPageId)
+159
View File
@@ -0,0 +1,159 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
handlers: new Map<string, (event: unknown, ...args: unknown[]) => unknown>(),
listeners: new Map<string, (event: unknown, ...args: unknown[]) => unknown>(),
isTrustedBrowserRenderer: vi.fn(),
reportDocPreviewLinkClick: vi.fn(),
getGuestWebContentsId: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: {
handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => {
mocks.handlers.set(channel, handler)
},
on: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => {
mocks.listeners.set(channel, listener)
}
}
}))
vi.mock('./browser-renderer-trust', () => ({
isTrustedBrowserRenderer: mocks.isTrustedBrowserRenderer
}))
vi.mock('../browser/doc-preview-guest-policy', () => ({
reportDocPreviewLinkClick: mocks.reportDocPreviewLinkClick
}))
vi.mock('../browser/browser-manager', () => ({
browserManager: { getGuestWebContentsId: mocks.getGuestWebContentsId }
}))
import {
registerDocPreviewGrantHandlers,
type DocPreviewGrantRequest
} from './doc-preview-grant-ipc'
import {
getDocPreviewGrant,
revokeAllDocPreviewGrants
} from '../browser/doc-preview-grant-registry'
import {
DOC_PREVIEW_LINK_CLICK_CHANNEL,
DOC_PREVIEW_MINT_GRANT_CHANNEL,
DOC_PREVIEW_REVOKE_GRANT_CHANNEL,
parseDocPreviewUrl
} from '../../shared/doc-preview-scheme'
const REQUEST: DocPreviewGrantRequest = {
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: 'doc-page-1'
}
const sender = { id: 7 }
function mint(request: DocPreviewGrantRequest = REQUEST): { grantId: string; url: string } {
const handler = mocks.handlers.get(DOC_PREVIEW_MINT_GRANT_CHANNEL)
if (!handler) {
throw new Error('mint handler not registered')
}
return handler({ sender }, request) as { grantId: string; url: string }
}
function revoke(grantId: string): boolean {
const handler = mocks.handlers.get(DOC_PREVIEW_REVOKE_GRANT_CHANNEL)
if (!handler) {
throw new Error('revoke handler not registered')
}
return handler({ sender }, grantId) as boolean
}
function reportLinkClick(url: unknown): void {
const listener = mocks.listeners.get(DOC_PREVIEW_LINK_CLICK_CHANNEL)
if (!listener) {
throw new Error('link click listener not registered')
}
listener({ sender }, url)
}
beforeEach(() => {
mocks.handlers.clear()
mocks.listeners.clear()
vi.clearAllMocks()
revokeAllDocPreviewGrants()
mocks.isTrustedBrowserRenderer.mockReturnValue(true)
mocks.getGuestWebContentsId.mockReturnValue(null)
registerDocPreviewGrantHandlers()
})
describe('document preview grant handlers', () => {
it('mints a grant addressable by the URL it returns', () => {
const result = mint()
expect(parseDocPreviewUrl(result.url)).toEqual({
grantId: result.grantId,
relativePath: 'index.html'
})
expect(getDocPreviewGrant(result.grantId)?.root).toBe('/home/alice/docs')
expect(mocks.isTrustedBrowserRenderer).toHaveBeenCalledWith(sender)
})
it('revokes a grant it minted', () => {
const result = mint()
expect(revoke(result.grantId)).toBe(true)
expect(getDocPreviewGrant(result.grantId)).toBeNull()
})
it('rejects a request that names no root, entry document or page', () => {
expect(() => mint({ ...REQUEST, root: ' ' })).toThrow(/Invalid/)
expect(() => mint({ ...REQUEST, entryRelativePath: '' })).toThrow(/Invalid/)
expect(() => mint({ ...REQUEST, browserPageId: ' ' })).toThrow(/Invalid/)
})
// Why this is refused here and not left to registration: this is where a page becomes a document
// page, and the two halves of the page registry have to stay disjoint. A page already hosting a
// browsing guest would otherwise resolve in both, and the tool door prefers the document one.
it('refuses to make a page that already hosts a browsing guest into a document page', () => {
mocks.getGuestWebContentsId.mockReturnValue(42)
expect(() => mint()).toThrow(/browsing page/)
expect(mocks.getGuestWebContentsId).toHaveBeenCalledWith('doc-page-1')
})
// Why: this channel hands out filesystem-read authority, so an untrusted sender must leave with
// nothing rather than with a grant id the scheme handler would honor.
it('mints nothing for a sender that is not the trusted renderer', () => {
mocks.isTrustedBrowserRenderer.mockReturnValue(false)
expect(() => mint()).toThrow(/Untrusted/)
})
it('refuses to revoke on behalf of a sender that is not the trusted renderer', () => {
const result = mint()
mocks.isTrustedBrowserRenderer.mockReturnValue(false)
expect(revoke(result.grantId)).toBe(false)
expect(getDocPreviewGrant(result.grantId)).not.toBeNull()
})
// Why this channel skips the trusted-renderer check: its sender is a preview guest rendering a
// workspace document, which is the untrusted side by construction. The guest policy holds the
// gate, so all this listener owes is the sender and a string.
it('hands a reported link click to the guest policy with the sender that reported it', () => {
reportLinkClick('https://example.com/docs')
expect(mocks.reportDocPreviewLinkClick).toHaveBeenCalledExactlyOnceWith(
sender,
'https://example.com/docs'
)
expect(mocks.isTrustedBrowserRenderer).not.toHaveBeenCalled()
})
it('ignores a reported click that is not a string at all', () => {
reportLinkClick({ url: 'https://example.com/docs' })
reportLinkClick(undefined)
expect(mocks.reportDocPreviewLinkClick).not.toHaveBeenCalled()
})
})
+95
View File
@@ -0,0 +1,95 @@
import { ipcMain } from 'electron'
import {
buildDocPreviewUrl,
DOC_PREVIEW_LINK_CLICK_CHANNEL,
DOC_PREVIEW_MINT_GRANT_CHANNEL,
DOC_PREVIEW_REVOKE_GRANT_CHANNEL
} from '../../shared/doc-preview-scheme'
import { browserManager } from '../browser/browser-manager'
import { reportDocPreviewLinkClick } from '../browser/doc-preview-guest-policy'
import {
mintDocPreviewGrant,
revokeDocPreviewGrant,
type DocPreviewOwner
} from '../browser/doc-preview-grant-registry'
import { isTrustedBrowserRenderer } from './browser-renderer-trust'
export type DocPreviewGrantRequest = {
owner: DocPreviewOwner
/** Containing directory of the opened document, on the owning host. */
root: string
/** Opened document, relative to `root`. */
entryRelativePath: string
/** Browser page the reader is opening the document in; main registers the guest under it. */
browserPageId: string
}
export type DocPreviewGrantResult = { grantId: string; url: string }
function isValidGrantRequest(request: DocPreviewGrantRequest): boolean {
if (!request.root.trim() || !request.entryRelativePath.trim()) {
return false
}
if (typeof request.browserPageId !== 'string' || !request.browserPageId.trim()) {
return false
}
if (request.owner.kind === 'ssh') {
return Boolean(request.owner.connectionId.trim())
}
return Boolean(
request.owner.environmentId.trim() &&
request.owner.worktreeSelector.trim() &&
request.owner.worktreeRoot.trim()
)
}
/**
* Minting never widens what the renderer can already read: an SSH grant reads
* through the same provider as `fs:readFile`, and a runtime grant through the
* same worktree-scoped `files.read` RPC the renderer can call directly.
*/
export function registerDocPreviewGrantHandlers(): void {
ipcMain.handle(
DOC_PREVIEW_MINT_GRANT_CHANNEL,
(event, request: DocPreviewGrantRequest): DocPreviewGrantResult => {
// Why gate a channel guests cannot reach today: this one hands out filesystem-read
// authority, so it holds the same sender check its sibling browser channels do rather than
// relying on guests never gaining an ipcRenderer.
if (!isTrustedBrowserRenderer(event.sender)) {
throw new Error('Untrusted document preview grant request')
}
if (!isValidGrantRequest(request)) {
throw new Error('Invalid document preview grant request')
}
// Why the other half of the registry is consulted here: this is where a page first becomes a
// document page, and the two halves must stay disjoint. Naming a page that already hosts a
// browsing guest would make one id resolve in both.
if (browserManager.getGuestWebContentsId(request.browserPageId) !== null) {
throw new Error('Document preview grant names a browsing page')
}
const grant = mintDocPreviewGrant({
owner: request.owner,
root: request.root,
entryRelativePath: request.entryRelativePath,
browserPageId: request.browserPageId
})
return {
grantId: grant.id,
url: buildDocPreviewUrl(grant.id, grant.entryRelativePath)
}
}
)
ipcMain.handle(DOC_PREVIEW_REVOKE_GRANT_CHANNEL, (event, grantId: string): boolean =>
isTrustedBrowserRenderer(event.sender) ? revokeDocPreviewGrant(grantId) : false
)
// Why no trusted-renderer check here: the sender is a preview guest rendering a workspace
// document, which is exactly the untrusted side. `reportDocPreviewLinkClick` is the gate — a
// live bound grant, a focused guest, a web URL — and it drops everything else silently.
ipcMain.on(DOC_PREVIEW_LINK_CLICK_CHANNEL, (event, url: unknown) => {
if (typeof url === 'string') {
reportDocPreviewLinkClick(event.sender, url)
}
})
}
@@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({
attachGuestPolicies: vi.fn(),
installNavigationPolicy: vi.fn(),
isAllowedPartition: vi.fn(),
attachRouteGuest: vi.fn(),
registerPluginGuard: vi.fn()
}))
@@ -20,20 +21,61 @@ vi.mock('../plugins/plugin-panel-navigation-guard', () => ({
vi.mock('./privileged-window-navigation', () => ({
installPrivilegedWindowNavigationPolicy: mocks.installNavigationPolicy
}))
vi.mock('../browser/browser-route-session-runtime', () => ({
browserRouteSessionRegistry: { isAllowedPartition: () => false },
browserRouteWebContentsRegistry: { attachGuest: mocks.attachRouteGuest }
}))
vi.mock('../browser/local-ssh-browser-partitions', () => ({
isLocalSshBrowserPartition: () => false,
enforceLocalSshWebRtcPolicyForGuest: vi.fn()
}))
vi.mock('../browser/doc-preview-protocol', () => ({
isDocPreviewSession: (candidate: unknown) => candidate === 'doc-preview-session'
}))
import { installMainWindowWebviewSecurity } from './main-window-webview-security'
import {
getDocPreviewGrant,
mintDocPreviewGrant,
revokeAllDocPreviewGrants
} from '../browser/doc-preview-grant-registry'
import {
publishDocPreviewFailure,
setDocPreviewFailureSink
} from '../browser/doc-preview-failure-notice'
import { buildDocPreviewUrl, DOC_PREVIEW_PARTITION } from '../../shared/doc-preview-scheme'
function installOnFakeWindow(): {
handlers: Record<string, (...args: never[]) => void>
webContents: { on: ReturnType<typeof vi.fn> }
} {
const handlers: Record<string, (...args: never[]) => void> = {}
const webContents = {
on: vi.fn((event: string, handler: (...args: never[]) => void) => {
handlers[event] = handler
})
}
installMainWindowWebviewSecurity({ webContents } as never)
return { handlers, webContents }
}
function mintPreviewGrant(): ReturnType<typeof mintDocPreviewGrant> {
return mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
}
describe('main window webview security', () => {
beforeEach(() => vi.clearAllMocks())
beforeEach(() => {
vi.clearAllMocks()
revokeAllDocPreviewGrants()
})
it('fails closed before applying hardened guest preferences', () => {
const handlers: Record<string, (...args: never[]) => void> = {}
const webContents = {
on: vi.fn((event: string, handler: (...args: never[]) => void) => {
handlers[event] = handler
})
}
installMainWindowWebviewSecurity({ webContents } as never)
const { handlers, webContents } = installOnFakeWindow()
mocks.isAllowedPartition.mockReturnValue(false)
const preventDefault = vi.fn()
@@ -49,13 +91,7 @@ describe('main window webview security', () => {
})
it('removes renderer preload input and restores every hardened preference', () => {
const handlers: Record<string, (...args: never[]) => void> = {}
const webContents = {
on: vi.fn((event: string, handler: (...args: never[]) => void) => {
handlers[event] = handler
})
}
installMainWindowWebviewSecurity({ webContents } as never)
const { handlers } = installOnFakeWindow()
mocks.isAllowedPartition.mockReturnValue(true)
const params = { src: 'https://example.com', preload: 'attacker.js' }
const preferences: Record<string, unknown> = {
@@ -85,3 +121,198 @@ describe('main window webview security', () => {
expect(String(preferences.preload)).toMatch(/browser-window-close-preload\.js$/)
})
})
describe('orca-preview scheme admission', () => {
beforeEach(() => {
vi.clearAllMocks()
revokeAllDocPreviewGrants()
})
it('admits a preview URL only on the preview partition and only with a live grant', () => {
// Install first, as the window does: installation itself clears the registry.
const { handlers } = installOnFakeWindow()
const grant = mintPreviewGrant()
mocks.isAllowedPartition.mockReturnValue(false)
const preventDefault = vi.fn()
const preferences: Record<string, unknown> = {
partition: DOC_PREVIEW_PARTITION,
preload: 'attacker.js',
sandbox: false
}
handlers['will-attach-webview']?.(
{ preventDefault } as never,
preferences as never,
{ src: buildDocPreviewUrl(grant.id, 'index.html'), preload: 'attacker.js' } as never
)
expect(preventDefault).not.toHaveBeenCalled()
expect(preferences).toMatchObject({
partition: DOC_PREVIEW_PARTITION,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true
})
expect(preferences).not.toHaveProperty('preloadURL')
})
// Why both directions: the preview preload is the only script that can turn a press into a
// browser tab, so a browsing guest must never receive it — and a preview must never receive
// anything else, least of all a value the renderer supplied.
it('pins the preview preload onto a preview attach, replacing whatever the renderer asked for', () => {
const { handlers } = installOnFakeWindow()
const grant = mintPreviewGrant()
mocks.isAllowedPartition.mockReturnValue(false)
const params = { src: buildDocPreviewUrl(grant.id, 'index.html'), preload: 'attacker.js' }
const preferences: Record<string, unknown> = {
partition: DOC_PREVIEW_PARTITION,
preload: 'attacker.js'
}
handlers['will-attach-webview']?.(
{ preventDefault: vi.fn() } as never,
preferences as never,
params as never
)
expect(params).not.toHaveProperty('preload')
expect(String(preferences.preload)).toMatch(/doc-preview-link-preload\.js$/)
})
it('keeps the preview preload off a browsing attach', () => {
const { handlers } = installOnFakeWindow()
mocks.isAllowedPartition.mockReturnValue(true)
const preferences: Record<string, unknown> = { partition: 'persist:orca-browser' }
handlers['will-attach-webview']?.(
{ preventDefault: vi.fn() } as never,
preferences as never,
{ src: 'https://example.com' } as never
)
expect(String(preferences.preload)).toMatch(/browser-window-close-preload\.js$/)
})
it('denies a preview URL whose grant is unknown or revoked', () => {
const { handlers } = installOnFakeWindow()
mocks.isAllowedPartition.mockReturnValue(false)
const preventDefault = vi.fn()
handlers['will-attach-webview']?.(
{ preventDefault } as never,
{ partition: DOC_PREVIEW_PARTITION } as never,
{ src: `orca-preview://${'0'.repeat(32)}/index.html` } as never
)
expect(preventDefault).toHaveBeenCalledOnce()
})
it('denies a preview URL smuggled onto a browsing partition', () => {
const { handlers } = installOnFakeWindow()
const grant = mintPreviewGrant()
// Even an allowlisted browsing partition must not load the preview scheme.
mocks.isAllowedPartition.mockReturnValue(true)
const preventDefault = vi.fn()
handlers['will-attach-webview']?.(
{ preventDefault } as never,
{ partition: 'persist:orca-browser' } as never,
{ src: buildDocPreviewUrl(grant.id, 'index.html') } as never
)
expect(preventDefault).toHaveBeenCalledOnce()
})
it('denies a web URL on the preview partition', () => {
const { handlers } = installOnFakeWindow()
mocks.isAllowedPartition.mockReturnValue(false)
const preventDefault = vi.fn()
handlers['will-attach-webview']?.(
{ preventDefault } as never,
{ partition: DOC_PREVIEW_PARTITION } as never,
{ src: 'https://example.com' } as never
)
expect(preventDefault).toHaveBeenCalledOnce()
})
// Why the host is asserted and not just the profile: it is the renderer that minted the grant,
// and it is the only sink a link the reader presses can be reported to. A preview attached
// against another window's contents would report its clicks to a reader who is not there.
it('attaches a preview guest under the workspace-doc profile, hosted by this window', () => {
const { handlers, webContents } = installOnFakeWindow()
handlers['did-attach-webview']?.({} as never, { session: 'doc-preview-session' } as never)
expect(mocks.attachGuestPolicies).toHaveBeenCalledWith(
{ session: 'doc-preview-session' },
null,
{
profile: 'workspace-doc',
host: webContents
}
)
expect(mocks.attachRouteGuest).not.toHaveBeenCalled()
})
it('keeps browser guests on the browsing profile and its route registration', () => {
const { handlers } = installOnFakeWindow()
handlers['did-attach-webview']?.({} as never, { session: 'browser-session' } as never)
expect(mocks.attachGuestPolicies).toHaveBeenCalledOnce()
expect(mocks.attachGuestPolicies.mock.calls[0]?.[2]).toBeUndefined()
expect(mocks.attachRouteGuest).toHaveBeenCalledOnce()
})
// Why: every live preview belongs to this window, so its teardown is the one moment no grant can
// still have a reader — and the failure sink must stop pointing at dead WebContents.
it('drops the failure sink and every grant when the window contents are destroyed', () => {
const { handlers } = installOnFakeWindow()
const grant = mintPreviewGrant()
const send = vi.fn()
setDocPreviewFailureSink({ send })
expect(getDocPreviewGrant(grant.id)).not.toBeNull()
handlers['destroyed']?.()
publishDocPreviewFailure({
grantId: grant.id,
relativePath: 'index.html',
reason: 'unreadable'
})
expect(getDocPreviewGrant(grant.id)).toBeNull()
expect(send).not.toHaveBeenCalled()
})
// Why: the renderer is the only side that remembers which preview owns which grant, so a grant
// that outlives its renderer is a read authority nobody can release.
it('clears grants a previous renderer left behind when the window is created', () => {
const stranded = mintPreviewGrant()
installOnFakeWindow()
expect(getDocPreviewGrant(stranded.id)).toBeNull()
})
it('clears grants the renderer forgot across a reload', () => {
const { handlers } = installOnFakeWindow()
const grant = mintPreviewGrant()
handlers['did-start-navigation']?.({ isMainFrame: true, isSameDocument: false } as never)
expect(getDocPreviewGrant(grant.id)).toBeNull()
})
it('keeps grants across an in-document or subframe navigation, which keeps the renderer', () => {
const { handlers } = installOnFakeWindow()
const grant = mintPreviewGrant()
handlers['did-start-navigation']?.({ isMainFrame: true, isSameDocument: true } as never)
handlers['did-start-navigation']?.({ isMainFrame: false, isSameDocument: false } as never)
expect(getDocPreviewGrant(grant.id)).not.toBeNull()
})
})
@@ -13,16 +13,56 @@ import {
browserRouteWebContentsRegistry
} from '../browser/browser-route-session-runtime'
import { ORCA_BROWSER_BLANK_URL } from '../../shared/constants'
import { DOC_PREVIEW_PARTITION, parseDocPreviewUrl } from '../../shared/doc-preview-scheme'
import { setDocPreviewFailureSink } from '../browser/doc-preview-failure-notice'
import {
getDocPreviewGrant,
revokeAllDocPreviewGrants
} from '../browser/doc-preview-grant-registry'
import { isDocPreviewSession } from '../browser/doc-preview-protocol'
import { registerPluginPanelNavigationGuard } from '../plugins/plugin-panel-navigation-guard'
import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation'
/**
* Why a separate admission rule: `normalizeBrowserNavigationUrl` answers only for
* http(s) and `file:`, so `orca-preview://` can only ever attach here — and only
* on the doc-preview partition, carrying a grant the main process minted for a
* deliberate user preview action. Web content has no way to reach either.
*/
function isAdmissibleDocPreviewAttach(partition: string, src: string): boolean {
if (partition !== DOC_PREVIEW_PARTITION) {
return false
}
const target = parseDocPreviewUrl(src)
return target !== null && getDocPreviewGrant(target.grantId) !== null
}
export function installMainWindowWebviewSecurity(mainWindow: BrowserWindow): void {
installPrivilegedWindowNavigationPolicy(mainWindow.webContents)
// Why here and on every fresh shell document: the renderer holds the only record of which
// preview owns which grant, and a reload throws that record away. Grants it can no longer
// release would stay live read authorities for the rest of the process.
revokeAllDocPreviewGrants()
mainWindow.webContents.on('did-start-navigation', (details) => {
if (details.isMainFrame && !details.isSameDocument) {
revokeAllDocPreviewGrants()
}
})
// Why these contents and not the window: every live preview is a guest of this WebContents, and
// it is also the failure sink itself — once it is destroyed no grant it minted has a reader left.
mainWindow.webContents.on('destroyed', () => {
setDocPreviewFailureSink(null)
revokeAllDocPreviewGrants()
})
// Why: containment must be listening before any plugin panel frame is created,
// so register it with the window's other navigation policy.
registerPluginPanelNavigationGuard(mainWindow.webContents)
const browserWindowClosePreload = join(__dirname, 'browser-window-close-preload.js')
// Why a preview gets a preload at all: it is our own editor surface, not a browsing guest. This
// one only decides what a click on a link means, and it is pinned here so no renderer-supplied
// value can reach a preview guest and no other attach path can acquire it.
const docPreviewLinkPreload = join(__dirname, 'doc-preview-link-preload.js')
mainWindow.webContents.on('will-attach-webview', (event, webPreferences, params) => {
const src = typeof params.src === 'string' ? params.src : ''
const normalizedSrc = normalizeBrowserNavigationUrl(src)
@@ -33,12 +73,14 @@ export function installMainWindowWebviewSecurity(mainWindow: BrowserWindow): voi
// so admission here can never race an unproxied session. They navigate like
// profile partitions — the renderer owns their URLs, no main-side grants.
const isLocalSshPartition = isLocalSshBrowserPartition(partition)
const isDocPreviewAttach = isAdmissibleDocPreviewAttach(partition, src)
// Why: fail closed — deny any src or partition not in the registry allowlist so a renderer bug can't smuggle preload/Node into an unprivileged guest.
if (
!normalizedSrc ||
(!isProfilePartition && !isRoutePartition && !isLocalSshPartition) ||
(isRoutePartition && normalizedSrc !== ORCA_BROWSER_BLANK_URL)
!isDocPreviewAttach &&
(!normalizedSrc ||
(!isProfilePartition && !isRoutePartition && !isLocalSshPartition) ||
(isRoutePartition && normalizedSrc !== ORCA_BROWSER_BLANK_URL))
) {
event.preventDefault()
return
@@ -46,7 +88,7 @@ export function installMainWindowWebviewSecurity(mainWindow: BrowserWindow): voi
delete params.preload
// Why: preload runs in the page's main world before inline scripts can call window.close().
webPreferences.preload = browserWindowClosePreload
webPreferences.preload = isDocPreviewAttach ? docPreviewLinkPreload : browserWindowClosePreload
// Why: older Electron builds expose preloadURL alongside preload; delete both so the guest can't inherit the main preload bridge.
delete (webPreferences as Record<string, unknown>).preloadURL
// Why delete something Electron does not set: 43 does not pass the embedder's
@@ -68,6 +110,17 @@ export function installMainWindowWebviewSecurity(mainWindow: BrowserWindow): voi
})
mainWindow.webContents.on('did-attach-webview', (_event, guest) => {
if (isDocPreviewSession(guest.session)) {
// Why: preview guests never join browser-tab routing, popups or anti-detection; the
// workspace-doc profile is what refuses all three. The attach is also the point a live window
// exists to receive read failures for that guest.
setDocPreviewFailureSink(mainWindow.webContents)
browserManager.attachGuestPolicies(guest, null, {
profile: 'workspace-doc',
host: mainWindow.webContents
})
return
}
// Why: attach guest popup/nav policy at creation; waiting for renderer registration races target=_blank/early redirects past it.
browserManager.attachGuestPolicies(guest)
// Why: route guests override the generic popup fallback and stay blank until exact main-owned registration.
+2
View File
@@ -22,6 +22,7 @@ import type { BrowserApi } from './api/browser-api'
import type { CliApi } from './api/cli-install-api'
import type { CrashReportsApi, FeedbackApi } from './api/crash-report-api'
import type { DashboardApi, TerminalPreviewApi } from './api/dashboard-api'
import type { DocPreviewApi } from './api/doc-preview-api'
import type { EmulatorApi } from './api/emulator-api'
import type { EphemeralVmApi } from './api/ephemeral-vm-api'
import type { ExportApi, FilesystemApi } from './api/filesystem-api'
@@ -125,6 +126,7 @@ export type PreloadApi = {
remoteWorkspace: WorkspaceSessionApi['remoteWorkspace']
updater: UpdaterApi
notebook: FilesystemApi['notebook']
docPreview: DocPreviewApi['docPreview']
stats: StatsApi
memory: MemoryApi
claudeUsage: ClaudeUsageApi
+29
View File
@@ -0,0 +1,29 @@
import type { DocPreviewFailure } from '../../shared/doc-preview-scheme'
export type DocPreviewGrantOwner =
| { kind: 'ssh'; connectionId: string }
| {
kind: 'runtime'
environmentId: string
worktreeSelector: string
worktreeRoot: string
}
export type DocPreviewGrantRequest = {
owner: DocPreviewGrantOwner
root: string
entryRelativePath: string
/** Browser page the document is being opened in; main registers its guest under this id. */
browserPageId: string
}
export type DocPreviewApi = {
docPreview: {
mintGrant: (request: DocPreviewGrantRequest) => Promise<{ grantId: string; url: string }>
revokeGrant: (grantId: string) => Promise<boolean>
/** External link the preview guest tried to open; the renderer turns it into a browser tab. */
onExternalLink: (callback: (payload: { url: string }) => void) => () => void
/** Why the guest is showing an error body instead of the document. */
onLoadFailure: (callback: (payload: DocPreviewFailure) => void) => () => void
}
}
@@ -0,0 +1,269 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DOC_PREVIEW_LINK_CLICK_CHANNEL } from '../shared/doc-preview-scheme'
import {
handleDocPreviewLinkAuxClick,
handleDocPreviewLinkClick,
installDocPreviewLinkInterception,
PRELOAD_DOC_PREVIEW_LINK_CLICK_CHANNEL
} from './doc-preview-link-interception'
const PREVIEW_GRANT_ID = 'a'.repeat(32)
let report: ReturnType<typeof vi.fn<(url: string) => void>>
/** The URL the guest is actually on, which is what decides whether an href is a pure fragment. */
function documentUrl(): string {
return window.location.href
}
function loadPreviewDocument(body: string): void {
document.body.innerHTML = body
report = vi.fn<(url: string) => void>()
}
/**
* An SVG anchor around a hit target, built rather than parsed: the href is an SVGAnimatedString
* carrying the raw attribute, which is what a plain string read misses and the environment does
* not model.
*/
function loadSvgAnchorDocument(href: string): void {
loadPreviewDocument('<div id="host"></div>')
const svgNamespace = 'http://www.w3.org/2000/svg'
const anchor = document.createElementNS(svgNamespace, 'a')
anchor.setAttribute('href', href)
Object.defineProperty(anchor, 'href', { value: { baseVal: href } })
const hit = document.createElementNS(svgNamespace, 'rect')
hit.setAttribute('id', 'svg-hit')
anchor.appendChild(hit)
const svg = document.createElementNS(svgNamespace, 'svg')
svg.appendChild(anchor)
document.getElementById('host')!.appendChild(svg)
}
/**
* Dispatches through the same capture-phase listener the preload installs, but registered for one
* press only — a document-level listener left behind would answer every later test's clicks too.
*/
function dispatch(selector: string, event: MouseEvent): MouseEvent {
const onClick = (candidate: Event): void => handleDocPreviewLinkClick(candidate, report)
const onAuxClick = (candidate: Event): void =>
handleDocPreviewLinkAuxClick(candidate as MouseEvent)
document.addEventListener('click', onClick, true)
document.addEventListener('auxclick', onAuxClick, true)
try {
document.querySelector(selector)!.dispatchEvent(event)
} finally {
document.removeEventListener('click', onClick, true)
document.removeEventListener('auxclick', onAuxClick, true)
}
return event
}
/** Chromium's own press. Nothing a document can dispatch carries this. */
function pressTrusted(selector: string, init: MouseEventInit = {}): MouseEvent {
const event = new MouseEvent('click', { bubbles: true, cancelable: true, ...init })
Object.defineProperty(event, 'isTrusted', { configurable: true, value: true })
return dispatch(selector, event)
}
beforeEach(() => {
// Why: the module decides "sibling document" and "pure fragment" against the URL the guest is
// really on, which in a preview is always the preview scheme.
;(window as never as { happyDOM: { setURL: (url: string) => void } }).happyDOM.setURL(
`orca-preview://${PREVIEW_GRANT_ID}/index.html`
)
vi.restoreAllMocks()
})
describe('doc preview link interception', () => {
it('sends on exactly the channel main listens on', () => {
expect(PRELOAD_DOC_PREVIEW_LINK_CLICK_CHANNEL).toBe(DOC_PREVIEW_LINK_CLICK_CHANNEL)
})
it('listens for both press kinds in the capture phase, so the document cannot consume them first', () => {
// Why not let it register: a document-level listener would survive into every later press here.
const addEventListener = vi.spyOn(document, 'addEventListener').mockImplementation(() => {})
installDocPreviewLinkInterception(vi.fn())
expect(addEventListener.mock.calls.map(([type, , options]) => [type, options])).toEqual([
['click', true],
['auxclick', true]
])
})
// Why the headline: this is the whole point of the design. A document that can read its grant
// must not be able to hand it to a browser tab by synthesizing the reader's click.
it('routes nothing for a click the document dispatched itself', () => {
loadPreviewDocument('<a id="external" href="https://attacker.test/?d=secret">go</a>')
const scripted = new MouseEvent('click', { bubbles: true, cancelable: true })
// Why set it: this is what Chromium stamps on anything a document dispatches, and the test
// environment leaves the property off entirely.
Object.defineProperty(scripted, 'isTrusted', { configurable: true, value: false })
const event = dispatch('#external', scripted)
expect(event.isTrusted).toBe(false)
expect(report).not.toHaveBeenCalled()
expect(event.defaultPrevented).toBe(false)
})
it('reports a trusted press on an external anchor exactly once and stops the navigation', () => {
loadPreviewDocument('<a id="external" href="https://example.com/docs">go</a>')
const event = pressTrusted('#external')
expect(report).toHaveBeenCalledExactlyOnceWith('https://example.com/docs')
expect(event.defaultPrevented).toBe(true)
})
it('reports the anchor even when the press landed on what it wraps', () => {
loadPreviewDocument('<a href="https://example.com/docs"><span id="inner">go</span></a>')
pressTrusted('#inner')
expect(report).toHaveBeenCalledExactlyOnceWith('https://example.com/docs')
})
// Why: an SVG anchor's href is an SVGAnimatedString, so reading it as a string finds nothing and
// the link would fall through to the guest's navigation guard and die there.
it('reports an SVG anchor by its animated href', () => {
loadSvgAnchorDocument('https://example.com/chart')
pressTrusted('#svg-hit')
expect(report).toHaveBeenCalledExactlyOnceWith('https://example.com/chart')
})
// Why these three: baseVal is the raw attribute, so an unresolved read sends a relative sibling
// out to a browser tab, drops SVG fragment links on the floor, and turns a rooted path into a
// file URL the guest refuses — each one a divergence from the identical HTML anchor.
it('leaves a relative SVG link to the guest, the same as the HTML anchor beside it', () => {
loadSvgAnchorDocument('guide.html')
const event = pressTrusted('#svg-hit')
expect(report).not.toHaveBeenCalled()
expect(event.defaultPrevented).toBe(false)
})
it('scrolls to an SVG fragment target instead of routing it', () => {
loadSvgAnchorDocument('#section-2')
const target = document.createElement('h2')
target.id = 'section-2'
document.body.appendChild(target)
const scrollIntoView = vi.spyOn(target, 'scrollIntoView')
const event = pressTrusted('#svg-hit')
expect(scrollIntoView).toHaveBeenCalledOnce()
expect(report).not.toHaveBeenCalled()
expect(event.defaultPrevented).toBe(true)
})
it('scrolls to the top for a bare # on an SVG anchor', () => {
loadSvgAnchorDocument('#')
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
pressTrusted('#svg-hit')
expect(scrollTo).toHaveBeenCalledOnce()
expect(report).not.toHaveBeenCalled()
})
it('keeps a rooted SVG link inside the preview rather than reading it as a filesystem path', () => {
loadSvgAnchorDocument('/assets/a.html')
const event = pressTrusted('#svg-hit')
expect(report).not.toHaveBeenCalled()
expect(event.defaultPrevented).toBe(false)
})
it('leaves a sibling preview document to the guest, which its policy already permits', () => {
loadPreviewDocument(
`<a id="sibling" href="orca-preview://${PREVIEW_GRANT_ID}/guide.html">guide</a>`
)
const event = pressTrusted('#sibling')
expect(report).not.toHaveBeenCalled()
expect(event.defaultPrevented).toBe(false)
})
it('scrolls to a fragment target in the document instead of routing it', () => {
loadPreviewDocument(
`<a id="jump" href="${documentUrl()}#section">jump</a><h2 id="section">s</h2>`
)
const scrollIntoView = vi.spyOn(document.getElementById('section')!, 'scrollIntoView')
const event = pressTrusted('#jump')
expect(scrollIntoView).toHaveBeenCalledOnce()
expect(report).not.toHaveBeenCalled()
expect(event.defaultPrevented).toBe(true)
})
it('finds a fragment target whose id the href percent-encoded', () => {
loadPreviewDocument(`<a id="jump" href="${documentUrl()}#a%20b">jump</a><h2 id="a b">s</h2>`)
const scrollIntoView = vi.spyOn(document.getElementById('a b')!, 'scrollIntoView')
pressTrusted('#jump')
expect(scrollIntoView).toHaveBeenCalledOnce()
expect(report).not.toHaveBeenCalled()
})
it('scrolls to the top for a bare # and for #top with nothing carrying that id', () => {
loadPreviewDocument(
`<a id="hash" href="#">top</a><a id="named" href="${documentUrl()}#top">top</a>`
)
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
pressTrusted('#hash')
pressTrusted('#named')
expect(scrollTo).toHaveBeenCalledTimes(2)
expect(report).not.toHaveBeenCalled()
})
it('routes a fragment href that belongs to another document', () => {
loadPreviewDocument('<a id="other" href="https://example.com/page#section">go</a>')
pressTrusted('#other')
expect(report).toHaveBeenCalledExactlyOnceWith('https://example.com/page#section')
})
it('opens nothing for a middle click on a link', () => {
loadPreviewDocument('<a id="external" href="https://example.com/docs">go</a>')
const event = new MouseEvent('auxclick', { bubbles: true, button: 1, cancelable: true })
Object.defineProperty(event, 'isTrusted', { configurable: true, value: true })
dispatch('#external', event)
expect(report).not.toHaveBeenCalled()
expect(event.defaultPrevented).toBe(true)
})
it('leaves a middle click the document dispatched itself alone', () => {
loadPreviewDocument('<a id="external" href="https://example.com/docs">go</a>')
const event = new MouseEvent('auxclick', { bubbles: true, button: 1, cancelable: true })
Object.defineProperty(event, 'isTrusted', { configurable: true, value: false })
dispatch('#external', event)
expect(event.defaultPrevented).toBe(false)
})
it('ignores a press that is not on a link at all', () => {
loadPreviewDocument('<p id="text">nothing here</p>')
const event = pressTrusted('#text')
expect(report).not.toHaveBeenCalled()
expect(event.defaultPrevented).toBe(false)
})
})
@@ -0,0 +1,152 @@
/**
* Decides what a click inside a preview guest means. This is the only way a URL leaves the
* preview, so it answers for a click the reader really made: navigation the document starts by
* itself never reaches here, and the guest's navigation policy refuses it outright.
*
* Runs in the guest's isolated world and exposes nothing to page script — the document cannot see
* these listeners, remove them, or call what they call.
*/
export type DocPreviewExternalLinkReporter = (url: string) => void
/**
* Why the channel name is written out rather than imported: this module is bundled into a
* sandboxed preload, whose `require` resolves only 'electron', so an import of shared code emits a
* chunk require the guest cannot load. The test pins it to the constant main listens on.
*/
export const PRELOAD_DOC_PREVIEW_LINK_CLICK_CHANNEL = 'docPreview:linkClick'
/** Why not `instanceof HTMLAnchorElement`: an SVG `<a>` is an anchor too, and carries an SVGAnimatedString href. */
function readAnchorHref(node: EventTarget): string | null {
const element = node as { tagName?: unknown; href?: unknown }
if (typeof element.tagName !== 'string' || element.tagName.toUpperCase() !== 'A') {
return null
}
if (typeof element.href === 'string') {
return element.href.length > 0 ? element.href : null
}
const baseVal = (element.href as { baseVal?: unknown } | null | undefined)?.baseVal
if (typeof baseVal !== 'string' || baseVal.length === 0) {
return null
}
// Why resolved here: baseVal is the raw attribute, unlike an HTML anchor's absolute href, so
// every branch below would read an SVG link differently from the identical HTML one.
try {
return new URL(baseVal, window.location.href).toString()
} catch {
return null
}
}
/** Why the composed path and not `event.target`: the press lands on whatever the anchor wraps, shadow roots included. */
function findClickedAnchor(event: Event): { element: EventTarget; href: string } | null {
for (const node of event.composedPath()) {
const href = readAnchorHref(node)
if (href !== null) {
return { element: node, href }
}
}
return null
}
function scrollToDocumentTop(): void {
window.scrollTo(0, 0)
}
function readAttribute(element: EventTarget, name: string): string | null {
const candidate = element as { getAttribute?: (attribute: string) => string | null }
return typeof candidate.getAttribute === 'function' ? candidate.getAttribute(name) : null
}
function scrollToFragment(fragment: string): void {
let target = document.getElementById(fragment)
if (!target) {
try {
target = document.getElementById(decodeURIComponent(fragment))
} catch {
target = null
}
}
if (target) {
target.scrollIntoView()
return
}
// Why: `#top` names the top of the document even when nothing carries that id.
if (fragment === 'top') {
scrollToDocumentTop()
}
}
/** True when the href only moves within the document already on screen, which this handles itself. */
function handleInDocumentFragment(element: EventTarget, href: string): boolean {
if (readAttribute(element, 'href') === '#') {
scrollToDocumentTop()
return true
}
const hashIndex = href.indexOf('#')
if (hashIndex === -1) {
return false
}
const currentHref = window.location.href
const currentHashIndex = currentHref.indexOf('#')
const currentBase = currentHashIndex === -1 ? currentHref : currentHref.slice(0, currentHashIndex)
if (href.slice(0, hashIndex) !== currentBase) {
return false
}
scrollToFragment(href.slice(hashIndex + 1))
return true
}
function isSameSchemeAsDocument(href: string): boolean {
try {
return new URL(href).protocol === window.location.protocol
} catch {
return false
}
}
export function handleDocPreviewLinkClick(
event: Event,
report: DocPreviewExternalLinkReporter
): void {
// Why first: a click the page dispatched is the document asking to leave, not the reader.
if (!event.isTrusted) {
return
}
const anchor = findClickedAnchor(event)
if (!anchor) {
return
}
if (handleInDocumentFragment(anchor.element, anchor.href)) {
event.preventDefault()
return
}
// Why left alone: a link served the way this document was is a sibling preview document, which
// the guest policy already answers for — it navigates natively and the preview keeps its history.
if (isSameSchemeAsDocument(anchor.href)) {
return
}
event.preventDefault()
report(anchor.href)
}
export function handleDocPreviewLinkAuxClick(event: MouseEvent): void {
if (!event.isTrusted || event.button !== 1) {
return
}
if (!findClickedAnchor(event)) {
return
}
// Why swallowed rather than routed: a middle click asks for a background tab, and honouring it
// would let one press the reader barely registered open a browser tab.
event.preventDefault()
}
export function installDocPreviewLinkInterception(report: DocPreviewExternalLinkReporter): void {
// Why capture: the document's own handlers must not be able to consume the press first.
document.addEventListener('click', (event) => handleDocPreviewLinkClick(event, report), true)
document.addEventListener(
'auxclick',
(event) => handleDocPreviewLinkAuxClick(event as MouseEvent),
true
)
}
+15
View File
@@ -0,0 +1,15 @@
import {
installDocPreviewLinkInterception,
PRELOAD_DOC_PREVIEW_LINK_CLICK_CHANNEL
} from './doc-preview-link-interception'
// Why: raw require keeps the sandboxed preload standalone in the main-process CJS build.
const { ipcRenderer } = require('electron') as {
ipcRenderer: { send: (channel: string, ...args: unknown[]) => void }
}
// Why no contextBridge: the document must not be able to call this. The listeners live in the
// isolated world, and main still refuses any report that does not come from a focused preview guest.
installDocPreviewLinkInterception((url) => {
ipcRenderer.send(PRELOAD_DOC_PREVIEW_LINK_CLICK_CHANNEL, url)
})
+27
View File
@@ -8,6 +8,14 @@ import type {
SkillDeleteRequest,
SkillDeleteResult
} from '../shared/skill-delete-contract'
import {
DOC_PREVIEW_EXTERNAL_LINK_CHANNEL,
DOC_PREVIEW_LOAD_FAILURE_CHANNEL,
DOC_PREVIEW_MINT_GRANT_CHANNEL,
DOC_PREVIEW_REVOKE_GRANT_CHANNEL,
type DocPreviewFailure
} from '../shared/doc-preview-scheme'
import type { DocPreviewGrantRequest } from './api/doc-preview-api'
import type { AppIdentity } from '../shared/app-identity'
import type { MacCapturedDigitRowChord } from '../shared/macos-symbolic-hotkeys'
import type { ComputerAwakeStatus } from '../shared/computer-awake-mode'
@@ -3322,6 +3330,25 @@ const api = {
}
} satisfies PreloadApi['updater'],
docPreview: {
mintGrant: (request: DocPreviewGrantRequest): Promise<{ grantId: string; url: string }> =>
ipcRenderer.invoke(DOC_PREVIEW_MINT_GRANT_CHANNEL, request),
revokeGrant: (grantId: string): Promise<boolean> =>
ipcRenderer.invoke(DOC_PREVIEW_REVOKE_GRANT_CHANNEL, grantId),
onExternalLink: (callback: (payload: { url: string }) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: { url: string }): void =>
callback(payload)
ipcRenderer.on(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, listener)
return () => ipcRenderer.removeListener(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, listener)
},
onLoadFailure: (callback: (payload: DocPreviewFailure) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: DocPreviewFailure): void =>
callback(payload)
ipcRenderer.on(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener)
return () => ipcRenderer.removeListener(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener)
}
},
notebook: {
runPythonCell: (args: {
filePath: string
@@ -21,6 +21,7 @@ import { ClientHostedBrowserUnavailableNotice } from './client-hosted-browser-un
import { useRestoredClientHostedRecoveryWindow } from './restored-client-hosted-recovery-window'
import BrowserFind from './assemble-chrome/BrowserFind'
import { BrowserNavigationControlRow } from './assemble-chrome/browser-navigation-control-row'
import BrowserAddressBar from './assemble-chrome/BrowserAddressBar'
import { BrowserPageContextMenu } from './assemble-chrome/browser-page-context-menu'
import { useBrowserPageChromeFocus } from './assemble-chrome/use-browser-page-chrome-focus'
import { useBrowserAddressBarEditSession } from './assemble-chrome/use-browser-address-bar-edit-session'
@@ -165,7 +166,7 @@ export function ClientHostedBrowserPagePane({
const navigateToUrl = useCallback(
(value: string) => {
const submission = resolveBrowserAddressBarSubmission(value)
const submission = resolveBrowserAddressBarSubmission(value, { allowFileUrls: false })
if (submission.status === 'invalid') {
onUpdatePageState(browserTab.id, { loadError: submission.loadError })
return
@@ -367,18 +368,23 @@ export function ClientHostedBrowserPagePane({
reload: () => reload.runReloadTrigger('button'),
navigate: navigateToUrl
}}
addressBarValue={addressBarValue}
onAddressBarChange={setAddressBarValue}
onSubmitAddressBar={() => navigateToUrl(addressBarValue)}
addressBarInputRef={addressBarInputRef}
addressBarEditSession={addressBarEditSession}
reloadLabel={reload.reloadButtonLabel}
addressBarLeadingIcon={
<RemoteRuntimeEgressIndicator
runtimeEnvironmentId={runtimeEnvironmentId}
presentation="client-hosted"
addressSlot={
<BrowserAddressBar
value={addressBarValue}
onChange={setAddressBarValue}
onSubmit={() => navigateToUrl(addressBarValue)}
onNavigate={navigateToUrl}
inputRef={addressBarInputRef}
editSession={addressBarEditSession}
leadingIcon={
<RemoteRuntimeEgressIndicator
runtimeEnvironmentId={runtimeEnvironmentId}
presentation="client-hosted"
/>
}
/>
}
reloadLabel={reload.reloadButtonLabel}
/>
</div>
<div ref={viewportRef} className="relative min-h-0 flex-1 overflow-hidden bg-background">
@@ -0,0 +1,189 @@
import type { MutableRefObject, RefObject } from 'react'
import { Copy, Image } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { translate } from '@/i18n/i18n'
import { MarkupOverlay } from './MarkupOverlay'
import type { MarkupModeController } from './useMarkupMode'
import type { GrabModeHook } from './useGrabMode'
import {
getBrowserOverlayAnchor,
type BrowserOverlayViewport
} from '../describe-page/browser-annotation-geometry'
import { BrowserPageAnnotationTray } from './browser-page-annotation-tray'
import { BrowserPageGrabToast } from './browser-page-grab-toast'
import { PendingBrowserAnnotationCard } from './pending-browser-annotation-card'
import type { useBrowserPageAnnotationSend } from './use-browser-page-annotation-send'
import type { useBrowserPageGrabAnnotations } from './use-browser-page-grab-annotations'
/**
* Everything the annotate and markup tools paint over a guest: the draw surface, the pending
* comment card, the annotation tray, the right-click grab menu and the inline confirmation toast.
*
* Shared because these overlays are the other half of the toolbar's tool cluster — a surface that
* offers the tools but not these would arm a picker whose result the reader could never see.
*/
export function BrowserGuestAnnotateOverlays({
markup,
grab,
annotationSend,
grabAnnotations,
containerRef,
webviewRef,
browserOverlayViewport,
worktreeId
}: {
markup: MarkupModeController
grab: GrabModeHook
annotationSend: ReturnType<typeof useBrowserPageAnnotationSend>
grabAnnotations: ReturnType<typeof useBrowserPageGrabAnnotations>
containerRef: RefObject<HTMLDivElement | null>
webviewRef: MutableRefObject<Electron.WebviewTag | null>
browserOverlayViewport: BrowserOverlayViewport
worktreeId: string
}): React.JSX.Element {
const {
pendingAnnotationPayload,
handleAddBrowserAnnotation,
handleCancelPendingBrowserAnnotation,
grabIntent,
grabMenuActionTakenRef,
handleGrabCopy,
handleGrabCopyScreenshot,
grabToast,
grabToastTimerRef,
dismissGrabToast,
setGrabToast
} = grabAnnotations
const {
browserAnnotations,
browserAnnotationTrayOpen,
annotationTraySendOpen,
handleAnnotationTraySendOpenChange,
activeGroupId,
browserAnnotationsPrompt,
handleBrowserAnnotationsSentToAgent,
handleCopyBrowserAnnotations,
browserAnnotationsCopied,
handleClearBrowserAnnotations,
handleDeleteBrowserAnnotation
} = annotationSend
return (
<>
{markup.isActive && markup.baseImage ? (
<MarkupOverlay
baseImage={markup.baseImage}
busy={markup.state === 'composing'}
onComplete={(input) => void markup.complete(input)}
onCancel={markup.cancel}
/>
) : null}
{pendingAnnotationPayload ? (
<PendingBrowserAnnotationCard
payload={pendingAnnotationPayload}
anchor={getBrowserOverlayAnchor(
pendingAnnotationPayload,
containerRef.current,
webviewRef.current,
browserOverlayViewport
)}
portalContainer={containerRef.current}
onAdd={handleAddBrowserAnnotation}
onCancel={handleCancelPendingBrowserAnnotation}
/>
) : null}
{browserAnnotations.length > 0 && browserAnnotationTrayOpen ? (
<BrowserPageAnnotationTray
browserAnnotations={browserAnnotations}
annotationTraySendOpen={annotationTraySendOpen}
handleAnnotationTraySendOpenChange={handleAnnotationTraySendOpenChange}
worktreeId={worktreeId}
activeGroupId={activeGroupId}
browserAnnotationsPrompt={browserAnnotationsPrompt}
handleBrowserAnnotationsSentToAgent={handleBrowserAnnotationsSentToAgent}
handleCopyBrowserAnnotations={handleCopyBrowserAnnotations}
browserAnnotationsCopied={browserAnnotationsCopied}
handleClearBrowserAnnotations={handleClearBrowserAnnotations}
handleDeleteBrowserAnnotation={handleDeleteBrowserAnnotation}
/>
) : null}
{/* Right-click context dropdown, positioned at the grabbed element's center. */}
<DropdownMenu
open={grab.state === 'confirming' && grab.contextMenu && grabIntent === 'copy'}
onOpenChange={(open) => {
if (!open && grab.state === 'confirming') {
// Why: skip rearm if a menu action already handled it — see grabMenuActionTakenRef.
if (grabMenuActionTakenRef.current) {
grabMenuActionTakenRef.current = false
return
}
grab.rearm()
}
}}
>
<DropdownMenuTrigger asChild>
<button
aria-hidden
tabIndex={-1}
className="pointer-events-none absolute size-px opacity-0"
style={(() => {
if (!grab.payload) {
return { left: 0, top: 0 }
}
const rect = grab.payload.target.rectViewport
const webview = webviewRef.current
const webviewRect = webview?.getBoundingClientRect()
const cRect = containerRef.current?.getBoundingClientRect()
const offsetX = (webviewRect?.left ?? 0) - (cRect?.left ?? 0)
const offsetY = (webviewRect?.top ?? 0) - (cRect?.top ?? 0)
return {
left: offsetX + rect.x + rect.width / 2,
top: offsetY + rect.y + rect.height / 2
}
})()}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={4}>
<DropdownMenuItem onSelect={handleGrabCopy}>
<Copy className="size-3.5" />
{translate('auto.components.browser.pane.BrowserPane.c2ef0359b9', 'Copy Contents')}
<DropdownMenuShortcut>C</DropdownMenuShortcut>
</DropdownMenuItem>
{grab.payload?.screenshot?.dataUrl?.startsWith('data:image/png;base64,') ? (
<DropdownMenuItem onSelect={handleGrabCopyScreenshot}>
<Image className="size-3.5" />
{translate('auto.components.browser.pane.BrowserPane.1ded0d3168', 'Copy Screenshot')}
<DropdownMenuShortcut>S</DropdownMenuShortcut>
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
grabMenuActionTakenRef.current = true
grab.cancel()
}}
>
{translate('auto.components.browser.pane.BrowserPane.fa6ea61de3', 'Cancel')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Inline toast bubble; flips above the element when near the viewport bottom so it doesn't occlude it. */}
{grabToast ? (
<BrowserPageGrabToast
grabToast={grabToast}
grabToastTimerRef={grabToastTimerRef}
dismissGrabToast={dismissGrabToast}
setGrabToast={setGrabToast}
/>
) : null}
</>
)
}
@@ -12,7 +12,7 @@ export function runBrowserGrabActionShortcut({
grabIntent,
grab,
grabPayloadRef,
browserTabIdRef,
toolTargetIdRef,
recordFeatureInteraction,
showGrabToast
}: {
@@ -20,7 +20,7 @@ export function runBrowserGrabActionShortcut({
grabIntent: GrabIntent
grab: GrabModeHook
grabPayloadRef: MutableRefObject<BrowserGrabPayload | null>
browserTabIdRef: MutableRefObject<string>
toolTargetIdRef: MutableRefObject<string>
recordFeatureInteraction: (feature: 'browser-grab') => void | Promise<void>
showGrabToast: (
message: string,
@@ -70,7 +70,7 @@ export function runBrowserGrabActionShortcut({
let result: Awaited<ReturnType<typeof window.api.browser.extractHoverPayload>>
try {
result = await window.api.browser.extractHoverPayload({
browserPageId: browserTabIdRef.current
browserPageId: toolTargetIdRef.current
})
} catch {
// Why: the guest can be destroyed or the IPC channel torn down mid-shortcut; surface it like a miss instead of an unhandled rejection.
@@ -86,7 +86,7 @@ export function runBrowserGrabActionShortcut({
if (key === 's') {
try {
const ssResult = await window.api.browser.captureSelectionScreenshot({
browserPageId: browserTabIdRef.current,
browserPageId: toolTargetIdRef.current,
rect: payload.target.rectViewport
})
if (ssResult.ok) {
@@ -0,0 +1,43 @@
import type {
BrowserGrabPayload,
BrowserPageAnnotation
} from '../../../../../shared/browser-grab-types'
/**
* Push the current annotation set into the guest, where badges render in-page so they track scroll
* without a message per frame. Shared by every surface that annotates a guest — the payload is
* derived only from the annotations themselves, so the two surfaces cannot disagree about it.
*/
export function syncGuestAnnotationViewportBridge({
toolTargetId,
annotations,
pendingPayload,
surfaceActive,
token
}: {
toolTargetId: string
annotations: BrowserPageAnnotation[]
pendingPayload: BrowserGrabPayload | null
surfaceActive: boolean
token: string
}): void {
// Why: existing badges render in-guest for smooth scroll; only the pending dialog needs viewport messages.
const markers = annotations.map((annotation, index) => ({
id: annotation.id,
index,
isFixed: annotation.payload.target.isFixed === true,
rectPage: annotation.payload.target.rectPage,
rectViewport: annotation.payload.target.rectViewport
}))
void window.api.browser
.setAnnotationViewportBridge({
browserPageId: toolTargetId,
emitViewport: pendingPayload !== null,
enabled: surfaceActive && (pendingPayload !== null || markers.length > 0),
markers,
token
})
.catch(() => {
// The viewport bridge is visual-only; stale markers beat breaking the surface on a destroyed guest.
})
}
@@ -43,6 +43,7 @@ const annotationAddedGrabToastMessage = (): string =>
export function useBrowserPageGrabAnnotations({
browserTabId,
toolTargetId = browserTabId,
isActive,
grab,
containerRef,
@@ -51,7 +52,14 @@ export function useBrowserPageGrabAnnotations({
browserAnnotationsLength,
setBrowserAnnotationTrayOpen
}: {
/** Scopes the stored annotations. Stable for the life of the surface. */
browserTabId: string
/**
* The id main resolves to a guest. Defaults to the annotation scope, which is the same string
* for a browser page — a preview re-mints this on recovery, and its annotations must not be
* orphaned when it does.
*/
toolTargetId?: string
isActive: boolean
grab: GrabModeHook
containerRef: MutableRefObject<HTMLDivElement | null>
@@ -75,7 +83,7 @@ export function useBrowserPageGrabAnnotations({
handleCancelPendingBrowserAnnotation: () => void
handleGrabActionShortcut: (key: 'c' | 's') => void
} {
const browserTabIdRef = useRef(browserTabId)
const toolTargetIdRef = useRef(toolTargetId)
const grabToastTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const [grabIntent, setGrabIntent] = useState<GrabIntent>('copy')
const grabIntentRef = useRef(grabIntent)
@@ -88,12 +96,12 @@ export function useBrowserPageGrabAnnotations({
const grabPayloadRef = useRef(grab.payload)
useLayoutEffect(() => {
browserTabIdRef.current = browserTabId
toolTargetIdRef.current = toolTargetId
grabIntentRef.current = grabIntent
pendingAnnotationPayloadRef.current = pendingAnnotationPayload
grabRef.current = grab
grabPayloadRef.current = grab.payload
}, [browserTabId, grab, grabIntent, pendingAnnotationPayload])
}, [grab, grabIntent, pendingAnnotationPayload, toolTargetId])
// Why: Radix fires onOpenChange(false) before onSelect, so this flag lets onOpenChange skip the rearm that would clear the payload first.
const grabMenuActionTakenRef = useRef(false)
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
@@ -225,7 +233,7 @@ export function useBrowserPageGrabAnnotations({
grabIntent,
grab,
grabPayloadRef,
browserTabIdRef,
toolTargetIdRef,
recordFeatureInteraction,
showGrabToast
})
@@ -393,7 +393,9 @@ export default function BrowserAddressBar({
// Why: min-w-11 keeps the leading globe a real hit target once the toolbar
// squeezes the bar away — without it neighbouring buttons overlap the only
// affordance for reopening the URL field.
<div ref={slotRef} className="flex min-w-11 flex-1 items-center">
// Why stretch: the toolbar row pins the address slot's height, and the bar must fill it rather
// than size itself — otherwise it and the document chip drift apart again.
<div ref={slotRef} className="flex min-w-11 flex-1 items-stretch">
<Popover
modal={false}
open={open}
@@ -0,0 +1,13 @@
/**
* The height every identity widget in the toolbar's address slot is stretched to.
*
* Why the row owns it rather than each widget: an address bar is a text input and a document chip
* is a line of text, so left to themselves they come out 8px apart and the whole toolbar changes
* height when the reader switches between a web tab and a document tab. 2.375rem is the address
* bar's own natural box (its input's line box plus the frame padding), so pinning to it keeps the
* browser chrome exactly as it looks today and brings every other slot up to match.
*/
export const BROWSER_CHROME_ADDRESS_SLOT_HEIGHT_CLASS = 'h-9.5'
/** Marks the slot wrapper so tests can prove both surfaces share one height contract. */
export const BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE = 'data-browser-chrome-address-slot'
@@ -0,0 +1,206 @@
import { cn } from '@/lib/utils'
import { Crosshair, ExternalLink, MessageSquarePlus, SquareCode } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import {
BrowserNavigationControlRow,
type BrowserNavigationControls
} from './browser-navigation-control-row'
import { MarkupDrawButton } from '../annotate/MarkupDrawButton'
import type { GrabIntent } from '../describe-page/browser-page-types'
/** The in-guest element picker, driving both Grab (copy) and Annotate (comment). */
export type BrowserChromeElementTools = {
/** The intent the picker is armed for right now, or null when it is idle. */
activeIntent: GrabIntent | null
onStartIntent: (intent: GrabIntent) => void
disabled: boolean
grabShortcutLabel: string
annotationCount: number
}
export type BrowserChromeMarkupTool = {
active: boolean
disabled: boolean
onToggle: () => void
/**
* Whether this surface may spend the draw tool's one-per-install discovery popover. False on a
* hidden pane (a portaled layer would anchor to a zero-size trigger) and false on any surface
* that does not own the nudge — a preview consuming it would burn the single view on a reader
* who came for a document, before the browsing pane ever offered it.
*/
canShowDiscoveryHint: boolean
}
export type BrowserChromeToolAction = {
onSelect: () => void
label: string
disabled?: boolean
}
/**
* The whole browser chrome bar — history, identity, tools — for every surface that renders guest
* content: the browsing pane and the workspace document preview.
*
* Why one component and not a shared row plus two tool clusters: a tool added here has to appear
* on both surfaces to stay honest, and a per-surface cluster is exactly how they drift apart. A
* surface omits a tool only by passing null, and each null below says why that tool cannot apply.
*/
export function BrowserChromeToolbar({
controls,
addressSlot,
reloadControl,
reloadLabel,
importControl,
elementTools,
markup,
shareControl,
viewSource,
openExternal,
overflowMenu,
showTourAnchors = false
}: {
controls: BrowserNavigationControls
addressSlot: React.ReactNode
reloadControl?: React.ReactNode
reloadLabel?: string
/** Cookie import — a browsing session concept; null where there is no session to import into. */
importControl?: React.ReactNode
elementTools: BrowserChromeElementTools | null
markup: BrowserChromeMarkupTool
shareControl?: React.ReactNode
viewSource: BrowserChromeToolAction | null
openExternal: BrowserChromeToolAction | null
overflowMenu?: React.ReactNode
/** Only the browsing pane anchors the contextual tour; a second anchor would steal its steps. */
showTourAnchors?: boolean
}): React.JSX.Element {
return (
<BrowserNavigationControlRow
controls={controls}
addressSlot={addressSlot}
reloadControl={reloadControl}
reloadLabel={reloadLabel}
showTourAnchors={showTourAnchors}
>
{importControl}
{elementTools ? (
<>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button
size="icon"
variant={elementTools.activeIntent === 'copy' ? 'default' : 'ghost'}
className={cn(
'h-8 w-8',
elementTools.activeIntent === 'copy' &&
'bg-foreground/80 text-background hover:bg-foreground/90'
)}
onClick={() => elementTools.onStartIntent('copy')}
disabled={elementTools.disabled}
aria-label={translate(
'auto.components.browser.pane.BrowserPane.fdfc7fe0ef',
'Grab page element'
)}
{...(showTourAnchors
? { 'data-contextual-tour-target': 'browser-grab-control' }
: {})}
>
<Crosshair className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{translate(
'auto.components.browser.pane.BrowserPane.acbe79fd01',
'Grab page element ({{value0}})',
{ value0: elementTools.grabShortcutLabel }
)}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
{/* Why: disabled <button> drops hover events, so wrap in a span so the tooltip trigger still fires. */}
<span className="inline-flex">
<Button
size="icon"
variant={elementTools.activeIntent === 'annotate' ? 'default' : 'ghost'}
className={cn(
'relative h-8 w-8',
elementTools.activeIntent === 'annotate' &&
'bg-foreground/80 text-background hover:bg-foreground/90'
)}
onClick={() => elementTools.onStartIntent('annotate')}
disabled={elementTools.disabled}
aria-label={translate(
'auto.components.browser.pane.BrowserPane.fc9be38f6f',
'Annotate page element'
)}
{...(showTourAnchors
? { 'data-contextual-tour-target': 'browser-annotation-control' }
: {})}
>
<MessageSquarePlus className="size-4" />
{elementTools.annotationCount > 0 ? (
<span className="absolute -top-1 -right-1 flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-4 text-primary-foreground">
{elementTools.annotationCount}
</span>
) : null}
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{translate(
'auto.components.browser.pane.BrowserPane.fc9be38f6f',
'Annotate page element'
)}
</TooltipContent>
</Tooltip>
</>
) : null}
<MarkupDrawButton
onClick={markup.onToggle}
disabled={markup.disabled}
active={markup.active}
surfaceActive={markup.canShowDiscoveryHint}
/>
{shareControl}
{viewSource ? (
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={viewSource.onSelect}
title={viewSource.label}
aria-label={viewSource.label}
disabled={viewSource.disabled}
>
<SquareCode className="size-4" />
</Button>
) : null}
{openExternal ? (
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={openExternal.onSelect}
title={openExternal.label}
aria-label={openExternal.label}
disabled={openExternal.disabled}
>
<ExternalLink className="size-4" />
</Button>
) : null}
{overflowMenu}
</BrowserNavigationControlRow>
)
}
@@ -16,6 +16,11 @@ vi.mock('./BrowserAddressBar', () => ({
}
}))
import BrowserAddressBar from './BrowserAddressBar'
import {
BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE,
BROWSER_CHROME_ADDRESS_SLOT_HEIGHT_CLASS
} from './browser-chrome-address-slot'
import {
BrowserNavigationControlRow,
type BrowserNavigationControls
@@ -37,10 +42,15 @@ function renderRow(overrides: Partial<BrowserNavigationControls> = {}): BrowserN
return (
<BrowserNavigationControlRow
controls={controls}
addressBarValue="https://example.com/"
onAddressBarChange={vi.fn()}
onSubmitAddressBar={vi.fn()}
addressBarInputRef={inputRef}
addressSlot={
<BrowserAddressBar
value="https://example.com/"
onChange={vi.fn()}
onSubmit={vi.fn()}
onNavigate={controls.navigate}
inputRef={inputRef}
/>
}
/>
)
}
@@ -48,6 +58,24 @@ function renderRow(overrides: Partial<BrowserNavigationControls> = {}): BrowserN
return controls
}
/** The read-only counterpart the document preview passes, standing in for any non-URL identity. */
function renderWithIdentityChip(): void {
render(
<BrowserNavigationControlRow
controls={{
canGoBack: false,
canGoForward: false,
loading: false,
goBack: vi.fn(),
goForward: vi.fn(),
reload: vi.fn(),
navigate: vi.fn()
}}
addressSlot={<span>docs/report.html</span>}
/>
)
}
describe('BrowserNavigationControlRow', () => {
afterEach(() => cleanup())
@@ -77,4 +105,34 @@ describe('BrowserNavigationControlRow', () => {
renderRow()
expect(document.querySelector('[data-contextual-tour-target="browser-toolbar"]')).not.toBeNull()
})
// Why: the row must not assume its middle is an address bar — a surface with no URL to type
// still gets the same history controls in the same chrome.
it('renders a non-address identity widget in the same slot', () => {
renderWithIdentityChip()
expect(screen.getByText('docs/report.html')).not.toBeNull()
expect(screen.queryByLabelText('Address')).toBeNull()
expect(screen.getByLabelText('Back')).not.toBeNull()
expect(screen.getByLabelText('Reload')).not.toBeNull()
})
// Why the row and not each widget: a text input and a line of text come out different heights,
// and the whole toolbar would change size when a document tab replaces a web tab.
it('gives every identity widget the same slot height, and stretches it to fill', () => {
renderRow()
const addressSlot = document.querySelector(`[${BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE}]`)
const addressSlotClass = addressSlot?.className ?? ''
const addressWidgetParent = screen.getByLabelText('Address').parentElement
cleanup()
renderWithIdentityChip()
const chipSlot = document.querySelector(`[${BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE}]`)
expect(addressSlotClass).toContain(BROWSER_CHROME_ADDRESS_SLOT_HEIGHT_CLASS)
expect(chipSlot?.className).toBe(addressSlotClass)
expect(addressSlotClass).toContain('items-stretch')
// Both widgets are direct children, so the slot's height reaches them instead of a wrapper.
expect(addressWidgetParent).toBe(addressSlot)
expect(screen.getByText('docs/report.html').parentElement).toBe(chipSlot)
})
})
@@ -1,8 +1,11 @@
import { ArrowLeft, ArrowRight, Loader2, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import BrowserAddressBar from './BrowserAddressBar'
import type { BrowserAddressBarEditSessionBinding } from './use-browser-address-bar-edit-session'
import { cn } from '@/lib/utils'
import {
BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE,
BROWSER_CHROME_ADDRESS_SLOT_HEIGHT_CLASS
} from './browser-chrome-address-slot'
/**
* The history/reload/navigate surface a browser backend must provide to be driven by
@@ -20,42 +23,35 @@ export type BrowserNavigationControls = {
}
/**
* The toolbar row every browser pane shares: back, forward, reload and the address bar.
* Panes with a richer reload affordance pass `reloadControl`; pane-specific tools
* (annotations, downloads, find) render as trailing children.
* The toolbar row every browser surface shares: back, forward, reload, then whatever names the
* thing on screen, then that surface's tools.
*
* Why the middle is a slot rather than the address bar: a web page is named by a URL you may
* retype, a workspace document by a path you may not. Both still sit in the same place, at the
* same size, between the same controls — so the identity widget is what varies, not the row.
*/
export function BrowserNavigationControlRow({
controls,
addressBarValue,
onAddressBarChange,
onSubmitAddressBar,
addressBarInputRef,
dismissSuggestionsRef,
addressBarEditSession,
addressSlot,
reloadControl,
reloadLabel,
addressBarLeadingIcon,
showTourAnchors = true,
children
}: {
controls: BrowserNavigationControls
addressBarValue: string
onAddressBarChange: (value: string) => void
onSubmitAddressBar: () => void
addressBarInputRef: React.RefObject<HTMLInputElement | null>
dismissSuggestionsRef?: React.MutableRefObject<(() => void) | null>
/** Set by panes React remounts mid-edit; see BrowserAddressBar's `editSession`. */
addressBarEditSession?: BrowserAddressBarEditSessionBinding | null
/** The surface's identity widget: an editable address bar, or a read-only document chip. */
addressSlot: React.ReactNode
reloadControl?: React.ReactNode
/** Accessible name for the default reload button, which doubles as Stop and Retry. */
reloadLabel?: string
/** Replaces the address bar's leading globe (e.g. the SSH egress indicator). */
addressBarLeadingIcon?: React.ReactNode
/** Off for surfaces the browsing tour does not cover — a second anchor would steal its steps. */
showTourAnchors?: boolean
children?: React.ReactNode
}): React.JSX.Element {
return (
<div
className="relative z-10 flex items-center gap-2 border-b border-border/70 bg-background/95 px-3 py-1.5"
data-contextual-tour-target="browser-toolbar"
className="relative z-10 flex shrink-0 items-center gap-2 border-b border-border/70 bg-background/95 px-3 py-1.5"
{...(showTourAnchors ? { 'data-contextual-tour-target': 'browser-toolbar' } : {})}
>
<Button
size="icon"
@@ -93,16 +89,15 @@ export function BrowserNavigationControlRow({
</Button>
)}
<BrowserAddressBar
value={addressBarValue}
onChange={onAddressBarChange}
onSubmit={onSubmitAddressBar}
onNavigate={controls.navigate}
inputRef={addressBarInputRef}
dismissSuggestionsRef={dismissSuggestionsRef}
editSession={addressBarEditSession}
leadingIcon={addressBarLeadingIcon}
/>
<div
className={cn(
'flex min-w-0 flex-1 items-stretch',
BROWSER_CHROME_ADDRESS_SLOT_HEIGHT_CLASS
)}
{...{ [BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE]: 'true' }}
>
{addressSlot}
</div>
{children}
</div>
@@ -1,36 +1,20 @@
import type { Dispatch, RefObject, SetStateAction } from 'react'
import { cn } from '@/lib/utils'
import {
Crosshair,
ExternalLink,
Loader2,
MessageSquarePlus,
RefreshCw,
SquareCode
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ArtifactPublishButton } from '@/components/artifacts/ArtifactPublishButton'
import { translate } from '@/i18n/i18n'
import type { BrowserReloadTrigger } from '../navigate/browser-reload-action'
import { BrowserNavigationControlRow } from './browser-navigation-control-row'
import BrowserAddressBar from './BrowserAddressBar'
import { BrowserChromeToolbar } from './browser-chrome-toolbar'
import { BrowserImportHintButton } from './BrowserImportHintButton'
import { BrowserReloadControl } from './browser-reload-control'
import { BrowserToolbarMenu } from './BrowserToolbarMenu'
import { SshEgressIndicator } from './browser-egress-indicator'
import { MarkupDrawButton } from '../annotate/MarkupDrawButton'
import { destroyPersistentWebview } from '../host-guest/webview-registry'
import { readBrowserHtmlArtifactRequest } from '../describe-page/browser-artifact-upload'
import type { GrabModeHook } from '../annotate/useGrabMode'
import type { BrowserViewportPresetId } from '../../../../../shared/browser-workspace-types'
import type { GrabIntent } from '../describe-page/browser-page-types'
/** Binds the shared browser chrome to a browsing page: an editable address bar and session tools. */
export function BrowserPageToolbar({
browserPageId,
workspaceId,
@@ -105,7 +89,8 @@ export function BrowserPageToolbar({
externalUrl: string | null
}): React.JSX.Element {
return (
<BrowserNavigationControlRow
<BrowserChromeToolbar
showTourAnchors
controls={{
canGoBack,
canGoForward,
@@ -115,199 +100,84 @@ export function BrowserPageToolbar({
reload: () => runReloadTrigger('button'),
navigate: navigateToUrl
}}
addressBarValue={addressBarValue}
onAddressBarChange={setAddressBarValue}
onSubmitAddressBar={submitAddressBar}
addressBarInputRef={addressBarInputRef}
dismissSuggestionsRef={dismissAddressBarSuggestionsRef}
addressBarLeadingIcon={<SshEgressIndicator worktreeId={worktreeId} />}
reloadControl={
<DropdownMenu modal={false} open={reloadMenuOpen} onOpenChange={setReloadMenuOpen}>
{/* Why: suppress the tooltip while the menu is open — both anchor below the button and would overlap. */}
<Tooltip open={reloadMenuOpen ? false : undefined}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
aria-label={reloadButtonLabel}
// Why: preventDefault suppresses Radix's open-on-left-click (composeEventHandlers skips its
// handler once defaultPrevented), keeping left-click on the primary action and the menu on right-click.
onPointerDown={(e) => {
if (e.button === 0) {
e.preventDefault()
}
}}
// Why: same trick for Radix's open-on-Enter/Space, which would otherwise preventDefault the
// synthesized click and strand keyboard users. ArrowDown still falls through to open the menu.
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
runReloadTrigger('button')
}
}}
onClick={() => runReloadTrigger('button')}
onContextMenu={(e) => {
e.preventDefault()
setReloadMenuOpen(true)
}}
>
{loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{reloadButtonLabel}
{/* Why: the chord maps to plain reload(), which is not what Stop or Retry do — only hint when they match. */}
{reloadShortcut && reloadButtonLabelKind === 'reload' ? ` · ${reloadShortcut}` : ''}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" alignOffset={-4}>
<DropdownMenuItem onClick={() => runReloadTrigger('reload')}>
{translate('auto.components.browser.pane.BrowserPane.0e080d820e', 'Reload')}
<DropdownMenuShortcut>{reloadShortcut}</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => runReloadTrigger('hard-reload')}>
{translate('auto.components.browser.pane.BrowserPane.a1f3c2e4b5', 'Hard Reload')}
<DropdownMenuShortcut>{hardReloadShortcut}</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
>
<BrowserImportHintButton profileId={sessionProfileId} />
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button
size="icon"
variant={grab.state !== 'idle' && grabIntent === 'copy' ? 'default' : 'ghost'}
className={cn(
'h-8 w-8',
grab.state !== 'idle' &&
grabIntent === 'copy' &&
'bg-foreground/80 text-background hover:bg-foreground/90'
)}
onClick={() => startGrabIntent('copy')}
disabled={isBlankTab || markupIsActive}
aria-label={translate(
'auto.components.browser.pane.BrowserPane.fdfc7fe0ef',
'Grab page element'
)}
data-contextual-tour-target="browser-grab-control"
>
<Crosshair className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{translate(
'auto.components.browser.pane.BrowserPane.acbe79fd01',
'Grab page element ({{value0}})',
{ value0: grabElementShortcut }
)}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
{/* Why: disabled <button> drops hover events, so wrap in a span so the tooltip trigger still fires. */}
<span className="inline-flex">
<Button
size="icon"
variant={grab.state !== 'idle' && grabIntent === 'annotate' ? 'default' : 'ghost'}
className={cn(
'relative h-8 w-8',
grab.state !== 'idle' &&
grabIntent === 'annotate' &&
'bg-foreground/80 text-background hover:bg-foreground/90'
)}
onClick={() => startGrabIntent('annotate')}
disabled={isBlankTab || markupIsActive}
aria-label={translate(
'auto.components.browser.pane.BrowserPane.fc9be38f6f',
'Annotate page element'
)}
data-contextual-tour-target="browser-annotation-control"
>
<MessageSquarePlus className="size-4" />
{browserAnnotationsLength > 0 ? (
<span className="absolute -top-1 -right-1 flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-4 text-primary-foreground">
{browserAnnotationsLength}
</span>
) : null}
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{translate(
'auto.components.browser.pane.BrowserPane.fc9be38f6f',
'Annotate page element'
)}
</TooltipContent>
</Tooltip>
<MarkupDrawButton
onClick={() => (markupIsActive ? markupCancel() : void markupStart())}
disabled={isBlankTab || grab.state !== 'idle'}
active={markupIsActive}
surfaceActive={isActive}
/>
{shareableArtifactFile ? (
<ArtifactPublishButton
sourceKey={shareableArtifactFile.filePath}
className="h-7 w-7"
createRequest={() => readBrowserHtmlArtifactRequest(currentBrowserUrl)}
addressSlot={
<BrowserAddressBar
value={addressBarValue}
onChange={setAddressBarValue}
onSubmit={submitAddressBar}
onNavigate={navigateToUrl}
inputRef={addressBarInputRef}
dismissSuggestionsRef={dismissAddressBarSuggestionsRef}
leadingIcon={<SshEgressIndicator worktreeId={worktreeId} />}
/>
) : null}
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => void window.api.browser.openDevTools({ browserPageId })}
title={translate(
}
reloadControl={
<BrowserReloadControl
menuOpen={reloadMenuOpen}
onMenuOpenChange={setReloadMenuOpen}
label={reloadButtonLabel}
loading={loading}
showShortcutHint={reloadButtonLabelKind === 'reload'}
reloadShortcut={reloadShortcut}
hardReloadShortcut={hardReloadShortcut}
onPrimary={() => runReloadTrigger('button')}
onReload={() => runReloadTrigger('reload')}
onHardReload={() => runReloadTrigger('hard-reload')}
/>
}
importControl={<BrowserImportHintButton profileId={sessionProfileId} />}
elementTools={{
activeIntent: grab.state !== 'idle' ? grabIntent : null,
onStartIntent: startGrabIntent,
disabled: isBlankTab || markupIsActive,
grabShortcutLabel: grabElementShortcut,
annotationCount: browserAnnotationsLength
}}
markup={{
active: markupIsActive,
disabled: isBlankTab || grab.state !== 'idle',
onToggle: () => (markupIsActive ? markupCancel() : void markupStart()),
canShowDiscoveryHint: isActive
}}
shareControl={
shareableArtifactFile ? (
<ArtifactPublishButton
sourceKey={shareableArtifactFile.filePath}
className="h-7 w-7"
createRequest={() => readBrowserHtmlArtifactRequest(currentBrowserUrl)}
/>
) : null
}
viewSource={{
onSelect: () => void window.api.browser.openDevTools({ browserPageId }),
label: translate(
'auto.components.browser.pane.BrowserPane.ec75d0c412',
'Open browser devtools'
)}
>
<SquareCode className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => {
)
}}
openExternal={{
onSelect: () => {
if (!externalUrl) {
return
}
void window.api.shell.openUrl(externalUrl)
}}
title={translate(
},
label: translate(
'auto.components.browser.pane.BrowserPane.0f41bf80c7',
'Open in default browser'
)}
disabled={!externalUrl}
>
<ExternalLink className="size-4" />
</Button>
<BrowserToolbarMenu
currentProfileId={sessionProfileId}
workspaceId={workspaceId}
browserPageId={browserPageId}
viewportPresetId={viewportPresetId}
onDestroyWebview={() => destroyPersistentWebview(browserPageId)}
isActive={isActive}
/>
</BrowserNavigationControlRow>
),
disabled: !externalUrl
}}
overflowMenu={
<BrowserToolbarMenu
currentProfileId={sessionProfileId}
workspaceId={workspaceId}
browserPageId={browserPageId}
viewportPresetId={viewportPresetId}
onDestroyWebview={() => destroyPersistentWebview(browserPageId)}
isActive={isActive}
/>
}
/>
)
}
@@ -1,14 +1,6 @@
import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from 'react'
import { cn } from '@/lib/utils'
import { Copy, Globe, Image } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Globe } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { toHttpsRecoveryUrl } from '../../../../../shared/browser-url'
import type {
@@ -19,17 +11,11 @@ import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from '../host-guest/browser-page-gu
import { BrowserLoadFailureOverlay } from '../navigate/browser-load-failure-overlay'
import { useSshWorkspaceProbeSkipRecheck } from '../use-ssh-workspace-browser-route'
import BrowserFind from './BrowserFind'
import { MarkupOverlay } from '../annotate/MarkupOverlay'
import { BrowserGuestAnnotateOverlays } from '../annotate/browser-guest-annotate-overlays'
import type { MarkupModeController } from '../annotate/useMarkupMode'
import type { GrabModeHook } from '../annotate/useGrabMode'
import {
getBrowserOverlayAnchor,
type BrowserOverlayViewport
} from '../describe-page/browser-annotation-geometry'
import { BrowserPageAnnotationTray } from '../annotate/browser-page-annotation-tray'
import { BrowserPageGrabToast } from '../annotate/browser-page-grab-toast'
import type { BrowserOverlayViewport } from '../describe-page/browser-annotation-geometry'
import { retryBrowserTabLoad, toDisplayUrl } from '../describe-page/browser-page-url-display'
import { PendingBrowserAnnotationCard } from '../annotate/pending-browser-annotation-card'
import type { BrowserTabPageState } from '../describe-page/browser-page-types'
import type { useBrowserPageAnnotationSend } from '../annotate/use-browser-page-annotation-send'
import type { useBrowserPageGrabAnnotations } from '../annotate/use-browser-page-grab-annotations'
@@ -84,42 +70,18 @@ export function BrowserPageViewportOverlays({
grabAnnotations: ReturnType<typeof useBrowserPageGrabAnnotations>
}): React.JSX.Element {
const recheckSshRoute = useSshWorkspaceProbeSkipRecheck(worktreeId)
const {
pendingAnnotationPayload,
handleAddBrowserAnnotation,
handleCancelPendingBrowserAnnotation,
grabIntent,
grabMenuActionTakenRef,
handleGrabCopy,
handleGrabCopyScreenshot,
grabToast,
grabToastTimerRef,
dismissGrabToast,
setGrabToast
} = grabAnnotations
const {
browserAnnotations,
browserAnnotationTrayOpen,
annotationTraySendOpen,
handleAnnotationTraySendOpenChange,
activeGroupId,
browserAnnotationsPrompt,
handleBrowserAnnotationsSentToAgent,
handleCopyBrowserAnnotations,
browserAnnotationsCopied,
handleClearBrowserAnnotations,
handleDeleteBrowserAnnotation
} = annotationSend
return (
<>
{markup.isActive && markup.baseImage ? (
<MarkupOverlay
baseImage={markup.baseImage}
busy={markup.state === 'composing'}
onComplete={(input) => void markup.complete(input)}
onCancel={markup.cancel}
/>
) : null}
<BrowserGuestAnnotateOverlays
markup={markup}
grab={grab}
annotationSend={annotationSend}
grabAnnotations={grabAnnotations}
containerRef={containerRef}
webviewRef={webviewRef}
browserOverlayViewport={browserOverlayViewport}
worktreeId={worktreeId}
/>
<div
role="status"
aria-live="polite"
@@ -190,105 +152,6 @@ export function BrowserPageViewportOverlays({
</div>
</div>
) : null}
{pendingAnnotationPayload ? (
<PendingBrowserAnnotationCard
payload={pendingAnnotationPayload}
anchor={getBrowserOverlayAnchor(
pendingAnnotationPayload,
containerRef.current,
webviewRef.current,
browserOverlayViewport
)}
portalContainer={containerRef.current}
onAdd={handleAddBrowserAnnotation}
onCancel={handleCancelPendingBrowserAnnotation}
/>
) : null}
{browserAnnotations.length > 0 && browserAnnotationTrayOpen ? (
<BrowserPageAnnotationTray
browserAnnotations={browserAnnotations}
annotationTraySendOpen={annotationTraySendOpen}
handleAnnotationTraySendOpenChange={handleAnnotationTraySendOpenChange}
worktreeId={worktreeId}
activeGroupId={activeGroupId}
browserAnnotationsPrompt={browserAnnotationsPrompt}
handleBrowserAnnotationsSentToAgent={handleBrowserAnnotationsSentToAgent}
handleCopyBrowserAnnotations={handleCopyBrowserAnnotations}
browserAnnotationsCopied={browserAnnotationsCopied}
handleClearBrowserAnnotations={handleClearBrowserAnnotations}
handleDeleteBrowserAnnotation={handleDeleteBrowserAnnotation}
/>
) : null}
{/* Right-click context dropdown, positioned at the grabbed element's center. */}
<DropdownMenu
open={grab.state === 'confirming' && grab.contextMenu && grabIntent === 'copy'}
onOpenChange={(open) => {
if (!open && grab.state === 'confirming') {
// Why: skip rearm if a menu action already handled it — see grabMenuActionTakenRef.
if (grabMenuActionTakenRef.current) {
grabMenuActionTakenRef.current = false
return
}
grab.rearm()
}
}}
>
<DropdownMenuTrigger asChild>
<button
aria-hidden
tabIndex={-1}
className="pointer-events-none absolute size-px opacity-0"
style={(() => {
if (!grab.payload) {
return { left: 0, top: 0 }
}
const rect = grab.payload.target.rectViewport
const webview = webviewRef.current
const webviewRect = webview?.getBoundingClientRect()
const cRect = containerRef.current?.getBoundingClientRect()
const offsetX = (webviewRect?.left ?? 0) - (cRect?.left ?? 0)
const offsetY = (webviewRect?.top ?? 0) - (cRect?.top ?? 0)
return {
left: offsetX + rect.x + rect.width / 2,
top: offsetY + rect.y + rect.height / 2
}
})()}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={4}>
<DropdownMenuItem onSelect={handleGrabCopy}>
<Copy className="size-3.5" />
{translate('auto.components.browser.pane.BrowserPane.c2ef0359b9', 'Copy Contents')}
<DropdownMenuShortcut>C</DropdownMenuShortcut>
</DropdownMenuItem>
{grab.payload?.screenshot?.dataUrl?.startsWith('data:image/png;base64,') ? (
<DropdownMenuItem onSelect={handleGrabCopyScreenshot}>
<Image className="size-3.5" />
{translate('auto.components.browser.pane.BrowserPane.1ded0d3168', 'Copy Screenshot')}
<DropdownMenuShortcut>S</DropdownMenuShortcut>
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
grabMenuActionTakenRef.current = true
grab.cancel()
}}
>
{translate('auto.components.browser.pane.BrowserPane.fa6ea61de3', 'Cancel')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Inline toast bubble; flips above the element when near the viewport bottom so it doesn't occlude it. */}
{grabToast ? (
<BrowserPageGrabToast
grabToast={grabToast}
grabToastTimerRef={grabToastTimerRef}
dismissGrabToast={dismissGrabToast}
setGrabToast={setGrabToast}
/>
) : null}
</>
)
}
@@ -0,0 +1,95 @@
import type { Dispatch, SetStateAction } from 'react'
import { Loader2, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
/**
* Reload as browsers do it: left-click reloads, right-click offers the harder variant. Shared so
* both guest surfaces get the same gesture what "hard" means is the caller's to define.
*/
export function BrowserReloadControl({
menuOpen,
onMenuOpenChange,
label,
loading,
showShortcutHint,
reloadShortcut,
hardReloadShortcut,
onPrimary,
onReload,
onHardReload
}: {
menuOpen: boolean
onMenuOpenChange: Dispatch<SetStateAction<boolean>>
label: string
loading: boolean
/** The chord maps to plain reload, which is not what Stop or Retry do — hint only when they match. */
showShortcutHint: boolean
reloadShortcut: string
hardReloadShortcut: string
onPrimary: () => void
onReload: () => void
onHardReload: () => void
}): React.JSX.Element {
return (
<DropdownMenu modal={false} open={menuOpen} onOpenChange={onMenuOpenChange}>
{/* Why: suppress the tooltip while the menu is open — both anchor below the button and would overlap. */}
<Tooltip open={menuOpen ? false : undefined}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
aria-label={label}
// Why: preventDefault suppresses Radix's open-on-left-click (composeEventHandlers skips its
// handler once defaultPrevented), keeping left-click on the primary action and the menu on right-click.
onPointerDown={(e) => {
if (e.button === 0) {
e.preventDefault()
}
}}
// Why: same trick for Radix's open-on-Enter/Space, which would otherwise preventDefault the
// synthesized click and strand keyboard users. ArrowDown still falls through to open the menu.
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onPrimary()
}
}}
onClick={onPrimary}
onContextMenu={(e) => {
e.preventDefault()
onMenuOpenChange(true)
}}
>
{loading ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{label}
{reloadShortcut && showShortcutHint ? ` · ${reloadShortcut}` : ''}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" alignOffset={-4}>
<DropdownMenuItem onClick={onReload}>
{translate('auto.components.browser.pane.BrowserPane.0e080d820e', 'Reload')}
<DropdownMenuShortcut>{reloadShortcut}</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onClick={onHardReload}>
{translate('auto.components.browser.pane.BrowserPane.a1f3c2e4b5', 'Hard Reload')}
<DropdownMenuShortcut>{hardReloadShortcut}</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -18,6 +18,7 @@ import type { BrowserChromeShortcutScope } from '../describe-page/browser-page-t
import { RemoteBrowserPagePane } from '../stream-remote/remote-browser-page-pane'
import { ClientHostedBrowserPagePane } from '../ClientHostedBrowserPagePane'
import { BrowserPagePane } from './browser-page-pane'
import { WorkspaceDocPagePane } from '../workspace-doc/workspace-doc-page-pane'
import { SshRoutedBrowserPageGate } from './ssh-routed-browser-page-gate'
export default function BrowserPane({
@@ -156,26 +157,34 @@ export default function BrowserPane({
>
{(routedPartition) => (
<div className="relative flex min-h-0 flex-1">
{renderedBrowserPages.map((page) => (
<BrowserPagePane
key={page.id}
browserTab={page}
workspaceId={browserTab.id}
worktreeId={browserTab.worktreeId}
sessionProfileId={browserTab.sessionProfileId ?? null}
sessionPartition={routedPartition ?? browserTab.sessionPartition ?? null}
isActive={isActive && page.id === activeBrowserPage?.id}
chromeShortcutScope={
page.id === activeBrowserPage?.id ? resolvedChromeShortcutScope : 'inactive'
}
isAutomationVisible={automationVisiblePageIds.has(page.id)}
isMobileDriven={mobileDrivenPageIds.has(page.id)}
isRemotelyViewed={remotelyViewedPageIds.has(page.id)}
inputLocked={activeBrowserDriver.kind === 'mobile'}
onUpdatePageState={updateBrowserPageState}
onSetUrl={setBrowserPageUrl}
/>
))}
{renderedBrowserPages.map((page) =>
page.docLocation ? (
<WorkspaceDocPagePane
key={page.id}
page={page}
isActive={isActive && page.id === activeBrowserPage?.id}
/>
) : (
<BrowserPagePane
key={page.id}
browserTab={page}
workspaceId={browserTab.id}
worktreeId={browserTab.worktreeId}
sessionProfileId={browserTab.sessionProfileId ?? null}
sessionPartition={routedPartition ?? browserTab.sessionPartition ?? null}
isActive={isActive && page.id === activeBrowserPage?.id}
chromeShortcutScope={
page.id === activeBrowserPage?.id ? resolvedChromeShortcutScope : 'inactive'
}
isAutomationVisible={automationVisiblePageIds.has(page.id)}
isMobileDriven={mobileDrivenPageIds.has(page.id)}
isRemotelyViewed={remotelyViewedPageIds.has(page.id)}
inputLocked={activeBrowserDriver.kind === 'mobile'}
onUpdatePageState={updateBrowserPageState}
onSetUrl={setBrowserPageUrl}
/>
)
)}
<BrowserMobileDriverOverlay
driver={activeBrowserDriver}
onTakeBack={reclaimActiveBrowserForDesktop}
@@ -22,6 +22,7 @@ import {
EMPTY_BROWSER_ANNOTATIONS,
type BrowserOverlayViewport
} from '../describe-page/browser-annotation-geometry'
import { syncGuestAnnotationViewportBridge } from '../annotate/guest-annotation-viewport-bridge'
import { attachBrowserPageWebview } from './attach-browser-page-webview'
import { setBrowserPageWebviewInputLock } from './browser-page-webview'
import type {
@@ -201,27 +202,13 @@ export function useBrowserPageWebviewLifecycle({
)
const syncBrowserAnnotationViewportBridge = useCallback((): void => {
const pendingPayload = pendingAnnotationPayloadRef.current
// Why: existing badges render in-guest for smooth scroll; only the pending dialog needs viewport messages.
const markers = browserAnnotationsRef.current.map((annotation, index) => ({
id: annotation.id,
index,
isFixed: annotation.payload.target.isFixed === true,
rectPage: annotation.payload.target.rectPage,
rectViewport: annotation.payload.target.rectViewport
}))
const enabled = isActiveRef.current && (pendingPayload !== null || markers.length > 0)
void window.api.browser
.setAnnotationViewportBridge({
browserPageId: browserTabId,
emitViewport: pendingPayload !== null,
enabled,
markers,
token: annotationViewportBridgeTokenRef.current
})
.catch(() => {
// The viewport bridge is visual-only; stale markers beat breaking the pane on a destroyed guest.
})
syncGuestAnnotationViewportBridge({
toolTargetId: browserTabId,
annotations: browserAnnotationsRef.current,
pendingPayload: pendingAnnotationPayloadRef.current,
surfaceActive: isActiveRef.current,
token: annotationViewportBridgeTokenRef.current
})
}, [browserTabId])
// Why: browserTab.url excluded from deps (changes every navigation → would destroy/recreate the webview); URL logic reads browserTabUrlRef.
@@ -0,0 +1,32 @@
import { useEffect, type MutableRefObject } from 'react'
import { useWebviewDragPassthroughActive } from './use-webview-drag-passthrough-active'
/**
* Enrols a single component-owned guest in the renderer's drag passthrough.
*
* Why it matters: a `<webview>` swallows the pointer stream the document never sees, so a
* dnd-kit drag stops receiving `pointermove` the instant the cursor crosses one the dragged
* tab stops following the cursor and the drop it was aiming for cannot be made. The browser
* pane's guests are held click-through through their registry; a guest that belongs to one
* component instead (the document preview) has no registry to be walked by, so it enrols here.
*/
export function useGuestDragPassthrough(
webviewRef: MutableRefObject<Electron.WebviewTag | null>,
/** Changes when the ref is pointed at a new guest, so one attached mid-drag is settled too. */
guestKey: string | null
): void {
const passthroughActive = useWebviewDragPassthroughActive()
useEffect(() => {
const webview = webviewRef.current
if (!webview) {
return
}
webview.style.pointerEvents = passthroughActive ? 'none' : ''
return () => {
// Why reset rather than restore: the guest outlives this state, and leaving it transparent
// would cost the reader every click on the document.
webview.style.pointerEvents = ''
}
}, [guestKey, passthroughActive, webviewRef])
}
@@ -56,4 +56,31 @@ describe('resolveBrowserAddressBarSubmission', () => {
it('treats blank input as the blank page rather than an error', () => {
expect(resolveBrowserAddressBarSubmission(' ')).toMatchObject({ status: 'navigate' })
})
it('keeps file URLs navigable for the local browser pane', () => {
expect(resolveBrowserAddressBarSubmission('/tmp/report.html')).toMatchObject({
status: 'navigate',
url: 'file:///tmp/report.html'
})
})
it('explains the refusal instead of blanking the tab when a client-hosted page gets a file URL', () => {
expect(
resolveBrowserAddressBarSubmission('/tmp/report.html', { allowFileUrls: false })
).toEqual({
status: 'invalid',
loadError: {
code: 0,
description:
'This browser tab cannot open local files. Use "Open Preview to the Side" on the file instead.',
validatedUrl: '/tmp/report.html'
}
})
})
it('still navigates http(s) when file URLs are refused', () => {
expect(
resolveBrowserAddressBarSubmission('https://example.com', { allowFileUrls: false })
).toMatchObject({ status: 'navigate' })
})
})
@@ -15,24 +15,36 @@ export type BrowserAddressBarSubmission =
* Kagi session link and the invalid-input failure cannot drift between backends.
* Callers stay responsible for routing the two outcomes into their own chrome.
*/
export function resolveBrowserAddressBarSubmission(rawValue: string): BrowserAddressBarSubmission {
export function resolveBrowserAddressBarSubmission(
rawValue: string,
options?: { allowFileUrls?: boolean }
): BrowserAddressBarSubmission {
const { browserDefaultSearchEngine, browserKagiSessionLink } = useAppStore.getState()
// Why: the search-engine argument opts into search fallback; without it typed
// queries parse as hosts ("google maps" -> https://google%20maps/).
const url = normalizeBrowserNavigationUrl(rawValue, browserDefaultSearchEngine, {
kagiSessionLink: browserKagiSessionLink
})
if (url) {
// Why: client-hosted guests refuse file: by design (a remote page must not probe
// this machine's disk). Saying so beats the blank tab that refusal used to produce.
const fileUrlUnsupported =
options?.allowFileUrls === false && Boolean(url) && url!.startsWith('file:')
if (url && !fileUrlUnsupported) {
return { status: 'navigate', url }
}
return {
status: 'invalid',
loadError: {
code: 0,
description: translate(
'auto.components.browser.pane.BrowserPane.87eb75f7d2',
'Enter a valid http(s) or localhost URL.'
),
description: fileUrlUnsupported
? translate(
'auto.components.browser.pane.BrowserPane.fileUrlUnsupported',
'This browser tab cannot open local files. Use "Open Preview to the Side" on the file instead.'
)
: translate(
'auto.components.browser.pane.BrowserPane.87eb75f7d2',
'Enter a valid http(s) or localhost URL.'
),
// Why: validatedUrl is persisted, so redact a possible Kagi session token first.
validatedUrl: redactKagiSessionToken(rawValue.trim()) || 'about:blank'
}
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { BrowserNavigationControlRow } from '../assemble-chrome/browser-navigation-control-row'
import BrowserAddressBar from '../assemble-chrome/BrowserAddressBar'
import type { BrowserAddressBarEditSessionBinding } from '../assemble-chrome/use-browser-address-bar-edit-session'
import { RemoteRuntimeEgressIndicator } from '../assemble-chrome/browser-egress-indicator'
import { MarkupDrawButton } from '../annotate/MarkupDrawButton'
@@ -54,15 +55,20 @@ export function RemoteBrowserPageToolbar({
reload: onReload,
navigate: onNavigateToUrl
}}
addressBarValue={addressBarValue}
onAddressBarChange={onAddressBarChange}
onSubmitAddressBar={onSubmitAddressBar}
addressBarInputRef={addressBarInputRef}
addressBarEditSession={addressBarEditSession}
addressBarLeadingIcon={
<RemoteRuntimeEgressIndicator
runtimeEnvironmentId={runtimeEnvironmentId}
presentation="streamed"
addressSlot={
<BrowserAddressBar
value={addressBarValue}
onChange={onAddressBarChange}
onSubmit={onSubmitAddressBar}
onNavigate={onNavigateToUrl}
inputRef={addressBarInputRef}
editSession={addressBarEditSession}
leadingIcon={
<RemoteRuntimeEgressIndicator
runtimeEnvironmentId={runtimeEnvironmentId}
presentation="streamed"
/>
}
/>
}
reloadControl={
@@ -222,7 +222,7 @@ export function useRemoteBrowserPageNavigation({
}, [isActive, keybindings, runRemoteNavigation])
const submitAddressBar = (): void => {
const submission = resolveBrowserAddressBarSubmission(addressBarValue)
const submission = resolveBrowserAddressBarSubmission(addressBarValue, { allowFileUrls: false })
if (submission.status === 'invalid') {
// 'direct': the only response to what the user just typed. With an empty address bar no
// load-error overlay renders either, so outranking this would make Enter do nothing visible.
@@ -0,0 +1,342 @@
// @vitest-environment happy-dom
//
// The preview guest paints the handler's error body as if it were the document, so every
// unreadable outcome arrives out-of-band on the failure channel. These pin that each reason
// reaches the reader as its own sentence, and that a broken subresource cannot blank the page.
//
// The payloads here are the ones the reader can actually produce. The entry document is served as
// text by every owner, so it fails only as too-large (a host-reported truncation) or unreadable (a
// read error or a revoked grant); 'unsupported-asset' comes from a subresource whose format the
// host declined to send — a font, say — and never from the document itself.
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { DocPreviewFailure } from '../../../../../shared/doc-preview-scheme'
const GRANT_ID = 'a'.repeat(32)
const REMINTED_GRANT_ID = 'c'.repeat(32)
const ENTRY_RELATIVE_PATH = 'doc.html'
const grantRuntime = vi.hoisted(() => ({ mints: 0, released: [] as string[] }))
vi.mock('@/lib/doc-preview-grants', () => ({
buildDocPreviewGrantRequest: () => ({
owner: {
kind: 'runtime' as const,
environmentId: 'env-1',
worktreeSelector: 'id:wt-1',
worktreeRoot: '/repo'
},
root: '/repo/docs',
entryRelativePath: ENTRY_RELATIVE_PATH
}),
ensureDocPreviewGrant: () => {
grantRuntime.mints += 1
// Why a fresh id per mint: a re-mint after a reconnect must bind the guest to the new grant.
const grantId = grantRuntime.mints === 1 ? GRANT_ID : REMINTED_GRANT_ID
return Promise.resolve({ grantId, url: `orca-preview://${grantId}/${ENTRY_RELATIVE_PATH}` })
},
releaseDocPreviewGrant: (previewId: string) => {
grantRuntime.released.push(previewId)
}
}))
vi.mock('@/components/browser-pane/host-guest/webview-registry', () => ({
moveFocusToRendererBeforeWebviewDetach: () => undefined
}))
const storeState = {
getKnownWorktreeById: () => ({ path: '/repo' }),
persistedUIReady: true,
settings: {},
keybindings: {},
browserAnnotationsByPageId: {} as Record<string, unknown[]>,
activeGroupIdByWorktree: {} as Record<string, string>,
agentSendPopoverTargetMode: null,
openAgentSendPopoverTargetMode: () => undefined,
closeAgentSendPopoverTargetMode: () => undefined,
addBrowserPageAnnotation: () => undefined,
deleteBrowserPageAnnotation: () => undefined,
clearBrowserPageAnnotations: () => undefined,
recordFeatureInteraction: () => undefined,
openFile: () => 'file-1'
}
vi.mock('@/store', () => ({
useAppStore: Object.assign(
(selector?: (state: typeof storeState) => unknown) =>
selector ? selector(storeState) : storeState,
{ getState: () => storeState }
)
}))
vi.mock('@/lib/execution-host-display-label', () => ({
selectWorktreeHostDisplayLabel: () => 'Studio Mac mini'
}))
const failureListeners: ((payload: DocPreviewFailure) => void)[] = []
function emitFailure(payload: DocPreviewFailure): void {
for (const listener of failureListeners.slice()) {
listener(payload)
}
}
async function renderPreview(container: HTMLDivElement, root: Root): Promise<void> {
const { HtmlDocPreview } = await import('./HtmlDocPreview')
await act(async () => {
root.render(
<TooltipProvider>
<HtmlDocPreview
previewId="preview-1"
filePath="/repo/docs/doc.html"
relativePath="docs/doc.html"
worktreeId="wt-1"
/>
</TooltipProvider>
)
})
expect(container.querySelector('webview')).not.toBeNull()
}
describe('HtmlDocPreview failure messages', () => {
let container: HTMLDivElement
let root: Root
let mounted = false
beforeEach(() => {
mounted = true
failureListeners.length = 0
grantRuntime.mints = 0
grantRuntime.released = []
;(window as unknown as { api: unknown }).api = {
docPreview: {
onLoadFailure: (callback: (payload: DocPreviewFailure) => void) => {
failureListeners.push(callback)
return () => {
const index = failureListeners.indexOf(callback)
if (index !== -1) {
failureListeners.splice(index, 1)
}
}
}
},
ui: { writeClipboardText: () => Promise.resolve() },
browser: { setAnnotationViewportBridge: () => Promise.resolve(true) }
}
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
})
afterEach(() => {
if (mounted) {
act(() => root.unmount())
mounted = false
}
container.remove()
})
it('tells the reader the document is too large instead of showing a bare failure', async () => {
await renderPreview(container, root)
await act(async () => {
emitFailure({ grantId: GRANT_ID, relativePath: ENTRY_RELATIVE_PATH, reason: 'too-large' })
})
expect(container.textContent).toContain(
'This document is too large to preview. Open it in the editor instead.'
)
})
// Why a notice and not the panel: the document rendered. Hiding it behind a failure screen would
// take away a page the reader can use over one asset the workspace would not send.
it('names the asset the workspace refused without hiding the document', async () => {
await renderPreview(container, root)
await act(async () => {
emitFailure({
grantId: GRANT_ID,
relativePath: 'assets/inter.woff2',
reason: 'unsupported-asset'
})
})
expect(container.textContent).toContain(
'This workspace cannot send assets/inter.woff2 to a preview.'
)
expect(container.textContent).not.toContain('Preview unavailable')
expect(container.querySelector('webview')).not.toBeNull()
})
it('names an unreadable or over-cap asset by path', async () => {
await renderPreview(container, root)
await act(async () => {
emitFailure({ grantId: GRANT_ID, relativePath: 'assets/logo.png', reason: 'unreadable' })
})
expect(container.textContent).toContain(
'Orca could not read assets/logo.png from the workspace.'
)
await act(async () => {
emitFailure({ grantId: GRANT_ID, relativePath: 'assets/data.json', reason: 'too-large' })
})
expect(container.textContent).toContain('2 files in this document could not be loaded.')
expect(container.textContent).not.toContain('Preview unavailable')
})
it('counts each failing asset once, however often the guest retries it', async () => {
await renderPreview(container, root)
await act(async () => {
emitFailure({ grantId: GRANT_ID, relativePath: 'assets/logo.png', reason: 'unreadable' })
emitFailure({ grantId: GRANT_ID, relativePath: 'assets/logo.png', reason: 'unreadable' })
})
expect(container.textContent).toContain(
'Orca could not read assets/logo.png from the workspace.'
)
expect(container.textContent).not.toContain('files in this document')
})
// Why: nothing rendered, so the notice strip would be a footnote on a blank page.
it('replaces the asset notice with the failure panel when the document itself fails', async () => {
await renderPreview(container, root)
await act(async () => {
emitFailure({ grantId: GRANT_ID, relativePath: 'assets/logo.png', reason: 'unreadable' })
emitFailure({ grantId: GRANT_ID, reason: 'download-blocked' })
emitFailure({ grantId: GRANT_ID, relativePath: ENTRY_RELATIVE_PATH, reason: 'too-large' })
})
expect(container.textContent).toContain('Preview unavailable')
expect(container.textContent).not.toContain('assets/logo.png')
expect(container.textContent).not.toContain('Downloads are disabled in document previews.')
})
// Why the notice exists at all: the preview partition cancels the download before it starts, so
// without this a pressed download button produces nothing the reader can tell from a bug.
it('tells the reader a refused download was refused, without taking the document away', async () => {
await renderPreview(container, root)
await act(async () => {
emitFailure({ grantId: GRANT_ID, reason: 'download-blocked' })
})
expect(container.textContent).toContain('Downloads are disabled in document previews.')
expect(container.textContent).not.toContain('Preview unavailable')
expect(container.querySelector('webview')).not.toBeNull()
})
// Why: the document chooses when and how often to ask, so a per-attempt row would let a page
// scroll Orca's own chrome off the screen.
it('shows one refusal notice however often the document asks', async () => {
await renderPreview(container, root)
await act(async () => {
emitFailure({ grantId: GRANT_ID, reason: 'download-blocked' })
emitFailure({ grantId: GRANT_ID, reason: 'download-blocked' })
emitFailure({ grantId: GRANT_ID, reason: 'download-blocked' })
})
const notices = container.querySelectorAll('[role="status"]')
expect(notices).toHaveLength(1)
expect(notices[0]?.textContent).toContain('Downloads are disabled in document previews.')
})
it('keeps a refused download and a failed asset as separate sentences', async () => {
await renderPreview(container, root)
await act(async () => {
emitFailure({ grantId: GRANT_ID, reason: 'download-blocked' })
emitFailure({ grantId: GRANT_ID, relativePath: 'assets/logo.png', reason: 'unreadable' })
})
expect(container.textContent).toContain('Downloads are disabled in document previews.')
expect(container.textContent).toContain(
'Orca could not read assets/logo.png from the workspace.'
)
// The asset count describes files the document could not load; a refusal is not one of them.
expect(container.textContent).not.toContain('2 files in this document')
})
it('ignores a refusal reported for another preview tab', async () => {
await renderPreview(container, root)
await act(async () => {
emitFailure({ grantId: 'b'.repeat(32), reason: 'download-blocked' })
})
expect(container.textContent).not.toContain('Downloads are disabled in document previews.')
})
it('falls back to the read failure for any other unreadable document', async () => {
await renderPreview(container, root)
await act(async () => {
emitFailure({ grantId: GRANT_ID, relativePath: ENTRY_RELATIVE_PATH, reason: 'unreadable' })
})
expect(container.textContent).toContain('Orca could not read this file from the workspace.')
})
it('ignores a failure minted for another preview tab', async () => {
await renderPreview(container, root)
await act(async () => {
emitFailure({
grantId: 'b'.repeat(32),
relativePath: ENTRY_RELATIVE_PATH,
reason: 'too-large'
})
})
expect(container.textContent).not.toContain('Preview unavailable')
})
// Why: a grant is pinned to the owner ids it was minted with, so after a pairing or SSH
// reconnect reloading the guest would just refetch the same failure forever.
it('re-mints the grant when reload is pressed from a failure', async () => {
await renderPreview(container, root)
await act(async () => {
emitFailure({ grantId: GRANT_ID, relativePath: ENTRY_RELATIVE_PATH, reason: 'unreadable' })
})
expect(grantRuntime.mints).toBe(1)
await act(async () => {
container.querySelector<HTMLButtonElement>('button[aria-label="Reload preview"]')?.click()
})
expect(grantRuntime.released).toEqual(['preview-1'])
expect(grantRuntime.mints).toBe(2)
expect(container.querySelector('webview')?.getAttribute('src')).toContain(REMINTED_GRANT_ID)
expect(container.textContent).not.toContain('Preview unavailable')
})
it('reloads the guest in place when the document is rendering fine', async () => {
await renderPreview(container, root)
await act(async () => {
container.querySelector<HTMLButtonElement>('button[aria-label="Reload preview"]')?.click()
})
expect(grantRuntime.released).toEqual([])
expect(grantRuntime.mints).toBe(1)
})
it('unsubscribes on unmount so a late failure cannot touch a torn-down preview', async () => {
await renderPreview(container, root)
expect(failureListeners).toHaveLength(1)
await act(async () => {
root.unmount()
mounted = false
})
expect(failureListeners).toHaveLength(0)
})
})
@@ -0,0 +1,582 @@
// @vitest-environment happy-dom
//
// The preview is an editor tab that has to read like a browser tab. These pin the parts of that
// illusion a reader can catch us on: the document names itself and its owning machine instead of
// showing the internal preview scheme, Back/Forward really drive the guest's history, and the chip
// hands over the path the owner spells rather than the one the grant was minted with.
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { acquireWebviewsDragPassthrough } from '@/components/browser-pane/host-guest/webview-drag-passthrough'
const GRANT_ID = 'a'.repeat(32)
// The draw-tool hint's own storage key; the hook that owns it keeps it private.
const MARKUP_DRAW_HINT_SEEN_KEY = 'orca.browser.markup-draw-hint-seen'
const ENTRY_RELATIVE_PATH = 'docs/reports/index.html'
const ABSOLUTE_PATH = '/repo/docs/reports/index.html'
const clipboard = vi.hoisted(() => ({ writes: [] as string[] }))
const grabCalls: { browserPageId: string; enabled: boolean }[] = []
const osOpens: string[] = []
vi.mock('@/lib/doc-preview-grants', () => ({
buildDocPreviewGrantRequest: () => ({
owner: {
kind: 'runtime' as const,
environmentId: 'env-1',
worktreeSelector: 'id:wt-1',
worktreeRoot: '/repo'
},
root: '/repo',
entryRelativePath: ENTRY_RELATIVE_PATH
}),
ensureDocPreviewGrant: () =>
Promise.resolve({
grantId: GRANT_ID,
url: `orca-preview://${GRANT_ID}/${ENTRY_RELATIVE_PATH}`
}),
releaseDocPreviewGrant: () => undefined
}))
vi.mock('@/components/browser-pane/host-guest/webview-registry', () => ({
moveFocusToRendererBeforeWebviewDetach: () => undefined
}))
// The real one walks half the store to decide who owns a worktree; the chip only cares that
// whatever it decides reaches the pill.
vi.mock('@/lib/execution-host-display-label', () => ({
selectWorktreeHostDisplayLabel: () => 'Studio Mac mini'
}))
const store = vi.hoisted(() => ({
openedFiles: [] as unknown[],
downloads: [] as string[],
pageStateUpdates: [] as { pageId: string; updates: { title?: string } }[]
}))
// The document lives on the SSH host that owns the workspace, which is what makes the preview a
// preview at all — the client OS has no copy of it.
vi.mock('@/lib/connection-context', () => ({
getConnectionId: () => 'ssh-1',
getConnectionIdForFile: () => 'ssh-1',
getConnectionIdFromState: () => 'ssh-1'
}))
vi.mock('@/lib/connection-owner-resolution', () => ({
getConnectionIdForFileFromState: () => 'ssh-1'
}))
vi.mock('@/components/terminal-pane/terminal-remote-file-download-open', () => ({
downloadAndOpenRemoteTerminalFile: (_context: unknown, filePath: string) => {
store.downloads.push(filePath)
return Promise.resolve()
}
}))
const storeState = {
getKnownWorktreeById: () => ({ path: '/repo' }),
persistedUIReady: true,
settings: { activeRuntimeEnvironmentId: 'env-1' },
keybindings: {},
browserAnnotationsByPageId: {} as Record<string, unknown[]>,
activeGroupIdByWorktree: {} as Record<string, string>,
agentSendPopoverTargetMode: null,
openAgentSendPopoverTargetMode: () => undefined,
closeAgentSendPopoverTargetMode: () => undefined,
addBrowserPageAnnotation: () => undefined,
deleteBrowserPageAnnotation: () => undefined,
clearBrowserPageAnnotations: () => undefined,
recordFeatureInteraction: () => undefined,
openFile: (file: unknown) => {
store.openedFiles.push(file)
return 'file-1'
},
updateBrowserPageState: (pageId: string, updates: { title?: string }) => {
store.pageStateUpdates.push({ pageId, updates })
}
}
vi.mock('@/store', () => ({
useAppStore: Object.assign(
(selector?: (state: typeof storeState) => unknown) =>
selector ? selector(storeState) : storeState,
{ getState: () => storeState }
)
}))
type StubWebview = Element & {
canGoBack: () => boolean
canGoForward: () => boolean
goBack: () => void
goForward: () => void
reload: () => void
}
async function renderPreview(
container: HTMLDivElement,
root: Root,
options: { holdsGuestFocus?: boolean } = {}
): Promise<StubWebview> {
const { HtmlDocPreview } = await import('./HtmlDocPreview')
await act(async () => {
root.render(
<TooltipProvider>
<HtmlDocPreview
previewId="preview-1"
filePath={ABSOLUTE_PATH}
relativePath={ENTRY_RELATIVE_PATH}
worktreeId="wt-1"
holdsGuestFocus={options.holdsGuestFocus ?? false}
/>
</TooltipProvider>
)
})
const webview = container.querySelector('webview') as StubWebview | null
expect(webview).not.toBeNull()
// Why: the tools only arm once something has painted, so every case starts from a settled load.
await act(async () => {
webview?.dispatchEvent(new Event('did-stop-loading'))
})
return webview as StubWebview
}
function stubHistory(
webview: StubWebview,
depth: { canGoBack: boolean; canGoForward: boolean }
): { goBack: ReturnType<typeof vi.fn>; goForward: ReturnType<typeof vi.fn> } {
const goBack = vi.fn()
const goForward = vi.fn()
webview.canGoBack = () => depth.canGoBack
webview.canGoForward = () => depth.canGoForward
webview.goBack = goBack
webview.goForward = goForward
webview.reload = vi.fn()
return { goBack, goForward }
}
function button(container: HTMLDivElement, label: string): HTMLButtonElement {
const element = container.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
expect(element).not.toBeNull()
return element as HTMLButtonElement
}
describe('HtmlDocPreview browser chrome', () => {
let container: HTMLDivElement
let root: Root
let mounted = false
beforeEach(() => {
mounted = true
clipboard.writes = []
store.openedFiles = []
store.downloads = []
grabCalls.length = 0
osOpens.length = 0
;(window as unknown as { api: unknown }).api = {
docPreview: { onLoadFailure: () => () => undefined },
ui: {
writeClipboardText: (text: string) => {
clipboard.writes.push(text)
return Promise.resolve()
},
writeClipboardImage: () => Promise.resolve()
},
shell: {
openFilePath: (filePath: string) => {
osOpens.push(filePath)
return Promise.resolve(true)
}
},
browser: {
setGrabMode: (args: { browserPageId: string; enabled: boolean }) => {
grabCalls.push(args)
return Promise.resolve({ ok: true })
},
cancelGrab: () => Promise.resolve(true),
awaitGrabSelection: () => new Promise(() => {}),
captureSelectionScreenshot: () => Promise.resolve({ ok: false }),
setAnnotationViewportBridge: () => Promise.resolve(true)
}
}
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
})
afterEach(() => {
if (mounted) {
act(() => root.unmount())
mounted = false
}
container.remove()
})
it('identifies the document by its workspace path and owning machine', async () => {
await renderPreview(container, root)
const text = container.textContent ?? ''
expect(text).toContain('docs/reports/')
expect(text).toContain('index.html')
expect(text).toContain('Workspace file')
expect(text).toContain('Studio Mac mini')
})
// The internal scheme is an implementation detail of how the workspace hands bytes to the guest.
// The guest's own src attribute necessarily carries it; no other node in the tree may.
it('never shows the internal preview scheme to the reader', async () => {
const webview = await renderPreview(container, root)
expect(webview.getAttribute('src')).toContain('orca-preview')
const withoutGuest = container.cloneNode(true) as HTMLElement
for (const guest of withoutGuest.querySelectorAll('webview')) {
guest.remove()
}
expect(withoutGuest.innerHTML).not.toContain('orca-preview')
})
// Why this is a drag bug and not a styling one: a <webview> takes the pointer stream the
// document never sees, so a tab drag stops getting pointermove the moment the cursor crosses
// the preview — the dragged tab stops following the cursor and the split cannot be dropped.
it('holds the guest click-through while a renderer drag is in flight', async () => {
const webview = await renderPreview(container, root)
const guest = webview as unknown as HTMLElement
let release: (() => void) | null = null
expect(guest.style.pointerEvents).toBe('')
await act(async () => {
release = acquireWebviewsDragPassthrough()
})
expect(guest.style.pointerEvents).toBe('none')
await act(async () => release?.())
expect(guest.style.pointerEvents).toBe('')
})
// Why append time and not the enrolling effect: dragging the preview's OWN tab across a split
// remounts this component mid-drag, and an effect settles a turn later — for the rest of that
// turn the fresh guest is hittable and eats the pointer stream, which is the original freeze.
it('holds a guest that appears mid-drag click-through the moment it is appended', async () => {
const appendedPointerEvents: string[] = []
const originalAppendChild = HTMLElement.prototype.appendChild
HTMLElement.prototype.appendChild = function <T extends Node>(node: T): T {
const element = node as unknown as HTMLElement
if (element.tagName?.toLowerCase() === 'webview') {
appendedPointerEvents.push(element.style.pointerEvents)
}
return originalAppendChild.call(this, node) as T
}
const release = acquireWebviewsDragPassthrough()
try {
await renderPreview(container, root)
expect(appendedPointerEvents).toEqual(['none'])
} finally {
HTMLElement.prototype.appendChild = originalAppendChild
release()
}
})
// Why: the chip sits in the row's height-pinned slot, and a wrapper of its own between the two
// would leave it its natural height again — the toolbar would shrink for document tabs.
it('hands the identity chip straight to the height-pinned address slot', async () => {
await renderPreview(container, root)
const chip = button(container, 'Copy file path')
const slot = container.querySelector('[data-browser-chrome-address-slot]')
expect(slot).not.toBeNull()
expect(chip.parentElement).toBe(slot)
expect(chip.className).not.toMatch(/(^|\s)h-/)
})
// Browser parity: a browser tab is named by the document it shows. What the document names is
// the tab; what it must never rename is the chip, which is the reader's only proof of which
// file on which host they are looking at.
it('lets the document name its tab while the chip keeps naming the file', async () => {
const webview = await renderPreview(container, root)
store.pageStateUpdates.length = 0
await act(async () => {
const event = new Event('page-title-updated')
Object.assign(event, { title: 'Quarterly Report' })
webview.dispatchEvent(event)
})
expect(store.pageStateUpdates).toEqual([
{ pageId: 'preview-1', updates: { title: 'Quarterly Report' } }
])
expect(button(container, 'Copy file path').textContent).toContain(ENTRY_RELATIVE_PATH)
})
// Why: the browsing tour walks anchors by name, and a preview answering to the browser pane's
// anchors would hand it steps about profiles and cookies that a document tab does not have.
it('claims none of the browsing tour anchors', async () => {
await renderPreview(container, root)
expect(container.querySelector('[data-contextual-tour-target]')).toBeNull()
})
it('copies the absolute path the owning machine spells, not the workspace-relative one', async () => {
await renderPreview(container, root)
await act(async () => {
button(container, 'Copy file path').click()
})
expect(clipboard.writes).toEqual([ABSOLUTE_PATH])
// The icon swap alone says nothing to a screen reader, so the control renames itself.
expect(button(container, 'Copied')).not.toBeNull()
})
it('starts with both history controls disabled', async () => {
await renderPreview(container, root)
expect(button(container, 'Back').disabled).toBe(true)
expect(button(container, 'Forward').disabled).toBe(true)
})
it('enables Back once the guest has somewhere to go back to and drives the guest', async () => {
const webview = await renderPreview(container, root)
const { goBack, goForward } = stubHistory(webview, { canGoBack: true, canGoForward: false })
await act(async () => {
webview.dispatchEvent(new Event('did-navigate'))
})
expect(button(container, 'Back').disabled).toBe(false)
expect(button(container, 'Forward').disabled).toBe(true)
await act(async () => {
button(container, 'Back').click()
button(container, 'Forward').click()
})
expect(goBack).toHaveBeenCalledTimes(1)
// Why: a disabled edge control must be inert, not merely dimmed.
expect(goForward).not.toHaveBeenCalled()
})
// Fragment links navigate in-document, which is still a history entry a reader expects Back to
// unwind — the guest reports it on a different event than a full navigation.
it('tracks in-document navigation as history too', async () => {
const webview = await renderPreview(container, root)
stubHistory(webview, { canGoBack: true, canGoForward: true })
await act(async () => {
webview.dispatchEvent(new Event('did-navigate-in-page'))
})
expect(button(container, 'Back').disabled).toBe(false)
expect(button(container, 'Forward').disabled).toBe(false)
})
it('still reloads the guest in place from the toolbar', async () => {
const webview = await renderPreview(container, root)
stubHistory(webview, { canGoBack: false, canGoForward: false })
await act(async () => {
button(container, 'Reload preview').click()
})
expect(webview.reload).toHaveBeenCalledTimes(1)
})
describe('tools', () => {
it('offers the same tool cluster the browsing pane does', async () => {
await renderPreview(container, root)
for (const label of [
'Grab page element',
'Annotate page element',
'Draw on screenshot',
'Open source file',
'Open with default app',
'Preview options'
]) {
expect(button(container, label)).not.toBeNull()
}
})
// Why: cookies land in a browsing session, and a preview reads workspace disk over a grant —
// there is no session for an import to reach.
it('hides cookie import, which a preview has no session for', async () => {
await renderPreview(container, root)
expect(container.querySelector('button[aria-label="Import cookies from browser"]')).toBeNull()
})
// The picker is driven through main, which resolves the page to whichever guest is rendering
// this document now — so the id it sends is the page, not the grant a re-mint would replace.
it('arms the element picker against the page the document is open in', async () => {
await renderPreview(container, root)
await act(async () => {
button(container, 'Grab page element').click()
})
expect(grabCalls).toEqual([{ browserPageId: 'preview-1', enabled: true }])
})
it('opens the document source as its own editor tab', async () => {
await renderPreview(container, root)
await act(async () => {
button(container, 'Open source file').click()
})
expect(store.openedFiles).toEqual([
expect.objectContaining({
filePath: ABSOLUTE_PATH,
relativePath: ENTRY_RELATIVE_PATH,
worktreeId: 'wt-1',
mode: 'edit'
})
])
})
// Why: a preview is a remote document by construction — the client OS has no copy to launch,
// so "open externally" has to download first.
it('downloads a runtime-owned document before handing it to the OS', async () => {
await renderPreview(container, root)
await act(async () => {
button(container, 'Open with default app').click()
})
expect(store.downloads).toEqual([ABSOLUTE_PATH])
expect(osOpens).toEqual([])
})
// Why the storage flag and not the popover: the nudge fires once per install, and the harm is
// consuming that one view — a reader who opened a document would spend the browsing pane's
// introduction to a tool they were not shown.
it('never spends the once-per-install draw-tool hint', async () => {
window.localStorage.removeItem(MARKUP_DRAW_HINT_SEEN_KEY)
await renderPreview(container, root)
expect(window.localStorage.getItem(MARKUP_DRAW_HINT_SEEN_KEY)).toBeNull()
})
})
// Why it has to be somewhere: the preview hides the editor's path header, and the relative path
// was only ever copyable from there — the chip and the menu both give the absolute one.
it('still offers the workspace-relative path the hidden editor header used to copy', async () => {
await renderPreview(container, root)
await act(async () => {
// Why not click(): the Radix trigger opens on pointerdown, which happy-dom does not synthesize.
button(container, 'Preview options').dispatchEvent(
new window.PointerEvent('pointerdown', { bubbles: true, button: 0 })
)
})
const relativeCopy = [...document.querySelectorAll('[role="menuitem"]')].find((item) =>
item.textContent?.includes('Copy relative path')
)
expect(relativeCopy).toBeDefined()
await act(async () => {
;(relativeCopy as HTMLElement).click()
})
expect(clipboard.writes).toEqual([ENTRY_RELATIVE_PATH])
})
})
// Why this is a test and not left to the pane: main answers a reported link click only from a
// focused guest, so a preview whose guest never takes focus has no route out at all — the failure
// is silent, and only the reader pressing a link ever sees it.
describe('HtmlDocPreview guest focus', () => {
let container: HTMLDivElement
let root: Root
let focused: Element[]
let focusSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
focused = []
focusSpy = vi
.spyOn(HTMLElement.prototype, 'focus')
.mockImplementation(function (this: HTMLElement) {
focused.push(this)
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(async () => {
await act(async () => root.unmount())
container.remove()
focusSpy.mockRestore()
})
async function settleFrames(): Promise<void> {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50))
})
}
it('hands the guest focus once the preview is the surface the reader is in', async () => {
const webview = await renderPreview(container, root, { holdsGuestFocus: true })
await settleFrames()
expect(focused).toContain(webview)
})
it('leaves focus alone while the reader is looking at something else', async () => {
const webview = await renderPreview(container, root, { holdsGuestFocus: false })
await settleFrames()
expect(focused).not.toContain(webview)
// And the window coming back does not reopen the offer a preview behind a terminal refused.
await act(async () => {
window.dispatchEvent(new Event('focus'))
})
await settleFrames()
expect(focused).not.toContain(webview)
})
// Why the window's own focus has to re-offer: another app taking the front pulls focus out of the
// guest, and coming back lands it on the embedder. The guest is where a clicked link is reported
// from, so without this the route out of the preview stays shut until something remounts it.
it('offers the guest focus again after the window gets it back', async () => {
const webview = await renderPreview(container, root, { holdsGuestFocus: true })
await settleFrames()
focused.length = 0
await act(async () => {
window.dispatchEvent(new Event('focus'))
})
await settleFrames()
expect(focused).toContain(webview)
})
// Why that re-offer has to yield: the window also gets focus back when the reader presses a tab,
// because the guest holding the keyboard is what blurred the embedder. Taking it back from there
// fights the reader for their own click.
it('leaves focus with whatever claimed it when the window comes back', async () => {
const webview = await renderPreview(container, root, { holdsGuestFocus: true })
await settleFrames()
focused.length = 0
const claimant = document.createElement('button')
document.body.append(claimant)
Object.defineProperty(document, 'activeElement', {
configurable: true,
get: () => claimant
})
await act(async () => {
window.dispatchEvent(new Event('focus'))
})
await settleFrames()
expect(focused).not.toContain(webview)
Reflect.deleteProperty(document, 'activeElement')
claimant.remove()
})
})
@@ -0,0 +1,413 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AlertCircle, Loader2 } from 'lucide-react'
import {
DOC_PREVIEW_PARTITION,
type DocPreviewFileFailure,
type DocPreviewFileFailureReason
} from '../../../../../shared/doc-preview-scheme'
import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../../shared/browser-guest-web-preferences'
import { BrowserGuestAnnotateOverlays } from '@/components/browser-pane/annotate/browser-guest-annotate-overlays'
import { useGuestDragPassthrough } from '@/components/browser-pane/host-guest/use-guest-drag-passthrough'
import { isWebviewDragPassthroughActive } from '@/components/browser-pane/host-guest/webview-drag-passthrough'
import { moveFocusToRendererBeforeWebviewDetach } from '@/components/browser-pane/host-guest/webview-registry'
import {
buildDocPreviewGrantRequest,
ensureDocPreviewGrant,
releaseDocPreviewGrant
} from '@/lib/doc-preview-grants'
import { selectWorktreeHostDisplayLabel } from '@/lib/execution-host-display-label'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { openDocPreviewExternally, openDocPreviewSource } from './doc-preview-document-actions'
import { buildDocPreviewDocumentIdentity } from './doc-preview-document-identity'
import {
docPreviewAssetNotice,
docPreviewDownloadBlockedNotice,
docPreviewFailureDetail
} from './doc-preview-failure-messages'
import { DocPreviewToolbar } from './doc-preview-toolbar'
import { useDocPreviewWebviewHistory } from './doc-preview-webview-history'
import { useDocPreviewGuestTools } from './use-doc-preview-guest-tools'
type PreviewState = 'loading' | 'ready' | 'unavailable'
function attachDocPreviewWebview({
container,
url,
ariaLabel,
onLoadStarted,
onLoadStopped,
onLoadFailed,
onNavigated,
onTitleUpdated
}: {
container: HTMLDivElement
url: string
ariaLabel: string
onLoadStarted: () => void
onLoadStopped: () => void
onLoadFailed: (event: Electron.DidFailLoadEvent) => void
onNavigated: () => void
onTitleUpdated: (event: Electron.PageTitleUpdatedEvent) => void
}): { webview: Electron.WebviewTag; detach: () => void; reload: () => void } {
const webview = document.createElement('webview') as Electron.WebviewTag
// Why no allowpopups: the guest's preload intercepts a trusted click on a link before Chromium
// considers a popup at all, so target="_blank" needs no popup path and every one stays denied.
webview.setAttribute('partition', DOC_PREVIEW_PARTITION)
webview.setAttribute('webpreferences', ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE)
webview.setAttribute('aria-label', ariaLabel)
// Browsers paint an undeclared page canvas white; the guest is transparent, so without this the
// editor's dark surface shows through and default black text becomes unreadable.
webview.style.backgroundColor = '#fff'
webview.style.display = 'flex'
webview.style.width = '100%'
webview.style.height = '100%'
webview.style.border = 'none'
webview.addEventListener('did-start-loading', onLoadStarted)
webview.addEventListener('did-stop-loading', onLoadStopped)
webview.addEventListener('did-fail-load', onLoadFailed)
// Both: a link to a sibling document is a full navigation, a fragment link is an in-page one,
// and only the pair together tracks what Back can actually return to.
webview.addEventListener('did-navigate', onNavigated)
webview.addEventListener('did-navigate-in-page', onNavigated)
// Why the document names its own tab: a preview is a browser tab, and this is how every other
// one is named. What the document cannot do is name it the grant it is served over.
webview.addEventListener('page-title-updated', onTitleUpdated)
// Why here and not in the enrolling hook: appending is what makes this guest hittable, and the
// registry's contract is that the path doing so settles it. Dragging the preview's own tab
// remounts this component mid-drag, and a hook effect lands a turn too late — for the rest of
// that turn the fresh guest eats the pointer stream and the drag freezes.
if (isWebviewDragPassthroughActive()) {
webview.style.pointerEvents = 'none'
}
container.appendChild(webview)
webview.setAttribute('src', url)
return {
webview,
detach: () => {
webview.removeEventListener('did-start-loading', onLoadStarted)
webview.removeEventListener('did-stop-loading', onLoadStopped)
webview.removeEventListener('did-fail-load', onLoadFailed)
webview.removeEventListener('did-navigate', onNavigated)
webview.removeEventListener('did-navigate-in-page', onNavigated)
webview.removeEventListener('page-title-updated', onTitleUpdated)
moveFocusToRendererBeforeWebviewDetach(webview)
webview.remove()
},
// Why: the protocol handler answers with no-store, so a reload re-reads the workspace disk.
reload: () => {
try {
webview.reload()
} catch {
webview.setAttribute('src', url)
}
}
}
}
/** Frames a preview keeps offering focus to a guest that is still attaching. */
const GUEST_FOCUS_FRAMES = 10
export function HtmlDocPreview({
previewId,
filePath,
relativePath,
worktreeId,
holdsGuestFocus = false,
runtimeEnvironmentId = null,
externalSshTargetId = null
}: {
previewId: string
filePath: string
relativePath: string
worktreeId: string
/** Whether this preview is the surface the reader is in, and so may hold the keyboard. */
holdsGuestFocus?: boolean
runtimeEnvironmentId?: string | null
externalSshTargetId?: string | null
}): React.JSX.Element {
const containerRef = useRef<HTMLDivElement | null>(null)
const webviewRef = useRef<Electron.WebviewTag | null>(null)
const reloadRef = useRef<(() => void) | null>(null)
const [state, setState] = useState<PreviewState>('loading')
const [failureReason, setFailureReason] = useState<DocPreviewFileFailureReason | null>(null)
const [assetFailures, setAssetFailures] = useState<DocPreviewFileFailure[]>([])
const [downloadBlocked, setDownloadBlocked] = useState(false)
const [remintCount, setRemintCount] = useState(0)
const [grantId, setGrantId] = useState<string | null>(null)
const history = useDocPreviewWebviewHistory(webviewRef)
const { sync: syncHistory, reset: resetHistory } = history
const worktreeRoot = useAppStore((store) => store.getKnownWorktreeById(worktreeId)?.path ?? null)
const hostLabel = useAppStore((store) => selectWorktreeHostDisplayLabel(store, worktreeId))
const identity = useMemo(
() => buildDocPreviewDocumentIdentity({ filePath, worktreeRoot, hostLabel }),
[filePath, hostLabel, worktreeRoot]
)
const isUnavailable = state === 'unavailable' || failureReason !== null
useGuestDragPassthrough(webviewRef, grantId)
const { grab, markup, annotationSend, grabAnnotations, browserOverlayViewport, elementTools } =
useDocPreviewGuestTools({
previewId,
worktreeId,
grantId,
webviewRef,
containerRef,
toolsReady: state === 'ready' && !isUnavailable
})
// Not `document`: shadowing the global inside a component is how a stray DOM call silently
// starts reading a plain object.
const previewDocument = useMemo(
() => ({ filePath, relativePath, worktreeId, runtimeEnvironmentId, externalSshTargetId }),
[externalSshTargetId, filePath, relativePath, runtimeEnvironmentId, worktreeId]
)
useEffect(() => {
let disposed = false
let detach: (() => void) | undefined
let loadFailed = false
const onLoadStarted = (): void => {
loadFailed = false
setFailureReason(null)
setAssetFailures([])
setDownloadBlocked(false)
setState('loading')
}
const onLoadStopped = (): void => {
// Why sync here too: a navigation's history entry is only committed once loading settles.
syncHistory()
if (!loadFailed) {
setState('ready')
}
}
const onLoadFailed = (event: Electron.DidFailLoadEvent): void => {
if (!event.isMainFrame || event.errorCode === -3) {
return
}
loadFailed = true
setState('unavailable')
}
setState('loading')
setFailureReason(null)
setAssetFailures([])
setDownloadBlocked(false)
setGrantId(null)
resetHistory()
const request = buildDocPreviewGrantRequest(useAppStore.getState(), worktreeId, filePath)
if (!request) {
setState('unavailable')
return () => {
disposed = true
}
}
// Why: an unreadable document answers with a status the guest renders as text, so the reason
// arrives out-of-band. Subscribe before minting so the entry document's failure cannot be missed.
let boundGrantId: string | null = null
const unsubscribeFailure = window.api.docPreview?.onLoadFailure?.((payload) => {
if (disposed || payload.grantId !== boundGrantId) {
return
}
// Why first: a refused download is the fences answering for the reader, not the document
// failing to load, so it can never take the page away — and it names no file to compare.
if (payload.reason === 'download-blocked') {
setDownloadBlocked(true)
return
}
if (payload.relativePath === request.entryRelativePath) {
setFailureReason(payload.reason)
return
}
setAssetFailures((current) =>
current.some((failure) => failure.relativePath === payload.relativePath)
? current
: [...current, payload]
)
})
void ensureDocPreviewGrant(previewId, request)
.then((handle) => {
boundGrantId = handle.grantId
if (disposed || !containerRef.current) {
return
}
const attached = attachDocPreviewWebview({
container: containerRef.current,
url: handle.url,
ariaLabel: translate(
'auto.components.editor.HtmlDocPreview.previewAriaLabel',
'HTML preview'
),
onLoadStarted,
onLoadStopped,
onLoadFailed,
onNavigated: syncHistory,
onTitleUpdated: (event) => {
useAppStore.getState().updateBrowserPageState(previewId, { title: event.title })
}
})
detach = attached.detach
reloadRef.current = attached.reload
webviewRef.current = attached.webview
// Why only now: main binds this grant to the guest on its first commit, so the tools have
// nothing to name until the webview exists and is pointed at it.
setGrantId(handle.grantId)
})
.catch(() => {
if (!disposed) {
setState('unavailable')
}
})
return () => {
disposed = true
reloadRef.current = null
webviewRef.current = null
unsubscribeFailure?.()
detach?.()
}
}, [filePath, previewId, remintCount, resetHistory, syncHistory, worktreeId])
// Why the guest is handed focus rather than left to the press that opens a link: main answers a
// reported link click only from a focused guest, and a preview has no chrome of its own to pass
// focus on — a URL page's address bar is what hands it over. Without this the one route out of a
// preview stays shut until something else happens to focus the document.
useEffect(() => {
if (!holdsGuestFocus || state !== 'ready') {
return
}
let frameId = 0
let attempts = 0
let claimedOnly = false
const focusGuest = (): void => {
const webview = webviewRef.current
attempts += 1
// Why a re-offer yields: it is a handoff for focus nothing else wanted, and the reader
// pressing a tab lands here first. Taking it back would fight them for the keyboard, which
// is what shut the tab strip while a preview was open.
if (
claimedOnly &&
document.activeElement !== document.body &&
document.activeElement !== webview
) {
return
}
try {
webview?.focus()
} catch {
// Why swallowed: WebViewElement.focus() reads null internals once the guest is destroyed.
return
}
// Why retried: the guest takes focus only once it is attached and laid out, a frame or two
// after it reports ready.
if (document.activeElement !== webview && attempts < GUEST_FOCUS_FRAMES) {
frameId = window.requestAnimationFrame(focusGuest)
}
}
const offerFocus = (yieldToOtherClaims: boolean): void => {
window.cancelAnimationFrame(frameId)
attempts = 0
claimedOnly = yieldToOtherClaims
frameId = window.requestAnimationFrame(focusGuest)
}
// Why assertive: the reader just made this preview their surface, so the handoff is the point.
offerFocus(false)
// Why re-offered on the window's own focus: another app taking the front takes focus out of the
// guest, and coming back puts it on the embedder. Nothing hands it on, so the route out of the
// preview would stay shut until something remounted it.
const reofferFocus = (): void => offerFocus(true)
window.addEventListener('focus', reofferFocus)
return () => {
window.removeEventListener('focus', reofferFocus)
window.cancelAnimationFrame(frameId)
}
}, [holdsGuestFocus, previewId, remintCount, state])
// Why: a grant is pinned to the owner ids resolved when it was minted, so after a pairing or
// SSH reconnect the old one reads nothing and reloading the guest would just refetch the
// failure. Drop it and mint against today's ids instead of making the user close the tab.
const handleHardReload = useCallback(() => {
releaseDocPreviewGrant(previewId)
setRemintCount((count) => count + 1)
}, [previewId])
const handleReload = useCallback(() => {
if (failureReason !== null || state === 'unavailable') {
handleHardReload()
return
}
reloadRef.current?.()
}, [failureReason, handleHardReload, state])
// Nothing rendered on an unavailable preview, so a notice strip would be a footnote on a blank page.
const notices = isUnavailable
? []
: [
downloadBlocked ? docPreviewDownloadBlockedNotice() : null,
docPreviewAssetNotice(assetFailures)
].filter((notice): notice is string => notice !== null)
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-editor-surface">
<DocPreviewToolbar
identity={identity}
history={history}
loading={state === 'loading' && failureReason === null}
onReload={handleReload}
onHardReload={handleHardReload}
onCopyPath={() => void window.api.ui.writeClipboardText(identity.absolutePath)}
onCopyRelativePath={() => void window.api.ui.writeClipboardText(relativePath)}
onOpenSource={() => openDocPreviewSource(previewDocument)}
onOpenExternally={() => openDocPreviewExternally(previewDocument)}
elementTools={elementTools}
markupActive={markup.isActive}
onToggleMarkup={() => (markup.isActive ? markup.cancel() : void markup.start())}
// Nothing has painted yet on a loading or failed preview, so there is nothing to draw on.
markupDisabled={isUnavailable || state !== 'ready' || grab.state !== 'idle'}
/>
{notices.map((notice) => (
<div
key={notice}
className="flex shrink-0 items-center gap-1.5 border-b px-2 py-1 text-xs text-muted-foreground"
role="status"
title={notice}
>
<AlertCircle className="size-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">{notice}</span>
</div>
))}
<div className="relative flex min-h-0 flex-1 overflow-hidden" ref={containerRef}>
<BrowserGuestAnnotateOverlays
markup={markup}
grab={grab}
annotationSend={annotationSend}
grabAnnotations={grabAnnotations}
containerRef={containerRef}
webviewRef={webviewRef}
browserOverlayViewport={browserOverlayViewport}
worktreeId={worktreeId}
/>
{state === 'loading' && failureReason === null ? (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-editor-surface">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : null}
{isUnavailable ? (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-editor-surface px-6 text-center">
<AlertCircle className="size-6 text-muted-foreground" />
<p className="text-sm font-medium">
{translate(
'auto.components.editor.HtmlDocPreview.previewUnavailableTitle',
'Preview unavailable'
)}
</p>
<p className="max-w-sm text-xs text-muted-foreground">
{docPreviewFailureDetail(failureReason)}
</p>
</div>
) : null}
</div>
</div>
)
}
@@ -0,0 +1,206 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
/** What the per-file resolver answers: null local, a string remote, undefined unresolved. */
connectionIdForFile: null as string | null | undefined,
/** What the workspace-scoped resolver answers, which is not always the same thing. */
connectionIdForWorkspace: null as string | null | undefined,
worktreePath: '/srv/repo' as string | null,
/** What the worktree resolves its runtime owner to, which the grant was minted against. */
worktreeRuntimeOwnerId: null as string | null,
activeRuntimeEnvironmentId: undefined as string | undefined,
openFile: vi.fn(),
openFilePath: vi.fn().mockResolvedValue(true),
downloadAndOpen: vi.fn(),
toastError: vi.fn()
}))
vi.mock('sonner', () => ({ toast: { error: mocks.toastError } }))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, params?: Record<string, string>) =>
fallback.replace('{{value0}}', params?.value0 ?? '')
}))
vi.mock('@/lib/connection-owner-resolution', () => ({
getConnectionIdForFileFromState: () => mocks.connectionIdForFile
}))
vi.mock('@/lib/worktree-runtime-owner', () => ({
getRuntimeEnvironmentIdForWorktree: () => mocks.worktreeRuntimeOwnerId
}))
vi.mock('@/lib/connection-context', () => ({
getConnectionId: () => mocks.connectionIdForWorkspace,
getConnectionIdForFile: () => mocks.connectionIdForFile
}))
vi.mock('@/components/terminal-pane/terminal-remote-file-download-open', () => ({
downloadAndOpenRemoteTerminalFile: mocks.downloadAndOpen
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => ({
settings: {
activeRuntimeEnvironmentId: mocks.activeRuntimeEnvironmentId
},
getKnownWorktreeById: () =>
mocks.worktreePath === null ? undefined : { id: 'wt-1', path: mocks.worktreePath },
openFile: mocks.openFile
})
}
}))
import { openDocPreviewExternally, openDocPreviewSource } from './doc-preview-document-actions'
const REMOTE_DOCUMENT = {
filePath: '/root/demo/report/index.html',
relativePath: 'report/index.html',
worktreeId: 'wt-1',
runtimeEnvironmentId: null,
externalSshTargetId: null
}
beforeEach(() => {
vi.clearAllMocks()
mocks.connectionIdForFile = null
mocks.connectionIdForWorkspace = null
mocks.worktreePath = '/srv/repo'
mocks.worktreeRuntimeOwnerId = null
mocks.activeRuntimeEnvironmentId = undefined
mocks.openFilePath.mockResolvedValue(true)
vi.stubGlobal('window', { api: { shell: { openFilePath: mocks.openFilePath } } })
})
describe('openDocPreviewSource', () => {
it('opens the document as an ordinary source tab with the editor language', () => {
openDocPreviewSource(REMOTE_DOCUMENT)
expect(mocks.openFile).toHaveBeenCalledWith(
expect.objectContaining({
filePath: REMOTE_DOCUMENT.filePath,
relativePath: REMOTE_DOCUMENT.relativePath,
worktreeId: 'wt-1',
language: 'html',
mode: 'edit'
})
)
})
// Why this field and not the rest: an absolute SSH path outside the worktree is indistinguishable
// from a client-local external file, so dropping it makes the reopened tab read the wrong host.
it('carries the external SSH target onto the source tab', () => {
openDocPreviewSource({ ...REMOTE_DOCUMENT, externalSshTargetId: 'ssh-7' })
expect(mocks.openFile).toHaveBeenCalledWith(
expect.objectContaining({ externalSshTargetId: 'ssh-7' })
)
})
it('omits the field entirely when the document has no external target', () => {
openDocPreviewSource(REMOTE_DOCUMENT)
expect(mocks.openFile.mock.calls[0]?.[0]).not.toHaveProperty('externalSshTargetId')
})
})
describe('openDocPreviewExternally', () => {
it('hands a local document straight to the OS', () => {
openDocPreviewExternally(REMOTE_DOCUMENT)
expect(mocks.openFilePath).toHaveBeenCalledWith(REMOTE_DOCUMENT.filePath)
expect(mocks.downloadAndOpen).not.toHaveBeenCalled()
})
it('downloads an SSH document before handing it over', () => {
mocks.connectionIdForFile = 'ssh-1'
mocks.connectionIdForWorkspace = 'ssh-1'
openDocPreviewExternally(REMOTE_DOCUMENT)
expect(mocks.openFilePath).not.toHaveBeenCalled()
expect(mocks.downloadAndOpen).toHaveBeenCalledWith(
expect.objectContaining({ connectionId: 'ssh-1' }),
REMOTE_DOCUMENT.filePath
)
})
// Why this case exists at all: a folder workspace can span repos on different hosts, so asking
// who owns the *workspace* answers nothing while the file itself has a definite owner. Routing on
// the workspace answer would hand the OS a remote absolute path — a silent no-op, or worse, an
// unrelated local file that happens to share the path.
it('routes on the file owner when the workspace-scoped owner is unresolved', () => {
mocks.connectionIdForFile = 'ssh-1'
mocks.connectionIdForWorkspace = undefined
openDocPreviewExternally(REMOTE_DOCUMENT)
expect(mocks.openFilePath).not.toHaveBeenCalled()
expect(mocks.downloadAndOpen).toHaveBeenCalledWith(
expect.objectContaining({ connectionId: 'ssh-1' }),
REMOTE_DOCUMENT.filePath
)
})
// Why refusing beats downloading here: with no owner at all the download route reads the absolute
// path on this machine, so a client holding a same-named file would be shown its contents under
// the remote document's name — the wrong-file outcome, one layer below the OS branch.
it('refuses instead of reading this machine when no owner resolves', () => {
mocks.connectionIdForFile = undefined
mocks.connectionIdForWorkspace = undefined
openDocPreviewExternally(REMOTE_DOCUMENT)
expect(mocks.openFilePath).not.toHaveBeenCalled()
expect(mocks.downloadAndOpen).not.toHaveBeenCalled()
expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('report/index.html'))
})
// Why this one varies only the root: every other case that loses the root also names another
// host, so without it nothing proves the root is required for the OS branch on its own.
it('refuses a document whose workspace root is unknown even when nothing names another host', () => {
mocks.worktreePath = null
openDocPreviewExternally(REMOTE_DOCUMENT)
expect(mocks.openFilePath).not.toHaveBeenCalled()
expect(mocks.downloadAndOpen).not.toHaveBeenCalled()
expect(mocks.toastError).toHaveBeenCalled()
})
// Why keep the tab's field as a fallback: the worktree stops resolving an owner once its runtime
// is torn down, and the tab that is still open is then the only record of who owned the document.
it('falls back to the tab runtime owner when the worktree resolves none', () => {
mocks.worktreeRuntimeOwnerId = null
mocks.activeRuntimeEnvironmentId = 'env-9'
openDocPreviewExternally({ ...REMOTE_DOCUMENT, runtimeEnvironmentId: 'env-9' })
expect(mocks.openFilePath).not.toHaveBeenCalled()
expect(mocks.toastError).not.toHaveBeenCalled()
expect(mocks.downloadAndOpen).toHaveBeenCalled()
})
// Why the tab's own field is not enough: a preview tab opened or restored before its worktree's
// runtime owner was known carries null there, while the grant it renders through was minted
// against the owner the worktree resolves to — routing on the stale field sends a remote
// document to the OS.
it('routes on the worktree runtime owner when the tab carries none', () => {
mocks.worktreeRuntimeOwnerId = 'env-1'
mocks.activeRuntimeEnvironmentId = 'env-1'
openDocPreviewExternally({ ...REMOTE_DOCUMENT, runtimeEnvironmentId: null })
expect(mocks.openFilePath).not.toHaveBeenCalled()
expect(mocks.downloadAndOpen).toHaveBeenCalled()
})
// Why the root matters: remote-runtime detection is relative to the workspace root, so an unknown
// root makes every path look outside the runtime — which reads as local.
it('downloads a runtime document whose workspace root is unknown', () => {
mocks.worktreePath = null
mocks.worktreeRuntimeOwnerId = 'env-1'
mocks.activeRuntimeEnvironmentId = 'env-1'
openDocPreviewExternally({ ...REMOTE_DOCUMENT, runtimeEnvironmentId: 'env-1' })
expect(mocks.openFilePath).not.toHaveBeenCalled()
expect(mocks.downloadAndOpen).toHaveBeenCalled()
})
})
@@ -0,0 +1,97 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { getConnectionIdForFileFromState } from '@/lib/connection-owner-resolution'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { detectLanguage } from '@/lib/language-detect'
import {
buildWorkspaceFileContextForFile,
canClientOsOpenWorkspaceFile
} from '@/lib/workspace-file-host-routing'
import { useAppStore } from '@/store'
import { downloadAndOpenRemoteTerminalFile } from '@/components/terminal-pane/terminal-remote-file-download-open'
export type DocPreviewDocument = {
filePath: string
relativePath: string
worktreeId: string
runtimeEnvironmentId: string | null
/**
* Only the source tab reads this today, because no path that opens a preview sets it. If one ever
* does, `openDocPreviewExternally` needs it as `expectedExternalSshTargetId` on the file context
* without it the read is not checked against the target the tab claims.
*/
externalSshTargetId: string | null
}
/**
* Open the previewed document as an ordinary source tab. A second tab, not a mode switch: the
* preview keeps its own id, so the reader can leave the rendered page where it was.
*/
export function openDocPreviewSource(document: DocPreviewDocument): void {
useAppStore.getState().openFile({
filePath: document.filePath,
relativePath: document.relativePath,
worktreeId: document.worktreeId,
// Why not the preview tab's language: it is pinned to 'html' for the preview itself, and the
// source tab needs the editor's own detection to pick a highlighter.
language: detectLanguage(document.filePath),
runtimeEnvironmentId: document.runtimeEnvironmentId,
...(document.externalSshTargetId ? { externalSshTargetId: document.externalSshTargetId } : {}),
mode: 'edit'
})
}
/**
* Hand the document to the reader's own machine. A preview is almost always remote, and the OS
* cannot launch a path it has no copy of so a remote document is downloaded first, exactly as
* the terminal's "Download & open with default app" does.
*/
export function openDocPreviewExternally(document: DocPreviewDocument): void {
const state = useAppStore.getState()
const worktreeRoot = state.getKnownWorktreeById(document.worktreeId)?.path ?? null
// Why the per-file resolver: this is the same document the grant authorized, and that grant was
// minted against the file's own owner. A folder workspace spanning hosts answers `undefined`
// workspace-wide, which downstream reads as local — the OS would then be handed a remote
// absolute path and either do nothing or open an unrelated file of the same name.
const connectionId = getConnectionIdForFileFromState(
state,
document.worktreeId,
document.filePath
)
// Why re-resolve the runtime owner rather than trust the tab's field: the grant this preview
// renders through was minted against the worktree's owner at render time, and a tab opened or
// restored before that owner was known still carries null — which reads as local.
const runtimeEnvironmentId =
getRuntimeEnvironmentIdForWorktree(state, document.worktreeId) ?? document.runtimeEnvironmentId
const fileContext = buildWorkspaceFileContextForFile(
document.worktreeId,
worktreeRoot ?? '',
document.filePath,
runtimeEnvironmentId
)
// Why these conditions rather than the shared predicate alone: it reads an unresolved owner, an
// unknown workspace root, and a runtime-owned path that sits outside that root as "local", and a
// preview really reaches all three. Only a document proven to live on this machine goes to the
// OS; a resolved remote owner downloads first, and no owner at all is refused below.
const ownedByThisMachine =
connectionId === null && runtimeEnvironmentId === null && worktreeRoot !== null
if (ownedByThisMachine && canClientOsOpenWorkspaceFile(fileContext, document.filePath)) {
void window.api.shell.openFilePath(document.filePath)
return
}
// Why refuse instead of downloading: with neither owner resolved the download route reads the
// absolute path on THIS machine, so a client that happens to hold a file of the same name would
// get its contents back under the remote document's name. Reachable once an owner un-resolves
// under a tab that already exists — the repo evicted, the SSH target removed.
if (connectionId == null && runtimeEnvironmentId === null) {
toast.error(
translate(
'auto.components.editor.HtmlDocPreview.openExternallyUnknownHostError',
"Can't open '{{value0}}': the host that owns it is no longer known.",
{ value0: document.relativePath }
)
)
return
}
void downloadAndOpenRemoteTerminalFile(fileContext, document.filePath)
}
@@ -0,0 +1,68 @@
import { Check, FileCode2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useClipboardTextCopyFeedback } from '@/hooks/use-clipboard-text-copy-feedback'
import { translate } from '@/i18n/i18n'
import type { DocPreviewDocumentIdentity } from './doc-preview-document-identity'
/**
* The preview's stand-in for an address bar. The guest's real origin is an internal scheme the
* reader has no use for, so this names the document the way the workspace does and says whose
* machine it was read from, which is the part a paired or SSH reader cannot otherwise tell.
*/
export function DocPreviewDocumentChip({
identity
}: {
identity: DocPreviewDocumentIdentity
}): React.JSX.Element {
const { copyText, status } = useClipboardTextCopyFeedback(identity.absolutePath)
const copied = status === 'copied'
const copyLabel = translate(
'auto.components.editor.HtmlDocPreview.copyDocumentPathControl',
'Copy file path'
)
// Why the label swaps: the icon change is the only other feedback, and an icon says nothing to
// a screen reader — the same trade TerminalLinkActionPopover makes.
const copiedLabel = translate(
'auto.components.editor.HtmlDocPreview.documentPathCopied',
'Copied'
)
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => void copyText()}
aria-label={copied ? copiedLabel : copyLabel}
className="flex min-w-0 flex-1 items-center gap-2 rounded-xl border border-border bg-background px-3 py-1 text-left shadow-sm hover:bg-accent/40 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"
>
{copied ? (
<Check className="size-4 shrink-0 text-muted-foreground" />
) : (
<FileCode2 className="size-4 shrink-0 text-muted-foreground" />
)}
{/* dir/ dimmed, filename normal — the same emphasis the file tree gives a path */}
<span className="min-w-0 flex-1 truncate text-sm">
<span className="text-muted-foreground">{identity.directoryPrefix}</span>
<span className="text-foreground">{identity.fileName}</span>
</span>
<span className="hidden shrink-0 items-center gap-1.5 text-xs text-muted-foreground sm:flex">
{translate(
'auto.components.editor.HtmlDocPreview.workspaceFileChipLabel',
'Workspace file'
)}
{identity.hostLabel ? (
<Badge variant="secondary" className="max-w-40 truncate font-normal">
{identity.hostLabel}
</Badge>
) : null}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{copied ? copiedLabel : `${copyLabel} · ${identity.absolutePath}`}
</TooltipContent>
</Tooltip>
)
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { buildDocPreviewDocumentIdentity } from './doc-preview-document-identity'
describe('buildDocPreviewDocumentIdentity', () => {
it('splits a workspace-relative path into a dimmable directory and a filename', () => {
expect(
buildDocPreviewDocumentIdentity({
filePath: '/repo/docs/reports/index.html',
worktreeRoot: '/repo',
hostLabel: 'Studio Mac mini'
})
).toEqual({
absolutePath: '/repo/docs/reports/index.html',
directoryPrefix: 'docs/reports/',
fileName: 'index.html',
hostLabel: 'Studio Mac mini'
})
})
it('leaves the directory empty for a document at the workspace root', () => {
const identity = buildDocPreviewDocumentIdentity({
filePath: '/repo/index.html',
worktreeRoot: '/repo',
hostLabel: null
})
expect(identity.directoryPrefix).toBe('')
expect(identity.fileName).toBe('index.html')
})
// Why: an SSH preview of a file outside the workspace has no relative form, and a bare filename
// would strip the only context the reader has for where it came from.
it('falls back to the absolute path when the file sits outside the workspace', () => {
const identity = buildDocPreviewDocumentIdentity({
filePath: '/elsewhere/notes/report.html',
worktreeRoot: '/repo',
hostLabel: 'build-box'
})
expect(identity.directoryPrefix).toBe('/elsewhere/notes/')
expect(identity.fileName).toBe('report.html')
})
// Why: the path is copied and read as the owning machine spells it, so a Windows host's
// backslashes must survive rather than being normalised into a path that host would not accept.
it('keeps the owning machine separator on Windows paths', () => {
const identity = buildDocPreviewDocumentIdentity({
filePath: 'C:\\repo\\docs\\index.html',
worktreeRoot: 'C:\\repo',
hostLabel: 'Windows box'
})
expect(identity.fileName).toBe('index.html')
expect(identity.directoryPrefix.endsWith('/')).toBe(true)
expect(identity.absolutePath).toBe('C:\\repo\\docs\\index.html')
})
it('carries an unknown owner through as null so the chip can drop the host pill', () => {
expect(
buildDocPreviewDocumentIdentity({
filePath: '/repo/a.html',
worktreeRoot: null,
hostLabel: null
}).hostLabel
).toBeNull()
})
})
@@ -0,0 +1,38 @@
import { basename, getRelativePathInsideRoot } from '@/lib/path'
export type DocPreviewDocumentIdentity = {
/** What clicking the chip copies — always the path as the owning machine spells it. */
absolutePath: string
/** Workspace-relative directory with a trailing separator; empty at the workspace root. */
directoryPrefix: string
fileName: string
/** Null while ownership is unknown, so the chip can drop the pill instead of inventing a host. */
hostLabel: string | null
}
/**
* What the preview toolbar shows instead of a URL. The reader never sees the internal preview
* origin, so the document has to identify itself: where it sits in the workspace, and whose disk
* that is.
*/
export function buildDocPreviewDocumentIdentity({
filePath,
worktreeRoot,
hostLabel
}: {
filePath: string
worktreeRoot: string | null
hostLabel: string | null
}): DocPreviewDocumentIdentity {
// Why fall back to the absolute path: an SSH preview of a file outside the workspace has no
// workspace-relative form, and a bare filename would strip the only context the reader has.
const displayPath = getRelativePathInsideRoot(filePath, worktreeRoot) ?? filePath
const fileName = basename(displayPath)
return {
absolutePath: filePath,
// Sliced rather than rebuilt from dirname so the owner's own separator survives on Windows.
directoryPrefix: displayPath.slice(0, displayPath.length - fileName.length),
fileName,
hostLabel
}
}
@@ -0,0 +1,70 @@
import type {
DocPreviewFileFailure,
DocPreviewFileFailureReason
} from '../../../../../shared/doc-preview-scheme'
import { translate } from '@/i18n/i18n'
export function docPreviewFailureDetail(reason: DocPreviewFileFailureReason | null): string {
if (reason === 'too-large') {
return translate(
'auto.components.editor.HtmlDocPreview.documentTooLargePanel',
'This document is too large to preview. Open it in the editor instead.'
)
}
// Why no 'unsupported-asset' sentence here: the entry document is served as text by every owner
// — only a subresource can be refused for its format, and that failure is a notice, not a panel.
return translate(
'auto.components.editor.HtmlDocPreview.documentUnreadablePanel',
'Orca could not read this file from the workspace.'
)
}
/**
* Why a notice and not the failure panel: only the entry document's failure leaves the reader with
* nothing to look at. A stylesheet, image or font the workspace would not send is a document that
* rendered degraded, and the reader deserves to know which piece is missing, but rendered.
*/
export function docPreviewAssetNotice(failures: DocPreviewFileFailure[]): string | null {
const [first] = failures
if (!first) {
return null
}
if (failures.length > 1) {
return translate(
'auto.components.editor.HtmlDocPreview.multipleAssetsFailedNotice',
'{{count}} files in this document could not be loaded.',
{ count: failures.length }
)
}
if (first.reason === 'too-large') {
return translate(
'auto.components.editor.HtmlDocPreview.assetTooLargeNotice',
'{{path}} is too large to load in this preview.',
{ path: first.relativePath }
)
}
if (first.reason === 'unsupported-asset') {
return translate(
'auto.components.editor.HtmlDocPreview.assetUnsupportedNotice',
'This workspace cannot send {{path}} to a preview.',
{ path: first.relativePath }
)
}
return translate(
'auto.components.editor.HtmlDocPreview.assetUnreadableNotice',
'Orca could not read {{path}} from the workspace.',
{ path: first.relativePath }
)
}
/**
* Why the sentence names no file and does not count attempts: the document chooses both, and this
* row is Orca's chrome. Constant text is also what makes repeated attempts unspammable the
* hundredth refusal renders exactly what the first one did.
*/
export function docPreviewDownloadBlockedNotice(): string {
return translate(
'auto.components.editor.HtmlDocPreview.downloadBlockedNotice',
'Downloads are disabled in document previews.'
)
}
@@ -0,0 +1,76 @@
import { Copy, FileCode2, MoreHorizontal, RefreshCw, RotateCcw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { translate } from '@/i18n/i18n'
/**
* The preview's overflow menu. It carries the document actions rather than the browsing pane's
* profile and viewport rows: a preview has no session to switch and no device to emulate.
*/
export function DocPreviewOverflowMenu({
onReload,
onHardReload,
onOpenSource,
onCopyPath,
onCopyRelativePath
}: {
onReload: () => void
onHardReload: () => void
onOpenSource: () => void
onCopyPath: () => void
/** Why it lives here: the preview hides the editor's path header, which was the only way to copy it. */
onCopyRelativePath: () => void
}): React.JSX.Element {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
aria-label={translate(
'auto.components.editor.HtmlDocPreview.previewMenuControl',
'Preview options'
)}
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={onReload}>
<RefreshCw className="size-3.5" />
{translate('auto.components.browser.pane.BrowserPane.0e080d820e', 'Reload')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onHardReload}>
<RotateCcw className="size-3.5" />
{translate('auto.components.browser.pane.BrowserPane.a1f3c2e4b5', 'Hard Reload')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onOpenSource}>
<FileCode2 className="size-3.5" />
{translate('auto.components.editor.HtmlDocPreview.openSourceControl', 'Open source file')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCopyPath}>
<Copy className="size-3.5" />
{translate(
'auto.components.editor.HtmlDocPreview.copyDocumentPathControl',
'Copy file path'
)}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCopyRelativePath}>
<Copy className="size-3.5" />
{translate(
'auto.components.editor.HtmlDocPreview.copyDocumentRelativePathControl',
'Copy relative path'
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,118 @@
import { useState } from 'react'
import {
BrowserChromeToolbar,
type BrowserChromeElementTools
} from '@/components/browser-pane/assemble-chrome/browser-chrome-toolbar'
import { BrowserReloadControl } from '@/components/browser-pane/assemble-chrome/browser-reload-control'
import { translate } from '@/i18n/i18n'
import { DocPreviewDocumentChip } from './doc-preview-document-chip'
import type { DocPreviewDocumentIdentity } from './doc-preview-document-identity'
import type { DocPreviewHistory } from './doc-preview-webview-history'
import { DocPreviewOverflowMenu } from './doc-preview-overflow-menu'
/**
* Binds the shared browser chrome to a workspace document: the address bar becomes a read-only
* identity chip, because a preview shows a path the reader cannot retype into something else.
*/
export function DocPreviewToolbar({
identity,
history,
loading,
onReload,
onHardReload,
onCopyPath,
onCopyRelativePath,
onOpenSource,
onOpenExternally,
elementTools,
markupActive,
onToggleMarkup,
markupDisabled
}: {
identity: DocPreviewDocumentIdentity
history: DocPreviewHistory
loading: boolean
onReload: () => void
/** Drops the grant and mints a new one — the preview's equivalent of ignoring every cache. */
onHardReload: () => void
onCopyPath: () => void
onCopyRelativePath: () => void
onOpenSource: () => void
onOpenExternally: () => void
elementTools: BrowserChromeElementTools
markupActive: boolean
onToggleMarkup: () => void
markupDisabled: boolean
}): React.JSX.Element {
const [reloadMenuOpen, setReloadMenuOpen] = useState(false)
const reloadLabel = translate(
'auto.components.editor.HtmlDocPreview.reloadPreviewControl',
'Reload preview'
)
return (
<BrowserChromeToolbar
controls={{
canGoBack: history.canGoBack,
canGoForward: history.canGoForward,
loading,
goBack: history.goBack,
goForward: history.goForward,
reload: onReload,
// Why a no-op: the identity chip has nothing to submit, so nothing can reach this.
navigate: () => {}
}}
addressSlot={<DocPreviewDocumentChip identity={identity} />}
reloadControl={
<BrowserReloadControl
menuOpen={reloadMenuOpen}
onMenuOpenChange={setReloadMenuOpen}
label={reloadLabel}
loading={loading}
showShortcutHint={false}
reloadShortcut=""
hardReloadShortcut=""
onPrimary={onReload}
onReload={onReload}
onHardReload={onHardReload}
/>
}
// Cookie import is a browsing-session action; a preview reads workspace disk over a grant
// and has no session for cookies to land in.
importControl={null}
elementTools={elementTools}
markup={{
active: markupActive,
disabled: markupDisabled,
onToggle: onToggleMarkup,
// Why false on a surface that is plainly visible: the draw-tool nudge fires once per
// install, and it belongs to the browsing pane. A reader who opened a document should not
// be the one who spends it.
canShowDiscoveryHint: false
}}
viewSource={{
onSelect: onOpenSource,
label: translate(
'auto.components.editor.HtmlDocPreview.openSourceControl',
'Open source file'
)
}}
openExternal={{
onSelect: onOpenExternally,
label: translate(
'auto.components.editor.HtmlDocPreview.openExternallyControl',
'Open with default app'
)
}}
overflowMenu={
<DocPreviewOverflowMenu
onReload={onReload}
onHardReload={onHardReload}
onOpenSource={onOpenSource}
onCopyPath={onCopyPath}
onCopyRelativePath={onCopyRelativePath}
/>
}
/>
)
}
@@ -0,0 +1,80 @@
import { useCallback, useRef, useState, type MutableRefObject } from 'react'
export type DocPreviewHistory = {
canGoBack: boolean
canGoForward: boolean
goBack: () => void
goForward: () => void
/** Re-read the guest's history depth; call from every navigation event. */
sync: () => void
/** Forget the old guest's depth when the preview re-mints and re-attaches. */
reset: () => void
}
/**
* Back/Forward for the preview guest. In-preview navigation between workspace documents under the
* same grant is allowed by the guest policy, so a report that links to a sibling page builds real
* history and fragments build in-document entries the same way a browser does.
*/
export function useDocPreviewWebviewHistory(
webviewRef: MutableRefObject<Electron.WebviewTag | null>
): DocPreviewHistory {
const [depth, setDepth] = useState({ canGoBack: false, canGoForward: false })
// Why a ref alongside state: sync fires per navigation event and would otherwise re-render on
// every one, even though the buttons only change at the edges of history.
const depthRef = useRef(depth)
const apply = useCallback((next: { canGoBack: boolean; canGoForward: boolean }): void => {
if (
depthRef.current.canGoBack === next.canGoBack &&
depthRef.current.canGoForward === next.canGoForward
) {
return
}
depthRef.current = next
setDepth(next)
}, [])
const sync = useCallback((): void => {
const webview = webviewRef.current
if (!webview) {
apply({ canGoBack: false, canGoForward: false })
return
}
try {
apply({ canGoBack: webview.canGoBack(), canGoForward: webview.canGoForward() })
} catch {
// The guest can detach between the event and this read; treat it as no history.
apply({ canGoBack: false, canGoForward: false })
}
}, [apply, webviewRef])
const reset = useCallback((): void => {
apply({ canGoBack: false, canGoForward: false })
}, [apply])
const goBack = useCallback((): void => {
try {
webviewRef.current?.goBack()
} catch {
/* detached guest */
}
}, [webviewRef])
const goForward = useCallback((): void => {
try {
webviewRef.current?.goForward()
} catch {
/* detached guest */
}
}, [webviewRef])
return {
canGoBack: depth.canGoBack,
canGoForward: depth.canGoForward,
goBack,
goForward,
sync,
reset
}
}
@@ -0,0 +1,134 @@
// @vitest-environment happy-dom
//
// One id runs through this hook, and it is the browser page. A re-mint replaces the guest under
// that page rather than renaming the surface, so annotations stay addressable and the tool target
// keeps naming something main can resolve — the two used to be separate ids, and swapping them was
// invisible until a preview re-minted.
import { act, createElement, useRef } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const calls = vi.hoisted(() => ({
annotationSend: [] as { browserTabId: string }[],
grabAnnotations: [] as { browserTabId: string; toolTargetId: string }[],
grabMode: [] as string[],
viewportBridge: [] as { toolTargetId: string }[]
}))
vi.mock('@/components/browser-pane/annotate/use-browser-page-annotation-send', () => ({
useBrowserPageAnnotationSend: (args: { browserTabId: string }) => {
calls.annotationSend.push({ browserTabId: args.browserTabId })
return { browserAnnotations: [], setBrowserAnnotationTrayOpen: () => undefined }
}
}))
vi.mock('@/components/browser-pane/annotate/use-browser-page-grab-annotations', () => ({
useBrowserPageGrabAnnotations: (args: { browserTabId: string; toolTargetId: string }) => {
calls.grabAnnotations.push({
browserTabId: args.browserTabId,
toolTargetId: args.toolTargetId
})
return { pendingAnnotationPayload: null, grabIntent: null, startGrabIntent: () => undefined }
}
}))
vi.mock('@/components/browser-pane/annotate/use-browser-page-markup-capture', () => ({
useBrowserPageMarkupCapture: () => ({ isActive: false })
}))
vi.mock('@/components/browser-pane/annotate/useGrabMode', () => ({
useGrabMode: (toolTargetId: string) => {
calls.grabMode.push(toolTargetId)
return { state: 'idle' }
}
}))
vi.mock('@/components/browser-pane/annotate/guest-annotation-viewport-bridge', () => ({
syncGuestAnnotationViewportBridge: (args: { toolTargetId: string }) => {
calls.viewportBridge.push({ toolTargetId: args.toolTargetId })
}
}))
vi.mock('@/hooks/useShortcutLabel', () => ({ useShortcutLabel: () => 'G' }))
import { useDocPreviewGuestTools } from './use-doc-preview-guest-tools'
const PREVIEW_ID = 'browser-page-9f2c'
const FIRST_GRANT = 'a'.repeat(32)
const SECOND_GRANT = 'b'.repeat(32)
function Harness({ grantId }: { grantId: string | null }): null {
const webviewRef = useRef(null)
const containerRef = useRef(null)
useDocPreviewGuestTools({
previewId: PREVIEW_ID,
worktreeId: 'wt-1',
grantId,
webviewRef: webviewRef as never,
containerRef: containerRef as never,
toolsReady: true
})
return null
}
let container: HTMLDivElement
let root: Root
function render(grantId: string | null): void {
act(() => {
root.render(createElement(Harness, { grantId }))
})
}
beforeEach(() => {
calls.annotationSend.length = 0
calls.grabAnnotations.length = 0
calls.grabMode.length = 0
calls.viewportBridge.length = 0
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
describe('useDocPreviewGuestTools ids', () => {
it('scopes annotations and tools to the browser page the document is open in', () => {
render(FIRST_GRANT)
expect(calls.annotationSend.at(-1)?.browserTabId).toBe(PREVIEW_ID)
expect(calls.grabAnnotations.at(-1)?.browserTabId).toBe(PREVIEW_ID)
expect(calls.grabAnnotations.at(-1)?.toolTargetId).toBe(PREVIEW_ID)
expect(calls.grabMode.at(-1)).toBe(PREVIEW_ID)
})
// Why a re-mint is still worth a test with one id: main re-points the page at the replacement
// guest, so the surface the reader is looking at must keep the same name through it. A hook that
// rebuilt its target from the grant would orphan annotations under an id nothing reads again.
it('keeps naming the same surface across a re-mint', () => {
render(FIRST_GRANT)
render(SECOND_GRANT)
expect(new Set(calls.annotationSend.map((call) => call.browserTabId))).toEqual(
new Set([PREVIEW_ID])
)
expect(new Set(calls.grabAnnotations.map((call) => call.toolTargetId))).toEqual(
new Set([PREVIEW_ID])
)
expect(calls.viewportBridge.at(-1)?.toolTargetId).toBe(PREVIEW_ID)
})
// Why an empty target and not the page id: before a grant exists no guest has committed to this
// page, so naming it would park every tool request in the registration wait for a document the
// reader may never get.
it('names no tool target before a grant exists', () => {
render(null)
expect(calls.grabMode.at(-1)).toBe('')
expect(calls.grabAnnotations.at(-1)?.toolTargetId).toBe('')
expect(calls.viewportBridge).toHaveLength(0)
})
})
@@ -0,0 +1,103 @@
import { useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import { syncGuestAnnotationViewportBridge } from '@/components/browser-pane/annotate/guest-annotation-viewport-bridge'
import { useBrowserPageAnnotationSend } from '@/components/browser-pane/annotate/use-browser-page-annotation-send'
import { useBrowserPageGrabAnnotations } from '@/components/browser-pane/annotate/use-browser-page-grab-annotations'
import { useBrowserPageMarkupCapture } from '@/components/browser-pane/annotate/use-browser-page-markup-capture'
import { useGrabMode } from '@/components/browser-pane/annotate/useGrabMode'
import type { BrowserOverlayViewport } from '@/components/browser-pane/describe-page/browser-annotation-geometry'
import type { BrowserChromeElementTools } from '@/components/browser-pane/assemble-chrome/browser-chrome-toolbar'
/**
* The preview's half of the browser tool cluster: the in-guest element picker, the annotation
* store and the markup canvas, wired exactly as the browsing pane wires them including the id,
* which is the browser page for both the stored annotations and the tool target. A re-mint
* replaces the guest under that page rather than renaming the surface, so nothing here has to
* track which grant is currently on screen.
*/
export function useDocPreviewGuestTools({
previewId,
worktreeId,
grantId,
webviewRef,
containerRef,
toolsReady
}: {
previewId: string
worktreeId: string
grantId: string | null
webviewRef: MutableRefObject<Electron.WebviewTag | null>
containerRef: MutableRefObject<HTMLDivElement | null>
toolsReady: boolean
}): {
grab: ReturnType<typeof useGrabMode>
markup: ReturnType<typeof useBrowserPageMarkupCapture>
annotationSend: ReturnType<typeof useBrowserPageAnnotationSend>
grabAnnotations: ReturnType<typeof useBrowserPageGrabAnnotations>
browserOverlayViewport: BrowserOverlayViewport
elementTools: BrowserChromeElementTools
} {
// Why still empty before the first grant: the page is only a tool target once a document is on
// screen, and useGrabMode needs a stable identity every render rather than one to guess with.
const toolTargetId = grantId === null ? '' : previewId
const annotationViewportBridgeTokenRef = useRef(createBrowserUuid().replaceAll('-', ''))
const [browserOverlayViewport, setBrowserOverlayViewport] = useState<BrowserOverlayViewport>({
scrollX: 0,
scrollY: 0,
version: 0
})
const grabElementShortcut = useShortcutLabel('browser.grabElement')
const grab = useGrabMode(toolTargetId)
const markup = useBrowserPageMarkupCapture(webviewRef, containerRef)
const annotationSend = useBrowserPageAnnotationSend({ browserTabId: previewId, worktreeId })
const grabAnnotations = useBrowserPageGrabAnnotations({
browserTabId: previewId,
toolTargetId,
isActive: toolsReady,
grab,
containerRef,
webviewRef,
setBrowserOverlayViewport,
browserAnnotationsLength: annotationSend.browserAnnotations.length,
setBrowserAnnotationTrayOpen: annotationSend.setBrowserAnnotationTrayOpen
})
const { browserAnnotations } = annotationSend
const { pendingAnnotationPayload } = grabAnnotations
useEffect(() => {
if (!toolTargetId) {
return
}
syncGuestAnnotationViewportBridge({
toolTargetId,
annotations: browserAnnotations,
pendingPayload: pendingAnnotationPayload,
surfaceActive: toolsReady,
token: annotationViewportBridgeTokenRef.current
})
}, [browserAnnotations, pendingAnnotationPayload, toolTargetId, toolsReady])
const elementTools = useMemo<BrowserChromeElementTools>(
() => ({
activeIntent: grab.state !== 'idle' ? grabAnnotations.grabIntent : null,
onStartIntent: grabAnnotations.startGrabIntent,
// Nothing has painted on a loading or failed preview, so there is no element to pick.
disabled: !toolsReady || markup.isActive,
grabShortcutLabel: grabElementShortcut,
annotationCount: browserAnnotations.length
}),
[
browserAnnotations.length,
grab.state,
grabAnnotations.grabIntent,
grabAnnotations.startGrabIntent,
grabElementShortcut,
markup.isActive,
toolsReady
]
)
return { grab, markup, annotationSend, grabAnnotations, browserOverlayViewport, elementTools }
}
@@ -0,0 +1,109 @@
/**
* @vitest-environment happy-dom
*
* STA-5557: a preview hands its guest the keyboard, so what counts as "the reader is in this
* preview" is load-bearing in both directions too narrow and the one route out of a preview stays
* shut, too wide and it takes the keyboard from the terminal the reader is actually typing in.
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { BrowserPage } from '../../../../../shared/browser-workspace-types'
const WORKTREE_ID = 'repo1::/path/wt1'
const WORKSPACE_ID = 'workspace-1'
const OTHER_WORKSPACE_ID = 'workspace-2'
const mocks = vi.hoisted(() => ({
storeState: {} as Record<string, unknown>,
handedFocus: [] as (boolean | undefined)[]
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: unknown) => unknown) => selector(mocks.storeState)
}))
vi.mock('@/lib/worktree-runtime-owner', () => ({
getRuntimeEnvironmentIdForWorktree: () => null
}))
vi.mock('./HtmlDocPreview', () => ({
HtmlDocPreview: ({ holdsGuestFocus }: { holdsGuestFocus?: boolean }) => {
mocks.handedFocus.push(holdsGuestFocus)
return null
}
}))
import { WorkspaceDocPagePane } from './workspace-doc-page-pane'
function docPage(): BrowserPage {
return {
id: 'page-1',
workspaceId: WORKSPACE_ID,
worktreeId: WORKTREE_ID,
url: 'about:blank',
title: 'index.html',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 0,
docLocation: {
kind: 'workspace-doc',
worktreeId: WORKTREE_ID,
filePath: '/path/wt1/report/index.html'
}
}
}
describe('the surface a document pane will hand its guest the keyboard from', () => {
let container: HTMLDivElement
let root: Root
beforeEach(() => {
mocks.handedFocus = []
mocks.storeState = {
activeTabTypeByWorktree: { [WORKTREE_ID]: 'browser' },
activeBrowserTabIdByWorktree: { [WORKTREE_ID]: WORKSPACE_ID },
getKnownWorktreeById: () => ({ path: '/path/wt1' })
}
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
function renderPane(isActive: boolean): boolean | undefined {
act(() => {
root.render(<WorkspaceDocPagePane page={docPage()} isActive={isActive} />)
})
return mocks.handedFocus.at(-1)
}
// The presence half: with the reader in this very preview the answer has to be yes, so a pane
// that had stopped offering focus at all would fail here rather than pass every refusal below.
it('hands focus on when the reader is in this preview', () => {
expect(renderPane(true)).toBe(true)
})
it('refuses while the reader is in a terminal in front of it', () => {
mocks.storeState.activeTabTypeByWorktree = { [WORKTREE_ID]: 'terminal' }
expect(renderPane(true)).toBe(false)
})
// Why this is separate from the terminal case: the reader can be in the browser and still be
// looking at another tab of it, which is a different half of the check.
it('refuses while the reader is in a different browser tab', () => {
mocks.storeState.activeBrowserTabIdByWorktree = { [WORKTREE_ID]: OTHER_WORKSPACE_ID }
expect(renderPane(true)).toBe(false)
})
it('refuses while its own page is not the active one', () => {
expect(renderPane(false)).toBe(false)
})
})
@@ -0,0 +1,60 @@
import { useMemo } from 'react'
import { getRelativePathInsideRoot } from '@/lib/path'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { useAppStore } from '@/store'
import { HtmlDocPreview } from './HtmlDocPreview'
import type { BrowserPage } from '../../../../../shared/browser-workspace-types'
/**
* The pane for a browser page located by a workspace document. Everything a URL page's chrome does
* address bar, history, favicon is answered by the document itself, so this stands in for the
* whole of `BrowserPagePane` rather than wrapping it.
*/
export function WorkspaceDocPagePane({
page,
isActive
}: {
page: BrowserPage
isActive: boolean
}): React.JSX.Element | null {
const docLocation = page.docLocation ?? null
const worktreeId = docLocation?.worktreeId ?? page.worktreeId
const filePath = docLocation?.filePath ?? ''
const worktreeRoot = useAppStore((store) => store.getKnownWorktreeById(worktreeId)?.path ?? null)
// Why resolved here rather than stored on the page: ownership moves. A page persisted before a
// pairing or an SSH reconnect would otherwise route its document actions at yesterday's host.
const runtimeEnvironmentId = useAppStore(
(store) => getRuntimeEnvironmentIdForWorktree(store, worktreeId) ?? null
)
const relativePath = useMemo(
() => getRelativePathInsideRoot(filePath, worktreeRoot) ?? filePath,
[filePath, worktreeRoot]
)
// Why the reader's whole surface and not just this page's activity: a preview keeps its pane
// mounted behind a terminal or an editor, and focusing its guest from there would take the
// keyboard away from what the reader is actually in.
const isReaderSurface = useAppStore(
(store) =>
store.activeTabTypeByWorktree[worktreeId] === 'browser' &&
store.activeBrowserTabIdByWorktree[worktreeId] === page.workspaceId
)
if (!docLocation) {
return null
}
return (
// Why hidden and not unmounted: an inactive page keeps its guest, its scroll position and any
// grab in flight, exactly as a URL page's pane does.
<div className="absolute inset-0 flex min-h-0 flex-col" hidden={!isActive}>
<HtmlDocPreview
holdsGuestFocus={isActive && isReaderSurface}
previewId={page.id}
filePath={filePath}
relativePath={relativePath}
worktreeId={worktreeId}
runtimeEnvironmentId={runtimeEnvironmentId}
/>
</div>
)
}
@@ -0,0 +1,124 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import type { OpenFile } from '@/store/slices/editor'
import { EditorPanelShell } from './EditorPanelShell'
// Why stub both children: the claim under test is which of them the shell renders for a given tab
// mode, and mounting the real editor surface would drag in Monaco for a layout question.
vi.mock('./EditorPanelHeader', () => ({
EditorPanelHeader: () => <div data-editor-panel-header />
}))
vi.mock('./EditorContent', () => ({
EditorContent: () => <div data-editor-content />
}))
vi.mock('./UntitledFileRenameDialog', () => ({
UntitledFileRenameDialog: () => null
}))
vi.mock('@/store', () => ({
useAppStore: Object.assign(
(selector: (state: Record<string, unknown>) => unknown) => selector({ worktreesByRepo: {} }),
{ getState: () => ({ worktreesByRepo: {} }) }
)
}))
function openFile(mode: OpenFile['mode']): OpenFile {
return {
id: `file-${mode}`,
filePath: '/home/alice/docs/report/index.html',
relativePath: 'report/index.html',
worktreeId: 'wt-1',
language: 'html',
mode
} as OpenFile
}
function renderShell(file: OpenFile, isCombinedDiff = false): string {
const model = {
isCombinedDiff,
isSingleDiff: false,
isDiffSurface: false,
isMarkdown: false,
isMermaid: false,
isCsv: false,
isNotebook: false,
hasEditorToggle: false,
availableEditorToggleModes: [],
effectiveToggleValue: 'edit',
canOpenPreviewToSide: false,
canShowMarkdownPreview: false,
canShowMarkdownTableOfContents: false,
isMarkdownTableOfContentsDisabled: false,
shouldShowMarkdownExportAction: false,
canExportMarkdownToPdf: false,
openFileState: { canOpen: false },
worktreeEntries: [],
resolvedLanguage: 'html',
mdViewMode: 'rich'
}
const noop = (): void => {}
return renderToStaticMarkup(
<EditorPanelShell
panelRef={null}
activeFile={file}
activeViewStateId={file.id}
model={model as never}
copiedPathVisible={false}
showMarkdownTableOfContents={false}
canShowMarkdownFrontmatterToggle={false}
markdownFrontmatterVisible={false}
sideBySide={false}
openFiles={[file]}
fileContents={{}}
diffContents={{}}
editorDrafts={{}}
pendingEditorReveal={null}
renameDialogFile={null}
renameError={null}
disableRenameBrowse={false}
onCopyPath={noop}
onOpenDiffTargetFile={noop}
onOpenPreviewToSide={noop}
onOpenMarkdownPreview={noop}
onOpenContainingFolder={noop}
onToggleSideBySide={noop}
onEditorToggleChange={noop}
onToggleMarkdownTableOfContents={noop}
onToggleMarkdownFrontmatter={noop}
onExportMarkdownToPdf={noop}
onContentChange={noop}
onContentChangeForFile={noop}
onDirtyStateHint={noop}
onSave={async () => true}
onSaveForFile={async () => true}
onReloadContent={noop}
onCloseMarkdownTableOfContents={noop}
onCloseRenameDialog={noop}
onRenameConfirm={async () => {}}
markdownAnnotationsEnabled={false}
/>
)
}
describe('EditorPanelShell path header', () => {
// Why assert every remaining mode: a gate that hides the header everywhere would satisfy the
// check-details claim below while silently removing the path from every ordinary file tab.
it.each(['edit', 'diff', 'conflict-review', 'markdown-preview'] as const)(
'keeps the header for a %s tab',
(mode) => {
expect(renderShell(openFile(mode))).toContain('data-editor-panel-header')
}
)
it('hides the header for check-details and combined diffs, as it always has', () => {
expect(renderShell(openFile('check-details'))).not.toContain('data-editor-panel-header')
expect(renderShell(openFile('edit'), true)).not.toContain('data-editor-panel-header')
})
it('renders the editor surface either way', () => {
expect(renderShell(openFile('check-details'))).toContain('data-editor-content')
expect(renderShell(openFile('edit'))).toContain('data-editor-content')
})
})
@@ -8,6 +8,7 @@ import { UntitledFileRenameDialog } from './UntitledFileRenameDialog'
import type { getEditorPanelRenderModel } from './editor-panel-render-model'
import type { DiffContent, FileContent } from './editor-panel-content-types'
import type { EditorToggleValue } from './EditorViewToggle'
import { shouldShowEditorPanelHeader } from './editor-header'
import { getUntitledFileRoot } from './untitled-file-rename-path'
import { translate } from '@/i18n/i18n'
import type { ArtifactWriteRequest } from '../../../../shared/artifacts'
@@ -97,7 +98,7 @@ export function EditorPanelShell({
}: EditorPanelShellProps): JSX.Element {
return (
<div ref={panelRef} className="flex flex-col flex-1 min-w-0 min-h-0">
{!model.isCombinedDiff && activeFile.mode !== 'check-details' && (
{shouldShowEditorPanelHeader(activeFile, model.isCombinedDiff) && (
<EditorPanelHeader
activeFile={activeFile}
copiedPathVisible={copiedPathVisible}
@@ -14,6 +14,11 @@ export type EditorHeaderOpenFileState = {
canOpen: boolean
}
/** Whether the panel shows its own path header; check-details names the document itself. */
export function shouldShowEditorPanelHeader(file: OpenFile, isCombinedDiff: boolean): boolean {
return !isCombinedDiff && file.mode !== 'check-details'
}
export function getEditorHeaderCopyState(file: OpenFile): EditorHeaderCopyState {
if (file.mode === 'conflict-review') {
return {
@@ -119,4 +119,42 @@ describe('useTabStripPointerActivation', () => {
expect(onActivate).not.toHaveBeenCalled()
})
// Why this is not that flush: an in-page <webview> holding the keyboard leaves the embedder
// blurred, so the press on the tab is itself what pulls window focus back. Treating it as an app
// switch is what shut the tab strip for anyone reading a browser page or a document preview.
it('still activates when the press is what pulled focus back from a guest', () => {
const onActivate = vi.fn()
const guest = document.createElement('webview')
document.body.append(guest)
guest.tabIndex = -1
guest.focus()
const { result } = renderHook(() => useTabStripPointerActivation({ onActivate }))
act(() => result.current.onPointerDown(pointerDownEvent(10, 10)))
act(() => window.dispatchEvent(new Event('focus')))
firePointer('pointerup', 11, 11)
expect(onActivate).toHaveBeenCalledTimes(1)
guest.remove()
})
// The forgiveness is spent on that one handoff: a press held across a real app switch still
// flushes rather than activating whenever the release happens to land.
it('flushes a press that outlives the guest handoff it started with', () => {
const onActivate = vi.fn()
const guest = document.createElement('webview')
document.body.append(guest)
guest.tabIndex = -1
guest.focus()
const { result } = renderHook(() => useTabStripPointerActivation({ onActivate }))
act(() => result.current.onPointerDown(pointerDownEvent(10, 10)))
act(() => window.dispatchEvent(new Event('focus')))
act(() => window.dispatchEvent(new Event('focus')))
firePointer('pointerup', 11, 11)
expect(onActivate).not.toHaveBeenCalled()
guest.remove()
})
})
@@ -17,6 +17,11 @@ import { beginTabStripPointerGesture } from './tab-strip-pointer-gesture'
* drag (activation suppressed). Because each press measures its own gesture, a
* click after a reorder always activates.
*/
/** Whether an in-page guest (a `<webview>`) currently owns the keyboard, which blurs the embedder. */
function isGuestHoldingKeyboard(): boolean {
return typeof document !== 'undefined' && document.activeElement?.tagName === 'WEBVIEW'
}
export function useTabStripPointerActivation({
onActivate,
disabled = false
@@ -51,12 +56,16 @@ export function useTabStripPointerActivation({
const startX = event.clientX
const startY = event.clientY
const releaseTabStripPointerGesture = beginTabStripPointerGesture()
// Why a press that starts under a guest forgives one window focus: an in-page <webview>
// holding the keyboard leaves the embedder blurred, so this very press is what pulls focus
// back and #7316's flush would eat the click that takes the reader out of a browser pane.
let pendingGuestFocusHandoff = isGuestHoldingKeyboard()
const cleanup = (): void => {
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', onPointerCancel)
window.removeEventListener('blur', onPointerCancel)
window.removeEventListener('focus', onPointerCancel)
window.removeEventListener('focus', onWindowFocus)
releaseTabStripPointerGesture()
cleanupRef.current = null
}
@@ -74,11 +83,18 @@ export function useTabStripPointerActivation({
const onPointerCancel = (): void => {
cleanup()
}
const onWindowFocus = (): void => {
if (pendingGuestFocusHandoff) {
pendingGuestFocusHandoff = false
return
}
cleanup()
}
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('pointercancel', onPointerCancel)
window.addEventListener('blur', onPointerCancel)
window.addEventListener('focus', onPointerCancel)
window.addEventListener('focus', onWindowFocus)
cleanupRef.current = cleanup
},
[disabled]
@@ -38,7 +38,9 @@ function ActionRow({
>
{action.external === true ? <ExternalLink className="size-3.5" /> : null}
{action.external === false ? <Globe className="size-3.5" /> : null}
<span className="min-w-0 flex-1 text-left">{action.label}</span>
<span className="min-w-0 flex-1 truncate text-left" title={action.label}>
{action.label}
</span>
<ShortcutKeyCombo keys={keys} keyCapClassName="min-w-5 px-1 py-0 text-[11px]" />
</Button>
)
@@ -125,7 +127,7 @@ export function TerminalLinkActionPopover({
side="top"
sideOffset={6}
collisionPadding={8}
className="w-max min-w-52 max-w-[min(17rem,calc(100vw-1rem))] p-1"
className="w-max min-w-52 max-w-[min(21rem,calc(100vw-1rem))] p-1"
data-terminal-link-action-popover
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
@@ -3,6 +3,7 @@ import type { TerminalLinkActionContext } from './terminal-link-action-request'
const mocks = vi.hoisted(() => ({
canOpenWithSystemDefault: true,
downloadAndOpen: vi.fn(),
openDetectedFilePath: vi.fn(),
worktreeRoot: false
}))
@@ -19,6 +20,10 @@ vi.mock('./terminal-worktree-path-link', () => ({
resolveKnownWorktreeRootPathLink: () => (mocks.worktreeRoot ? { id: 'wt-2' } : null)
}))
vi.mock('./terminal-remote-file-download-open', () => ({
downloadAndOpenRemoteTerminalFile: mocks.downloadAndOpen
}))
import { handleTerminalFileLink } from './terminal-file-link-actions'
const deps = { worktreeId: 'wt-1', worktreePath: '/repo' }
@@ -95,4 +100,61 @@ describe('terminal file link actions', () => {
)
expect(actionRequest).not.toHaveProperty('alternate')
})
it('offers the same rows for a remote previewable file, downloading before the OS opens it', () => {
mocks.canOpenWithSystemDefault = false
const request = vi.fn()
handleTerminalFileLink(
'/repo/docs/report.html',
null,
null,
plainEvent(),
deps,
context(request)
)
const actionRequest = request.mock.calls[0][0]
expect(actionRequest.primary.label).toBe('Open file')
expect(actionRequest.alternate.label).toBe('Download & open with default app')
actionRequest.alternate.run()
expect(mocks.downloadAndOpen).toHaveBeenCalledWith({}, '/repo/docs/report.html')
expect(mocks.openDetectedFilePath).not.toHaveBeenCalled()
})
it('keeps row parity between local and remote previewable files', () => {
const localRequest = vi.fn()
handleTerminalFileLink(
'/repo/docs/report.html',
null,
null,
plainEvent(),
deps,
context(localRequest)
)
mocks.canOpenWithSystemDefault = false
const remoteRequest = vi.fn()
handleTerminalFileLink(
'/repo/docs/report.html',
null,
null,
plainEvent(),
deps,
context(remoteRequest)
)
const rowCount = (call: { alternate?: unknown }): number => 1 + (call.alternate ? 1 : 0)
expect(rowCount(remoteRequest.mock.calls[0][0])).toBe(rowCount(localRequest.mock.calls[0][0]))
})
// Why: a directory has nothing to hand the OS, and the download row would offer a transfer that
// can only fail. The popover is built on hover, so the path shape decides rather than a stat.
it('drops the remote download row for a path that announces itself as a directory', () => {
mocks.canOpenWithSystemDefault = false
const request = vi.fn()
handleTerminalFileLink('/repo/docs/', null, null, plainEvent(), deps, context(request))
expect(request.mock.calls[0][0]).not.toHaveProperty('alternate')
})
})
@@ -11,6 +11,7 @@ import {
type TerminalLinkActionContext
} from './terminal-link-action-request'
import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link'
import { downloadAndOpenRemoteTerminalFile } from './terminal-remote-file-download-open'
import { translate } from '@/i18n/i18n'
export type TerminalFileLinkActionDeps = {
@@ -52,6 +53,45 @@ export function handleTerminalFileLink(
const canOpenWithSystemDefault = shouldOpenTerminalFileWithSystemDefault(fileContext, mappedPath)
const isMac = navigator.userAgent.includes('Mac')
// Why: the OS can only launch a local file, so remote links keep the same row by
// downloading first — local and remote workspaces offer the same actions.
const systemDefaultRow = worktreeRoot
? canOpenWithSystemDefault
? {
label: isMac
? translate(
'auto.components.terminal.pane.TerminalLinkActionPopover.openInFinder',
'Open in Finder'
)
: translate(
'auto.components.terminal.pane.TerminalLinkActionPopover.openFolder',
'Open folder'
),
run: () =>
openDetectedFilePath(filePath, line, column, { ...deps, openWithSystemDefault: true })
}
: null
: canOpenWithSystemDefault
? {
label: translate(
'auto.components.terminal.pane.TerminalLinkActionPopover.openWithDefaultApp',
'Open with default app'
),
run: () =>
openDetectedFilePath(filePath, line, column, { ...deps, openWithSystemDefault: true })
}
: // Why the path shape and not a stat: the popover is built synchronously on hover, and a
// remote stat per link would put a round-trip in front of every terminal path. A directory
// that does not announce itself with a separator still fails visibly, in the download toast.
/[/\\]$/.test(mappedPath)
? null
: {
label: translate(
'auto.components.terminal.pane.TerminalLinkActionPopover.downloadOpenWithDefaultApp',
'Download & open with default app'
),
run: () => downloadAndOpenRemoteTerminalFile(fileContext, mappedPath)
}
return requestTerminalLinkAction(event, actionContext, {
destination: actionDestination ?? mappedPath,
kind: worktreeRoot ? 'workspace' : 'file',
@@ -67,30 +107,6 @@ export function handleTerminalFileLink(
),
run: () => openDetectedFilePath(filePath, line, column, deps)
},
...(canOpenWithSystemDefault
? {
alternate: {
label: worktreeRoot
? isMac
? translate(
'auto.components.terminal.pane.TerminalLinkActionPopover.openInFinder',
'Open in Finder'
)
: translate(
'auto.components.terminal.pane.TerminalLinkActionPopover.openFolder',
'Open folder'
)
: translate(
'auto.components.terminal.pane.TerminalLinkActionPopover.openWithDefaultApp',
'Open with default app'
),
run: () =>
openDetectedFilePath(filePath, line, column, {
...deps,
openWithSystemDefault: true
})
}
}
: {})
...(systemDefaultRow ? { alternate: systemDefaultRow } : {})
})
}
@@ -1,14 +1,14 @@
import { absolutePathToFileUri } from '@/components/editor/markdown-internal-links'
import { getConnectionId } from '@/lib/connection-context'
import { getWorkspaceFilePreviewPlan, openFileInBrowserTab } from '@/lib/file-preview'
import { downloadAndOpenRemoteTerminalFile } from './terminal-remote-file-download-open'
import { detectLanguage } from '@/lib/language-detect'
import { findWorkspaceFileRoute } from '@/lib/runtime-workspace-file-route'
import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links'
import {
isRemoteRuntimeFileOperation,
statRuntimePath,
type RuntimeFileOperationArgs
} from '@/runtime/runtime-file-client'
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
buildWorkspaceFileContext,
canClientOsOpenWorkspaceFile
} from '@/lib/workspace-file-host-routing'
import { statRuntimePath, type RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
import { useAppStore } from '@/store'
import { activateAndRevealWorkspace, activateAndRevealWorktree } from '@/lib/worktree-activation'
import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link'
@@ -50,13 +50,7 @@ export function getTerminalFileContext(
worktreePath: string,
runtimeEnvironmentId?: string | null
): RuntimeFileOperationArgs {
const settings = useAppStore.getState().settings
return {
settings: settingsForRuntimeOwner(settings, runtimeEnvironmentId),
worktreeId: worktreeId || null,
worktreePath,
connectionId: getConnectionId(worktreeId || null) ?? undefined
}
return buildWorkspaceFileContext(worktreeId, worktreePath, runtimeEnvironmentId)
}
// Why: a WSL-runtime pane prints POSIX paths even when the worktree lives on a
@@ -97,7 +91,7 @@ export function shouldOpenTerminalFileWithSystemDefault(
fileContext: RuntimeFileOperationArgs,
filePath: string
): boolean {
return !fileContext.connectionId && !isRemoteRuntimeFileOperation(fileContext, filePath)
return canClientOsOpenWorkspaceFile(fileContext, filePath)
}
let latestOpenDetectedFilePathRequestId = 0
@@ -196,14 +190,28 @@ export function openDetectedFilePath(
return
}
if (openWithSystemDefault && !canOpenWithSystemDefault) {
// Why: the popover names Shift+Cmd/Ctrl "Download & open with default app", and the OS
// cannot launch a remote path, so the direct gesture must reach the same download.
await downloadAndOpenRemoteTerminalFile(fileContext, mappedFilePath)
return
}
// Why: local HTML files render in Orca's browser for ordinary Cmd/Ctrl-click,
// and remain the fallback if Shift+Cmd/Ctrl cannot launch the OS default.
if (
isHtmlFilePath(mappedFilePath) &&
shouldOpenTerminalFileWithSystemDefault(fileContext, mappedFilePath)
) {
openHtmlFileInBrowser(mappedFilePath, worktreeId)
return
if (isHtmlFilePath(mappedFilePath)) {
if (shouldOpenTerminalFileWithSystemDefault(fileContext, mappedFilePath)) {
openHtmlFileInBrowser(mappedFilePath, worktreeId)
return
}
// Why: the same gesture renders remote HTML too, through the doc preview; only an
// unsupported plan (e.g. a paired doc outside the worktree) falls back to source.
const plan = getWorkspaceFilePreviewPlan(useAppStore.getState(), worktreeId, mappedFilePath)
if (plan.status === 'doc-preview') {
activateAndRevealWorktree(worktreeId, { providesInitialSurface: true })
openFileInBrowserTab({ filePath: mappedFilePath, worktreeId })
return
}
}
const store = useAppStore.getState()
@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import { openDetectedFilePath } from './terminal-link-handlers'
import { getConnectionId } from '@/lib/connection-context'
import { getWorkspaceFilePreviewPlan, openFileInBrowserTab } from '@/lib/file-preview'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { downloadAndOpenRemoteTerminalFile } from './terminal-remote-file-download-open'
import { createTerminalLinkTestDoubles } from './terminal-link-handlers-test-fixtures'
import {
flushAsyncWork,
@@ -39,6 +42,15 @@ vi.mock('@/lib/connection-context', () => ({
getConnectionId: vi.fn(() => null)
}))
vi.mock('./terminal-remote-file-download-open', () => ({
downloadAndOpenRemoteTerminalFile: vi.fn()
}))
vi.mock('@/lib/file-preview', () => ({
getWorkspaceFilePreviewPlan: vi.fn(() => ({ status: 'doc-preview' })),
openFileInBrowserTab: vi.fn()
}))
installTerminalLinkTestEnvironment(doubles)
describe('handleOscLink', () => {
@@ -114,8 +126,7 @@ describe('handleOscLink', () => {
openDetectedFilePath('/home/me/repo/src/main.ts', null, null, {
worktreeId: 'wt-1',
worktreePath: '/home/me/repo',
openWithSystemDefault: true
worktreePath: '/home/me/repo'
})
await new Promise((resolve) => setTimeout(resolve, 0))
@@ -205,7 +216,7 @@ describe('handleOscLink', () => {
)
})
it('does not open SSH html file links as client-local file browser tabs', async () => {
it('downloads shift-modifier SSH file links before the OS opens them, like the popover row', async () => {
setPlatform('Macintosh')
vi.mocked(getConnectionId).mockReturnValue('ssh-1')
@@ -217,6 +228,53 @@ describe('handleOscLink', () => {
await new Promise((resolve) => setTimeout(resolve, 0))
expect(createBrowserTabMock).not.toHaveBeenCalled()
expect(openFilePathMock).not.toHaveBeenCalled()
expect(openFileMock).not.toHaveBeenCalled()
expect(downloadAndOpenRemoteTerminalFile).toHaveBeenCalledWith(
expect.objectContaining({ connectionId: 'ssh-1' }),
'/home/me/repo/report.html'
)
})
it('renders plain-modifier SSH html links in the doc preview, not a client-local browser tab', async () => {
setPlatform('Macintosh')
vi.mocked(getConnectionId).mockReturnValue('ssh-1')
openDetectedFilePath('/home/me/repo/report.html', null, null, {
worktreeId: 'wt-1',
worktreePath: '/home/me/repo'
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(createBrowserTabMock).not.toHaveBeenCalled()
expect(openFileMock).not.toHaveBeenCalled()
expect(openFileInBrowserTab).toHaveBeenCalledWith({
filePath: '/home/me/repo/report.html',
worktreeId: 'wt-1'
})
// Why: the preview tab is the surface — activation must not re-seed a shell into a
// workspace whose last terminal the user closed.
expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', {
providesInitialSurface: true
})
})
it('falls back to the source editor when the preview plan is unsupported', async () => {
setPlatform('Macintosh')
vi.mocked(getConnectionId).mockReturnValue('ssh-1')
vi.mocked(getWorkspaceFilePreviewPlan).mockReturnValueOnce({
status: 'unsupported',
message: 'nope',
reason: 'outside-worktree'
})
openDetectedFilePath('/home/me/repo/report.html', null, null, {
worktreeId: 'wt-1',
worktreePath: '/home/me/repo'
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(openFileInBrowserTab).not.toHaveBeenCalled()
expect(openFileMock).toHaveBeenCalledWith(
expect.objectContaining({
filePath: '/home/me/repo/report.html',
@@ -0,0 +1,37 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
import { basename } from '@/lib/path'
import { downloadRuntimeFile, type RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
/**
* Remote counterpart of "Open with default app": the OS can only launch a local
* file, so the workspace copy is downloaded to a user-chosen path first.
*/
export async function downloadAndOpenRemoteTerminalFile(
fileContext: RuntimeFileOperationArgs,
filePath: string
): Promise<void> {
const name = basename(filePath) || filePath
try {
const result = fileContext.connectionId
? await window.api.fs.downloadFile({ filePath, connectionId: fileContext.connectionId })
: await downloadRuntimeFile(fileContext, filePath, name)
// Why: cancelling the native save dialog is a deliberate no-op, not a failure.
if (result.canceled) {
return
}
await window.api.shell.openFilePath(result.destinationPath)
} catch (error) {
toast.error(
extractIpcErrorMessage(
error,
translate(
'auto.components.terminal.pane.TerminalLinkActionPopover.downloadOpenFailed',
"Failed to download '{{value0}}'.",
{ value0: name }
)
)
)
}
}
@@ -1,4 +1,6 @@
import { toast } from 'sonner'
import { rememberLiveBrowserUrl } from '@/components/browser-pane/describe-page/live-browser-url-registry'
import { translate } from '@/i18n/i18n'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { redactKagiSessionToken } from '../../../../shared/browser-url'
import { useAppStore } from '../../store'
@@ -105,4 +107,31 @@ export function registerBrowserStateIpcBridge(
})
})
)
// Why: the doc-preview scheme is desktop-only, so hosts without it (web client) simply have no channel.
if (typeof window.api.docPreview?.onExternalLink === 'function') {
unsubs.push(
window.api.docPreview.onExternalLink(({ url }) => {
// Why: an external link in a doc preview leaves the preview entirely — it becomes a normal
// browser tab through the same path as any other new tab, local or paired.
// Why: the click already left the preview, so a refused tab is a dead end unless it says so.
const reportLinkFailure = (): void => {
toast.error(
translate(
'auto.hooks.ipc.events.browserStateIpcBridge.docPreviewLinkFailed',
'Could not open this link in Orca Browser.'
)
)
}
void useAppStore
.getState()
.openBrowserProfileTabInActiveWorkspace(url, null)
.then((opened) => {
if (!opened) {
reportLinkFailure()
}
})
.catch(reportLinkFailure)
})
)
}
}
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
toastError: vi.fn(),
openBrowserProfileTabInActiveWorkspace: vi.fn()
}))
vi.mock('sonner', () => ({ toast: { error: mocks.toastError } }))
vi.mock('@/components/browser-pane/describe-page/live-browser-url-registry', () => ({
rememberLiveBrowserUrl: vi.fn()
}))
vi.mock('@/lib/worktree-runtime-owner', () => ({ getRuntimeEnvironmentIdForWorktree: () => null }))
vi.mock('./browser-automation-bootstrap-lease', () => ({
acquireBrowserAutomationBootstrapLease: vi.fn()
}))
vi.mock('../../store', () => ({
useAppStore: {
getState: () => ({
openBrowserProfileTabInActiveWorkspace: mocks.openBrowserProfileTabInActiveWorkspace,
remoteBrowserPageHandlesByPageId: {}
})
}
}))
import { registerBrowserStateIpcBridge } from './browser-state-ipc-bridge'
/** Every channel the bridge subscribes to, stubbed; only the preview link one is exercised here. */
function installBridge(): (payload: { url: string }) => void {
let externalLinkHandler: ((payload: { url: string }) => void) | null = null
const noopSubscribe = (): (() => void) => () => {}
vi.stubGlobal('window', {
api: {
ui: { onFullscreenChanged: noopSubscribe },
browser: new Proxy(
{},
{
get: () => (): (() => void) => () => {}
}
),
docPreview: {
onExternalLink: (callback: (payload: { url: string }) => void): (() => void) => {
externalLinkHandler = callback
return () => {}
}
}
}
})
registerBrowserStateIpcBridge([], () => false)
if (!externalLinkHandler) {
throw new Error('bridge did not subscribe to the doc preview external link channel')
}
return externalLinkHandler
}
beforeEach(() => {
vi.clearAllMocks()
mocks.openBrowserProfileTabInActiveWorkspace.mockResolvedValue(true)
})
describe('doc preview external links', () => {
it('routes an external link into a browser tab', async () => {
installBridge()({ url: 'https://example.com/docs' })
await vi.waitFor(() =>
expect(mocks.openBrowserProfileTabInActiveWorkspace).toHaveBeenCalledWith(
'https://example.com/docs',
null
)
)
expect(mocks.toastError).not.toHaveBeenCalled()
})
// Why: the click already left the preview behind, so a refused tab is a dead end unless it says
// so — the store reports that refusal by returning false, not by throwing.
it('surfaces a refused tab instead of dropping the click', async () => {
mocks.openBrowserProfileTabInActiveWorkspace.mockResolvedValue(false)
installBridge()({ url: 'https://example.com/docs' })
await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
})
// Why the same sentence for a rejection: to the reader a tab that threw and a tab that was refused
// are the same dead end, and an unhandled rejection would leave the press with no answer at all.
it('surfaces a tab that failed rather than refused', async () => {
mocks.openBrowserProfileTabInActiveWorkspace.mockRejectedValue(new Error('no workspace'))
installBridge()({ url: 'https://example.com/docs' })
await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
})
})
@@ -15,6 +15,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [
'browser.onNavigationUpdate',
'browser.onOpenLinkInOrcaTab',
'browser.onPaneFocus',
'docPreview.onExternalLink',
'emulator.onAutoAttach',
'emulator.onPaneFocus',
'gh.onPRRefreshEvent',
@@ -159,6 +160,7 @@ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [
'browser.onActivateView',
'browser.onPaneFocus',
'browser.onOpenLinkInOrcaTab',
'docPreview.onExternalLink',
'ui.onNewBrowserTab',
'ui.onNewMarkdownTab',
'ui.onNewSimulatorTab',
+37 -2
View File
@@ -925,6 +925,11 @@
},
"ephemeralVmWorktreeCreation": {
"sparseCheckoutUnsupported": "Provisioned-root recipes do not support sparse checkout."
},
"file": {
"preview": {
"pairedOutsideWorktree": "Files outside the workspace can't be previewed on a paired server yet."
}
}
},
"hooks": {
@@ -1083,6 +1088,13 @@
"description": "Running Orca terminals are hosted by a daemon started by a previous Orca installation. macOS may not apply Orcas Accessibility, Automation, or protected-file permissions to them. Restart the daemon from Manage Sessions to restore access. This will close all running Orca terminals.",
"openManageSessions": "Open Manage Sessions",
"dismiss": "Dismiss"
},
"ipc": {
"events": {
"browserStateIpcBridge": {
"docPreviewLinkFailed": "Could not open this link in Orca Browser."
}
}
}
},
"components": {
@@ -3072,7 +3084,9 @@
"copyLink": "Copy link",
"copied": "Copied",
"copiedLink": "Copied link",
"copyLinkFailed": "Failed to copy link"
"copyLinkFailed": "Failed to copy link",
"downloadOpenWithDefaultApp": "Download & open with default app",
"downloadOpenFailed": "Failed to download '{{value0}}'."
},
"TerminalSessionStateSaveFailureDialog": {
"6bee0c8f17": "Open Disk Space Analyzer",
@@ -14763,6 +14777,26 @@
"insertColumnRight": "Insert column right",
"deleteRow": "Delete row",
"deleteColumn": "Delete column"
},
"HtmlDocPreview": {
"documentTooLargePanel": "This document is too large to preview. Open it in the editor instead.",
"documentUnreadablePanel": "Orca could not read this file from the workspace.",
"multipleAssetsFailedNotice": "{{count}} files in this document could not be loaded.",
"assetTooLargeNotice": "{{path}} is too large to load in this preview.",
"assetUnsupportedNotice": "This workspace cannot send {{path}} to a preview.",
"assetUnreadableNotice": "Orca could not read {{path}} from the workspace.",
"previewAriaLabel": "HTML preview",
"reloadPreviewControl": "Reload preview",
"previewUnavailableTitle": "Preview unavailable",
"copyDocumentPathControl": "Copy file path",
"documentPathCopied": "Copied",
"workspaceFileChipLabel": "Workspace file",
"previewMenuControl": "Preview options",
"openSourceControl": "Open source file",
"openExternallyControl": "Open with default app",
"copyDocumentRelativePathControl": "Copy relative path",
"openExternallyUnknownHostError": "Can't open '{{value0}}': the host that owns it is no longer known.",
"downloadBlockedNotice": "Downloads are disabled in document previews."
}
},
"diff": {
@@ -15082,7 +15116,8 @@
"6e776f9ef9": "Download failed",
"756bfc25c9": "Open",
"09a9489aa5": "Show",
"2a4c4b8e1f": "Copy"
"2a4c4b8e1f": "Copy",
"fileUrlUnsupported": "This browser tab cannot open local files. Use \"Open Preview to the Side\" on the file instead."
},
"BrowserToolbarMenu": {
"429ef481f9": "Cancel",
+71 -6
View File
@@ -3879,17 +3879,82 @@
},
"skills": {
"SkillsPage": {
"cb142070b4": "새로 고침", "a68dee6a32": "스킬 검색", "f43ad6edf3": "스킬", "ea72d6185b": "스킬을 검색하지 못했습니다", "dc4c3328ee": "파일 표시", "9963dff6d3": "설명을 찾을 수 없습니다.", "995fde8337": "스킬 파일을 표시하지 못했습니다", "4acd6d68ec": "스킬을 찾을 수 없습니다", "6a62a0168c": "일치하는 항목 없음", "08a321a984": "현재 검색 및 필터와 일치하는 스킬이 없습니다.", "cd7893fbc1": "스킬 검색 중", "35b9a724a0": "사용 가능", "c13b82793c": "설치 관리", "aee7b99cc6": "링크에서 설치", "filterProvider": "에이전트별 필터", "filterSource": "소스별 필터", "allSources": "전체", "clearFilters": "필터 지우기", "closeSkills": "스킬 닫기", "closeTooltip": "닫기 · Esc", "moreActions": "추가 작업", "sharedLinks": "공유 링크", "emptyCopy": "검색한 스킬 폴더가 비어 있습니다. 공유 번들을 설치하거나 스킬을 추가한 후 새로 고치세요.", "retry": "다시 시도", "remoteShareNotice": "이 스킬은 {{host}}에 있습니다. 공유하려면 해당 머신에서 스킬을 여세요.", "viewSwitch": "표시", "searchLinks": "링크 검색", "deleteSkills": "스킬 삭제…"
"cb142070b4": "새로 고침",
"a68dee6a32": "스킬 검색",
"f43ad6edf3": "스킬",
"ea72d6185b": "스킬을 검색하지 못했습니다",
"dc4c3328ee": "파일 표시",
"9963dff6d3": "설명을 찾을 수 없습니다.",
"995fde8337": "스킬 파일을 표시하지 못했습니다",
"4acd6d68ec": "스킬을 찾을 수 없습니다",
"6a62a0168c": "일치하는 항목 없음",
"08a321a984": "현재 검색 및 필터와 일치하는 스킬이 없습니다.",
"cd7893fbc1": "스킬 검색 중",
"35b9a724a0": "사용 가능",
"c13b82793c": "설치 관리",
"aee7b99cc6": "링크에서 설치",
"filterProvider": "에이전트별 필터",
"filterSource": "소스별 필터",
"allSources": "전체",
"clearFilters": "필터 지우기",
"closeSkills": "스킬 닫기",
"closeTooltip": "닫기 · Esc",
"moreActions": "추가 작업",
"sharedLinks": "공유 링크",
"emptyCopy": "검색한 스킬 폴더가 비어 있습니다. 공유 번들을 설치하거나 스킬을 추가한 후 새로 고치세요.",
"retry": "다시 시도",
"remoteShareNotice": "이 스킬은 {{host}}에 있습니다. 공유하려면 해당 머신에서 스킬을 여세요.",
"viewSwitch": "표시",
"searchLinks": "링크 검색",
"deleteSkills": "스킬 삭제…"
},
"SkillShareSelectionControls": { "01c5a15e02": "스킬 공유" },
"SkillRow": { "updatedUnknown": "날짜 없음", "pathCopied": "경로 복사됨", "copyPath": "경로 복사", "detailPath": "경로", "skillActions": "{{value0}} 작업", "viewDetails": "세부 정보 보기", "deleteSkill": "삭제…" },
"SkillRow": {
"updatedUnknown": "날짜 없음",
"pathCopied": "경로 복사됨",
"copyPath": "경로 복사",
"detailPath": "경로",
"skillActions": "{{value0}} 작업",
"viewDetails": "세부 정보 보기",
"deleteSkill": "삭제…"
},
"SkillsList": { "listLabel": "스킬" },
"sourceStatus": { "missing": "폴더를 찾을 수 없음", "remoteRepo": "원격 리포지토리 — 검색 안 됨", "unavailable": "검색 안 됨" },
"sourceStatus": {
"missing": "폴더를 찾을 수 없음",
"remoteRepo": "원격 리포지토리 — 검색 안 됨",
"unavailable": "검색 안 됨"
},
"sources": { "heading": "스킬 폴더" },
"sourceKind": { "home": "홈", "workspace": "워크스페이스", "bundled": "번들", "plugin": "플러그인" },
"count": { "skillOne": "스킬 {{count}}개", "skillOther": "스킬 {{count}}개", "sourceOne": "소스 {{count}}개", "sourceOther": "소스 {{count}}개", "fileOne": "파일 {{count}}개", "fileOther": "파일 {{count}}개", "resultOne": "결과 {{count}}개", "resultOther": "결과 {{count}}개", "selected": "{{count}}개 선택됨", "shareOne": "스킬 {{count}}개 공유", "shareOther": "스킬 {{count}}개 공유", "linkOne": "링크 {{count}}개", "linkOther": "링크 {{count}}개" },
"sourceKind": {
"home": "홈",
"workspace": "워크스페이스",
"bundled": "번들",
"plugin": "플러그인"
},
"count": {
"skillOne": "스킬 {{count}}개",
"skillOther": "스킬 {{count}}개",
"sourceOne": "소스 {{count}}개",
"sourceOther": "소스 {{count}}개",
"fileOne": "파일 {{count}}개",
"fileOther": "파일 {{count}}개",
"resultOne": "결과 {{count}}개",
"resultOther": "결과 {{count}}개",
"selected": "{{count}}개 선택됨",
"shareOne": "스킬 {{count}}개 공유",
"shareOther": "스킬 {{count}}개 공유",
"linkOne": "링크 {{count}}개",
"linkOther": "링크 {{count}}개"
},
"filter": { "allAgents": "모든 에이전트", "sharedAgent": "공유됨 (.agents)" },
"SkillsSelectionHeader": { "exit": "선택 나가기", "exitTooltip": "선택 나가기 · Esc", "title": "공유할 스킬 선택", "selectAll": "가능한 {{count}}개 모두 선택", "clear": "지우기", "deleteTitle": "삭제할 스킬 선택" },
"SkillsSelectionHeader": {
"exit": "선택 나가기",
"exitTooltip": "선택 나가기 · Esc",
"title": "공유할 스킬 선택",
"selectAll": "가능한 {{count}}개 모두 선택",
"clear": "지우기",
"deleteTitle": "삭제할 스킬 선택"
},
"SkillDetailDialog": { "agents": "에이전트", "updated": "업데이트됨", "copy": "복사" },
"SkillFreshnessNudge": {
"titleOne": "설치된 Orca 스킬이 오래되었습니다",
+79 -6
View File
@@ -3889,17 +3889,90 @@
},
"skills": {
"SkillsPage": {
"cb142070b4": "刷新", "a68dee6a32": "搜索技能", "f43ad6edf3": "技能", "ea72d6185b": "无法扫描技能", "dc4c3328ee": "显示文件", "9963dff6d3": "未找到说明。", "995fde8337": "无法显示技能文件", "4acd6d68ec": "未找到技能", "6a62a0168c": "无匹配项", "08a321a984": "没有技能匹配当前搜索和筛选条件。", "cd7893fbc1": "正在扫描技能", "35b9a724a0": "可用", "c13b82793c": "管理安装", "aee7b99cc6": "从链接安装", "filterProvider": "按 Agent 筛选", "filterSource": "按来源筛选", "allSources": "全部", "clearFilters": "清除筛选", "closeSkills": "关闭技能", "closeTooltip": "关闭 · Esc", "moreActions": "更多操作", "sharedLinks": "共享链接", "emptyCopy": "扫描的技能文件夹为空。请安装共享包,或添加技能后刷新。", "retry": "重试", "remoteShareNotice": "这些技能位于 {{host}}。请在该机器上打开“技能”以进行共享。", "viewSwitch": "显示", "searchLinks": "搜索链接", "deleteSkills": "删除技能…"
"cb142070b4": "刷新",
"a68dee6a32": "搜索技能",
"f43ad6edf3": "技能",
"ea72d6185b": "无法扫描技能",
"dc4c3328ee": "显示文件",
"9963dff6d3": "未找到说明。",
"995fde8337": "无法显示技能文件",
"4acd6d68ec": "未找到技能",
"6a62a0168c": "无匹配项",
"08a321a984": "没有技能匹配当前搜索和筛选条件。",
"cd7893fbc1": "正在扫描技能",
"35b9a724a0": "可用",
"c13b82793c": "管理安装",
"aee7b99cc6": "从链接安装",
"filterProvider": "按 Agent 筛选",
"filterSource": "按来源筛选",
"allSources": "全部",
"clearFilters": "清除筛选",
"closeSkills": "关闭技能",
"closeTooltip": "关闭 · Esc",
"moreActions": "更多操作",
"sharedLinks": "共享链接",
"emptyCopy": "扫描的技能文件夹为空。请安装共享包,或添加技能后刷新。",
"retry": "重试",
"remoteShareNotice": "这些技能位于 {{host}}。请在该机器上打开“技能”以进行共享。",
"viewSwitch": "显示",
"searchLinks": "搜索链接",
"deleteSkills": "删除技能…"
},
"SkillShareSelectionControls": { "01c5a15e02": "共享技能" },
"SkillRow": { "updatedUnknown": "无日期", "pathCopied": "路径已复制", "copyPath": "复制路径", "detailPath": "路径", "skillActions": "{{value0}} 的操作", "viewDetails": "查看详情", "deleteSkill": "删除…" },
"SkillRow": {
"updatedUnknown": "无日期",
"pathCopied": "路径已复制",
"copyPath": "复制路径",
"detailPath": "路径",
"skillActions": "{{value0}} 的操作",
"viewDetails": "查看详情",
"deleteSkill": "删除…"
},
"SkillsList": { "listLabel": "技能" },
"sourceStatus": { "missing": "未找到文件夹", "remoteRepo": "远程仓库 — 未扫描", "unavailable": "未扫描" },
"sourceStatus": {
"missing": "未找到文件夹",
"remoteRepo": "远程仓库 — 未扫描",
"unavailable": "未扫描"
},
"sources": { "heading": "技能文件夹" },
"sourceKind": { "home": "主目录", "workspace": "工作区", "bundled": "内置", "plugin": "插件" },
"count": { "skillOne": "{{count}} 个技能", "skillOther": "{{count}} 个技能", "sourceOne": "{{count}} 个来源", "sourceOther": "{{count}} 个来源", "fileOne": "{{count}} 个文件", "fileOther": "{{count}} 个文件", "resultOne": "{{count}} 个结果", "resultOther": "{{count}} 个结果", "selected": "已选择 {{count}} 个", "shareOne": "共享 {{count}} 个技能", "shareOther": "共享 {{count}} 个技能", "linkOne": "{{count}} 个链接", "linkOther": "{{count}} 个链接", "deleteOne": "删除 {{count}} 个技能", "deleteOther": "删除 {{count}} 个技能", "deletedOne": "已删除 {{count}} 个技能", "deletedOther": "已删除 {{count}} 个技能", "deleteFolderOne": "{{count}} 个文件夹", "deleteFolderOther": "{{count}} 个文件夹", "deleteLinkOne": "{{count}} 个链接", "deleteLinkOther": "{{count}} 个链接" },
"sourceKind": {
"home": "主目录",
"workspace": "工作区",
"bundled": "内置",
"plugin": "插件"
},
"count": {
"skillOne": "{{count}} 个技能",
"skillOther": "{{count}} 个技能",
"sourceOne": "{{count}} 个来源",
"sourceOther": "{{count}} 个来源",
"fileOne": "{{count}} 个文件",
"fileOther": "{{count}} 个文件",
"resultOne": "{{count}} 个结果",
"resultOther": "{{count}} 个结果",
"selected": "已选择 {{count}} 个",
"shareOne": "共享 {{count}} 个技能",
"shareOther": "共享 {{count}} 个技能",
"linkOne": "{{count}} 个链接",
"linkOther": "{{count}} 个链接",
"deleteOne": "删除 {{count}} 个技能",
"deleteOther": "删除 {{count}} 个技能",
"deletedOne": "已删除 {{count}} 个技能",
"deletedOther": "已删除 {{count}} 个技能",
"deleteFolderOne": "{{count}} 个文件夹",
"deleteFolderOther": "{{count}} 个文件夹",
"deleteLinkOne": "{{count}} 个链接",
"deleteLinkOther": "{{count}} 个链接"
},
"filter": { "allAgents": "所有 Agent", "sharedAgent": "共享 (.agents)" },
"SkillsSelectionHeader": { "exit": "退出选择", "exitTooltip": "退出选择 · Esc", "title": "选择要共享的技能", "selectAll": "选择全部 {{count}} 个符合条件的项目", "clear": "清除", "deleteTitle": "选择要删除的技能" },
"SkillsSelectionHeader": {
"exit": "退出选择",
"exitTooltip": "退出选择 · Esc",
"title": "选择要共享的技能",
"selectAll": "选择全部 {{count}} 个符合条件的项目",
"clear": "清除",
"deleteTitle": "选择要删除的技能"
},
"SkillDetailDialog": { "agents": "Agent", "updated": "已更新", "copy": "复制" },
"SkillFreshnessNudge": {
"titleOne": "已安装的 Orca 技能已过期",
@@ -1,4 +1,5 @@
import { useAppStore } from '@/store'
import { activateBrowserWorkspaceTab } from '@/lib/browser-workspace-tab-activation'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { isBlankBrowserUrl } from './browser-palette-search'
import { activateAndRevealWorktree } from './worktree-activation'
@@ -59,18 +60,16 @@ export function activateBrowserPagePaletteResult({
return { status: 'failed', reason: 'missing-worktree' }
}
const state = useAppStore.getState()
const matchingUnifiedTab = (state.unifiedTabsByWorktree[worktree.id] ?? []).find(
(candidate) => candidate.contentType === 'browser' && candidate.entityId === workspace.id
)
// Why: the pane renders whatever the group's active tab is, so without a unified
// tab the browser state would go active behind a tab that never shows the page.
if (!matchingUnifiedTab) {
// Why the failure and not a bare activation: without a unified tab the browser state would go
// active behind a tab that never shows the page.
if (
!activateBrowserWorkspaceTab({
worktreeId: worktree.id,
workspaceId: workspace.id,
pageId
})
) {
return { status: 'failed', reason: 'missing-tab' }
}
state.focusGroup(worktree.id, matchingUnifiedTab.groupId)
state.activateTab(matchingUnifiedTab.id)
state.setActiveBrowserTab(workspace.id)
state.setActiveBrowserPage(workspace.id, pageId)
return { status: 'activated', pageId, focusTarget }
}
@@ -0,0 +1,30 @@
import { useAppStore } from '@/store'
/**
* Bring a browser workspace forward as the surface the reader is in.
*
* Why the unified tab and not just the browser state: the pane renders whatever its group's active
* tab is, so selecting the workspace alone leaves the page live behind a tab that never shows it.
* Returns false when the workspace has no unified tab yet, which is the caller's cue that there is
* nothing to bring forward.
*/
export function activateBrowserWorkspaceTab(params: {
worktreeId: string
workspaceId: string
pageId?: string
}): boolean {
const state = useAppStore.getState()
const unifiedTab = (state.unifiedTabsByWorktree[params.worktreeId] ?? []).find(
(candidate) => candidate.contentType === 'browser' && candidate.entityId === params.workspaceId
)
if (!unifiedTab) {
return false
}
state.focusGroup(params.worktreeId, unifiedTab.groupId)
state.activateTab(unifiedTab.id)
state.setActiveBrowserTab(params.workspaceId)
if (params.pageId) {
state.setActiveBrowserPage(params.workspaceId, params.pageId)
}
return true
}
@@ -0,0 +1,169 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
connectionId: null as string | null | undefined,
environmentId: null as string | null,
mintGrant: vi.fn(),
revokeGrant: vi.fn()
}))
vi.mock('@/lib/connection-owner-resolution', () => ({
getConnectionIdForFileFromState: () => mocks.connectionId
}))
vi.mock('@/lib/worktree-runtime-owner', () => ({
getRuntimeEnvironmentIdForWorktree: () => mocks.environmentId
}))
import {
buildDocPreviewGrantRequest,
ensureDocPreviewGrant,
releaseDocPreviewGrant
} from './doc-preview-grants'
const state = { getKnownWorktreeById: () => ({ id: 'wt-1', path: '/srv/repo' }) } as never
beforeEach(() => {
vi.clearAllMocks()
mocks.connectionId = null
mocks.environmentId = null
mocks.mintGrant.mockResolvedValue({ grantId: 'grant-1', url: 'orca-preview://grant-1/a.html' })
vi.stubGlobal('window', {
api: { docPreview: { mintGrant: mocks.mintGrant, revokeGrant: mocks.revokeGrant } }
})
})
describe('buildDocPreviewGrantRequest', () => {
// Why: SSH previews are unrestricted by design, and a document outside every workspace has no
// boundary to root a grant in.
it('roots an SSH grant outside the workspace at the document directory', () => {
mocks.connectionId = 'ssh-1'
expect(buildDocPreviewGrantRequest(state, 'wt-1', '/home/alice/docs/report.html')).toEqual({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'report.html'
})
})
// Why: reports keep their assets in a sibling directory, so `../assets/app.css` has to resolve.
it('roots a document inside the workspace at the workspace root', () => {
mocks.connectionId = 'ssh-1'
expect(buildDocPreviewGrantRequest(state, 'wt-1', '/srv/repo/docs/report.html')).toEqual({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/srv/repo',
entryRelativePath: 'docs/report.html'
})
})
it('carries the worktree selector and root for a paired runtime grant', () => {
mocks.environmentId = 'env-1'
expect(buildDocPreviewGrantRequest(state, 'wt-1', '/srv/repo/docs/report.html')).toEqual({
owner: {
kind: 'runtime',
environmentId: 'env-1',
worktreeSelector: 'id:wt-1',
worktreeRoot: '/srv/repo'
},
root: '/srv/repo',
entryRelativePath: 'docs/report.html'
})
})
// Why: files.read is worktree-scoped, so a paired document outside it has no readable root.
it('refuses a paired document outside the workspace', () => {
mocks.environmentId = 'env-1'
expect(buildDocPreviewGrantRequest(state, 'wt-1', '/var/tmp/report.html')).toBeNull()
})
it('refuses a local workspace, which has no remote channel to read over', () => {
expect(buildDocPreviewGrantRequest(state, 'wt-1', '/tmp/report.html')).toBeNull()
})
})
describe('doc preview grant lifetime', () => {
it('mints once for repeated mounts of the same preview tab', async () => {
const request = {
owner: { kind: 'ssh' as const, connectionId: 'ssh-1' },
root: '/d',
entryRelativePath: 'a.html'
}
const [first, second] = await Promise.all([
ensureDocPreviewGrant('preview-1', request),
ensureDocPreviewGrant('preview-1', request)
])
// Why: React StrictMode double-invokes mount effects in dev; a second mint would
// strand the first grant and a mount-scoped revoke would kill the live webview.
expect(mocks.mintGrant).toHaveBeenCalledOnce()
expect(first).toBe(second)
expect(mocks.revokeGrant).not.toHaveBeenCalled()
})
it('revokes on release and mints fresh afterwards', async () => {
const request = {
owner: { kind: 'ssh' as const, connectionId: 'ssh-1' },
root: '/d',
entryRelativePath: 'a.html'
}
await ensureDocPreviewGrant('preview-2', request)
releaseDocPreviewGrant('preview-2')
await vi.waitFor(() => expect(mocks.revokeGrant).toHaveBeenCalledWith('grant-1'))
await ensureDocPreviewGrant('preview-2', request)
expect(mocks.mintGrant).toHaveBeenCalledTimes(2)
})
it('ignores a release for a tab that never minted a grant', () => {
releaseDocPreviewGrant('never-opened')
expect(mocks.revokeGrant).not.toHaveBeenCalled()
})
// Why: a mint that rejects after the tab was released and reopened must not evict the entry the
// reopen created, or that grant can never be revoked from the tab that owns it.
it('leaves the entry of a later mint alone when an earlier one rejects', async () => {
const request = {
owner: { kind: 'ssh' as const, connectionId: 'ssh-1' },
root: '/d',
entryRelativePath: 'a.html'
}
let failStaleMint: (error: Error) => void = () => {}
mocks.mintGrant.mockReturnValueOnce(
new Promise((_resolve, reject) => {
failStaleMint = reject
})
)
mocks.mintGrant.mockResolvedValueOnce({
grantId: 'grant-2',
url: 'orca-preview://grant-2/a.html'
})
const stale = ensureDocPreviewGrant('preview-4', request)
releaseDocPreviewGrant('preview-4')
await ensureDocPreviewGrant('preview-4', request)
failStaleMint(new Error('runtime offline'))
await expect(stale).rejects.toThrow('runtime offline')
releaseDocPreviewGrant('preview-4')
await vi.waitFor(() => expect(mocks.revokeGrant).toHaveBeenCalledWith('grant-2'))
})
it('does not cache a failed mint', async () => {
mocks.mintGrant.mockRejectedValueOnce(new Error('runtime offline'))
const request = {
owner: { kind: 'ssh' as const, connectionId: 'ssh-1' },
root: '/d',
entryRelativePath: 'a.html'
}
await expect(ensureDocPreviewGrant('preview-3', request)).rejects.toThrow('runtime offline')
await expect(ensureDocPreviewGrant('preview-3', request)).resolves.toMatchObject({
grantId: 'grant-1'
})
})
})
@@ -0,0 +1,86 @@
import type { DocPreviewGrantRequest } from '../../../preload/api/doc-preview-api'
import { basename, dirname, getRelativePathInsideRoot } from '@/lib/path'
import { getConnectionIdForFileFromState } from '@/lib/connection-owner-resolution'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
import type { AppState } from '@/store/types'
export type DocPreviewGrantHandle = { grantId: string; url: string }
/**
* Grants are keyed by preview tab id, never by effect mount: React StrictMode
* double-invokes mount effects in dev, and a mount-scoped grant would be revoked
* out from under the surviving webview. Release is driven by tab close instead.
*/
const grantsByPreviewId = new Map<string, Promise<DocPreviewGrantHandle>>()
/** The page is filled in by `ensureDocPreviewGrant`, so the grant and its key name one surface. */
export type DocPreviewGrantLocation = Omit<DocPreviewGrantRequest, 'browserPageId'>
export function buildDocPreviewGrantRequest(
state: AppState,
worktreeId: string,
filePath: string
): DocPreviewGrantLocation | null {
const worktreeRoot = state.getKnownWorktreeById(worktreeId)?.path ?? null
// Why the workspace root and not the document's folder: reports keep their assets in a sibling
// directory (`../assets/app.css`), which a folder-rooted grant refuses. This is no wider than the
// channel already allows — files.read is worktree-scoped on paired hosts either way.
const worktreeRelativePath = getRelativePathInsideRoot(filePath, worktreeRoot)
const root = worktreeRoot && worktreeRelativePath ? worktreeRoot : dirname(filePath)
const entryRelativePath = worktreeRelativePath ?? basename(filePath)
if (!root || !entryRelativePath) {
return null
}
const connectionId = getConnectionIdForFileFromState(state, worktreeId, filePath)
if (connectionId) {
// Why SSH keeps a document-folder root when the file sits outside the workspace: those previews
// are unrestricted by design, and there is no workspace boundary to root them in.
return { owner: { kind: 'ssh', connectionId }, root, entryRelativePath }
}
const environmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId)
if (!environmentId || !worktreeRoot || !worktreeRelativePath) {
return null
}
return {
owner: {
kind: 'runtime',
environmentId,
worktreeSelector: toRuntimeWorktreeSelector(worktreeId),
worktreeRoot
},
root,
entryRelativePath
}
}
export function ensureDocPreviewGrant(
previewId: string,
location: DocPreviewGrantLocation
): Promise<DocPreviewGrantHandle> {
const existing = grantsByPreviewId.get(previewId)
if (existing) {
return existing
}
const pending: Promise<DocPreviewGrantHandle> = window.api.docPreview
.mintGrant({ ...location, browserPageId: previewId })
.catch((error: unknown) => {
// Why the identity check: a release and a fresh ensure can both land before this rejects, and
// an unconditional delete would evict the newer entry, leaving its grant unrevokable.
if (grantsByPreviewId.get(previewId) === pending) {
grantsByPreviewId.delete(previewId)
}
throw error
})
grantsByPreviewId.set(previewId, pending)
return pending
}
export function releaseDocPreviewGrant(previewId: string): void {
const pending = grantsByPreviewId.get(previewId)
if (!pending) {
return
}
grantsByPreviewId.delete(previewId)
void pending.then((handle) => window.api.docPreview.revokeGrant(handle.grantId)).catch(() => {})
}
@@ -0,0 +1,61 @@
import {
getExecutionHostLabel,
parseExecutionHostId,
type ExecutionHostId
} from '../../../shared/execution-host'
import { getHostSettingOverride } from '../../../shared/host-setting-overrides'
import {
getExecutionHostIdForWorktree,
getExplicitRuntimeEnvironmentIdForWorktree
} from '@/lib/worktree-runtime-owner'
import { selectRuntimeAwareSshTargetLabel } from '@/store/slices/runtime-environment-ssh-selectors'
import type { AppState } from '@/store/types'
/**
* What to call an execution host in front of a user: their own rename first, then the machine's
* published name (paired runtime) or the SSH target's label, and the raw id only as a last resort.
*/
export function selectExecutionHostDisplayLabel(
state: AppState,
hostId: ExecutionHostId,
// SSH labels are published per runtime environment when the target is reached through one.
options: { sshEnvironmentId?: string | null } = {}
): string {
const override = getHostSettingOverride(state.settings, hostId, 'displayLabel')
if (override) {
return override
}
const parsed = parseExecutionHostId(hostId)
if (parsed?.kind === 'runtime') {
const name = state.runtimeEnvironments
?.find((environment) => environment.id === parsed.environmentId)
?.name.trim()
if (name) {
return name
}
}
if (parsed?.kind === 'ssh') {
return selectRuntimeAwareSshTargetLabel(
state,
options.sshEnvironmentId ?? null,
parsed.targetId
)
}
return getExecutionHostLabel(hostId)
}
/**
* The machine a worktree's files live on, or null when ownership is still contested the
* `unresolved-owner` sentinel is routing bookkeeping and must never reach a reader as a host name.
*/
export function selectWorktreeHostDisplayLabel(state: AppState, worktreeId: string): string | null {
const hostId = getExecutionHostIdForWorktree(state, worktreeId)
const parsed = parseExecutionHostId(hostId)
if (parsed?.kind === 'runtime' && parsed.environmentId === 'unresolved-owner') {
return null
}
return selectExecutionHostDisplayLabel(state, hostId, {
sshEnvironmentId:
parsed?.kind === 'ssh' ? getExplicitRuntimeEnvironmentIdForWorktree(state, worktreeId) : null
})
}
@@ -0,0 +1,85 @@
// @vitest-environment happy-dom
//
// The Explorer row decides whether to show the preview action with the hook, and activating it
// runs the plan. The hook used to re-derive that answer from its own copy of the rules; it now
// delegates, and these pin the two to one answer so a future rule change cannot split them.
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
browserAvailability: { state: 'enabled', provider: 'local-client' } as
| { state: 'enabled'; provider: 'local-client' | 'paired-runtime' }
| { state: 'hidden'; reason: string },
environmentId: null as string | null
}))
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
vi.mock('@/lib/client-creation-action-policy', () => ({
getClientCreationActionPolicy: () => ({ 'managed-browser': mocks.browserAvailability })
}))
vi.mock('@/lib/worktree-runtime-owner', () => ({
getRuntimeEnvironmentIdForWorktree: () => mocks.environmentId
}))
const storeState = {
getKnownWorktreeById: () => ({ id: 'wt-1', path: '/repo' }),
repos: [{ id: 'repo-1', connectionId: null }],
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] }
}
vi.mock('@/store', () => ({
useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(storeState), {
getState: () => storeState
})
}))
import {
canShowWorkspaceFileBrowserAction,
useWorkspaceFileBrowserActionPredicate
} from './file-preview'
const FILE_PATH = '/repo/report.html'
function predicateAnswer(): boolean {
return renderHook(() => useWorkspaceFileBrowserActionPredicate('wt-1')).result.current(FILE_PATH)
}
beforeEach(() => {
mocks.browserAvailability = { state: 'enabled', provider: 'local-client' }
mocks.environmentId = null
})
describe('workspace file browser action visibility', () => {
it('agrees with the plan for a local workspace that can open a browser', () => {
expect(predicateAnswer()).toBe(
canShowWorkspaceFileBrowserAction(storeState as never, 'wt-1', FILE_PATH)
)
expect(predicateAnswer()).toBe(true)
})
it('hides the action when the local workspace has no managed browser', () => {
mocks.browserAvailability = { state: 'hidden', reason: 'browser unavailable' }
expect(canShowWorkspaceFileBrowserAction(storeState as never, 'wt-1', FILE_PATH)).toBe(false)
expect(predicateAnswer()).toBe(false)
})
it('agrees with the plan on a paired workspace with no managed browser', () => {
mocks.environmentId = 'env-1'
mocks.browserAvailability = { state: 'hidden', reason: 'browser unavailable' }
expect(predicateAnswer()).toBe(
canShowWorkspaceFileBrowserAction(storeState as never, 'wt-1', FILE_PATH)
)
expect(predicateAnswer()).toBe(true)
})
it('keeps a stable predicate identity across re-renders of the same workspace', () => {
const { result, rerender } = renderHook(() => useWorkspaceFileBrowserActionPredicate('wt-1'))
const first = result.current
rerender()
expect(result.current).toBe(first)
})
})

Some files were not shown because too many files have changed in this diff Show More