fix(browser-preview): require explicit preview capabilities (STA-5758) (#16921)

* fix(browser-preview): require explicit preview capabilities (STA-5758)

Scope document reads to approved directories, confirm external links before opening them, revoke grants with tab lifecycle, and keep document-preview session state rollback-safe across mixed client/runtime versions.

* Harden document preview lifecycle and permissions

* Document preview DNS prefetch residual

* Make preview E2E guest focus explicit

* fix(browser-preview): entry-file-only authority for root-level docs, contained chip layout, re-issued gate paths (STA-5758)

A grant whose document directory is its own request base — a doc at the
workspace root, or outside any workspace — now reads nothing but the entry
file until the reader approves a directory, at both the lexical and the
canonical containment pass. The DNS-prefetch residual can only beacon what
the page can read, and a root-level document could previously read the
whole worktree silently.

The identity chip's host badge overflowed the chip's layout box under
squeeze (Linux CI): every row member can now shrink and truncate, verified
by a width sweep in isolated Chromium down to ~120px chips.

The Allow banner says what it grants: 'Allow folder', reading files in the
named directory, for the life of the preview.

The reliability-gate manifest command, testFiles entry, assertion refs and
dated evidence naming the deleted doc-preview-external-link-bridge.test.ts
are re-issued at doc-preview-external-link-confirmation.test.ts with a
fresh 189/189 run; the focus-gate assertion text follows the shipped gate.

* fix(browser-preview): hide the chip identity row below 24rem instead of clipping it, ellipsize the host badge, catalog the new i18n keys (STA-5758)

CI's preview pane leaves the chip ~40px: no truncation shows anything
there, so the Workspace-file label and host badge now hide whole below a
24rem container threshold sized so that visible implies contained. The
badge text gains an inner text box — text directly inside the flex pill
clipped both ends with no ellipsis. The e2e geometry oracle asserts
containment when the row shows and the threshold when it does not.

verify:localization-catalog: the hardening's new preview keys (and the
renamed allowDirectory) join en.json via sync:localization-catalog.

* feat(browser-preview): batch blocked folders into one access decision (STA-5758)

Sequential per-folder banners trained the allow reflex without adding
judgment — a reader cannot weigh assets/ against data/. The banner now
accumulates every folder a load surfaces, names them (three, then a
count, full list in the title), and grants exactly that set with one
Allow-N-folders click and one reload. Dismiss fences the whole named
set. The map lives behind a ref with a version tick so a dismissal
fences an offer landing in the same event batch.
This commit is contained in:
Jinwoo Hong
2026-08-28 00:42:07 -04:00
committed by GitHub
parent b19a397d3e
commit cb848647e5
46 changed files with 1642 additions and 329 deletions
File diff suppressed because one or more lines are too long
@@ -3,7 +3,9 @@ import type { BrowserSessionProfile } from '../../shared/browser-workspace-types
const mocks = vi.hoisted(() => ({
handleGuestWillDownload: vi.fn(),
noticeDocPreviewDownloadBlocked: vi.fn()
noticeDocPreviewDownloadBlocked: vi.fn(),
clearBrowserWebAuthnAccessHandlers: vi.fn(),
installBrowserWebAuthnAccessHandlers: vi.fn()
}))
type WillDownloadListener = (
@@ -88,13 +90,13 @@ vi.mock('./browser-session-user-agent-mode', () => ({
}))
vi.mock('./browser-webauthn-access', () => ({
allowsBrowserWebAuthnPermission: () => false,
clearBrowserWebAuthnAccessHandlers: vi.fn(),
installBrowserWebAuthnAccessHandlers: vi.fn()
clearBrowserWebAuthnAccessHandlers: mocks.clearBrowserWebAuthnAccessHandlers,
installBrowserWebAuthnAccessHandlers: mocks.installBrowserWebAuthnAccessHandlers
}))
type PartitionPolicyInstaller = (
profile: BrowserSessionProfile,
options?: { downloads?: 'route' | 'deny' }
options?: { downloads?: 'route' | 'deny'; permissions?: 'browser' | 'deny' }
) => void
// Why imported per test rather than at the top: the installer remembers which partitions it has
@@ -183,3 +185,51 @@ describe('partition download policy', () => {
expect(mocks.handleGuestWillDownload).toHaveBeenCalledTimes(1)
})
})
describe('partition permission policy', () => {
it('keeps ordinary browser partitions on the browser permission policy', async () => {
const install = await loadInstaller()
install(profileFor('persist:browsing-1'))
expect(mocks.installBrowserWebAuthnAccessHandlers).toHaveBeenCalledWith(
sessionsByPartition.get('persist:browsing-1')
)
expect(mocks.clearBrowserWebAuthnAccessHandlers).not.toHaveBeenCalled()
})
it('denies every request and check on a strict partition without WebAuthn handlers', async () => {
const install = await loadInstaller()
install(profileFor('orca-doc-preview'), { permissions: 'deny' })
const sess = sessionsByPartition.get('orca-doc-preview')
if (!sess) {
throw new Error('Expected the preview session')
}
const requestHandler = sess.setPermissionRequestHandler.mock.calls[0]?.[0] as (
webContents: Electron.WebContents,
permission: string,
callback: (allowed: boolean) => void
) => void
const checkHandler = sess.setPermissionCheckHandler.mock.calls[0]?.[0] as (
webContents: Electron.WebContents,
permission: string
) => boolean
const displayMediaHandler = sess.setDisplayMediaRequestHandler.mock.calls[0]?.[0] as (
request: Electron.DisplayMediaRequestHandlerHandlerRequest,
callback: (streams: { video?: Electron.WebFrameMain; audio?: 'loopback' }) => void
) => void
for (const permission of ['media', 'clipboard-read', 'notifications', 'fullscreen']) {
let decision: boolean | null = null
requestHandler({} as Electron.WebContents, permission, (allowed) => (decision = allowed))
expect(decision).toBe(false)
expect(checkHandler({} as Electron.WebContents, permission)).toBe(false)
}
expect(mocks.installBrowserWebAuthnAccessHandlers).not.toHaveBeenCalled()
expect(mocks.clearBrowserWebAuthnAccessHandlers).toHaveBeenCalledWith(sess)
let displayMediaDecision: { video?: Electron.WebFrameMain; audio?: 'loopback' } | null = null
displayMediaHandler({} as Electron.DisplayMediaRequestHandlerHandlerRequest, (decision) => {
displayMediaDecision = decision
})
expect(displayMediaDecision).toEqual({ video: undefined, audio: undefined })
})
})
@@ -57,10 +57,14 @@ function resolvePermissionNoticeUrl(
/** `route` hands the item to the owning page's download flow; `deny` cancels it before it starts. */
export type BrowserPartitionDownloadPolicy = 'route' | 'deny'
export type BrowserPartitionPermissionPolicy = 'browser' | 'deny'
export function installBrowserSessionPartitionPolicies(
profile: BrowserSessionProfile,
options?: { downloads?: BrowserPartitionDownloadPolicy }
options?: {
downloads?: BrowserPartitionDownloadPolicy
permissions?: BrowserPartitionPermissionPolicy
}
): void {
const { partition } = profile
const sess = session.fromPartition(partition)
@@ -75,57 +79,63 @@ export function installBrowserSessionPartitionPolicies(
sess.setUserAgent(cleanUA)
setupClientHintsOverride(sess, cleanUA)
}
sess.setPermissionRequestHandler((webContents, permission, callback, details) => {
// Why: defer media to macOS TCC; denying at the session layer throws NotAllowedError even after the user granted Camera/Mic to the OS.
if (permission === 'media') {
// Capture before async handling; opaque frames cannot be attributed to a named site.
const rawUrl = resolvePermissionNoticeUrl(webContents, details)
void requestSystemMediaAccess(
details as Electron.MediaAccessPermissionRequest | undefined
).then(
(granted) => {
if (!granted) {
if (options?.permissions === 'deny') {
sess.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false))
sess.setPermissionCheckHandler(() => false)
clearBrowserWebAuthnAccessHandlers(sess)
} else {
sess.setPermissionRequestHandler((webContents, permission, callback, details) => {
// Why: defer media to macOS TCC; denying at the session layer throws NotAllowedError even after the user granted Camera/Mic to the OS.
if (permission === 'media') {
// Capture before async handling; opaque frames cannot be attributed to a named site.
const rawUrl = resolvePermissionNoticeUrl(webContents, details)
void requestSystemMediaAccess(
details as Electron.MediaAccessPermissionRequest | undefined
).then(
(granted) => {
if (!granted) {
browserManager.notifyPermissionDenied({
guestWebContentsId: webContents.id,
permission,
rawUrl
})
}
callback(granted)
},
(error: unknown) => {
console.error('[permissions] Browser media access failed:', error)
browserManager.notifyPermissionDenied({
guestWebContentsId: webContents.id,
permission,
rawUrl
})
callback(false)
}
callback(granted)
},
(error: unknown) => {
console.error('[permissions] Browser media access failed:', error)
browserManager.notifyPermissionDenied({
guestWebContentsId: webContents.id,
permission,
rawUrl
})
callback(false)
}
)
return
}
const allowed = isAutoGrantedBrowserSessionPermission(permission)
if (!allowed) {
const rawUrl = resolvePermissionNoticeUrl(webContents, details)
browserManager.notifyPermissionDenied({
guestWebContentsId: webContents.id,
permission,
rawUrl
})
}
callback(allowed)
})
sess.setPermissionCheckHandler((_webContents, permission, _origin, details) => {
if (permission === 'media') {
return hasSystemMediaAccess(details?.mediaType)
}
if (allowsBrowserWebAuthnPermission(permission, details)) {
return true
}
return isAutoGrantedBrowserSessionPermission(permission)
})
installBrowserWebAuthnAccessHandlers(sess)
)
return
}
const allowed = isAutoGrantedBrowserSessionPermission(permission)
if (!allowed) {
const rawUrl = resolvePermissionNoticeUrl(webContents, details)
browserManager.notifyPermissionDenied({
guestWebContentsId: webContents.id,
permission,
rawUrl
})
}
callback(allowed)
})
sess.setPermissionCheckHandler((_webContents, permission, _origin, details) => {
if (permission === 'media') {
return hasSystemMediaAccess(details?.mediaType)
}
if (allowsBrowserWebAuthnPermission(permission, details)) {
return true
}
return isAutoGrantedBrowserSessionPermission(permission)
})
installBrowserWebAuthnAccessHandlers(sess)
}
sess.setDisplayMediaRequestHandler((_request, callback) => {
callback({ video: undefined, audio: undefined })
})
@@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
publishDocPreviewFailure: vi.fn(),
boundGrantIdByGuest: new Map<object, string>()
boundGrantIdByGuest: new Map<object, string>(),
revocationListener: null as null | ((grant: { id: string }) => void)
}))
vi.mock('./doc-preview-failure-notice', () => ({
@@ -11,6 +12,12 @@ vi.mock('./doc-preview-failure-notice', () => ({
vi.mock('./doc-preview-guest-policy', () => ({
readDocPreviewGuestBoundGrantId: (guest: object) => mocks.boundGrantIdByGuest.get(guest) ?? null
}))
vi.mock('./doc-preview-grant-registry', () => ({
onDocPreviewGrantRevoked: (listener: (grant: { id: string }) => void) => {
mocks.revocationListener = listener
return vi.fn()
}
}))
const GRANT_ID = 'a'.repeat(32)
const OTHER_GRANT_ID = 'b'.repeat(32)
@@ -32,6 +39,7 @@ async function loadNotifier(): Promise<(guest: Electron.WebContents) => void> {
beforeEach(() => {
vi.clearAllMocks()
mocks.boundGrantIdByGuest.clear()
mocks.revocationListener = null
// Why per test: the module remembers which grants it has already told the reader about.
vi.resetModules()
vi.useFakeTimers()
@@ -94,6 +102,17 @@ describe('noticeDocPreviewDownloadBlocked', () => {
})
})
it('forgets a preview as soon as its grant is revoked', async () => {
const notice = await loadNotifier()
const guest = guestBoundTo(GRANT_ID)
notice(guest)
mocks.revocationListener?.({ id: GRANT_ID })
notice(guest)
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2)
})
// 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.
@@ -1,4 +1,5 @@
import { publishDocPreviewFailure } from './doc-preview-failure-notice'
import { onDocPreviewGrantRevoked } from './doc-preview-grant-registry'
import { readDocPreviewGuestBoundGrantId } from './doc-preview-guest-policy'
/**
@@ -12,6 +13,8 @@ import { readDocPreviewGuestBoundGrantId } from './doc-preview-guest-policy'
const NOTICE_MIN_INTERVAL_MS = 2_000
const noticedAtByGrantId = new Map<string, number>()
onDocPreviewGrantRevoked((grant) => noticedAtByGrantId.delete(grant.id))
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.
@@ -17,19 +17,27 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({
import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader'
import { docPreviewContentType, readDocPreviewFile } from './doc-preview-file-reader'
import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry'
import {
authorizeDocPreviewDirectory,
mintDocPreviewGrant,
revokeAllDocPreviewGrants
} from './doc-preview-grant-registry'
// Why the fixtures approve the document directory up front: these tests exercise the transport
// half of a read — an entry-only grant's approval flow is pinned in its own test below.
function sshGrant(): ReturnType<typeof mintDocPreviewGrant> {
return mintDocPreviewGrant({
const grant = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
authorizeDocPreviewDirectory(grant.id, grant.entryRelativePath)
return grant
}
function runtimeGrant(root = '/srv/repo/docs'): ReturnType<typeof mintDocPreviewGrant> {
return mintDocPreviewGrant({
const grant = mintDocPreviewGrant({
owner: {
kind: 'runtime',
environmentId: 'env-1',
@@ -40,6 +48,8 @@ function runtimeGrant(root = '/srv/repo/docs'): ReturnType<typeof mintDocPreview
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
authorizeDocPreviewDirectory(grant.id, grant.entryRelativePath)
return grant
}
beforeEach(() => {
@@ -165,6 +175,29 @@ describe('readDocPreviewFile — ssh owner', () => {
expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled()
})
it('requires approval for a sibling directory before touching the SSH provider', async () => {
const grant = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
requestBase: '/home/alice',
root: '/home/alice/docs',
entryRelativePath: 'docs/index.html',
browserPageId: 'page-1'
})
await expect(readDocPreviewFile(grant, 'assets/logo.png')).resolves.toMatchObject({
ok: false,
status: 403,
reason: 'authorization-required'
})
expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled()
authorizeDocPreviewDirectory(grant.id, 'assets/logo.png')
mocks.readFile.mockResolvedValue({ content: 'logo', isBinary: false })
await expect(readDocPreviewFile(grant, 'assets/logo.png')).resolves.toMatchObject({ ok: true })
expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/assets/logo.png')
})
it('reports an over-cap SSH file as too large rather than unreadable', async () => {
mocks.readFile.mockRejectedValue(new FileReadCapExceededError('exceeds client cap'))
@@ -330,4 +363,41 @@ describe('readDocPreviewFile — paired runtime owner', () => {
expect(outcome).toMatchObject({ ok: false, status: 404 })
expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled()
})
it('requires approval for a sibling directory before touching the runtime', async () => {
const grant = mintDocPreviewGrant({
owner: {
kind: 'runtime',
environmentId: 'env-1',
worktreeSelector: 'id:wt-1',
worktreeRoot: '/srv/repo'
},
requestBase: '/srv/repo',
root: '/srv/repo/docs',
entryRelativePath: 'docs/index.html',
browserPageId: 'page-1'
})
await expect(readDocPreviewFile(grant, 'assets/app.js')).resolves.toMatchObject({
ok: false,
status: 403,
reason: 'authorization-required'
})
expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled()
authorizeDocPreviewDirectory(grant.id, 'assets/app.js')
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: true,
result: { content: 'console.log(1)', truncated: false, byteLength: 14 }
})
await expect(readDocPreviewFile(grant, 'assets/app.js')).resolves.toMatchObject({ ok: true })
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
'/user-data',
'env-1',
'files.read',
{ worktree: 'id:wt-1', relativePath: 'assets/app.js' },
15_000
)
})
})
+11 -1
View File
@@ -10,6 +10,7 @@ import { getCanonicalUserDataPath } from '../persistence'
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import {
resolveCanonicalDocPreviewPath,
resolveDocPreviewCandidatePath,
resolveDocPreviewTargetPath,
toRuntimeWorktreeRelativePath,
type DocPreviewGrant
@@ -153,9 +154,18 @@ export async function readDocPreviewFile(
grant: DocPreviewGrant,
relativePath: string
): Promise<DocPreviewReadOutcome> {
const candidatePath = resolveDocPreviewCandidatePath(grant, relativePath)
if (!candidatePath) {
return notFoundOutcome()
}
const absolutePath = resolveDocPreviewTargetPath(grant, relativePath)
if (!absolutePath) {
return notFoundOutcome()
return {
ok: false,
status: 403,
reason: 'authorization-required',
message: 'This file needs permission before the preview can read it.'
}
}
const contentType = docPreviewContentType(relativePath)
try {
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
authorizeDocPreviewDirectory,
getDocPreviewGrant,
mintDocPreviewGrant,
resolveCanonicalDocPreviewPath,
@@ -46,13 +47,45 @@ describe('doc preview grants', () => {
})
describe('resolveDocPreviewTargetPath', () => {
it('resolves paths inside the grant root', () => {
// A grant whose root IS its request base — a document at the workspace root, or outside any
// workspace — starts with the entry file alone: that directory is where secrets live, and a
// DNS-prefetch beacon needs no click, so nothing beside the entry reads without the reader.
it('reads only the entry document until the reader approves its directory', () => {
const grant = mintPosixGrant()
expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html')
expect(resolveDocPreviewTargetPath(grant, 'assets/logo.png')).toBeNull()
expect(resolveDocPreviewTargetPath(grant, 'secrets.env')).toBeNull()
expect(authorizeDocPreviewDirectory(grant.id, 'assets/logo.png')).toBe(true)
expect(resolveDocPreviewTargetPath(grant, 'assets/logo.png')).toBe(
'/srv/repo/docs/assets/logo.png'
)
expect(resolveDocPreviewTargetPath(grant, 'secrets.env')).toBeNull()
})
it('keeps silent authority over a document directory strictly inside the workspace, and only there', () => {
const nested = mintDocPreviewGrant({
owner: sshOwner,
requestBase: '/srv/repo',
root: '/srv/repo/docs',
entryRelativePath: 'docs/report.html',
browserPageId: 'page-1'
})
expect(resolveDocPreviewTargetPath(nested, 'docs/styles.css')).toBe('/srv/repo/docs/styles.css')
const atWorkspaceRoot = mintDocPreviewGrant({
owner: sshOwner,
requestBase: '/srv/repo',
root: '/srv/repo',
entryRelativePath: 'report.html',
browserPageId: 'page-2'
})
expect(resolveDocPreviewTargetPath(atWorkspaceRoot, 'report.html')).toBe(
'/srv/repo/report.html'
)
expect(resolveDocPreviewTargetPath(atWorkspaceRoot, '.env')).toBeNull()
expect(resolveDocPreviewTargetPath(atWorkspaceRoot, 'docs/styles.css')).toBeNull()
})
it('refuses parent traversal, absolute escapes and empty paths', () => {
@@ -101,6 +134,7 @@ describe('resolveDocPreviewTargetPath', () => {
browserPageId: 'page-1'
})
expect(authorizeDocPreviewDirectory(windowsGrant.id, 'assets/logo.png')).toBe(true)
expect(resolveDocPreviewTargetPath(windowsGrant, 'assets/logo.png')).toBe(
'C:\\srv\\repo\\docs\\assets\\logo.png'
)
@@ -118,6 +152,60 @@ describe('resolveDocPreviewTargetPath', () => {
expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html')
expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull()
})
it('authorizes only the requested sibling directory', () => {
const grant = mintDocPreviewGrant({
owner: sshOwner,
requestBase: '/srv/repo',
root: '/srv/repo/docs',
entryRelativePath: 'docs/report.html',
browserPageId: 'page-1'
})
expect(resolveDocPreviewTargetPath(grant, 'docs/report.html')).toBe(
'/srv/repo/docs/report.html'
)
expect(resolveDocPreviewTargetPath(grant, 'assets/chart.js')).toBeNull()
expect(resolveDocPreviewTargetPath(grant, '.env')).toBeNull()
expect(authorizeDocPreviewDirectory(grant.id, 'assets/chart.js')).toBe(true)
expect(resolveDocPreviewTargetPath(grant, 'assets/chart.js')).toBe('/srv/repo/assets/chart.js')
expect(resolveDocPreviewTargetPath(grant, 'assets/theme.css')).toBe(
'/srv/repo/assets/theme.css'
)
expect(resolveDocPreviewTargetPath(grant, '.env')).toBeNull()
})
it('refuses malformed authorization requests and unknown grants', () => {
const grant = mintDocPreviewGrant({
owner: sshOwner,
requestBase: '/srv/repo',
root: '/srv/repo/docs',
entryRelativePath: 'docs/report.html',
browserPageId: 'page-1'
})
expect(authorizeDocPreviewDirectory(grant.id, '../outside/secret.txt')).toBe(false)
expect(authorizeDocPreviewDirectory(grant.id, 'assets\\secret.txt')).toBe(false)
expect(authorizeDocPreviewDirectory('0'.repeat(32), 'assets/chart.js')).toBe(false)
expect(grant.authorizedRoots).toEqual([])
})
it('uses Windows semantics when authorizing a sibling directory', () => {
const grant = mintDocPreviewGrant({
owner: sshOwner,
requestBase: 'C:\\srv\\repo',
root: 'C:\\srv\\repo\\docs',
entryRelativePath: 'docs/report.html',
browserPageId: 'page-1'
})
expect(authorizeDocPreviewDirectory(grant.id, 'assets/chart.js')).toBe(true)
expect(resolveDocPreviewTargetPath(grant, 'assets/chart.js')).toBe(
'C:\\srv\\repo\\assets\\chart.js'
)
expect(authorizeDocPreviewDirectory(grant.id, '../outside/secret.txt')).toBe(false)
})
})
describe('resolveCanonicalDocPreviewPath', () => {
@@ -128,7 +216,7 @@ describe('resolveCanonicalDocPreviewPath', () => {
const grant = mintPosixGrant()
await expect(
resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) =>
resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/index.html', async (path) =>
path === grant.root ? path : '/srv/repo/docs-private/secret.html'
)
).resolves.toBeNull()
@@ -138,8 +226,39 @@ describe('resolveCanonicalDocPreviewPath', () => {
const grant = mintPosixGrant()
await expect(
resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) => path)
).resolves.toBe('/srv/repo/docs/report.html')
resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/index.html', async (path) => path)
).resolves.toBe('/srv/repo/docs/index.html')
})
it('refuses a sibling of an entry-only document even when it canonicalizes cleanly', async () => {
const grant = mintPosixGrant()
await expect(
resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/notes.txt', async (path) => path)
).resolves.toBeNull()
})
it('keeps an approved SSH directory inside the canonical workspace boundary', async () => {
const grant = mintDocPreviewGrant({
owner: sshOwner,
requestBase: '/srv/repo',
root: '/srv/repo/docs',
entryRelativePath: 'docs/report.html',
browserPageId: 'page-1'
})
authorizeDocPreviewDirectory(grant.id, 'assets/chart.js')
await expect(
resolveCanonicalDocPreviewPath(grant, '/srv/repo/assets/chart.js', async (path) => {
if (path === '/srv/repo/assets') {
return '/srv/repo/assets'
}
if (path === '/srv/repo/assets/chart.js') {
return '/etc/shadow'
}
return path
})
).resolves.toBeNull()
})
})
+117 -20
View File
@@ -21,9 +21,18 @@ export type DocPreviewOwner =
export type DocPreviewGrant = {
id: string
owner: DocPreviewOwner
/** Containing directory of the opened document, on the owning host. */
/** Directory relative preview URLs resolve against. */
requestBase: string
/**
* Containing directory of the opened document, on the owning host. It carries silent read
* authority only while it sits strictly inside `requestBase`: a document at the workspace root —
* or outside any workspace, where its directory IS the request base — starts with the entry file
* alone, because that directory is where secrets live and a DNS-prefetch beacon needs no click.
*/
root: string
/** Path of the opened document relative to `root`. */
/** Additional directories the reader approved for this grant. */
authorizedRoots: string[]
/** Path of the opened document relative to `requestBase`. */
entryRelativePath: string
/**
* Browser page the reader opened this document in. Main registers the guest under it once the
@@ -56,14 +65,23 @@ function normalizeRootPath(root: string): string {
export function mintDocPreviewGrant(params: {
owner: DocPreviewOwner
requestBase?: string
root: string
entryRelativePath: string
browserPageId: string
}): DocPreviewGrant {
const requestBase = normalizeRootPath(params.requestBase ?? params.root)
const root = normalizeRootPath(params.root)
const flavor = pathFlavorFor(requestBase)
if (!isAtOrInsideRoot(requestBase, root, flavor)) {
throw new Error('Document preview root is outside its request base')
}
const grant: DocPreviewGrant = {
id: randomBytes(16).toString('hex'),
owner: params.owner,
root: normalizeRootPath(params.root),
requestBase,
root,
authorizedRoots: [],
entryRelativePath: params.entryRelativePath.replace(/\\/g, '/'),
browserPageId: params.browserPageId
}
@@ -126,12 +144,8 @@ function hasUnsafeSegment(segments: string[]): boolean {
)
}
/**
* 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(
/** Resolves a safe request inside the workspace boundary, before grant authorization. */
export function resolveDocPreviewCandidatePath(
grant: DocPreviewGrant,
relativePath: string
): string | null {
@@ -142,9 +156,9 @@ export function resolveDocPreviewTargetPath(
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
const flavor = pathFlavorFor(grant.requestBase)
const resolved = flavor.normalize(flavor.join(grant.requestBase, ...segments))
return isInsideRoot(grant.requestBase, resolved, flavor) ? resolved : null
}
function isInsideRoot(
@@ -156,8 +170,70 @@ function isInsideRoot(
return candidate.startsWith(rootPrefix)
}
function isAtOrInsideRoot(
root: string,
candidate: string,
flavor: typeof posix | typeof win32
): boolean {
return candidate === root || isInsideRoot(root, candidate, flavor)
}
function directoryAuthorityRoots(grant: DocPreviewGrant): string[] {
return grant.root === grant.requestBase
? [...grant.authorizedRoots]
: [grant.root, ...grant.authorizedRoots]
}
/** The one path an entry-only grant can read before the reader approves a directory. */
function resolveEntryAbsolutePath(grant: DocPreviewGrant): string | null {
return resolveDocPreviewCandidatePath(grant, grant.entryRelativePath)
}
/** Resolves a request only when it is the entry document or its directory is authorized. */
export function resolveDocPreviewTargetPath(
grant: DocPreviewGrant,
relativePath: string
): string | null {
const resolved = resolveDocPreviewCandidatePath(grant, relativePath)
if (!resolved) {
return null
}
if (resolved === resolveEntryAbsolutePath(grant)) {
return resolved
}
const flavor = pathFlavorFor(grant.requestBase)
return directoryAuthorityRoots(grant).some((root) => isInsideRoot(root, resolved, flavor))
? resolved
: null
}
/** Expands a live grant to the directory containing one reader-approved request. */
export function authorizeDocPreviewDirectory(grantId: string, relativePath: string): boolean {
const grant = grantsById.get(grantId)
if (!grant) {
return false
}
const candidate = resolveDocPreviewCandidatePath(grant, relativePath)
if (!candidate) {
return false
}
const flavor = pathFlavorFor(grant.requestBase)
const directory = normalizeRootPath(flavor.dirname(candidate))
if (!isAtOrInsideRoot(grant.requestBase, directory, flavor)) {
return false
}
if (!directoryAuthorityRoots(grant).some((root) => isAtOrInsideRoot(root, directory, flavor))) {
grant.authorizedRoots.push(directory)
canonicalRootByGrantId.delete(grant.id)
}
return true
}
/** Why: realpath is a host round-trip, and a grant's root is fixed for its lifetime. */
const canonicalRootByGrantId = new Map<string, Promise<string>>()
const canonicalRootByGrantId = new Map<
string,
Promise<{ boundary: string; roots: string[]; entry: string | null }>
>()
/**
* Second containment pass for hosts where the lexical one is not enough: a symlink
@@ -171,14 +247,35 @@ export async function resolveCanonicalDocPreviewPath(
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)
let canonicalRoots = canonicalRootByGrantId.get(grant.id)
if (!canonicalRoots) {
const entryAbsolute = resolveEntryAbsolutePath(grant)
canonicalRoots = Promise.all([
realpath(grant.requestBase),
entryAbsolute === null ? Promise.resolve(null) : realpath(entryAbsolute),
...directoryAuthorityRoots(grant).map((root) => realpath(root))
]).then(([boundaryPath, entryPath, ...rootPaths]) => {
const boundary = normalizeRootPath(boundaryPath)
const flavor = pathFlavorFor(boundary)
return {
boundary,
roots: rootPaths
.map(normalizeRootPath)
.filter((root) => isAtOrInsideRoot(boundary, root, flavor)),
entry: entryPath !== null && isInsideRoot(boundary, entryPath, flavor) ? entryPath : null
}
})
canonicalRootByGrantId.set(grant.id, canonicalRoots)
}
const [root, canonicalPath] = await Promise.all([canonicalRoot, realpath(absolutePath)])
const flavor = pathFlavorFor(root)
return isInsideRoot(root, canonicalPath, flavor) ? canonicalPath : null
const [{ boundary, roots, entry }, canonicalPath] = await Promise.all([
canonicalRoots,
realpath(absolutePath)
])
const flavor = pathFlavorFor(boundary)
return isInsideRoot(boundary, canonicalPath, flavor) &&
(canonicalPath === entry || roots.some((root) => 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)
@@ -270,13 +270,15 @@ describe('doc preview guest policy', () => {
})
})
it('drops a click reported while the guest is not the contents the reader is looking at', () => {
it("does not mistake Electron's false webview focus flag for an untrusted click", () => {
const { guest } = boundGuest()
guest.isFocused.mockReturnValue(false)
reportClick(guest, 'https://example.com/docs')
expect(guest.send).not.toHaveBeenCalled()
expect(guest.send).toHaveBeenCalledExactlyOnceWith('docPreview:externalLink', {
url: 'https://example.com/docs'
})
})
it('drops a click reported by a sender that is not a preview guest', () => {
+4 -11
View File
@@ -15,7 +15,6 @@ type PreviewHostRenderer = {
type PreviewGuestRegistration = {
host: PreviewHostRenderer
readBoundGrantId: () => string | null
isFocused: () => boolean
}
/**
@@ -125,8 +124,7 @@ export function installDocPreviewGuestPolicy(
previewGuests.set(guest, {
host,
readBoundGrantId: () => boundGrantId,
isFocused: () => guest.isFocused()
readBoundGrantId: () => boundGrantId
})
const forgetGuest = (): void => {
previewGuests.delete(guest)
@@ -221,9 +219,9 @@ function isWebUrl(url: string): boolean {
}
/**
* 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.
* The only way a URL leaves a preview. Every condition is load-bearing: the isolated preload must
* report a trusted anchor click, the sender must be a live preview guest still bound to a grant,
* and the target must be the web. Anything else is dropped without a trace the document can observe.
*/
export function reportDocPreviewLinkClick(sender: Electron.WebContents, rawUrl: string): void {
const registration = previewGuests.get(sender)
@@ -234,11 +232,6 @@ export function reportDocPreviewLinkClick(sender: Electron.WebContents, rawUrl:
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
@@ -261,8 +261,8 @@ describe('installDocPreviewProtocolHandler', () => {
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.
// Why: preview guests still use the shared installer for certificate, UA, permission and
// download hooks, with a stricter decision than ordinary browsing partitions.
it('applies the shared browser partition policies to the preview session', () => {
installDocPreviewProtocolHandler()
@@ -272,14 +272,12 @@ describe('installDocPreviewProtocolHandler', () => {
)
})
// 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', () => {
it('denies downloads and ambient browser permissions on the preview partition', () => {
installDocPreviewProtocolHandler()
expect(mocks.installBrowserSessionPartitionPolicies).toHaveBeenCalledWith(expect.anything(), {
downloads: 'deny'
downloads: 'deny',
permissions: 'deny'
})
})
})
+7 -5
View File
@@ -42,7 +42,9 @@ export function isDocPreviewSession(candidate: Electron.Session): boolean {
* 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.
* stylesheet, font, script, or analytics beacon deliberately does not load. Electron 43 still
* resolves explicit DNS-prefetch hints outside session hooks; the accepted residual is covered by
* `browser-route-dns-prefetch.electron.test.ts` and can beacon grant-readable bytes in DNS labels.
*/
const DOC_PREVIEW_CONTENT_SECURITY_POLICY = [
"default-src 'self'",
@@ -120,12 +122,12 @@ export function installDocPreviewProtocolHandler(): void {
}
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.
// carry network requests even if an element bypasses CSP. DNS-prefetch does not reach this hook.
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.
// Why: the shared installer still owns certificate, UA, download and permission hooks, while
// preview content receives no ambient browser/device permissions.
installBrowserSessionPartitionPolicies(
{
id: DOC_PREVIEW_PARTITION,
@@ -139,6 +141,6 @@ export function installDocPreviewProtocolHandler(): void {
// 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' }
{ downloads: 'deny', permissions: 'deny' }
)
}
@@ -37,6 +37,7 @@ import {
revokeAllDocPreviewGrants
} from '../browser/doc-preview-grant-registry'
import {
DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL,
DOC_PREVIEW_LINK_CLICK_CHANNEL,
DOC_PREVIEW_MINT_GRANT_CHANNEL,
DOC_PREVIEW_REVOKE_GRANT_CHANNEL,
@@ -45,6 +46,7 @@ import {
const REQUEST: DocPreviewGrantRequest = {
owner: { kind: 'ssh', connectionId: 'ssh-1' },
requestBase: '/home/alice/docs',
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: 'doc-page-1'
@@ -68,6 +70,14 @@ function revoke(grantId: string): boolean {
return handler({ sender }, grantId) as boolean
}
function authorize(grantId: unknown, relativePath: unknown): boolean {
const handler = mocks.handlers.get(DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL)
if (!handler) {
throw new Error('authorize handler not registered')
}
return handler({ sender }, grantId, relativePath) as boolean
}
function reportLinkClick(url: unknown): void {
const listener = mocks.listeners.get(DOC_PREVIEW_LINK_CLICK_CHANNEL)
if (!listener) {
@@ -137,6 +147,31 @@ describe('document preview grant handlers', () => {
expect(getDocPreviewGrant(result.grantId)).not.toBeNull()
})
it('lets only the trusted renderer authorize a valid requested directory', () => {
const result = mint({
...REQUEST,
requestBase: '/home/alice',
root: '/home/alice/docs',
entryRelativePath: 'docs/index.html'
})
expect(authorize(result.grantId, 'assets/app.js')).toBe(true)
expect(getDocPreviewGrant(result.grantId)?.authorizedRoots).toEqual(['/home/alice/assets'])
mocks.isTrustedBrowserRenderer.mockReturnValue(false)
expect(authorize(result.grantId, 'secrets/token.txt')).toBe(false)
expect(getDocPreviewGrant(result.grantId)?.authorizedRoots).toEqual(['/home/alice/assets'])
})
it('refuses malformed directory authorization arguments', () => {
const result = mint()
expect(authorize(result.grantId, '../secret.txt')).toBe(false)
expect(authorize(result.grantId, null)).toBe(false)
expect(authorize(null, 'assets/app.js')).toBe(false)
expect(authorize('0'.repeat(32), 'assets/app.js')).toBe(false)
})
// 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.
+17 -3
View File
@@ -1,6 +1,7 @@
import { ipcMain } from 'electron'
import {
buildDocPreviewUrl,
DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL,
DOC_PREVIEW_LINK_CLICK_CHANNEL,
DOC_PREVIEW_MINT_GRANT_CHANNEL,
DOC_PREVIEW_REVOKE_GRANT_CHANNEL
@@ -8,6 +9,7 @@ import {
import { browserManager } from '../browser/browser-manager'
import { reportDocPreviewLinkClick } from '../browser/doc-preview-guest-policy'
import {
authorizeDocPreviewDirectory,
mintDocPreviewGrant,
revokeDocPreviewGrant,
type DocPreviewOwner
@@ -16,9 +18,11 @@ import { isTrustedBrowserRenderer } from './browser-renderer-trust'
export type DocPreviewGrantRequest = {
owner: DocPreviewOwner
/** Directory relative preview URLs resolve against. */
requestBase: string
/** Containing directory of the opened document, on the owning host. */
root: string
/** Opened document, relative to `root`. */
/** Opened document, relative to `requestBase`. */
entryRelativePath: string
/** Browser page the reader is opening the document in; main registers the guest under it. */
browserPageId: string
@@ -27,7 +31,7 @@ export type DocPreviewGrantRequest = {
export type DocPreviewGrantResult = { grantId: string; url: string }
function isValidGrantRequest(request: DocPreviewGrantRequest): boolean {
if (!request.root.trim() || !request.entryRelativePath.trim()) {
if (!request.requestBase.trim() || !request.root.trim() || !request.entryRelativePath.trim()) {
return false
}
if (typeof request.browserPageId !== 'string' || !request.browserPageId.trim()) {
@@ -69,6 +73,7 @@ export function registerDocPreviewGrantHandlers(): void {
}
const grant = mintDocPreviewGrant({
owner: request.owner,
requestBase: request.requestBase,
root: request.root,
entryRelativePath: request.entryRelativePath,
browserPageId: request.browserPageId
@@ -84,9 +89,18 @@ export function registerDocPreviewGrantHandlers(): void {
isTrustedBrowserRenderer(event.sender) ? revokeDocPreviewGrant(grantId) : false
)
ipcMain.handle(
DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL,
(event, grantId: unknown, relativePath: unknown): boolean =>
isTrustedBrowserRenderer(event.sender) &&
typeof grantId === 'string' &&
typeof relativePath === 'string' &&
authorizeDocPreviewDirectory(grantId, relativePath)
)
// 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.
// live bound grant and 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)
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import {
mergeWorkspaceSessions,
removeRepoFromWorkspaceSession
@@ -84,6 +85,57 @@ describe('profile project session state', () => {
)
})
it('rekeys document-preview ownership during project transfer', () => {
const session = {
...getDefaultWorkspaceSession(),
browserTabsByWorktree: {
[REMOVED_WORKTREE_ID]: [
{
id: 'browser-1',
worktreeId: REMOVED_WORKTREE_ID,
docLocation: {
kind: 'workspace-doc',
worktreeId: REMOVED_WORKTREE_ID,
filePath: '/removed/docs/report.html'
}
}
]
},
browserPagesByWorkspace: {
'browser-1': [
{
id: 'page-1',
workspaceId: 'browser-1',
worktreeId: REMOVED_WORKTREE_ID,
docLocation: {
kind: 'workspace-doc',
worktreeId: REMOVED_WORKTREE_ID,
filePath: '/removed/docs/report.html'
}
}
]
}
}
const result = extractSessionForTransfer(
session as unknown as WorkspaceSessionState,
REMOVED_REPO_ID,
'repo-c'
)
const transferredWorktreeId = 'repo-c::/removed'
expect(result.browserTabsByWorktree?.[transferredWorktreeId]?.[0]?.docLocation).toEqual({
kind: 'workspace-doc',
worktreeId: transferredWorktreeId,
filePath: '/removed/docs/report.html'
})
expect(result.browserPagesByWorkspace?.['browser-1']?.[0]?.docLocation).toEqual({
kind: 'workspace-doc',
worktreeId: transferredWorktreeId,
filePath: '/removed/docs/report.html'
})
})
it('prunes terminal membership authority records with a removed repo', () => {
const session = {
...getDefaultWorkspaceSession(),
@@ -1,6 +1,11 @@
import { getDefaultWorkspaceSession } from '../../shared/constants'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { BrowserPage, BrowserWorkspace } from '../../shared/browser-workspace-types'
import type {
BrowserPage,
BrowserPageDocLocation,
BrowserWorkspace
} from '../../shared/browser-workspace-types'
import { remapBrowserPageDocLocation } from '../../shared/browser-page-doc-location'
import type { Tab, TabGroup } from '../../shared/tab-types'
import type { TerminalTab } from '../../shared/terminal-tab-types'
import type {
@@ -175,6 +180,9 @@ function rekeyBrowserWorkspace(
return {
...structuredClone(workspace),
worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, workspace.worktreeId),
...(workspace.docLocation
? { docLocation: rekeyBrowserDocLocation(workspace.docLocation, oldRepoId, newRepoId) }
: {}),
// Why: both the session profile and the resolved partition string are
// source-profile-scoped; carrying either across would point the restored
// pane at a partition the target profile's allowlist rejects.
@@ -186,10 +194,22 @@ function rekeyBrowserWorkspace(
function rekeyBrowserPage(page: BrowserPage, oldRepoId: string, newRepoId: string): BrowserPage {
return {
...structuredClone(page),
worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, page.worktreeId)
worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, page.worktreeId),
...(page.docLocation
? { docLocation: rekeyBrowserDocLocation(page.docLocation, oldRepoId, newRepoId) }
: {})
}
}
function rekeyBrowserDocLocation(
location: BrowserPageDocLocation,
oldRepoId: string,
newRepoId: string
): BrowserPageDocLocation {
const nextWorktreeId = rekeyWorktreeId(oldRepoId, newRepoId, location.worktreeId)
return remapBrowserPageDocLocation(location, location.worktreeId, nextWorktreeId)
}
function copyBrowserPages(
pagesByWorkspace: Record<string, BrowserPage[]> | undefined,
workspaceIds: ReadonlySet<string>,
@@ -243,10 +243,33 @@ describe('Store.migrateWorktreeIdentity', () => {
},
activeFileIdByWorktree: { [OLD]: '/ws/cunner/a.ts' },
browserTabsByWorktree: {
[OLD]: [{ id: 'browser1', worktreeId: OLD, title: 'Browser', url: 'about:blank' }]
[OLD]: [
{
id: 'browser1',
worktreeId: OLD,
title: 'Browser',
url: 'about:blank',
docLocation: {
kind: 'workspace-doc',
worktreeId: OLD,
filePath: '/ws/cunner/docs/report.html'
}
}
]
},
browserPagesByWorkspace: {
browser1: [{ id: 'page1', workspaceId: 'browser1', worktreeId: OLD }]
browser1: [
{
id: 'page1',
workspaceId: 'browser1',
worktreeId: OLD,
docLocation: {
kind: 'workspace-doc',
worktreeId: OLD,
filePath: '/ws/cunner/docs/report.html'
}
}
]
},
activeBrowserTabIdByWorktree: { [OLD]: 'browser1' },
activeTabTypeByWorktree: { [OLD]: 'browser' },
@@ -327,6 +350,16 @@ describe('Store.migrateWorktreeIdentity', () => {
expect(session.browserTabsByWorktree?.[OLD]).toBeUndefined()
expect(session.browserTabsByWorktree?.[NEW]?.[0]?.worktreeId).toBe(NEW)
expect(session.browserPagesByWorkspace?.browser1?.[0]?.worktreeId).toBe(NEW)
expect(session.browserTabsByWorktree?.[NEW]?.[0]?.docLocation).toEqual({
kind: 'workspace-doc',
worktreeId: NEW,
filePath: '/ws/worktree-creation-spinner/docs/report.html'
})
expect(session.browserPagesByWorkspace?.browser1?.[0]?.docLocation).toEqual({
kind: 'workspace-doc',
worktreeId: NEW,
filePath: '/ws/worktree-creation-spinner/docs/report.html'
})
expect(session.activeBrowserTabIdByWorktree?.[NEW]).toBe('browser1')
expect(session.activeTabTypeByWorktree?.[NEW]).toBe('browser')
expect(session.activeWorktreeId).toBe(NEW)
@@ -1,4 +1,6 @@
import type { WorkspaceKey } from '../../../shared/folder-workspace-types'
import type { BrowserPage, BrowserWorkspace } from '../../../shared/browser-workspace-types'
import { remapBrowserPageDocLocation } from '../../../shared/browser-page-doc-location'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { worktreeWorkspaceKey } from '../../../shared/workspace-scope'
import type { PersistedState } from '../../../shared/persisted-state-types'
@@ -6,6 +8,7 @@ import {
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from '../../../shared/worktree/host-qualified-identity'
import { splitWorktreeIdForFilesystem } from '../../../shared/worktree/id'
/**
* Re-keys every worktreeId-keyed record in `state` from `oldWorktreeId` to `newWorktreeId`. Mutates `state` in place;
@@ -35,6 +38,23 @@ export function migrateWorktreeIdentity(
}
const withNewWorktreeId = <T extends { worktreeId: string }>(value: T): T =>
value.worktreeId === oldWorktreeId ? { ...value, worktreeId: newWorktreeId } : value
const oldWorktreePath = splitWorktreeIdForFilesystem(oldWorktreeId)?.worktreePath
const newWorktreePath = splitWorktreeIdForFilesystem(newWorktreeId)?.worktreePath
const withNewBrowserWorktreeId = <T extends BrowserPage | BrowserWorkspace>(value: T): T => {
const renamedValue = withNewWorktreeId(value)
return value.docLocation?.worktreeId === oldWorktreeId
? {
...renamedValue,
docLocation: remapBrowserPageDocLocation(
value.docLocation,
oldWorktreeId,
newWorktreeId,
oldWorktreePath,
newWorktreePath
)
}
: renamedValue
}
const migrateSession = (session: WorkspaceSessionState | undefined): boolean => {
if (!session) {
return false
@@ -72,16 +92,21 @@ export function migrateWorktreeIdentity(
sessionChanged = moveSessionKey(session.activeFileIdByWorktree) || sessionChanged
sessionChanged =
moveSessionKey(session.browserTabsByWorktree, (workspaces) =>
workspaces.map(withNewWorktreeId)
workspaces.map(withNewBrowserWorktreeId)
) || sessionChanged
if (session.browserPagesByWorkspace) {
let pagesChanged = false
const nextPagesByWorkspace = { ...session.browserPagesByWorkspace }
for (const [workspaceId, pages] of Object.entries(nextPagesByWorkspace)) {
if (!pages.some((page) => page.worktreeId === oldWorktreeId)) {
if (
!pages.some(
(page) =>
page.worktreeId === oldWorktreeId || page.docLocation?.worktreeId === oldWorktreeId
)
) {
continue
}
nextPagesByWorkspace[workspaceId] = pages.map(withNewWorktreeId)
nextPagesByWorkspace[workspaceId] = pages.map(withNewBrowserWorktreeId)
pagesChanged = true
}
if (pagesChanged) {
+4
View File
@@ -11,6 +11,9 @@ export type DocPreviewGrantOwner =
export type DocPreviewGrantRequest = {
owner: DocPreviewGrantOwner
/** Directory that relative preview URLs resolve against. */
requestBase: string
/** Initial filesystem authority; always the opened document's directory. */
root: string
entryRelativePath: string
/** Browser page the document is being opened in; main registers its guest under this id. */
@@ -21,6 +24,7 @@ export type DocPreviewApi = {
docPreview: {
mintGrant: (request: DocPreviewGrantRequest) => Promise<{ grantId: string; url: string }>
revokeGrant: (grantId: string) => Promise<boolean>
authorizeDirectory: (grantId: string, relativePath: 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. */
+1 -1
View File
@@ -9,7 +9,7 @@ const { ipcRenderer } = require('electron') as {
}
// 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.
// isolated world, and main still refuses any report that does not come from a live, grant-bound preview guest.
installDocPreviewLinkInterception((url) => {
ipcRenderer.send(PRELOAD_DOC_PREVIEW_LINK_CLICK_CHANNEL, url)
})
+3
View File
@@ -11,6 +11,7 @@ import type {
import {
DOC_PREVIEW_EXTERNAL_LINK_CHANNEL,
DOC_PREVIEW_LOAD_FAILURE_CHANNEL,
DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL,
DOC_PREVIEW_MINT_GRANT_CHANNEL,
DOC_PREVIEW_REVOKE_GRANT_CHANNEL,
type DocPreviewFailure
@@ -3335,6 +3336,8 @@ const api = {
ipcRenderer.invoke(DOC_PREVIEW_MINT_GRANT_CHANNEL, request),
revokeGrant: (grantId: string): Promise<boolean> =>
ipcRenderer.invoke(DOC_PREVIEW_REVOKE_GRANT_CHANNEL, grantId),
authorizeDirectory: (grantId: string, relativePath: string): Promise<boolean> =>
ipcRenderer.invoke(DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL, grantId, relativePath),
onExternalLink: (callback: (payload: { url: string }) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: { url: string }): void =>
callback(payload)
+2
View File
@@ -3,6 +3,7 @@ import { Toaster } from '@/components/ui/sonner'
import { TooltipProvider } from '@/components/ui/tooltip'
import { ConfirmationDialogProvider } from './components/confirmation-dialog'
import { BrowserWebAuthnAccountDialog } from './components/browser-webauthn-account-dialog'
import { DocPreviewExternalLinkConfirmation } from './components/browser-pane/workspace-doc/doc-preview-external-link-confirmation'
import { LinkRoutingPreferenceDialogProvider } from './components/link-routing-preference-dialog'
import { SkillFreshnessNudge } from './components/skills/SkillFreshnessNudge'
import PinnedTabCloseDialog from './components/terminal-pane/PinnedTabCloseDialog'
@@ -90,6 +91,7 @@ function App(): React.JSX.Element {
>
<TooltipProvider delayDuration={400}>
<ConfirmationDialogProvider>
<DocPreviewExternalLinkConfirmation />
<LinkRoutingPreferenceDialogProvider>
<AppBackgroundServices />
<AppWorkspaceShell layout={layout} floatingWorkspace={floatingWorkspace} />
@@ -71,7 +71,11 @@ export function BrowserReloadControl({
onMenuOpenChange(true)
}}
>
{loading ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
{loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
@@ -18,7 +18,11 @@ 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[] }))
const grantRuntime = vi.hoisted(() => ({
mints: 0,
released: [] as string[],
authorizations: [] as { grantId: string; relativePath: string }[]
}))
vi.mock('@/lib/doc-preview-grants', () => ({
buildDocPreviewGrantRequest: () => ({
@@ -28,6 +32,7 @@ vi.mock('@/lib/doc-preview-grants', () => ({
worktreeSelector: 'id:wt-1',
worktreeRoot: '/repo'
},
requestBase: '/repo',
root: '/repo/docs',
entryRelativePath: ENTRY_RELATIVE_PATH
}),
@@ -110,8 +115,13 @@ describe('HtmlDocPreview failure messages', () => {
failureListeners.length = 0
grantRuntime.mints = 0
grantRuntime.released = []
grantRuntime.authorizations = []
;(window as unknown as { api: unknown }).api = {
docPreview: {
authorizeDirectory: (grantId: string, relativePath: string) => {
grantRuntime.authorizations.push({ grantId, relativePath })
return Promise.resolve(true)
},
onLoadFailure: (callback: (payload: DocPreviewFailure) => void) => {
failureListeners.push(callback)
return () => {
@@ -203,6 +213,180 @@ describe('HtmlDocPreview failure messages', () => {
expect(container.textContent).not.toContain('files in this document')
})
it('caps document-authored failure rows', async () => {
await renderPreview(container, root)
await act(async () => {
for (let index = 0; index < 75; index += 1) {
emitFailure({
grantId: GRANT_ID,
relativePath: `assets/missing-${index}.png`,
reason: 'unreadable'
})
}
})
expect(container.textContent).toContain('50 files in this document could not be loaded.')
expect(container.textContent).not.toContain('75 files in this document could not be loaded.')
})
it('asks before reading a directory outside the document folder', async () => {
await renderPreview(container, root)
await act(async () => {
emitFailure({
grantId: GRANT_ID,
relativePath: 'assets/app.js',
reason: 'authorization-required'
})
})
expect(container.textContent).toContain('This preview wants to read files in assets.')
expect(container.textContent).toContain('Dismiss')
expect(container.textContent).toContain('Allow folder')
expect(grantRuntime.authorizations).toEqual([])
})
it('names the full workspace root when the document asks for a root file', async () => {
await renderPreview(container, root)
await act(async () => {
emitFailure({
grantId: GRANT_ID,
relativePath: '.env',
reason: 'authorization-required'
})
})
expect(container.textContent).toContain('This preview wants to read files in /repo.')
})
it('authorizes only after Allow folder and reloads the guest', async () => {
await renderPreview(container, root)
const reload = vi.fn()
Object.assign(container.querySelector('webview')!, { reload })
await act(async () => {
emitFailure({
grantId: GRANT_ID,
relativePath: 'assets/app.js',
reason: 'authorization-required'
})
})
const allowButton = [...container.querySelectorAll('button')].find(
(button) => button.textContent?.trim() === 'Allow folder'
)
await act(async () => {
allowButton?.click()
})
expect(grantRuntime.authorizations).toEqual([
{ grantId: GRANT_ID, relativePath: 'assets/app.js' }
])
expect(reload).toHaveBeenCalledOnce()
expect(container.textContent).not.toContain('This preview wants to read files in assets.')
})
it('batches folders blocked in one load into a single decision', async () => {
await renderPreview(container, root)
const reload = vi.fn()
Object.assign(container.querySelector('webview')!, { reload })
await act(async () => {
emitFailure({
grantId: GRANT_ID,
relativePath: 'assets/app.js',
reason: 'authorization-required'
})
emitFailure({
grantId: GRANT_ID,
relativePath: 'assets/theme.css',
reason: 'authorization-required'
})
emitFailure({
grantId: GRANT_ID,
relativePath: 'data/rows.json',
reason: 'authorization-required'
})
})
expect(container.textContent).toContain('This preview wants to read files in assets and data.')
const allowButton = [...container.querySelectorAll('button')].find(
(button) => button.textContent?.trim() === 'Allow 2 folders'
)
expect(allowButton).toBeDefined()
await act(async () => {
allowButton?.click()
})
// One decision grants exactly the named set, then one reload picks it all up.
expect(grantRuntime.authorizations).toEqual([
{ grantId: GRANT_ID, relativePath: 'assets/app.js' },
{ grantId: GRANT_ID, relativePath: 'data/rows.json' }
])
expect(reload).toHaveBeenCalledOnce()
expect(container.textContent).not.toContain('This preview wants to read files in')
})
it('dismisses every folder the banner named, not just the first', async () => {
await renderPreview(container, root)
await act(async () => {
emitFailure({
grantId: GRANT_ID,
relativePath: 'assets/app.js',
reason: 'authorization-required'
})
emitFailure({
grantId: GRANT_ID,
relativePath: 'data/rows.json',
reason: 'authorization-required'
})
})
const dismissButton = [...container.querySelectorAll('button')].find(
(button) => button.textContent?.trim() === 'Dismiss'
)
await act(async () => {
dismissButton?.click()
emitFailure({
grantId: GRANT_ID,
relativePath: 'data/other.json',
reason: 'authorization-required'
})
})
expect(container.textContent).not.toContain('This preview wants to read files in')
expect(grantRuntime.authorizations).toEqual([])
})
it('does not reprompt for a dismissed directory during the grant lifetime', async () => {
await renderPreview(container, root)
await act(async () => {
emitFailure({
grantId: GRANT_ID,
relativePath: 'assets/app.js',
reason: 'authorization-required'
})
})
const dismissButton = [...container.querySelectorAll('button')].find(
(button) => button.textContent?.trim() === 'Dismiss'
)
await act(async () => {
dismissButton?.click()
emitFailure({
grantId: GRANT_ID,
relativePath: 'assets/theme.css',
reason: 'authorization-required'
})
})
expect(container.textContent).not.toContain('This preview wants to read files in assets.')
expect(grantRuntime.authorizations).toEqual([])
})
// 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)
@@ -484,9 +484,8 @@ describe('HtmlDocPreview browser chrome', () => {
})
})
// 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.
// Why this is a test and not left to the pane: a preview has no address bar to hand focus to the
// document, so without this its keyboard and link input can silently land outside the visible guest.
describe('HtmlDocPreview guest focus', () => {
let container: HTMLDivElement
let root: Root
@@ -19,6 +19,10 @@ import { selectWorktreeHostDisplayLabel } from '@/lib/execution-host-display-lab
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { openDocPreviewExternally, openDocPreviewSource } from './doc-preview-document-actions'
import {
DocPreviewDirectoryAccessBanner,
useDocPreviewDirectoryAccess
} from './doc-preview-directory-access'
import { buildDocPreviewDocumentIdentity } from './doc-preview-document-identity'
import {
docPreviewAssetNotice,
@@ -108,6 +112,7 @@ function attachDocPreviewWebview({
/** Frames a preview keeps offering focus to a guest that is still attaching. */
const GUEST_FOCUS_FRAMES = 10
const MAX_ASSET_FAILURES = 50
export function HtmlDocPreview({
previewId,
@@ -136,6 +141,14 @@ export function HtmlDocPreview({
const [downloadBlocked, setDownloadBlocked] = useState(false)
const [remintCount, setRemintCount] = useState(0)
const [grantId, setGrantId] = useState<string | null>(null)
const {
requests: accessRequests,
busy: accessRequestBusy,
offer: offerDirectoryAccess,
reset: resetDirectoryAccess,
dismiss: dismissDirectoryAccess,
allow: allowDirectoryAccess
} = useDocPreviewDirectoryAccess({ grantId, reloadRef })
const history = useDocPreviewWebviewHistory(webviewRef)
const { sync: syncHistory, reset: resetHistory } = history
@@ -193,6 +206,7 @@ export function HtmlDocPreview({
setState('loading')
setFailureReason(null)
setAssetFailures([])
resetDirectoryAccess()
setDownloadBlocked(false)
setGrantId(null)
resetHistory()
@@ -216,11 +230,16 @@ export function HtmlDocPreview({
setDownloadBlocked(true)
return
}
if (payload.reason === 'authorization-required') {
offerDirectoryAccess(payload)
return
}
if (payload.relativePath === request.entryRelativePath) {
setFailureReason(payload.reason)
return
}
setAssetFailures((current) =>
current.length >= MAX_ASSET_FAILURES ||
current.some((failure) => failure.relativePath === payload.relativePath)
? current
: [...current, payload]
@@ -267,12 +286,19 @@ export function HtmlDocPreview({
unsubscribeFailure?.()
detach?.()
}
}, [filePath, previewId, remintCount, resetHistory, syncHistory, worktreeId])
}, [
filePath,
offerDirectoryAccess,
previewId,
remintCount,
resetDirectoryAccess,
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.
// Why the guest is handed focus: a preview has no address bar to make the usual handoff, so a
// surfaced document would otherwise look active while its keyboard and link input land elsewhere.
useEffect(() => {
if (!holdsGuestFocus || state !== 'ready') {
return
@@ -377,6 +403,15 @@ export function HtmlDocPreview({
<span className="min-w-0 flex-1 truncate">{notice}</span>
</div>
))}
{accessRequests.length > 0 && !isUnavailable ? (
<DocPreviewDirectoryAccessBanner
requests={accessRequests}
busy={accessRequestBusy}
worktreeRoot={worktreeRoot}
onDismiss={dismissDirectoryAccess}
onAllow={allowDirectoryAccess}
/>
) : null}
<div className="relative flex min-h-0 flex-1 overflow-hidden" ref={containerRef}>
<BrowserGuestAnnotateOverlays
markup={markup}
@@ -0,0 +1,173 @@
import { useCallback, useRef, useState, type RefObject } from 'react'
import { AlertCircle, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import type { DocPreviewFileFailure } from '../../../../../shared/doc-preview-scheme'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
function requestedDirectory(relativePath: string): string {
const separator = relativePath.lastIndexOf('/')
return separator === -1 ? '.' : relativePath.slice(0, separator)
}
function requestedDirectoryLabel(relativePath: string, worktreeRoot: string | null): string {
const directory = requestedDirectory(relativePath)
return directory === '.' && worktreeRoot ? worktreeRoot : directory
}
function reportAuthorizationFailure(): void {
toast.error(
translate(
'auto.components.editor.HtmlDocPreview.directoryAuthorizationFailed',
'Could not allow access to this directory.'
)
)
}
/**
* All blocked folders a load has surfaced so far, batched into one decision: a reader cannot
* meaningfully judge `assets/` and `data/` separately, and N sequential banners only train the
* allow reflex. What is granted is exactly the set named on the banner, never more.
*/
export function useDocPreviewDirectoryAccess({
grantId,
reloadRef
}: {
grantId: string | null
reloadRef: RefObject<(() => void) | null>
}): {
requests: DocPreviewFileFailure[]
busy: boolean
offer: (failure: DocPreviewFileFailure) => void
reset: () => void
dismiss: () => void
allow: () => Promise<void>
} {
// Why a ref plus a version tick and not state alone: dismiss must fence the directory against
// an offer landing in the same event batch, which a state updater sees one render too late.
const requestsByDirectoryRef = useRef(new Map<string, DocPreviewFileFailure>())
const [, setRequestsVersion] = useState(0)
const [busy, setBusy] = useState(false)
const dismissedDirectoriesRef = useRef(new Set<string>())
const offer = useCallback((failure: DocPreviewFileFailure) => {
const directory = requestedDirectory(failure.relativePath)
if (
dismissedDirectoriesRef.current.has(directory) ||
requestsByDirectoryRef.current.has(directory)
) {
return
}
requestsByDirectoryRef.current.set(directory, failure)
setRequestsVersion((version) => version + 1)
}, [])
const reset = useCallback(() => {
requestsByDirectoryRef.current = new Map()
dismissedDirectoriesRef.current.clear()
setBusy(false)
setRequestsVersion((version) => version + 1)
}, [])
const dismiss = useCallback(() => {
for (const directory of requestsByDirectoryRef.current.keys()) {
dismissedDirectoriesRef.current.add(directory)
}
requestsByDirectoryRef.current = new Map()
setRequestsVersion((version) => version + 1)
}, [])
const allow = useCallback(async () => {
const pending = [...requestsByDirectoryRef.current.values()]
if (pending.length === 0 || !grantId || busy) {
return
}
setBusy(true)
try {
for (const failure of pending) {
if (!(await window.api.docPreview.authorizeDirectory(grantId, failure.relativePath))) {
reportAuthorizationFailure()
return
}
}
requestsByDirectoryRef.current = new Map()
setRequestsVersion((version) => version + 1)
reloadRef.current?.()
} catch {
reportAuthorizationFailure()
} finally {
setBusy(false)
}
}, [busy, grantId, reloadRef])
return {
requests: [...requestsByDirectoryRef.current.values()],
busy,
offer,
reset,
dismiss,
allow
}
}
const MAX_NAMED_FOLDERS = 3
function requestedFolderSentence(labels: string[]): string {
if (labels.length === 1) {
return translate(
'auto.components.editor.HtmlDocPreview.directoryAccessRequest',
'This preview wants to read files in {{path}}.',
{ path: labels[0] }
)
}
const named = labels.slice(0, MAX_NAMED_FOLDERS)
const remainder = labels.length - named.length
const folders =
remainder > 0
? translate(
'auto.components.editor.HtmlDocPreview.directoryAccessRequestOverflow',
'{{folders}}, and {{count}} more',
{ folders: named.join(', '), count: remainder }
)
: `${named.slice(0, -1).join(', ')} and ${named.at(-1)}`
return translate(
'auto.components.editor.HtmlDocPreview.directoryAccessRequestMultiple',
'This preview wants to read files in {{folders}}.',
{ folders }
)
}
export function DocPreviewDirectoryAccessBanner({
requests,
busy,
worktreeRoot,
onDismiss,
onAllow
}: {
requests: DocPreviewFileFailure[]
busy: boolean
worktreeRoot: string | null
onDismiss: () => void
onAllow: () => Promise<void>
}): React.JSX.Element {
const labels = requests.map((request) =>
requestedDirectoryLabel(request.relativePath, worktreeRoot)
)
return (
<div className="flex shrink-0 items-center gap-2 border-b px-2 py-1 text-xs" role="status">
<AlertCircle className="size-3.5 shrink-0 text-muted-foreground" />
{/* The title carries every folder in full, for when the sentence truncates past three. */}
<span className="min-w-0 flex-1 text-muted-foreground" title={labels.join('\n')}>
{requestedFolderSentence(labels)}
</span>
<Button type="button" variant="ghost" size="xs" disabled={busy} onClick={onDismiss}>
{translate('auto.components.editor.HtmlDocPreview.dismissAccessRequest', 'Dismiss')}
</Button>
<Button type="button" size="xs" disabled={busy} onClick={() => void onAllow()}>
{busy ? <Loader2 className="size-3 animate-spin" /> : null}
{labels.length === 1
? translate('auto.components.editor.HtmlDocPreview.allowDirectory', 'Allow folder')
: translate(
'auto.components.editor.HtmlDocPreview.allowDirectories',
'Allow {{count}} folders',
{ count: labels.length }
)}
</Button>
</div>
)
}
@@ -35,7 +35,7 @@ export function DocPreviewDocumentChip({
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"
className="@container flex min-w-0 flex-1 items-center gap-2 overflow-hidden 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" />
@@ -47,14 +47,21 @@ export function DocPreviewDocumentChip({
<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'
)}
{/* Below 24rem of chip width this row hides whole rather than clipping into slivers —
384px is what icon + label + a capped badge + a readable path stub need, so visible
implies contained. The tooltip keeps the full identity either way. */}
<span className="hidden shrink-0 items-center gap-1.5 text-xs text-muted-foreground @[24rem]:flex">
<span className="shrink-0">
{translate(
'auto.components.editor.HtmlDocPreview.workspaceFileChipLabel',
'Workspace file'
)}
</span>
{identity.hostLabel ? (
<Badge variant="secondary" className="max-w-40 truncate font-normal">
{identity.hostLabel}
<Badge variant="secondary" className="min-w-0 max-w-40 shrink font-normal">
{/* Why the inner span: text directly inside the flex pill clips both ends with no
ellipsis — text-overflow needs a non-flex text box. */}
<span className="min-w-0 truncate">{identity.hostLabel}</span>
</Badge>
) : null}
</span>
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ConfirmationDialogContextValue } from '@/components/confirmation-dialog-context'
const mocks = vi.hoisted(() => ({
toastError: vi.fn(),
openBrowserProfileTabInActiveWorkspace: vi.fn()
}))
vi.mock('sonner', () => ({ toast: { error: mocks.toastError } }))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => ({
openBrowserProfileTabInActiveWorkspace: mocks.openBrowserProfileTabInActiveWorkspace
})
}
}))
import { subscribeDocPreviewExternalLinkConfirmation } from './doc-preview-external-link-confirmation'
function installSubscription(confirm: ConfirmationDialogContextValue): (url: string) => void {
let listener: ((payload: { url: string }) => void) | null = null
vi.stubGlobal('window', {
api: {
docPreview: {
onExternalLink: (callback: (payload: { url: string }) => void): (() => void) => {
listener = callback
return () => {}
}
}
}
})
subscribeDocPreviewExternalLinkConfirmation(confirm)
if (!listener) {
throw new Error('external-link confirmation did not subscribe')
}
return (url) => listener?.({ url })
}
beforeEach(() => {
vi.clearAllMocks()
mocks.openBrowserProfileTabInActiveWorkspace.mockResolvedValue(true)
})
describe('document preview external-link confirmation', () => {
it('shows the exact destination and opens only after confirmation', async () => {
const confirm = vi.fn<ConfirmationDialogContextValue>().mockResolvedValue(true)
const emit = installSubscription(confirm)
emit('https://example.com/docs?source=preview')
await vi.waitFor(() => expect(confirm).toHaveBeenCalledOnce())
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Open link to example.com?',
description: 'https://example.com/docs?source=preview'
})
)
await vi.waitFor(() =>
expect(mocks.openBrowserProfileTabInActiveWorkspace).toHaveBeenCalledWith(
'https://example.com/docs?source=preview',
null
)
)
})
it('opens nothing when the reader cancels', async () => {
const confirm = vi.fn<ConfirmationDialogContextValue>().mockResolvedValue(false)
const emit = installSubscription(confirm)
emit('https://example.com/docs')
await vi.waitFor(() => expect(confirm).toHaveBeenCalledOnce())
expect(mocks.openBrowserProfileTabInActiveWorkspace).not.toHaveBeenCalled()
})
it('surfaces a confirmed link that the browser refuses', async () => {
mocks.openBrowserProfileTabInActiveWorkspace.mockResolvedValue(false)
const emit = installSubscription(
vi.fn<ConfirmationDialogContextValue>().mockResolvedValue(true)
)
emit('https://example.com/docs')
await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
})
})
@@ -0,0 +1,75 @@
import { useEffect } from 'react'
import { toast } from 'sonner'
import {
useConfirmationDialog,
type ConfirmationDialogContextValue
} from '@/components/confirmation-dialog-context'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
function displayHost(url: string): string {
try {
return new URL(url).host
} catch {
return url
}
}
async function openConfirmedExternalLink(url: string): Promise<void> {
try {
const opened = await useAppStore.getState().openBrowserProfileTabInActiveWorkspace(url, null)
if (opened) {
return
}
} catch {
// The same reader-facing result covers refusal and rejection.
}
toast.error(
translate(
'auto.hooks.ipc.events.browserStateIpcBridge.docPreviewLinkFailed',
'Could not open this link in Orca Browser.'
)
)
}
export function subscribeDocPreviewExternalLinkConfirmation(
confirm: ConfirmationDialogContextValue
): () => void {
if (typeof window.api.docPreview?.onExternalLink !== 'function') {
return () => {}
}
let active = true
const unsubscribe = window.api.docPreview.onExternalLink(({ url }) => {
void confirm({
title: translate(
'auto.components.browserPane.workspaceDoc.externalLinkTitle',
'Open link to {{host}}?',
{ host: displayHost(url) }
),
description: url,
descriptionClassName: 'break-all font-mono text-xs',
confirmLabel: translate(
'auto.components.browserPane.workspaceDoc.externalLinkConfirm',
'Open link'
),
cancelLabel: translate(
'auto.components.browserPane.workspaceDoc.externalLinkCancel',
'Cancel'
)
}).then((confirmed) => {
if (active && confirmed) {
void openConfirmedExternalLink(url)
}
})
})
return () => {
active = false
unsubscribe()
}
}
export function DocPreviewExternalLinkConfirmation(): null {
const confirm = useConfirmationDialog()
useEffect(() => subscribeDocPreviewExternalLinkConfirmation(confirm), [confirm])
return null
}
@@ -5,6 +5,7 @@ import { createContext, useContext } from 'react'
export type ConfirmationDialogOptions = {
title: string
description?: string
descriptionClassName?: string
confirmLabel?: string
cancelLabel?: string
confirmVariant?: 'default' | 'destructive'
@@ -18,6 +18,7 @@ import {
} from '@/components/confirmation-dialog-context'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
type ConfirmationDialogRequest = {
id: number
@@ -100,7 +101,9 @@ export function ConfirmationDialogProvider({
<DialogTitle>{displayedRequest?.options.title}</DialogTitle>
{displayedRequest?.options.description ? (
// Callers pass multi-line descriptions (e.g. one path per line).
<DialogDescription className="whitespace-pre-line">
<DialogDescription
className={cn('whitespace-pre-line', displayedRequest.options.descriptionClassName)}
>
{displayedRequest.options.description}
</DialogDescription>
) : null}
@@ -1,6 +1,4 @@
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'
@@ -107,31 +105,4 @@ 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)
})
)
}
}
@@ -1,92 +0,0 @@
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,7 +15,6 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [
'browser.onNavigationUpdate',
'browser.onOpenLinkInOrcaTab',
'browser.onPaneFocus',
'docPreview.onExternalLink',
'emulator.onAutoAttach',
'emulator.onPaneFocus',
'gh.onPRRefreshEvent',
@@ -160,7 +159,6 @@ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [
'browser.onActivateView',
'browser.onPaneFocus',
'browser.onOpenLinkInOrcaTab',
'docPreview.onExternalLink',
'ui.onNewBrowserTab',
'ui.onNewMarkdownTab',
'ui.onNewSimulatorTab',
+15 -1
View File
@@ -14811,7 +14811,14 @@
"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."
"downloadBlockedNotice": "Downloads are disabled in document previews.",
"directoryAuthorizationFailed": "Could not allow access to this directory.",
"directoryAccessRequest": "This preview wants to read files in {{path}}.",
"dismissAccessRequest": "Dismiss",
"allowDirectory": "Allow folder",
"directoryAccessRequestOverflow": "{{folders}}, and {{count}} more",
"directoryAccessRequestMultiple": "This preview wants to read files in {{folders}}.",
"allowDirectories": "Allow {{count}} folders"
}
},
"diff": {
@@ -16355,6 +16362,13 @@
"description": "Nests this workspace under another in the sidebar. Does not change the base branch.",
"searchPlaceholder": "Search workspaces...",
"noMatches": "No matches."
},
"browserPane": {
"workspaceDoc": {
"externalLinkTitle": "Open link to {{host}}?",
"externalLinkConfirm": "Open link",
"externalLinkCancel": "Cancel"
}
}
},
"i18n": {
@@ -33,25 +33,25 @@ beforeEach(() => {
})
describe('buildDocPreviewGrantRequest', () => {
// Why: SSH previews are unrestricted by design, and a document outside every workspace has no
// boundary to root a grant in.
// A document outside every workspace resolves and authorizes from its own directory.
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' },
requestBase: '/home/alice/docs',
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', () => {
it('resolves from the workspace but authorizes only the document directory', () => {
mocks.connectionId = 'ssh-1'
expect(buildDocPreviewGrantRequest(state, 'wt-1', '/srv/repo/docs/report.html')).toEqual({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/srv/repo',
requestBase: '/srv/repo',
root: '/srv/repo/docs',
entryRelativePath: 'docs/report.html'
})
})
@@ -66,7 +66,8 @@ describe('buildDocPreviewGrantRequest', () => {
worktreeSelector: 'id:wt-1',
worktreeRoot: '/srv/repo'
},
root: '/srv/repo',
requestBase: '/srv/repo',
root: '/srv/repo/docs',
entryRelativePath: 'docs/report.html'
})
})
@@ -87,6 +88,7 @@ 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' },
requestBase: '/d',
root: '/d',
entryRelativePath: 'a.html'
}
@@ -106,6 +108,7 @@ describe('doc preview grant lifetime', () => {
it('revokes on release and mints fresh afterwards', async () => {
const request = {
owner: { kind: 'ssh' as const, connectionId: 'ssh-1' },
requestBase: '/d',
root: '/d',
entryRelativePath: 'a.html'
}
@@ -129,6 +132,7 @@ describe('doc preview grant lifetime', () => {
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' },
requestBase: '/d',
root: '/d',
entryRelativePath: 'a.html'
}
@@ -157,6 +161,7 @@ describe('doc preview grant lifetime', () => {
mocks.mintGrant.mockRejectedValueOnce(new Error('runtime offline'))
const request = {
owner: { kind: 'ssh' as const, connectionId: 'ssh-1' },
requestBase: '/d',
root: '/d',
entryRelativePath: 'a.html'
}
+8 -8
View File
@@ -23,20 +23,19 @@ export function buildDocPreviewGrantRequest(
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 root = dirname(filePath)
const requestBase = worktreeRoot && worktreeRelativePath ? worktreeRoot : root
const entryRelativePath = worktreeRelativePath ?? basename(filePath)
if (!root || !entryRelativePath) {
if (!requestBase || !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 }
// Outside a workspace there is no broader request base: the document directory bounds
// resolution, and main starts such a grant at the entry file alone — an out-of-workspace
// directory is often a home directory, which is where secrets live.
return { owner: { kind: 'ssh', connectionId }, requestBase, root, entryRelativePath }
}
const environmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId)
if (!environmentId || !worktreeRoot || !worktreeRelativePath) {
@@ -49,6 +48,7 @@ export function buildDocPreviewGrantRequest(
worktreeSelector: toRuntimeWorktreeSelector(worktreeId),
worktreeRoot
},
requestBase,
root,
entryRelativePath
}
@@ -264,6 +264,56 @@ describe('a browser page that shows a workspace document', () => {
expect(mocks.releaseDocPreviewGrant).toHaveBeenCalledTimes(1)
})
it('revokes a document grant when only that page is closed', () => {
mocks.releaseDocPreviewGrant.mockClear()
const store = createStoreWithWorktree()
const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, {
docLocation: DOC_LOCATION,
browserRuntimeEnvironmentId: null
})
const docPageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? ''
store.getState().createBrowserPage(tab.id, 'https://example.com/')
store.getState().closeBrowserPage(docPageId)
expect(mocks.releaseDocPreviewGrant).toHaveBeenCalledExactlyOnceWith(docPageId)
expect(store.getState().browserPagesByWorkspace[tab.id]).toHaveLength(1)
})
it('reopens a closed document tab with its document identity', () => {
const store = createStoreWithWorktree()
const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, {
docLocation: DOC_LOCATION,
browserRuntimeEnvironmentId: null
})
store.getState().closeBrowserTab(tab.id)
const reopened = store.getState().reopenClosedBrowserTab(WORKTREE_ID)
const page = reopened ? store.getState().browserPagesByWorkspace[reopened.id]?.[0] : undefined
expect(page?.docLocation).toEqual(DOC_LOCATION)
expect(page?.url).toBe(ORCA_BROWSER_BLANK_URL)
expect(reopened?.docLocation).toEqual(DOC_LOCATION)
})
it('reopens a closed document page with its document identity', () => {
const store = createStoreWithWorktree()
const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/')
const documentPage = store.getState().createBrowserPage(tab.id, LIVE_GRANT_URL, {
docLocation: DOC_LOCATION,
browserRuntimeEnvironmentId: null
})
if (!documentPage) {
throw new Error('Expected a document page')
}
store.getState().closeBrowserPage(documentPage.id)
const reopened = store.getState().reopenClosedBrowserPage(tab.id)
expect(reopened?.docLocation).toEqual(DOC_LOCATION)
expect(reopened?.url).toBe(ORCA_BROWSER_BLANK_URL)
})
it('writes the document and not the grant to the session', () => {
const store = createStoreWithWorktree()
const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, {
+19 -4
View File
@@ -97,6 +97,8 @@ type CreateBrowserPageOptions = {
activate?: boolean
title?: string
browserRuntimeEnvironmentId?: string | null
/** Creates a page that shows a workspace document instead of a URL. */
docLocation?: BrowserPageDocLocation
}
type BrowserTabPageState = {
@@ -1187,6 +1189,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
activate: true,
sessionProfileId,
sessionPartition,
...(snap.docLocation ? { docLocation: snap.docLocation } : {}),
targetGroupId: entryToRestore.position?.groupId
})
restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, entryToRestore.position)
@@ -1201,14 +1204,16 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
sessionProfileId,
sessionPartition,
targetGroupId: entryToRestore.position?.groupId,
browserRuntimeEnvironmentId: firstPage.browserRuntimeEnvironmentId
browserRuntimeEnvironmentId: firstPage.browserRuntimeEnvironmentId,
...(firstPage.docLocation ? { docLocation: firstPage.docLocation } : {})
})
for (const p of restPages) {
get().createBrowserPage(restored.id, p.url, {
activate: false,
title: p.title,
browserRuntimeEnvironmentId: p.browserRuntimeEnvironmentId
browserRuntimeEnvironmentId: p.browserRuntimeEnvironmentId,
...(p.docLocation ? { docLocation: p.docLocation } : {})
})
}
@@ -1288,7 +1293,9 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
workspace.worktreeId,
url,
options?.title,
options?.browserRuntimeEnvironmentId
options?.browserRuntimeEnvironmentId,
undefined,
options?.docLocation
)
set((s) => {
@@ -1309,6 +1316,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
s.activeBrowserTabIdByWorktree[workspace.worktreeId] === workspaceId
const shouldFocusAddressBar =
shouldUpdateGlobalActiveSurface &&
!page.docLocation &&
(page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL)
return {
@@ -1351,6 +1359,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
closeBrowserPage: (pageId) => {
let closedWorkspaceIdForLabel: string | null = null
let docPageIdToRelease: string | null = null
const remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = []
set((s) => {
const page = findPage(s.browserPagesByWorkspace, pageId)
@@ -1362,6 +1371,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
return s
}
closedWorkspaceIdForLabel = page.workspaceId
docPageIdToRelease = page.docLocation ? page.id : null
const currentPages = s.browserPagesByWorkspace[workspace.id] ?? []
const nextPages = currentPages.filter((entry) => entry.id !== pageId)
const closedIdx = currentPages.findIndex((entry) => entry.id === pageId)
@@ -1432,6 +1442,10 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
closeRemoteBrowserPageInOwningEnvironment(remotePage.worktreeId, remotePage.handle)
}
if (docPageIdToRelease) {
releaseDocPreviewGrant(docPageIdToRelease)
}
const closedWorkspaceId = closedWorkspaceIdForLabel
if (!closedWorkspaceId) {
return
@@ -1470,7 +1484,8 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
return get().createBrowserPage(workspaceId, pageToRestore.url, {
title: pageToRestore.title,
activate: true,
browserRuntimeEnvironmentId: pageToRestore.browserRuntimeEnvironmentId
browserRuntimeEnvironmentId: pageToRestore.browserRuntimeEnvironmentId,
...(pageToRestore.docLocation ? { docLocation: pageToRestore.docLocation } : {})
})
},
@@ -74,8 +74,32 @@ describe('migrateWorktreeIdentity', () => {
[OLD]: { resultOwner: { worktreeId: OLD, runtimeEnvironmentId: 'runtime-a' } }
},
activeTabIdByWorktree: { [OLD]: 'tab1' },
browserTabsByWorktree: { [OLD]: [{ id: 'browser1', worktreeId: OLD }] },
browserPagesByWorkspace: { browser1: [{ id: 'page1', worktreeId: OLD }] },
browserTabsByWorktree: {
[OLD]: [
{
id: 'browser1',
worktreeId: OLD,
docLocation: {
kind: 'workspace-doc',
worktreeId: OLD,
filePath: '/ws/cunner/docs/report.html'
}
}
]
},
browserPagesByWorkspace: {
browser1: [
{
id: 'page1',
worktreeId: OLD,
docLocation: {
kind: 'workspace-doc',
worktreeId: OLD,
filePath: '/ws/cunner/docs/report.html'
}
}
]
},
recentlyClosedBrowserTabsByWorktree: {
[OLD]: [
{ workspace: { id: 'closed-browser', worktreeId: OLD }, pages: [{ worktreeId: OLD }] }
@@ -127,6 +151,16 @@ describe('migrateWorktreeIdentity', () => {
expect(s.activeTabIdByWorktree[NEW]).toBe('tab1')
expect(s.browserTabsByWorktree[NEW]?.[0]?.worktreeId).toBe(NEW)
expect(s.browserPagesByWorkspace.browser1?.[0]?.worktreeId).toBe(NEW)
expect(s.browserTabsByWorktree[NEW]?.[0]?.docLocation).toEqual({
kind: 'workspace-doc',
worktreeId: NEW,
filePath: '/ws/worktree-creation-spinner/docs/report.html'
})
expect(s.browserPagesByWorkspace.browser1?.[0]?.docLocation).toEqual({
kind: 'workspace-doc',
worktreeId: NEW,
filePath: '/ws/worktree-creation-spinner/docs/report.html'
})
expect(s.recentlyClosedBrowserTabsByWorktree[NEW]?.[0]?.workspace.worktreeId).toBe(NEW)
expect(s.recentlyClosedBrowserTabsByWorktree[NEW]?.[0]?.pages[0]?.worktreeId).toBe(NEW)
expect(s.recentlyClosedBrowserPagesByWorkspace.browser1?.[0]?.worktreeId).toBe(NEW)
@@ -1,4 +1,9 @@
import type { AppState } from '../../../types'
import type {
BrowserPage,
BrowserWorkspace
} from '../../../../../../shared/browser-workspace-types'
import { remapBrowserPageDocLocation } from '../../../../../../shared/browser-page-doc-location'
import { splitWorktreeIdForFilesystem } from '../../../../../../shared/worktree/id'
import { worktreeWorkspaceKey } from '../../../../../../shared/workspace-scope'
import { getWorktreeIdFromVisitKey } from '@/lib/worktree-visit-recency'
@@ -76,17 +81,34 @@ export function buildWorktreeRenameState(
}
const withNewWorktreeId = <T extends { worktreeId: string }>(value: T): T =>
value.worktreeId === oldWorktreeId ? { ...value, worktreeId: newWorktreeId } : value
const oldWorktreePath = splitWorktreeIdForFilesystem(oldWorktreeId)?.worktreePath
const newWorktreePath = splitWorktreeIdForFilesystem(newWorktreeId)?.worktreePath
const withNewBrowserWorktreeId = <T extends BrowserPage | BrowserWorkspace>(value: T): T => {
const renamedValue = withNewWorktreeId(value)
return value.docLocation?.worktreeId === oldWorktreeId
? {
...renamedValue,
docLocation: remapBrowserPageDocLocation(
value.docLocation,
oldWorktreeId,
newWorktreeId,
oldWorktreePath,
newWorktreePath
)
}
: renamedValue
}
const renameValueByKey: Partial<Record<(typeof WORKTREE_ID_KEYED_MAP_KEYS)[number], unknown>> = {
tabsByWorktree: (tabs: { worktreeId: string }[]) => tabs.map(withNewWorktreeId),
browserTabsByWorktree: (workspaces: { worktreeId: string }[]) =>
workspaces.map(withNewWorktreeId),
browserTabsByWorktree: (workspaces: BrowserWorkspace[]) =>
workspaces.map(withNewBrowserWorktreeId),
recentlyClosedBrowserTabsByWorktree: (
snapshots: { workspace: { worktreeId: string }; pages: { worktreeId: string }[] }[]
snapshots: { workspace: BrowserWorkspace; pages: BrowserPage[] }[]
) =>
snapshots.map((snapshot) => ({
...snapshot,
workspace: withNewWorktreeId(snapshot.workspace),
pages: snapshot.pages.map(withNewWorktreeId)
workspace: withNewBrowserWorktreeId(snapshot.workspace),
pages: snapshot.pages.map(withNewBrowserWorktreeId)
})),
fileSearchStateByWorktree: (searchState: AppState['fileSearchStateByWorktree'][string]) => ({
...searchState,
@@ -121,8 +143,6 @@ export function buildWorktreeRenameState(
files.map(withNewWorktreeId)
)
// Why: terminal reopen snapshots hold absolute startupCwd paths under the old folder; remap or Cmd+Shift+T respawns into a directory that no longer exists after the rename.
const oldWorktreePath = splitWorktreeIdForFilesystem(oldWorktreeId)?.worktreePath
const newWorktreePath = splitWorktreeIdForFilesystem(newWorktreeId)?.worktreePath
renameKey('recentlyClosedTerminalTabsByWorktree', (snapshots: ClosedTerminalTabSnapshot[]) =>
oldWorktreePath && newWorktreePath
? remapClosedTerminalTabSnapshotCwds(snapshots, oldWorktreePath, newWorktreePath)
@@ -137,23 +157,29 @@ export function buildWorktreeRenameState(
: s.openFiles
const currentBrowserPagesByWorkspace = s.browserPagesByWorkspace ?? {}
const browserPagesByWorkspace = Object.values(currentBrowserPagesByWorkspace).some((pages) =>
pages.some((page) => page.worktreeId === oldWorktreeId)
pages.some(
(page) => page.worktreeId === oldWorktreeId || page.docLocation?.worktreeId === oldWorktreeId
)
)
? Object.fromEntries(
Object.entries(currentBrowserPagesByWorkspace).map(([workspaceId, pages]) => [
workspaceId,
pages.map(withNewWorktreeId)
pages.map(withNewBrowserWorktreeId)
])
)
: s.browserPagesByWorkspace
const currentRecentlyClosedBrowserPagesByWorkspace = s.recentlyClosedBrowserPagesByWorkspace ?? {}
const recentlyClosedBrowserPagesByWorkspace = Object.values(
currentRecentlyClosedBrowserPagesByWorkspace
).some((pages) => pages.some((page) => page.worktreeId === oldWorktreeId))
).some((pages) =>
pages.some(
(page) => page.worktreeId === oldWorktreeId || page.docLocation?.worktreeId === oldWorktreeId
)
)
? Object.fromEntries(
Object.entries(currentRecentlyClosedBrowserPagesByWorkspace).map(([workspaceId, pages]) => [
workspaceId,
pages.map(withNewWorktreeId)
pages.map(withNewBrowserWorktreeId)
])
)
: s.recentlyClosedBrowserPagesByWorkspace
+24
View File
@@ -3,6 +3,7 @@ import type {
BrowserPageDocLocation,
BrowserWorkspace
} from './browser-workspace-types'
import { relativePathInsideRoot, resolveRuntimePath } from './cross-platform-path'
/**
* Why an explicit comparison and not object identity: the mirror rebuilds the workspace's copy of
@@ -25,3 +26,26 @@ export function isWorkspaceDocSurface(
): boolean {
return Boolean(surface.docLocation)
}
export function remapBrowserPageDocLocation(
location: BrowserPageDocLocation,
oldWorktreeId: string,
newWorktreeId: string,
oldWorktreePath?: string,
newWorktreePath?: string
): BrowserPageDocLocation {
if (location.worktreeId !== oldWorktreeId) {
return location
}
const relativePath =
oldWorktreePath && newWorktreePath
? relativePathInsideRoot(oldWorktreePath, location.filePath)
: null
return {
...location,
worktreeId: newWorktreeId,
...(relativePath !== null && newWorktreePath
? { filePath: resolveRuntimePath(newWorktreePath, relativePath) }
: {})
}
}
+7 -2
View File
@@ -11,10 +11,11 @@ export const DOC_PREVIEW_PARTITION = 'orca-doc-preview'
export const DOC_PREVIEW_MINT_GRANT_CHANNEL = 'docPreview:mintGrant'
export const DOC_PREVIEW_REVOKE_GRANT_CHANNEL = 'docPreview:revokeGrant'
export const DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL = 'docPreview:authorizeDirectory'
export const DOC_PREVIEW_EXTERNAL_LINK_CHANNEL = 'docPreview:externalLink'
/**
* The preview guest's preload reports a trusted anchor click here. Renderer↔main only — no paired
* client ever sees it, and main gates every report on the sender being a focused preview guest.
* client ever sees it, and main gates every report on the sender being a live, grant-bound preview guest.
*/
export const DOC_PREVIEW_LINK_CLICK_CHANNEL = 'docPreview:linkClick'
/** The one out-of-band route from the preview's main-side fences to the shell hosting it. */
@@ -22,7 +23,11 @@ export const DOC_PREVIEW_LOAD_FAILURE_CHANNEL = 'docPreview:loadFailure'
/** Why: an unreadable document still answers with a real HTTP status, so the guest paints the
* handler's plain-text body instead of failing to load. The shell needs the reason out-of-band. */
export type DocPreviewFileFailureReason = 'too-large' | 'unsupported-asset' | 'unreadable'
export type DocPreviewFileFailureReason =
| 'authorization-required'
| 'too-large'
| 'unsupported-asset'
| 'unreadable'
export type DocPreviewFileFailure = {
grantId: string
@@ -1,4 +1,4 @@
import { writeFileSync } from 'node:fs'
import { mkdirSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import type { Locator, Page, TestInfo } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
@@ -37,6 +37,9 @@ const SCRIPTED_EGRESS_URL = 'https://exfil.test/?d=scripted'
/** The same exfiltration, but riding a press the reader really made somewhere else in the document. */
const POST_INPUT_EGRESS_URL = 'https://exfil.test/?d=after-input'
const BLANK_PAGE_URL = 'data:text/html,'
const SCOPED_FIXTURE_NAME = 'scoped-preview.html'
const SCOPED_FIXTURE_HEADING = 'scoped preview rendered'
const SCOPED_ASSET_TEXT = 'approved sibling asset loaded'
type PreparedPairedClient = {
client: PairedElectronClient
@@ -286,6 +289,23 @@ test('renders a paired HTML doc as a document browser tab while the host gains n
const pathChip = page.getByRole('button', { name: 'Copy file path', exact: true })
await expect(pathChip).toBeVisible({ timeout: 30_000 })
await expect(pathChip).toContainText(FIXTURE_NAME)
// Below 24rem of chip width the identity row hides whole instead of clipping into slivers;
// when it shows, the badge must sit inside the chip's own layout box. Which arm runs depends
// on how much width this platform's toolbar leaves the chip — both are the contract.
const hostBadge = pathChip.locator('[data-slot="badge"]')
const pathChipBox = await pathChip.boundingBox()
expect(pathChipBox).not.toBeNull()
if (await hostBadge.isVisible()) {
const hostBadgeBox = await hostBadge.boundingBox()
expect(hostBadgeBox).not.toBeNull()
expect((hostBadgeBox?.x ?? 0) + (hostBadgeBox?.width ?? 0)).toBeLessThanOrEqual(
(pathChipBox?.x ?? 0) + (pathChipBox?.width ?? 0) + 0.5
)
} else {
// A chip too narrow for the identity row hides it whole; a wide one must show it. 26rem of
// border-box width clears the 24rem content-box container threshold plus padding.
expect(pathChipBox?.width ?? 0).toBeLessThan(416)
}
await expect(page.locator(`[data-tab-group-body-id="${sourceGroupId}"]`)).toBeVisible()
await expect(page.locator(`[data-tab-group-body-id="${previewRow.groupId}"]`)).toBeVisible()
@@ -553,36 +573,56 @@ test('renders a paired HTML doc as a document browser tab while the host gains n
hostBrowserPages: linkBaseline.hostBrowserPages.length,
routedCalls: []
})
console.log(
`[preview-e2e] before-focus ${JSON.stringify(
await page.evaluate(() => {
const active = document.activeElement
const guest = document.querySelector(
'webview[src^="orca-preview://"]'
) as HTMLElement | null
const before = active?.tagName ?? null
guest?.focus()
return { before, after: document.activeElement?.tagName ?? null }
})
)}`
)
// Why the failure is dressed rather than left bare: "nothing routed" has three very different
// causes — the press missed the anchor, the pane had no layout, or the fences refused what the
// press reported — and the counts below are what tell them apart. Finding the guest-focus gap
// took exactly these three numbers.
const guestFocus = await page.evaluate(() => {
const active = document.activeElement
const guest = document.querySelector('webview[src^="orca-preview://"]') as HTMLElement | null
const before = active?.tagName ?? null
guest?.focus()
return { before, after: document.activeElement?.tagName ?? null }
})
console.log(`[preview-e2e] before-focus ${JSON.stringify(guestFocus)}`)
const confirmationTitle = page.getByRole('heading', { name: 'Open link to example.com?' })
await expect
.poll(
async () => {
if (!(await confirmationTitle.isVisible())) {
const point = await readDocPreviewElementCenter(page, '#external')
if (point) {
await page.mouse.click(point.x, point.y)
}
}
return confirmationTitle.isVisible()
},
{
timeout: 60_000,
intervals: [2_000],
message: 'a target=_blank click never showed its destination confirmation'
}
)
.toBe(true)
await expect(page.getByText(EXTERNAL_LINK_URL, { exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
await expect(confirmationTitle).not.toBeVisible()
const afterCancel = await readPairedHtmlPreviewInventory(page, inventoryArgs)
expect({
routedCalls: await readPairedHtmlPreviewLinkRouting(page),
browserCount: afterCancel.clientBrowserWorkspaceCountAllWorktrees
}).toEqual({
routedCalls: [],
browserCount: linkBaseline.clientBrowserWorkspaceCountAllWorktrees
})
try {
const point = await readDocPreviewElementCenter(page, '#external')
if (!point) {
throw new Error('external link lost its clickable point after cancellation')
}
await page.mouse.click(point.x, point.y)
await expect(confirmationTitle).toBeVisible({ timeout: 30_000 })
await page.getByRole('button', { name: 'Open link', exact: true }).click()
await expect
.poll(
async () => {
const routedCalls = await readPairedHtmlPreviewLinkRouting(page)
// Why only while nothing has routed: a retry after a successful route would open a
// second tab and turn this oracle into a counter of presses.
if (routedCalls.length === 0) {
const point = await readDocPreviewElementCenter(page, '#external')
if (point) {
await page.mouse.click(point.x, point.y)
}
}
const opened = await readPairedHtmlPreviewInventory(page, inventoryArgs)
return {
routedCalls: await readPairedHtmlPreviewLinkRouting(page),
@@ -596,7 +636,7 @@ test('renders a paired HTML doc as a document browser tab while the host gains n
{
timeout: 60_000,
intervals: [2_000],
message: 'a target=_blank click in the preview never opened an Orca browser tab'
message: 'a confirmed preview link never opened an Orca browser tab'
}
)
.toMatchObject({
@@ -617,6 +657,100 @@ test('renders a paired HTML doc as a document browser tab while the host gains n
}
})
test('asks before a paired preview reads a sibling directory', async ({
orcaPage,
testRepoPath
}, testInfo) => {
test.setTimeout(300_000)
const docsDirectory = path.join(testRepoPath, 'preview-docs')
const assetsDirectory = path.join(testRepoPath, 'preview-assets')
mkdirSync(docsDirectory, { recursive: true })
mkdirSync(assetsDirectory, { recursive: true })
writeFileSync(
path.join(docsDirectory, SCOPED_FIXTURE_NAME),
`<!doctype html><html><head><title>Scoped Preview</title></head><body>` +
`<h1>${SCOPED_FIXTURE_HEADING}</h1><div id="asset-result">blocked</div>` +
`<script src="../preview-assets/scoped-preview.js"></script></body></html>\n`
)
writeFileSync(
path.join(assetsDirectory, 'scoped-preview.js'),
`document.getElementById('asset-result').textContent=${JSON.stringify(SCOPED_ASSET_TEXT)}\n`
)
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
const offer = await createRuntimeDesktopPairingOffer(orcaPage)
let prepared: PreparedPairedClient | null = null
try {
prepared = await preparePairedClient(offer, testInfo, 'Scoped HTML preview', testRepoPath)
const { client, worktreeId, worktreePath } = prepared
const page = client.page
const docFilePath = path.join(worktreePath, 'preview-docs', SCOPED_FIXTURE_NAME)
await page.evaluate(
({ environmentId, filePath, relativePath, targetWorktreeId }) => {
window.__store?.getState().openFile(
{
filePath,
relativePath,
worktreeId: targetWorktreeId,
language: 'html',
runtimeEnvironmentId: environmentId,
mode: 'edit'
},
{ preview: false, focusEditor: true }
)
},
{
environmentId: client.environmentId,
filePath: docFilePath,
relativePath: `preview-docs/${SCOPED_FIXTURE_NAME}`,
targetWorktreeId: worktreeId
}
)
const openPreviewToSide = page.getByRole('button', { name: 'Open Preview to the Side' })
await expect(openPreviewToSide).toBeVisible({ timeout: 30_000 })
await openPreviewToSide.click()
await expect
.poll(() => readDocPreviewRenderedText(page, 'h1'), {
timeout: 60_000,
message: 'the scoped preview document never rendered'
})
.toBe(SCOPED_FIXTURE_HEADING)
const workspace = await page.evaluate((targetWorktreeId) => {
const state = window.__store?.getState()
return (state?.browserTabsByWorktree[targetWorktreeId] ?? []).find((candidate) =>
candidate.docLocation?.filePath.endsWith('/scoped-preview.html')
)?.id
}, worktreeId)
if (!workspace) {
throw new Error('scoped preview had no document browser workspace')
}
await focusBrowserWorkspace(page, worktreeId, workspace)
await page.locator(`[data-tab-id="${workspace}"]`).click()
await expect(page.getByText('This preview wants to read files in preview-assets.')).toBeVisible(
{
timeout: 30_000
}
)
await expect.poll(() => readDocPreviewRenderedText(page, '#asset-result')).toBe('blocked')
await page.getByRole('button', { name: 'Allow folder', exact: true }).click()
await expect(
page.getByText('This preview wants to read files in preview-assets.')
).not.toBeVisible()
await expect
.poll(() => readDocPreviewRenderedText(page, '#asset-result'), {
timeout: 60_000,
message: 'the approved sibling asset never loaded after reload'
})
.toBe(SCOPED_ASSET_TEXT)
} finally {
await prepared?.client.dispose()
}
})
/**
* A document tab is a browser tab, so it comes back the way one does. What it may not do is come
* back holding yesterday's grant: the URL it is served over is minted fresh by the client that