From 15ce4ccf2c17fdb504dd28c91b386b390111d275 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 27 Aug 2026 21:04:32 -0400 Subject: [PATCH] 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. --- .../browser/doc-preview-file-reader.test.ts | 66 +++++++- src/main/browser/doc-preview-file-reader.ts | 12 +- .../doc-preview-grant-registry.test.ts | 78 +++++++++ .../browser/doc-preview-grant-registry.ts | 114 ++++++++++--- .../browser/doc-preview-guest-policy.test.ts | 6 +- src/main/browser/doc-preview-guest-policy.ts | 15 +- src/main/ipc/doc-preview-grant-ipc.test.ts | 35 ++++ src/main/ipc/doc-preview-grant-ipc.ts | 20 ++- src/preload/api/doc-preview-api.ts | 4 + src/preload/doc-preview-link.ts | 2 +- src/preload/index.ts | 3 + src/renderer/src/App.tsx | 2 + .../browser-reload-control.tsx | 6 +- .../HtmlDocPreview.failure-message.test.tsx | 113 ++++++++++++- .../HtmlDocPreview.toolbar.test.tsx | 5 +- .../workspace-doc/HtmlDocPreview.tsx | 45 ++++- .../doc-preview-directory-access.tsx | 113 +++++++++++++ .../doc-preview-document-chip.tsx | 16 +- ...preview-external-link-confirmation.test.ts | 86 ++++++++++ ...doc-preview-external-link-confirmation.tsx | 75 +++++++++ .../components/confirmation-dialog-context.ts | 1 + .../src/components/confirmation-dialog.tsx | 5 +- .../ipc-events/browser-state-ipc-bridge.ts | 29 ---- .../doc-preview-external-link-bridge.test.ts | 92 ---------- .../src/hooks/useIpcEvents-lifecycle.test.ts | 2 - .../src/lib/doc-preview-grants.test.ts | 17 +- src/renderer/src/lib/doc-preview-grants.ts | 15 +- .../browser-workspace-doc-location.test.ts | 16 ++ src/renderer/src/store/slices/browser.ts | 6 + src/shared/doc-preview-scheme.ts | 9 +- ...d-remote-html-preview-local-render.spec.ts | 158 ++++++++++++++++-- 31 files changed, 956 insertions(+), 210 deletions(-) create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-directory-access.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-external-link-confirmation.test.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-external-link-confirmation.tsx delete mode 100644 src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts diff --git a/src/main/browser/doc-preview-file-reader.test.ts b/src/main/browser/doc-preview-file-reader.test.ts index fc707279f8f..4476acf5029 100644 --- a/src/main/browser/doc-preview-file-reader.test.ts +++ b/src/main/browser/doc-preview-file-reader.test.ts @@ -17,7 +17,11 @@ 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' function sshGrant(): ReturnType { return mintDocPreviewGrant({ @@ -165,6 +169,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 +357,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 + ) + }) }) diff --git a/src/main/browser/doc-preview-file-reader.ts b/src/main/browser/doc-preview-file-reader.ts index 967461a91f9..87e78c1d2e3 100644 --- a/src/main/browser/doc-preview-file-reader.ts +++ b/src/main/browser/doc-preview-file-reader.ts @@ -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 { + 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 { diff --git a/src/main/browser/doc-preview-grant-registry.test.ts b/src/main/browser/doc-preview-grant-registry.test.ts index 8aadf9ed695..062ee810dfa 100644 --- a/src/main/browser/doc-preview-grant-registry.test.ts +++ b/src/main/browser/doc-preview-grant-registry.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest' import { + authorizeDocPreviewDirectory, getDocPreviewGrant, mintDocPreviewGrant, resolveCanonicalDocPreviewPath, @@ -118,6 +119,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', () => { @@ -141,6 +196,29 @@ describe('resolveCanonicalDocPreviewPath', () => { resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) => path) ).resolves.toBe('/srv/repo/docs/report.html') }) + + 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() + }) }) describe('toRuntimeWorktreeRelativePath', () => { diff --git a/src/main/browser/doc-preview-grant-registry.ts b/src/main/browser/doc-preview-grant-registry.ts index 51237a685f1..1ecfc7d0292 100644 --- a/src/main/browser/doc-preview-grant-registry.ts +++ b/src/main/browser/doc-preview-grant-registry.ts @@ -21,9 +21,13 @@ export type DocPreviewOwner = export type DocPreviewGrant = { id: string owner: DocPreviewOwner + /** Directory relative preview URLs resolve against. */ + requestBase: string /** Containing directory of the opened document, on the owning host. */ 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 +60,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 +139,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 +151,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 +165,57 @@ 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 authorizedRoots(grant: DocPreviewGrant): string[] { + return [grant.root, ...grant.authorizedRoots] +} + +/** Resolves a request only when its directory is authorized by this grant. */ +export function resolveDocPreviewTargetPath( + grant: DocPreviewGrant, + relativePath: string +): string | null { + const resolved = resolveDocPreviewCandidatePath(grant, relativePath) + if (!resolved) { + return null + } + const flavor = pathFlavorFor(grant.requestBase) + return authorizedRoots(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 (!authorizedRoots(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>() +const canonicalRootByGrantId = new Map>() /** * Second containment pass for hosts where the lexical one is not enough: a symlink @@ -171,14 +229,32 @@ export async function resolveCanonicalDocPreviewPath( realpath: (path: string) => Promise ): Promise { 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) { + canonicalRoots = Promise.all([ + realpath(grant.requestBase), + ...authorizedRoots(grant).map((root) => realpath(root)) + ]).then(([boundaryPath, ...rootPaths]) => { + const boundary = normalizeRootPath(boundaryPath) + const flavor = pathFlavorFor(boundary) + return { + boundary, + roots: rootPaths + .map(normalizeRootPath) + .filter((root) => isAtOrInsideRoot(boundary, root, flavor)) + } + }) + 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 }, canonicalPath] = await Promise.all([ + canonicalRoots, + realpath(absolutePath) + ]) + const flavor = pathFlavorFor(boundary) + return isInsideRoot(boundary, canonicalPath, flavor) && + 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) diff --git a/src/main/browser/doc-preview-guest-policy.test.ts b/src/main/browser/doc-preview-guest-policy.test.ts index 26b117c0fc0..5813d60e110 100644 --- a/src/main/browser/doc-preview-guest-policy.test.ts +++ b/src/main/browser/doc-preview-guest-policy.test.ts @@ -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', () => { diff --git a/src/main/browser/doc-preview-guest-policy.ts b/src/main/browser/doc-preview-guest-policy.ts index 0c09eb13b0f..a869cab3641 100644 --- a/src/main/browser/doc-preview-guest-policy.ts +++ b/src/main/browser/doc-preview-guest-policy.ts @@ -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 diff --git a/src/main/ipc/doc-preview-grant-ipc.test.ts b/src/main/ipc/doc-preview-grant-ipc.test.ts index 3a5d394c58e..453f3ac854c 100644 --- a/src/main/ipc/doc-preview-grant-ipc.test.ts +++ b/src/main/ipc/doc-preview-grant-ipc.test.ts @@ -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. diff --git a/src/main/ipc/doc-preview-grant-ipc.ts b/src/main/ipc/doc-preview-grant-ipc.ts index e86ec4afe02..9b3c6636322 100644 --- a/src/main/ipc/doc-preview-grant-ipc.ts +++ b/src/main/ipc/doc-preview-grant-ipc.ts @@ -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) diff --git a/src/preload/api/doc-preview-api.ts b/src/preload/api/doc-preview-api.ts index 5199a312b65..51e928096b3 100644 --- a/src/preload/api/doc-preview-api.ts +++ b/src/preload/api/doc-preview-api.ts @@ -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 + authorizeDirectory: (grantId: string, relativePath: string) => Promise /** 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. */ diff --git a/src/preload/doc-preview-link.ts b/src/preload/doc-preview-link.ts index f333d313836..e88e4ab7801 100644 --- a/src/preload/doc-preview-link.ts +++ b/src/preload/doc-preview-link.ts @@ -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) }) diff --git a/src/preload/index.ts b/src/preload/index.ts index 3974d1b4140..1b75b5b56da 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke(DOC_PREVIEW_REVOKE_GRANT_CHANNEL, grantId), + authorizeDirectory: (grantId: string, relativePath: string): Promise => + 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) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index cdd08d323d6..903c8b81447 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 { > + diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-reload-control.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-reload-control.tsx index fda6cadcc11..32cf8ad50e0 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-reload-control.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-reload-control.tsx @@ -71,7 +71,11 @@ export function BrowserReloadControl({ onMenuOpenChange(true) }} > - {loading ? : } + {loading ? ( + + ) : ( + + )} diff --git a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx index 3abd75fff59..e07e1f8984e 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx @@ -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,107 @@ 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 needs access to assets.') + expect(container.textContent).toContain('Dismiss') + expect(container.textContent).toContain('Allow once') + 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 needs access to /repo.') + }) + + it('authorizes only after Allow once 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 once' + ) + + 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 needs access to assets.') + }) + + 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 needs access to 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) diff --git a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx index 0e545703932..d989303c9f9 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx @@ -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 diff --git a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx index 334df32c435..64158d9fc02 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx @@ -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(null) + const { + request: accessRequest, + 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({ {notice} ))} + {accessRequest && !isUnavailable ? ( + + ) : null}
void) | null> +}): { + request: DocPreviewFileFailure | null + busy: boolean + offer: (failure: DocPreviewFileFailure) => void + reset: () => void + dismiss: () => void + allow: () => Promise +} { + const [request, setRequest] = useState(null) + const [busy, setBusy] = useState(false) + const dismissedDirectoriesRef = useRef(new Set()) + const offer = useCallback((failure: DocPreviewFileFailure) => { + if (!dismissedDirectoriesRef.current.has(requestedDirectory(failure.relativePath))) { + setRequest((current) => current ?? failure) + } + }, []) + const reset = useCallback(() => { + setRequest(null) + setBusy(false) + dismissedDirectoriesRef.current.clear() + }, []) + const dismiss = useCallback(() => { + if (request) { + dismissedDirectoriesRef.current.add(requestedDirectory(request.relativePath)) + } + setRequest(null) + }, [request]) + const allow = useCallback(async () => { + if (!request || !grantId || busy) { + return + } + setBusy(true) + try { + if (!(await window.api.docPreview.authorizeDirectory(grantId, request.relativePath))) { + reportAuthorizationFailure() + return + } + setRequest(null) + reloadRef.current?.() + } catch { + reportAuthorizationFailure() + } finally { + setBusy(false) + } + }, [busy, grantId, reloadRef, request]) + return { request, busy, offer, reset, dismiss, allow } +} + +export function DocPreviewDirectoryAccessBanner({ + request, + busy, + worktreeRoot, + onDismiss, + onAllow +}: { + request: DocPreviewFileFailure + busy: boolean + worktreeRoot: string | null + onDismiss: () => void + onAllow: () => Promise +}): React.JSX.Element { + return ( +
+ + + {translate( + 'auto.components.editor.HtmlDocPreview.directoryAccessRequest', + 'This preview needs access to {{path}}.', + { path: requestedDirectoryLabel(request.relativePath, worktreeRoot) } + )} + + + +
+ ) +} diff --git a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-chip.tsx b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-chip.tsx index 8ca681195f4..6897e4a1e35 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-chip.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-chip.tsx @@ -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="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 ? ( @@ -47,13 +47,15 @@ export function DocPreviewDocumentChip({ {identity.directoryPrefix} {identity.fileName} - - {translate( - 'auto.components.editor.HtmlDocPreview.workspaceFileChipLabel', - 'Workspace file' - )} + + + {translate( + 'auto.components.editor.HtmlDocPreview.workspaceFileChipLabel', + 'Workspace file' + )} + {identity.hostLabel ? ( - + {identity.hostLabel} ) : null} diff --git a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-external-link-confirmation.test.ts b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-external-link-confirmation.test.ts new file mode 100644 index 00000000000..9ea527417fa --- /dev/null +++ b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-external-link-confirmation.test.ts @@ -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().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().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().mockResolvedValue(true) + ) + + emit('https://example.com/docs') + + await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) + }) +}) diff --git a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-external-link-confirmation.tsx b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-external-link-confirmation.tsx new file mode 100644 index 00000000000..f8e2f185589 --- /dev/null +++ b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-external-link-confirmation.tsx @@ -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 { + 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 +} diff --git a/src/renderer/src/components/confirmation-dialog-context.ts b/src/renderer/src/components/confirmation-dialog-context.ts index 8dbaf36498e..4675c181fb7 100644 --- a/src/renderer/src/components/confirmation-dialog-context.ts +++ b/src/renderer/src/components/confirmation-dialog-context.ts @@ -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' diff --git a/src/renderer/src/components/confirmation-dialog.tsx b/src/renderer/src/components/confirmation-dialog.tsx index c9303096b7a..a1670b9bb22 100644 --- a/src/renderer/src/components/confirmation-dialog.tsx +++ b/src/renderer/src/components/confirmation-dialog.tsx @@ -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({ {displayedRequest?.options.title} {displayedRequest?.options.description ? ( // Callers pass multi-line descriptions (e.g. one path per line). - + {displayedRequest.options.description} ) : null} diff --git a/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts index c20a39a5882..4f3450dae85 100644 --- a/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts @@ -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) - }) - ) - } } diff --git a/src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts b/src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts deleted file mode 100644 index 552c54ff0dc..00000000000 --- a/src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts +++ /dev/null @@ -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()) - }) -}) diff --git a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts index 2d84a77c1a2..ea84f1b2811 100644 --- a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts @@ -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', diff --git a/src/renderer/src/lib/doc-preview-grants.test.ts b/src/renderer/src/lib/doc-preview-grants.test.ts index daf2a216576..c7afa8c77b7 100644 --- a/src/renderer/src/lib/doc-preview-grants.test.ts +++ b/src/renderer/src/lib/doc-preview-grants.test.ts @@ -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' } diff --git a/src/renderer/src/lib/doc-preview-grants.ts b/src/renderer/src/lib/doc-preview-grants.ts index a4c5bd2d530..98583355a1d 100644 --- a/src/renderer/src/lib/doc-preview-grants.ts +++ b/src/renderer/src/lib/doc-preview-grants.ts @@ -23,20 +23,18 @@ 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 is both the + // resolution boundary and the initial authority. + return { owner: { kind: 'ssh', connectionId }, requestBase, root, entryRelativePath } } const environmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) if (!environmentId || !worktreeRoot || !worktreeRelativePath) { @@ -49,6 +47,7 @@ export function buildDocPreviewGrantRequest( worktreeSelector: toRuntimeWorktreeSelector(worktreeId), worktreeRoot }, + requestBase, root, entryRelativePath } diff --git a/src/renderer/src/store/slices/browser-workspace-doc-location.test.ts b/src/renderer/src/store/slices/browser-workspace-doc-location.test.ts index b7e28759238..1c621752d19 100644 --- a/src/renderer/src/store/slices/browser-workspace-doc-location.test.ts +++ b/src/renderer/src/store/slices/browser-workspace-doc-location.test.ts @@ -264,6 +264,22 @@ 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('writes the document and not the grant to the session', () => { const store = createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 25733605b70..081ea469a45 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -1351,6 +1351,7 @@ export const createBrowserSlice: StateCreator = 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 +1363,7 @@ export const createBrowserSlice: StateCreator = 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 +1434,10 @@ export const createBrowserSlice: StateCreator = closeRemoteBrowserPageInOwningEnvironment(remotePage.worktreeId, remotePage.handle) } + if (docPageIdToRelease) { + releaseDocPreviewGrant(docPageIdToRelease) + } + const closedWorkspaceId = closedWorkspaceIdForLabel if (!closedWorkspaceId) { return diff --git a/src/shared/doc-preview-scheme.ts b/src/shared/doc-preview-scheme.ts index 8818cded00f..8046e9b75ce 100644 --- a/src/shared/doc-preview-scheme.ts +++ b/src/shared/doc-preview-scheme.ts @@ -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 diff --git a/tests/e2e/paired-remote-html-preview-local-render.spec.ts b/tests/e2e/paired-remote-html-preview-local-render.spec.ts index 35deddd18c4..65ef10bf11b 100644 --- a/tests/e2e/paired-remote-html-preview-local-render.spec.ts +++ b/tests/e2e/paired-remote-html-preview-local-render.spec.ts @@ -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,16 @@ 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) + const hostBadge = pathChip.locator('[data-slot="badge"]') + const [pathChipBox, hostBadgeBox] = await Promise.all([ + pathChip.boundingBox(), + hostBadge.boundingBox() + ]) + expect(pathChipBox).not.toBeNull() + expect(hostBadgeBox).not.toBeNull() + expect((hostBadgeBox?.x ?? 0) + (hostBadgeBox?.width ?? 0)).toBeLessThanOrEqual( + (pathChipBox?.x ?? 0) + (pathChipBox?.width ?? 0) + 0.5 + ) await expect(page.locator(`[data-tab-group-body-id="${sourceGroupId}"]`)).toBeVisible() await expect(page.locator(`[data-tab-group-body-id="${previewRow.groupId}"]`)).toBeVisible() @@ -566,23 +579,48 @@ test('renders a paired HTML doc as a document browser tab while the host gains n }) )}` ) - // 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 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 +634,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 +655,96 @@ 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), + `Scoped Preview` + + `

${SCOPED_FIXTURE_HEADING}

blocked
` + + `\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 needs access to preview-assets.')).toBeVisible({ + timeout: 30_000 + }) + await expect.poll(() => readDocPreviewRenderedText(page, '#asset-result')).toBe('blocked') + await page.getByRole('button', { name: 'Allow once', exact: true }).click() + await expect(page.getByText('This preview needs access to 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