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.
This commit is contained in:
Jinwoo-H
2026-08-28 00:28:10 -04:00
parent b19a397d3e
commit 15ce4ccf2c
31 changed files with 956 additions and 210 deletions
@@ -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<typeof mintDocPreviewGrant> {
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
)
})
})
+11 -1
View File
@@ -10,6 +10,7 @@ import { getCanonicalUserDataPath } from '../persistence'
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import {
resolveCanonicalDocPreviewPath,
resolveDocPreviewCandidatePath,
resolveDocPreviewTargetPath,
toRuntimeWorktreeRelativePath,
type DocPreviewGrant
@@ -153,9 +154,18 @@ export async function readDocPreviewFile(
grant: DocPreviewGrant,
relativePath: string
): Promise<DocPreviewReadOutcome> {
const candidatePath = resolveDocPreviewCandidatePath(grant, relativePath)
if (!candidatePath) {
return notFoundOutcome()
}
const absolutePath = resolveDocPreviewTargetPath(grant, relativePath)
if (!absolutePath) {
return notFoundOutcome()
return {
ok: false,
status: 403,
reason: 'authorization-required',
message: 'This file needs permission before the preview can read it.'
}
}
const contentType = docPreviewContentType(relativePath)
try {
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
authorizeDocPreviewDirectory,
getDocPreviewGrant,
mintDocPreviewGrant,
resolveCanonicalDocPreviewPath,
@@ -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', () => {
+95 -19
View File
@@ -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<string, Promise<string>>()
const canonicalRootByGrantId = new Map<string, Promise<{ boundary: string; roots: string[] }>>()
/**
* 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<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) {
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)
@@ -270,13 +270,15 @@ describe('doc preview guest policy', () => {
})
})
it('drops a click reported while the guest is not the contents the reader is looking at', () => {
it("does not mistake Electron's false webview focus flag for an untrusted click", () => {
const { guest } = boundGuest()
guest.isFocused.mockReturnValue(false)
reportClick(guest, 'https://example.com/docs')
expect(guest.send).not.toHaveBeenCalled()
expect(guest.send).toHaveBeenCalledExactlyOnceWith('docPreview:externalLink', {
url: 'https://example.com/docs'
})
})
it('drops a click reported by a sender that is not a preview guest', () => {
+4 -11
View File
@@ -15,7 +15,6 @@ type PreviewHostRenderer = {
type PreviewGuestRegistration = {
host: PreviewHostRenderer
readBoundGrantId: () => string | null
isFocused: () => boolean
}
/**
@@ -125,8 +124,7 @@ export function installDocPreviewGuestPolicy(
previewGuests.set(guest, {
host,
readBoundGrantId: () => boundGrantId,
isFocused: () => guest.isFocused()
readBoundGrantId: () => boundGrantId
})
const forgetGuest = (): void => {
previewGuests.delete(guest)
@@ -221,9 +219,9 @@ function isWebUrl(url: string): boolean {
}
/**
* The only way a URL leaves a preview. Every condition is load-bearing: the sender must be a live
* preview guest still bound to a grant, it must be the contents the reader is looking at, and the
* target must be the web. Anything else is dropped without a trace the document could observe.
* The only way a URL leaves a preview. Every condition is load-bearing: the isolated preload must
* report a trusted anchor click, the sender must be a live preview guest still bound to a grant,
* and the target must be the web. Anything else is dropped without a trace the document can observe.
*/
export function reportDocPreviewLinkClick(sender: Electron.WebContents, rawUrl: string): void {
const registration = previewGuests.get(sender)
@@ -234,11 +232,6 @@ export function reportDocPreviewLinkClick(sender: Electron.WebContents, rawUrl:
if (boundGrantId === null || !getDocPreviewGrant(boundGrantId)) {
return
}
// Why focus and not just the preload's trusted-click check: that check runs inside the guest, so
// it holds only while the guest renderer does. Focus is the half main can verify for itself.
if (!registration.isFocused()) {
return
}
const externalUrl = normalizeExternalBrowserUrl(rawUrl)
if (!externalUrl || !isWebUrl(externalUrl)) {
return
@@ -37,6 +37,7 @@ import {
revokeAllDocPreviewGrants
} from '../browser/doc-preview-grant-registry'
import {
DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL,
DOC_PREVIEW_LINK_CLICK_CHANNEL,
DOC_PREVIEW_MINT_GRANT_CHANNEL,
DOC_PREVIEW_REVOKE_GRANT_CHANNEL,
@@ -45,6 +46,7 @@ import {
const REQUEST: DocPreviewGrantRequest = {
owner: { kind: 'ssh', connectionId: 'ssh-1' },
requestBase: '/home/alice/docs',
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: 'doc-page-1'
@@ -68,6 +70,14 @@ function revoke(grantId: string): boolean {
return handler({ sender }, grantId) as boolean
}
function authorize(grantId: unknown, relativePath: unknown): boolean {
const handler = mocks.handlers.get(DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL)
if (!handler) {
throw new Error('authorize handler not registered')
}
return handler({ sender }, grantId, relativePath) as boolean
}
function reportLinkClick(url: unknown): void {
const listener = mocks.listeners.get(DOC_PREVIEW_LINK_CLICK_CHANNEL)
if (!listener) {
@@ -137,6 +147,31 @@ describe('document preview grant handlers', () => {
expect(getDocPreviewGrant(result.grantId)).not.toBeNull()
})
it('lets only the trusted renderer authorize a valid requested directory', () => {
const result = mint({
...REQUEST,
requestBase: '/home/alice',
root: '/home/alice/docs',
entryRelativePath: 'docs/index.html'
})
expect(authorize(result.grantId, 'assets/app.js')).toBe(true)
expect(getDocPreviewGrant(result.grantId)?.authorizedRoots).toEqual(['/home/alice/assets'])
mocks.isTrustedBrowserRenderer.mockReturnValue(false)
expect(authorize(result.grantId, 'secrets/token.txt')).toBe(false)
expect(getDocPreviewGrant(result.grantId)?.authorizedRoots).toEqual(['/home/alice/assets'])
})
it('refuses malformed directory authorization arguments', () => {
const result = mint()
expect(authorize(result.grantId, '../secret.txt')).toBe(false)
expect(authorize(result.grantId, null)).toBe(false)
expect(authorize(null, 'assets/app.js')).toBe(false)
expect(authorize('0'.repeat(32), 'assets/app.js')).toBe(false)
})
// Why this channel skips the trusted-renderer check: its sender is a preview guest rendering a
// workspace document, which is the untrusted side by construction. The guest policy holds the
// gate, so all this listener owes is the sender and a string.
+17 -3
View File
@@ -1,6 +1,7 @@
import { ipcMain } from 'electron'
import {
buildDocPreviewUrl,
DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL,
DOC_PREVIEW_LINK_CLICK_CHANNEL,
DOC_PREVIEW_MINT_GRANT_CHANNEL,
DOC_PREVIEW_REVOKE_GRANT_CHANNEL
@@ -8,6 +9,7 @@ import {
import { browserManager } from '../browser/browser-manager'
import { reportDocPreviewLinkClick } from '../browser/doc-preview-guest-policy'
import {
authorizeDocPreviewDirectory,
mintDocPreviewGrant,
revokeDocPreviewGrant,
type DocPreviewOwner
@@ -16,9 +18,11 @@ import { isTrustedBrowserRenderer } from './browser-renderer-trust'
export type DocPreviewGrantRequest = {
owner: DocPreviewOwner
/** Directory relative preview URLs resolve against. */
requestBase: string
/** Containing directory of the opened document, on the owning host. */
root: string
/** Opened document, relative to `root`. */
/** Opened document, relative to `requestBase`. */
entryRelativePath: string
/** Browser page the reader is opening the document in; main registers the guest under it. */
browserPageId: string
@@ -27,7 +31,7 @@ export type DocPreviewGrantRequest = {
export type DocPreviewGrantResult = { grantId: string; url: string }
function isValidGrantRequest(request: DocPreviewGrantRequest): boolean {
if (!request.root.trim() || !request.entryRelativePath.trim()) {
if (!request.requestBase.trim() || !request.root.trim() || !request.entryRelativePath.trim()) {
return false
}
if (typeof request.browserPageId !== 'string' || !request.browserPageId.trim()) {
@@ -69,6 +73,7 @@ export function registerDocPreviewGrantHandlers(): void {
}
const grant = mintDocPreviewGrant({
owner: request.owner,
requestBase: request.requestBase,
root: request.root,
entryRelativePath: request.entryRelativePath,
browserPageId: request.browserPageId
@@ -84,9 +89,18 @@ export function registerDocPreviewGrantHandlers(): void {
isTrustedBrowserRenderer(event.sender) ? revokeDocPreviewGrant(grantId) : false
)
ipcMain.handle(
DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL,
(event, grantId: unknown, relativePath: unknown): boolean =>
isTrustedBrowserRenderer(event.sender) &&
typeof grantId === 'string' &&
typeof relativePath === 'string' &&
authorizeDocPreviewDirectory(grantId, relativePath)
)
// Why no trusted-renderer check here: the sender is a preview guest rendering a workspace
// document, which is exactly the untrusted side. `reportDocPreviewLinkClick` is the gate — a
// live bound grant, a focused guest, a web URL — and it drops everything else silently.
// live bound grant and a web URL — and it drops everything else silently.
ipcMain.on(DOC_PREVIEW_LINK_CLICK_CHANNEL, (event, url: unknown) => {
if (typeof url === 'string') {
reportDocPreviewLinkClick(event.sender, url)
+4
View File
@@ -11,6 +11,9 @@ export type DocPreviewGrantOwner =
export type DocPreviewGrantRequest = {
owner: DocPreviewGrantOwner
/** Directory that relative preview URLs resolve against. */
requestBase: string
/** Initial filesystem authority; always the opened document's directory. */
root: string
entryRelativePath: string
/** Browser page the document is being opened in; main registers its guest under this id. */
@@ -21,6 +24,7 @@ export type DocPreviewApi = {
docPreview: {
mintGrant: (request: DocPreviewGrantRequest) => Promise<{ grantId: string; url: string }>
revokeGrant: (grantId: string) => Promise<boolean>
authorizeDirectory: (grantId: string, relativePath: string) => Promise<boolean>
/** External link the preview guest tried to open; the renderer turns it into a browser tab. */
onExternalLink: (callback: (payload: { url: string }) => void) => () => void
/** Why the guest is showing an error body instead of the document. */
+1 -1
View File
@@ -9,7 +9,7 @@ const { ipcRenderer } = require('electron') as {
}
// Why no contextBridge: the document must not be able to call this. The listeners live in the
// isolated world, and main still refuses any report that does not come from a focused preview guest.
// isolated world, and main still refuses any report that does not come from a live, grant-bound preview guest.
installDocPreviewLinkInterception((url) => {
ipcRenderer.send(PRELOAD_DOC_PREVIEW_LINK_CLICK_CHANNEL, url)
})
+3
View File
@@ -11,6 +11,7 @@ import type {
import {
DOC_PREVIEW_EXTERNAL_LINK_CHANNEL,
DOC_PREVIEW_LOAD_FAILURE_CHANNEL,
DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL,
DOC_PREVIEW_MINT_GRANT_CHANNEL,
DOC_PREVIEW_REVOKE_GRANT_CHANNEL,
type DocPreviewFailure
@@ -3335,6 +3336,8 @@ const api = {
ipcRenderer.invoke(DOC_PREVIEW_MINT_GRANT_CHANNEL, request),
revokeGrant: (grantId: string): Promise<boolean> =>
ipcRenderer.invoke(DOC_PREVIEW_REVOKE_GRANT_CHANNEL, grantId),
authorizeDirectory: (grantId: string, relativePath: string): Promise<boolean> =>
ipcRenderer.invoke(DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL, grantId, relativePath),
onExternalLink: (callback: (payload: { url: string }) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: { url: string }): void =>
callback(payload)
+2
View File
@@ -3,6 +3,7 @@ import { Toaster } from '@/components/ui/sonner'
import { TooltipProvider } from '@/components/ui/tooltip'
import { ConfirmationDialogProvider } from './components/confirmation-dialog'
import { BrowserWebAuthnAccountDialog } from './components/browser-webauthn-account-dialog'
import { DocPreviewExternalLinkConfirmation } from './components/browser-pane/workspace-doc/doc-preview-external-link-confirmation'
import { LinkRoutingPreferenceDialogProvider } from './components/link-routing-preference-dialog'
import { SkillFreshnessNudge } from './components/skills/SkillFreshnessNudge'
import PinnedTabCloseDialog from './components/terminal-pane/PinnedTabCloseDialog'
@@ -90,6 +91,7 @@ function App(): React.JSX.Element {
>
<TooltipProvider delayDuration={400}>
<ConfirmationDialogProvider>
<DocPreviewExternalLinkConfirmation />
<LinkRoutingPreferenceDialogProvider>
<AppBackgroundServices />
<AppWorkspaceShell layout={layout} floatingWorkspace={floatingWorkspace} />
@@ -71,7 +71,11 @@ export function BrowserReloadControl({
onMenuOpenChange(true)
}}
>
{loading ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
{loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
@@ -18,7 +18,11 @@ const GRANT_ID = 'a'.repeat(32)
const REMINTED_GRANT_ID = 'c'.repeat(32)
const ENTRY_RELATIVE_PATH = 'doc.html'
const grantRuntime = vi.hoisted(() => ({ mints: 0, released: [] as string[] }))
const grantRuntime = vi.hoisted(() => ({
mints: 0,
released: [] as string[],
authorizations: [] as { grantId: string; relativePath: string }[]
}))
vi.mock('@/lib/doc-preview-grants', () => ({
buildDocPreviewGrantRequest: () => ({
@@ -28,6 +32,7 @@ vi.mock('@/lib/doc-preview-grants', () => ({
worktreeSelector: 'id:wt-1',
worktreeRoot: '/repo'
},
requestBase: '/repo',
root: '/repo/docs',
entryRelativePath: ENTRY_RELATIVE_PATH
}),
@@ -110,8 +115,13 @@ describe('HtmlDocPreview failure messages', () => {
failureListeners.length = 0
grantRuntime.mints = 0
grantRuntime.released = []
grantRuntime.authorizations = []
;(window as unknown as { api: unknown }).api = {
docPreview: {
authorizeDirectory: (grantId: string, relativePath: string) => {
grantRuntime.authorizations.push({ grantId, relativePath })
return Promise.resolve(true)
},
onLoadFailure: (callback: (payload: DocPreviewFailure) => void) => {
failureListeners.push(callback)
return () => {
@@ -203,6 +213,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)
@@ -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 {
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({
<span className="min-w-0 flex-1 truncate">{notice}</span>
</div>
))}
{accessRequest && !isUnavailable ? (
<DocPreviewDirectoryAccessBanner
request={accessRequest}
busy={accessRequestBusy}
worktreeRoot={worktreeRoot}
onDismiss={dismissDirectoryAccess}
onAllow={allowDirectoryAccess}
/>
) : null}
<div className="relative flex min-h-0 flex-1 overflow-hidden" ref={containerRef}>
<BrowserGuestAnnotateOverlays
markup={markup}
@@ -0,0 +1,113 @@
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.'
)
)
}
export function useDocPreviewDirectoryAccess({
grantId,
reloadRef
}: {
grantId: string | null
reloadRef: RefObject<(() => void) | null>
}): {
request: DocPreviewFileFailure | null
busy: boolean
offer: (failure: DocPreviewFileFailure) => void
reset: () => void
dismiss: () => void
allow: () => Promise<void>
} {
const [request, setRequest] = useState<DocPreviewFileFailure | null>(null)
const [busy, setBusy] = useState(false)
const dismissedDirectoriesRef = useRef(new Set<string>())
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<void>
}): React.JSX.Element {
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" />
<span className="min-w-0 flex-1 text-muted-foreground">
{translate(
'auto.components.editor.HtmlDocPreview.directoryAccessRequest',
'This preview needs access to {{path}}.',
{ path: requestedDirectoryLabel(request.relativePath, worktreeRoot) }
)}
</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}
{translate('auto.components.editor.HtmlDocPreview.allowDirectoryOnce', 'Allow once')}
</Button>
</div>
)
}
@@ -35,7 +35,7 @@ export function DocPreviewDocumentChip({
type="button"
onClick={() => void copyText()}
aria-label={copied ? copiedLabel : copyLabel}
className="flex min-w-0 flex-1 items-center gap-2 rounded-xl border border-border bg-background px-3 py-1 text-left shadow-sm hover:bg-accent/40 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"
className="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,13 +47,15 @@ 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'
)}
<span className="hidden min-w-0 items-center gap-1.5 text-xs text-muted-foreground sm: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">
<Badge variant="secondary" className="min-w-0 max-w-40 shrink truncate font-normal">
{identity.hostLabel}
</Badge>
) : null}
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ConfirmationDialogContextValue } from '@/components/confirmation-dialog-context'
const mocks = vi.hoisted(() => ({
toastError: vi.fn(),
openBrowserProfileTabInActiveWorkspace: vi.fn()
}))
vi.mock('sonner', () => ({ toast: { error: mocks.toastError } }))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => ({
openBrowserProfileTabInActiveWorkspace: mocks.openBrowserProfileTabInActiveWorkspace
})
}
}))
import { subscribeDocPreviewExternalLinkConfirmation } from './doc-preview-external-link-confirmation'
function installSubscription(confirm: ConfirmationDialogContextValue): (url: string) => void {
let listener: ((payload: { url: string }) => void) | null = null
vi.stubGlobal('window', {
api: {
docPreview: {
onExternalLink: (callback: (payload: { url: string }) => void): (() => void) => {
listener = callback
return () => {}
}
}
}
})
subscribeDocPreviewExternalLinkConfirmation(confirm)
if (!listener) {
throw new Error('external-link confirmation did not subscribe')
}
return (url) => listener?.({ url })
}
beforeEach(() => {
vi.clearAllMocks()
mocks.openBrowserProfileTabInActiveWorkspace.mockResolvedValue(true)
})
describe('document preview external-link confirmation', () => {
it('shows the exact destination and opens only after confirmation', async () => {
const confirm = vi.fn<ConfirmationDialogContextValue>().mockResolvedValue(true)
const emit = installSubscription(confirm)
emit('https://example.com/docs?source=preview')
await vi.waitFor(() => expect(confirm).toHaveBeenCalledOnce())
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Open link to example.com?',
description: 'https://example.com/docs?source=preview'
})
)
await vi.waitFor(() =>
expect(mocks.openBrowserProfileTabInActiveWorkspace).toHaveBeenCalledWith(
'https://example.com/docs?source=preview',
null
)
)
})
it('opens nothing when the reader cancels', async () => {
const confirm = vi.fn<ConfirmationDialogContextValue>().mockResolvedValue(false)
const emit = installSubscription(confirm)
emit('https://example.com/docs')
await vi.waitFor(() => expect(confirm).toHaveBeenCalledOnce())
expect(mocks.openBrowserProfileTabInActiveWorkspace).not.toHaveBeenCalled()
})
it('surfaces a confirmed link that the browser refuses', async () => {
mocks.openBrowserProfileTabInActiveWorkspace.mockResolvedValue(false)
const emit = installSubscription(
vi.fn<ConfirmationDialogContextValue>().mockResolvedValue(true)
)
emit('https://example.com/docs')
await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
})
})
@@ -0,0 +1,75 @@
import { useEffect } from 'react'
import { toast } from 'sonner'
import {
useConfirmationDialog,
type ConfirmationDialogContextValue
} from '@/components/confirmation-dialog-context'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
function displayHost(url: string): string {
try {
return new URL(url).host
} catch {
return url
}
}
async function openConfirmedExternalLink(url: string): Promise<void> {
try {
const opened = await useAppStore.getState().openBrowserProfileTabInActiveWorkspace(url, null)
if (opened) {
return
}
} catch {
// The same reader-facing result covers refusal and rejection.
}
toast.error(
translate(
'auto.hooks.ipc.events.browserStateIpcBridge.docPreviewLinkFailed',
'Could not open this link in Orca Browser.'
)
)
}
export function subscribeDocPreviewExternalLinkConfirmation(
confirm: ConfirmationDialogContextValue
): () => void {
if (typeof window.api.docPreview?.onExternalLink !== 'function') {
return () => {}
}
let active = true
const unsubscribe = window.api.docPreview.onExternalLink(({ url }) => {
void confirm({
title: translate(
'auto.components.browserPane.workspaceDoc.externalLinkTitle',
'Open link to {{host}}?',
{ host: displayHost(url) }
),
description: url,
descriptionClassName: 'break-all font-mono text-xs',
confirmLabel: translate(
'auto.components.browserPane.workspaceDoc.externalLinkConfirm',
'Open link'
),
cancelLabel: translate(
'auto.components.browserPane.workspaceDoc.externalLinkCancel',
'Cancel'
)
}).then((confirmed) => {
if (active && confirmed) {
void openConfirmedExternalLink(url)
}
})
})
return () => {
active = false
unsubscribe()
}
}
export function DocPreviewExternalLinkConfirmation(): null {
const confirm = useConfirmationDialog()
useEffect(() => subscribeDocPreviewExternalLinkConfirmation(confirm), [confirm])
return null
}
@@ -5,6 +5,7 @@ import { createContext, useContext } from 'react'
export type ConfirmationDialogOptions = {
title: string
description?: string
descriptionClassName?: string
confirmLabel?: string
cancelLabel?: string
confirmVariant?: 'default' | 'destructive'
@@ -18,6 +18,7 @@ import {
} from '@/components/confirmation-dialog-context'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
type ConfirmationDialogRequest = {
id: number
@@ -100,7 +101,9 @@ export function ConfirmationDialogProvider({
<DialogTitle>{displayedRequest?.options.title}</DialogTitle>
{displayedRequest?.options.description ? (
// Callers pass multi-line descriptions (e.g. one path per line).
<DialogDescription className="whitespace-pre-line">
<DialogDescription
className={cn('whitespace-pre-line', displayedRequest.options.descriptionClassName)}
>
{displayedRequest.options.description}
</DialogDescription>
) : null}
@@ -1,6 +1,4 @@
import { toast } from 'sonner'
import { rememberLiveBrowserUrl } from '@/components/browser-pane/describe-page/live-browser-url-registry'
import { translate } from '@/i18n/i18n'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { redactKagiSessionToken } from '../../../../shared/browser-url'
import { useAppStore } from '../../store'
@@ -107,31 +105,4 @@ export function registerBrowserStateIpcBridge(
})
})
)
// Why: the doc-preview scheme is desktop-only, so hosts without it (web client) simply have no channel.
if (typeof window.api.docPreview?.onExternalLink === 'function') {
unsubs.push(
window.api.docPreview.onExternalLink(({ url }) => {
// Why: an external link in a doc preview leaves the preview entirely — it becomes a normal
// browser tab through the same path as any other new tab, local or paired.
// Why: the click already left the preview, so a refused tab is a dead end unless it says so.
const reportLinkFailure = (): void => {
toast.error(
translate(
'auto.hooks.ipc.events.browserStateIpcBridge.docPreviewLinkFailed',
'Could not open this link in Orca Browser.'
)
)
}
void useAppStore
.getState()
.openBrowserProfileTabInActiveWorkspace(url, null)
.then((opened) => {
if (!opened) {
reportLinkFailure()
}
})
.catch(reportLinkFailure)
})
)
}
}
@@ -1,92 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
toastError: vi.fn(),
openBrowserProfileTabInActiveWorkspace: vi.fn()
}))
vi.mock('sonner', () => ({ toast: { error: mocks.toastError } }))
vi.mock('@/components/browser-pane/describe-page/live-browser-url-registry', () => ({
rememberLiveBrowserUrl: vi.fn()
}))
vi.mock('@/lib/worktree-runtime-owner', () => ({ getRuntimeEnvironmentIdForWorktree: () => null }))
vi.mock('./browser-automation-bootstrap-lease', () => ({
acquireBrowserAutomationBootstrapLease: vi.fn()
}))
vi.mock('../../store', () => ({
useAppStore: {
getState: () => ({
openBrowserProfileTabInActiveWorkspace: mocks.openBrowserProfileTabInActiveWorkspace,
remoteBrowserPageHandlesByPageId: {}
})
}
}))
import { registerBrowserStateIpcBridge } from './browser-state-ipc-bridge'
/** Every channel the bridge subscribes to, stubbed; only the preview link one is exercised here. */
function installBridge(): (payload: { url: string }) => void {
let externalLinkHandler: ((payload: { url: string }) => void) | null = null
const noopSubscribe = (): (() => void) => () => {}
vi.stubGlobal('window', {
api: {
ui: { onFullscreenChanged: noopSubscribe },
browser: new Proxy(
{},
{
get: () => (): (() => void) => () => {}
}
),
docPreview: {
onExternalLink: (callback: (payload: { url: string }) => void): (() => void) => {
externalLinkHandler = callback
return () => {}
}
}
}
})
registerBrowserStateIpcBridge([], () => false)
if (!externalLinkHandler) {
throw new Error('bridge did not subscribe to the doc preview external link channel')
}
return externalLinkHandler
}
beforeEach(() => {
vi.clearAllMocks()
mocks.openBrowserProfileTabInActiveWorkspace.mockResolvedValue(true)
})
describe('doc preview external links', () => {
it('routes an external link into a browser tab', async () => {
installBridge()({ url: 'https://example.com/docs' })
await vi.waitFor(() =>
expect(mocks.openBrowserProfileTabInActiveWorkspace).toHaveBeenCalledWith(
'https://example.com/docs',
null
)
)
expect(mocks.toastError).not.toHaveBeenCalled()
})
// Why: the click already left the preview behind, so a refused tab is a dead end unless it says
// so — the store reports that refusal by returning false, not by throwing.
it('surfaces a refused tab instead of dropping the click', async () => {
mocks.openBrowserProfileTabInActiveWorkspace.mockResolvedValue(false)
installBridge()({ url: 'https://example.com/docs' })
await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
})
// Why the same sentence for a rejection: to the reader a tab that threw and a tab that was refused
// are the same dead end, and an unhandled rejection would leave the press with no answer at all.
it('surfaces a tab that failed rather than refused', async () => {
mocks.openBrowserProfileTabInActiveWorkspace.mockRejectedValue(new Error('no workspace'))
installBridge()({ url: 'https://example.com/docs' })
await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
})
})
@@ -15,7 +15,6 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [
'browser.onNavigationUpdate',
'browser.onOpenLinkInOrcaTab',
'browser.onPaneFocus',
'docPreview.onExternalLink',
'emulator.onAutoAttach',
'emulator.onPaneFocus',
'gh.onPRRefreshEvent',
@@ -160,7 +159,6 @@ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [
'browser.onActivateView',
'browser.onPaneFocus',
'browser.onOpenLinkInOrcaTab',
'docPreview.onExternalLink',
'ui.onNewBrowserTab',
'ui.onNewMarkdownTab',
'ui.onNewSimulatorTab',
@@ -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'
}
+7 -8
View File
@@ -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
}
@@ -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, {
+6
View File
@@ -1351,6 +1351,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 +1363,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 +1434,10 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
closeRemoteBrowserPageInOwningEnvironment(remotePage.worktreeId, remotePage.handle)
}
if (docPageIdToRelease) {
releaseDocPreviewGrant(docPageIdToRelease)
}
const closedWorkspaceId = closedWorkspaceIdForLabel
if (!closedWorkspaceId) {
return
+7 -2
View File
@@ -11,10 +11,11 @@ export const DOC_PREVIEW_PARTITION = 'orca-doc-preview'
export const DOC_PREVIEW_MINT_GRANT_CHANNEL = 'docPreview:mintGrant'
export const DOC_PREVIEW_REVOKE_GRANT_CHANNEL = 'docPreview:revokeGrant'
export const DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL = 'docPreview:authorizeDirectory'
export const DOC_PREVIEW_EXTERNAL_LINK_CHANNEL = 'docPreview:externalLink'
/**
* The preview guest's preload reports a trusted anchor click here. Renderer↔main only — no paired
* client ever sees it, and main gates every report on the sender being a focused preview guest.
* client ever sees it, and main gates every report on the sender being a live, grant-bound preview guest.
*/
export const DOC_PREVIEW_LINK_CLICK_CHANNEL = 'docPreview:linkClick'
/** The one out-of-band route from the preview's main-side fences to the shell hosting it. */
@@ -22,7 +23,11 @@ export const DOC_PREVIEW_LOAD_FAILURE_CHANNEL = 'docPreview:loadFailure'
/** Why: an unreadable document still answers with a real HTTP status, so the guest paints the
* handler's plain-text body instead of failing to load. The shell needs the reason out-of-band. */
export type DocPreviewFileFailureReason = 'too-large' | 'unsupported-asset' | 'unreadable'
export type DocPreviewFileFailureReason =
| 'authorization-required'
| 'too-large'
| 'unsupported-asset'
| 'unreadable'
export type DocPreviewFileFailure = {
grantId: string
@@ -1,4 +1,4 @@
import { writeFileSync } from 'node:fs'
import { mkdirSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import type { Locator, Page, TestInfo } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
@@ -37,6 +37,9 @@ const SCRIPTED_EGRESS_URL = 'https://exfil.test/?d=scripted'
/** The same exfiltration, but riding a press the reader really made somewhere else in the document. */
const POST_INPUT_EGRESS_URL = 'https://exfil.test/?d=after-input'
const BLANK_PAGE_URL = 'data:text/html,'
const SCOPED_FIXTURE_NAME = 'scoped-preview.html'
const SCOPED_FIXTURE_HEADING = 'scoped preview rendered'
const SCOPED_ASSET_TEXT = 'approved sibling asset loaded'
type PreparedPairedClient = {
client: PairedElectronClient
@@ -286,6 +289,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),
`<!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 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