mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
+185
-1
@@ -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)
|
||||
|
||||
+2
-3
@@ -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}
|
||||
|
||||
+173
@@ -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>
|
||||
)
|
||||
}
|
||||
+15
-8
@@ -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>
|
||||
|
||||
+86
@@ -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())
|
||||
})
|
||||
})
|
||||
+75
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user