mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 00:03:59 +00:00
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:
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -11,6 +11,9 @@ export type DocPreviewGrantOwner =
|
||||
|
||||
export type DocPreviewGrantRequest = {
|
||||
owner: DocPreviewGrantOwner
|
||||
/** Directory that relative preview URLs resolve against. */
|
||||
requestBase: string
|
||||
/** Initial filesystem authority; always the opened document's directory. */
|
||||
root: string
|
||||
entryRelativePath: string
|
||||
/** Browser page the document is being opened in; main registers its guest under this id. */
|
||||
@@ -21,6 +24,7 @@ export type DocPreviewApi = {
|
||||
docPreview: {
|
||||
mintGrant: (request: DocPreviewGrantRequest) => Promise<{ grantId: string; url: string }>
|
||||
revokeGrant: (grantId: string) => Promise<boolean>
|
||||
authorizeDirectory: (grantId: string, relativePath: string) => Promise<boolean>
|
||||
/** External link the preview guest tried to open; the renderer turns it into a browser tab. */
|
||||
onExternalLink: (callback: (payload: { url: string }) => void) => () => void
|
||||
/** Why the guest is showing an error body instead of the document. */
|
||||
|
||||
@@ -9,7 +9,7 @@ const { ipcRenderer } = require('electron') as {
|
||||
}
|
||||
|
||||
// Why no contextBridge: the document must not be able to call this. The listeners live in the
|
||||
// isolated world, and main still refuses any report that does not come from a focused preview guest.
|
||||
// isolated world, and main still refuses any report that does not come from a live, grant-bound preview guest.
|
||||
installDocPreviewLinkInterception((url) => {
|
||||
ipcRenderer.send(PRELOAD_DOC_PREVIEW_LINK_CLICK_CHANNEL, url)
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
import {
|
||||
DOC_PREVIEW_EXTERNAL_LINK_CHANNEL,
|
||||
DOC_PREVIEW_LOAD_FAILURE_CHANNEL,
|
||||
DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL,
|
||||
DOC_PREVIEW_MINT_GRANT_CHANNEL,
|
||||
DOC_PREVIEW_REVOKE_GRANT_CHANNEL,
|
||||
type DocPreviewFailure
|
||||
@@ -3335,6 +3336,8 @@ const api = {
|
||||
ipcRenderer.invoke(DOC_PREVIEW_MINT_GRANT_CHANNEL, request),
|
||||
revokeGrant: (grantId: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke(DOC_PREVIEW_REVOKE_GRANT_CHANNEL, grantId),
|
||||
authorizeDirectory: (grantId: string, relativePath: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke(DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL, grantId, relativePath),
|
||||
onExternalLink: (callback: (payload: { url: string }) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload: { url: string }): void =>
|
||||
callback(payload)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Toaster } from '@/components/ui/sonner'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { ConfirmationDialogProvider } from './components/confirmation-dialog'
|
||||
import { BrowserWebAuthnAccountDialog } from './components/browser-webauthn-account-dialog'
|
||||
import { DocPreviewExternalLinkConfirmation } from './components/browser-pane/workspace-doc/doc-preview-external-link-confirmation'
|
||||
import { LinkRoutingPreferenceDialogProvider } from './components/link-routing-preference-dialog'
|
||||
import { SkillFreshnessNudge } from './components/skills/SkillFreshnessNudge'
|
||||
import PinnedTabCloseDialog from './components/terminal-pane/PinnedTabCloseDialog'
|
||||
@@ -90,6 +91,7 @@ function App(): React.JSX.Element {
|
||||
>
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<ConfirmationDialogProvider>
|
||||
<DocPreviewExternalLinkConfirmation />
|
||||
<LinkRoutingPreferenceDialogProvider>
|
||||
<AppBackgroundServices />
|
||||
<AppWorkspaceShell layout={layout} floatingWorkspace={floatingWorkspace} />
|
||||
|
||||
@@ -71,7 +71,11 @@ export function BrowserReloadControl({
|
||||
onMenuOpenChange(true)
|
||||
}}
|
||||
>
|
||||
{loading ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
|
||||
{loading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
|
||||
+112
-1
@@ -18,7 +18,11 @@ const GRANT_ID = 'a'.repeat(32)
|
||||
const REMINTED_GRANT_ID = 'c'.repeat(32)
|
||||
const ENTRY_RELATIVE_PATH = 'doc.html'
|
||||
|
||||
const grantRuntime = vi.hoisted(() => ({ mints: 0, released: [] as string[] }))
|
||||
const grantRuntime = vi.hoisted(() => ({
|
||||
mints: 0,
|
||||
released: [] as string[],
|
||||
authorizations: [] as { grantId: string; relativePath: string }[]
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/doc-preview-grants', () => ({
|
||||
buildDocPreviewGrantRequest: () => ({
|
||||
@@ -28,6 +32,7 @@ vi.mock('@/lib/doc-preview-grants', () => ({
|
||||
worktreeSelector: 'id:wt-1',
|
||||
worktreeRoot: '/repo'
|
||||
},
|
||||
requestBase: '/repo',
|
||||
root: '/repo/docs',
|
||||
entryRelativePath: ENTRY_RELATIVE_PATH
|
||||
}),
|
||||
@@ -110,8 +115,13 @@ describe('HtmlDocPreview failure messages', () => {
|
||||
failureListeners.length = 0
|
||||
grantRuntime.mints = 0
|
||||
grantRuntime.released = []
|
||||
grantRuntime.authorizations = []
|
||||
;(window as unknown as { api: unknown }).api = {
|
||||
docPreview: {
|
||||
authorizeDirectory: (grantId: string, relativePath: string) => {
|
||||
grantRuntime.authorizations.push({ grantId, relativePath })
|
||||
return Promise.resolve(true)
|
||||
},
|
||||
onLoadFailure: (callback: (payload: DocPreviewFailure) => void) => {
|
||||
failureListeners.push(callback)
|
||||
return () => {
|
||||
@@ -203,6 +213,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)
|
||||
|
||||
+2
-3
@@ -484,9 +484,8 @@ describe('HtmlDocPreview browser chrome', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Why this is a test and not left to the pane: main answers a reported link click only from a
|
||||
// focused guest, so a preview whose guest never takes focus has no route out at all — the failure
|
||||
// is silent, and only the reader pressing a link ever sees it.
|
||||
// Why this is a test and not left to the pane: a preview has no address bar to hand focus to the
|
||||
// document, so without this its keyboard and link input can silently land outside the visible guest.
|
||||
describe('HtmlDocPreview guest focus', () => {
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
@@ -19,6 +19,10 @@ import { selectWorktreeHostDisplayLabel } from '@/lib/execution-host-display-lab
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store'
|
||||
import { openDocPreviewExternally, openDocPreviewSource } from './doc-preview-document-actions'
|
||||
import {
|
||||
DocPreviewDirectoryAccessBanner,
|
||||
useDocPreviewDirectoryAccess
|
||||
} from './doc-preview-directory-access'
|
||||
import { buildDocPreviewDocumentIdentity } from './doc-preview-document-identity'
|
||||
import {
|
||||
docPreviewAssetNotice,
|
||||
@@ -108,6 +112,7 @@ function attachDocPreviewWebview({
|
||||
|
||||
/** Frames a preview keeps offering focus to a guest that is still attaching. */
|
||||
const GUEST_FOCUS_FRAMES = 10
|
||||
const MAX_ASSET_FAILURES = 50
|
||||
|
||||
export function HtmlDocPreview({
|
||||
previewId,
|
||||
@@ -136,6 +141,14 @@ export function HtmlDocPreview({
|
||||
const [downloadBlocked, setDownloadBlocked] = useState(false)
|
||||
const [remintCount, setRemintCount] = useState(0)
|
||||
const [grantId, setGrantId] = useState<string | null>(null)
|
||||
const {
|
||||
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}
|
||||
|
||||
+113
@@ -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>
|
||||
)
|
||||
}
|
||||
+9
-7
@@ -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}
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ConfirmationDialogContextValue } from '@/components/confirmation-dialog-context'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
toastError: vi.fn(),
|
||||
openBrowserProfileTabInActiveWorkspace: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: mocks.toastError } }))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => ({
|
||||
openBrowserProfileTabInActiveWorkspace: mocks.openBrowserProfileTabInActiveWorkspace
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
import { subscribeDocPreviewExternalLinkConfirmation } from './doc-preview-external-link-confirmation'
|
||||
|
||||
function installSubscription(confirm: ConfirmationDialogContextValue): (url: string) => void {
|
||||
let listener: ((payload: { url: string }) => void) | null = null
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
docPreview: {
|
||||
onExternalLink: (callback: (payload: { url: string }) => void): (() => void) => {
|
||||
listener = callback
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
subscribeDocPreviewExternalLinkConfirmation(confirm)
|
||||
if (!listener) {
|
||||
throw new Error('external-link confirmation did not subscribe')
|
||||
}
|
||||
return (url) => listener?.({ url })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.openBrowserProfileTabInActiveWorkspace.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
describe('document preview external-link confirmation', () => {
|
||||
it('shows the exact destination and opens only after confirmation', async () => {
|
||||
const confirm = vi.fn<ConfirmationDialogContextValue>().mockResolvedValue(true)
|
||||
const emit = installSubscription(confirm)
|
||||
|
||||
emit('https://example.com/docs?source=preview')
|
||||
|
||||
await vi.waitFor(() => expect(confirm).toHaveBeenCalledOnce())
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Open link to example.com?',
|
||||
description: 'https://example.com/docs?source=preview'
|
||||
})
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.openBrowserProfileTabInActiveWorkspace).toHaveBeenCalledWith(
|
||||
'https://example.com/docs?source=preview',
|
||||
null
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('opens nothing when the reader cancels', async () => {
|
||||
const confirm = vi.fn<ConfirmationDialogContextValue>().mockResolvedValue(false)
|
||||
const emit = installSubscription(confirm)
|
||||
|
||||
emit('https://example.com/docs')
|
||||
|
||||
await vi.waitFor(() => expect(confirm).toHaveBeenCalledOnce())
|
||||
expect(mocks.openBrowserProfileTabInActiveWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a confirmed link that the browser refuses', async () => {
|
||||
mocks.openBrowserProfileTabInActiveWorkspace.mockResolvedValue(false)
|
||||
const emit = installSubscription(
|
||||
vi.fn<ConfirmationDialogContextValue>().mockResolvedValue(true)
|
||||
)
|
||||
|
||||
emit('https://example.com/docs')
|
||||
|
||||
await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
|
||||
})
|
||||
})
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
useConfirmationDialog,
|
||||
type ConfirmationDialogContextValue
|
||||
} from '@/components/confirmation-dialog-context'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
function displayHost(url: string): string {
|
||||
try {
|
||||
return new URL(url).host
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
async function openConfirmedExternalLink(url: string): Promise<void> {
|
||||
try {
|
||||
const opened = await useAppStore.getState().openBrowserProfileTabInActiveWorkspace(url, null)
|
||||
if (opened) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// The same reader-facing result covers refusal and rejection.
|
||||
}
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.hooks.ipc.events.browserStateIpcBridge.docPreviewLinkFailed',
|
||||
'Could not open this link in Orca Browser.'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function subscribeDocPreviewExternalLinkConfirmation(
|
||||
confirm: ConfirmationDialogContextValue
|
||||
): () => void {
|
||||
if (typeof window.api.docPreview?.onExternalLink !== 'function') {
|
||||
return () => {}
|
||||
}
|
||||
let active = true
|
||||
const unsubscribe = window.api.docPreview.onExternalLink(({ url }) => {
|
||||
void confirm({
|
||||
title: translate(
|
||||
'auto.components.browserPane.workspaceDoc.externalLinkTitle',
|
||||
'Open link to {{host}}?',
|
||||
{ host: displayHost(url) }
|
||||
),
|
||||
description: url,
|
||||
descriptionClassName: 'break-all font-mono text-xs',
|
||||
confirmLabel: translate(
|
||||
'auto.components.browserPane.workspaceDoc.externalLinkConfirm',
|
||||
'Open link'
|
||||
),
|
||||
cancelLabel: translate(
|
||||
'auto.components.browserPane.workspaceDoc.externalLinkCancel',
|
||||
'Cancel'
|
||||
)
|
||||
}).then((confirmed) => {
|
||||
if (active && confirmed) {
|
||||
void openConfirmedExternalLink(url)
|
||||
}
|
||||
})
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
export function DocPreviewExternalLinkConfirmation(): null {
|
||||
const confirm = useConfirmationDialog()
|
||||
useEffect(() => subscribeDocPreviewExternalLinkConfirmation(confirm), [confirm])
|
||||
return null
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { createContext, useContext } from 'react'
|
||||
export type ConfirmationDialogOptions = {
|
||||
title: string
|
||||
description?: string
|
||||
descriptionClassName?: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
confirmVariant?: 'default' | 'destructive'
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@/components/confirmation-dialog-context'
|
||||
import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type ConfirmationDialogRequest = {
|
||||
id: number
|
||||
@@ -100,7 +101,9 @@ export function ConfirmationDialogProvider({
|
||||
<DialogTitle>{displayedRequest?.options.title}</DialogTitle>
|
||||
{displayedRequest?.options.description ? (
|
||||
// Callers pass multi-line descriptions (e.g. one path per line).
|
||||
<DialogDescription className="whitespace-pre-line">
|
||||
<DialogDescription
|
||||
className={cn('whitespace-pre-line', displayedRequest.options.descriptionClassName)}
|
||||
>
|
||||
{displayedRequest.options.description}
|
||||
</DialogDescription>
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { toast } from 'sonner'
|
||||
import { rememberLiveBrowserUrl } from '@/components/browser-pane/describe-page/live-browser-url-registry'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { redactKagiSessionToken } from '../../../../shared/browser-url'
|
||||
import { useAppStore } from '../../store'
|
||||
@@ -107,31 +105,4 @@ export function registerBrowserStateIpcBridge(
|
||||
})
|
||||
})
|
||||
)
|
||||
// Why: the doc-preview scheme is desktop-only, so hosts without it (web client) simply have no channel.
|
||||
if (typeof window.api.docPreview?.onExternalLink === 'function') {
|
||||
unsubs.push(
|
||||
window.api.docPreview.onExternalLink(({ url }) => {
|
||||
// Why: an external link in a doc preview leaves the preview entirely — it becomes a normal
|
||||
// browser tab through the same path as any other new tab, local or paired.
|
||||
// Why: the click already left the preview, so a refused tab is a dead end unless it says so.
|
||||
const reportLinkFailure = (): void => {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.hooks.ipc.events.browserStateIpcBridge.docPreviewLinkFailed',
|
||||
'Could not open this link in Orca Browser.'
|
||||
)
|
||||
)
|
||||
}
|
||||
void useAppStore
|
||||
.getState()
|
||||
.openBrowserProfileTabInActiveWorkspace(url, null)
|
||||
.then((opened) => {
|
||||
if (!opened) {
|
||||
reportLinkFailure()
|
||||
}
|
||||
})
|
||||
.catch(reportLinkFailure)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
toastError: vi.fn(),
|
||||
openBrowserProfileTabInActiveWorkspace: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: mocks.toastError } }))
|
||||
vi.mock('@/components/browser-pane/describe-page/live-browser-url-registry', () => ({
|
||||
rememberLiveBrowserUrl: vi.fn()
|
||||
}))
|
||||
vi.mock('@/lib/worktree-runtime-owner', () => ({ getRuntimeEnvironmentIdForWorktree: () => null }))
|
||||
vi.mock('./browser-automation-bootstrap-lease', () => ({
|
||||
acquireBrowserAutomationBootstrapLease: vi.fn()
|
||||
}))
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => ({
|
||||
openBrowserProfileTabInActiveWorkspace: mocks.openBrowserProfileTabInActiveWorkspace,
|
||||
remoteBrowserPageHandlesByPageId: {}
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
import { registerBrowserStateIpcBridge } from './browser-state-ipc-bridge'
|
||||
|
||||
/** Every channel the bridge subscribes to, stubbed; only the preview link one is exercised here. */
|
||||
function installBridge(): (payload: { url: string }) => void {
|
||||
let externalLinkHandler: ((payload: { url: string }) => void) | null = null
|
||||
const noopSubscribe = (): (() => void) => () => {}
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
ui: { onFullscreenChanged: noopSubscribe },
|
||||
browser: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: () => (): (() => void) => () => {}
|
||||
}
|
||||
),
|
||||
docPreview: {
|
||||
onExternalLink: (callback: (payload: { url: string }) => void): (() => void) => {
|
||||
externalLinkHandler = callback
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
registerBrowserStateIpcBridge([], () => false)
|
||||
if (!externalLinkHandler) {
|
||||
throw new Error('bridge did not subscribe to the doc preview external link channel')
|
||||
}
|
||||
return externalLinkHandler
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.openBrowserProfileTabInActiveWorkspace.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
describe('doc preview external links', () => {
|
||||
it('routes an external link into a browser tab', async () => {
|
||||
installBridge()({ url: 'https://example.com/docs' })
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.openBrowserProfileTabInActiveWorkspace).toHaveBeenCalledWith(
|
||||
'https://example.com/docs',
|
||||
null
|
||||
)
|
||||
)
|
||||
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: the click already left the preview behind, so a refused tab is a dead end unless it says
|
||||
// so — the store reports that refusal by returning false, not by throwing.
|
||||
it('surfaces a refused tab instead of dropping the click', async () => {
|
||||
mocks.openBrowserProfileTabInActiveWorkspace.mockResolvedValue(false)
|
||||
|
||||
installBridge()({ url: 'https://example.com/docs' })
|
||||
|
||||
await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
// Why the same sentence for a rejection: to the reader a tab that threw and a tab that was refused
|
||||
// are the same dead end, and an unhandled rejection would leave the press with no answer at all.
|
||||
it('surfaces a tab that failed rather than refused', async () => {
|
||||
mocks.openBrowserProfileTabInActiveWorkspace.mockRejectedValue(new Error('no workspace'))
|
||||
|
||||
installBridge()({ url: 'https://example.com/docs' })
|
||||
|
||||
await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
|
||||
})
|
||||
})
|
||||
@@ -15,7 +15,6 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [
|
||||
'browser.onNavigationUpdate',
|
||||
'browser.onOpenLinkInOrcaTab',
|
||||
'browser.onPaneFocus',
|
||||
'docPreview.onExternalLink',
|
||||
'emulator.onAutoAttach',
|
||||
'emulator.onPaneFocus',
|
||||
'gh.onPRRefreshEvent',
|
||||
@@ -160,7 +159,6 @@ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [
|
||||
'browser.onActivateView',
|
||||
'browser.onPaneFocus',
|
||||
'browser.onOpenLinkInOrcaTab',
|
||||
'docPreview.onExternalLink',
|
||||
'ui.onNewBrowserTab',
|
||||
'ui.onNewMarkdownTab',
|
||||
'ui.onNewSimulatorTab',
|
||||
|
||||
@@ -33,25 +33,25 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('buildDocPreviewGrantRequest', () => {
|
||||
// Why: SSH previews are unrestricted by design, and a document outside every workspace has no
|
||||
// boundary to root a grant in.
|
||||
// A document outside every workspace resolves and authorizes from its own directory.
|
||||
it('roots an SSH grant outside the workspace at the document directory', () => {
|
||||
mocks.connectionId = 'ssh-1'
|
||||
|
||||
expect(buildDocPreviewGrantRequest(state, 'wt-1', '/home/alice/docs/report.html')).toEqual({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
requestBase: '/home/alice/docs',
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'report.html'
|
||||
})
|
||||
})
|
||||
|
||||
// Why: reports keep their assets in a sibling directory, so `../assets/app.css` has to resolve.
|
||||
it('roots a document inside the workspace at the workspace root', () => {
|
||||
it('resolves from the workspace but authorizes only the document directory', () => {
|
||||
mocks.connectionId = 'ssh-1'
|
||||
|
||||
expect(buildDocPreviewGrantRequest(state, 'wt-1', '/srv/repo/docs/report.html')).toEqual({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/srv/repo',
|
||||
requestBase: '/srv/repo',
|
||||
root: '/srv/repo/docs',
|
||||
entryRelativePath: 'docs/report.html'
|
||||
})
|
||||
})
|
||||
@@ -66,7 +66,8 @@ describe('buildDocPreviewGrantRequest', () => {
|
||||
worktreeSelector: 'id:wt-1',
|
||||
worktreeRoot: '/srv/repo'
|
||||
},
|
||||
root: '/srv/repo',
|
||||
requestBase: '/srv/repo',
|
||||
root: '/srv/repo/docs',
|
||||
entryRelativePath: 'docs/report.html'
|
||||
})
|
||||
})
|
||||
@@ -87,6 +88,7 @@ describe('doc preview grant lifetime', () => {
|
||||
it('mints once for repeated mounts of the same preview tab', async () => {
|
||||
const request = {
|
||||
owner: { kind: 'ssh' as const, connectionId: 'ssh-1' },
|
||||
requestBase: '/d',
|
||||
root: '/d',
|
||||
entryRelativePath: 'a.html'
|
||||
}
|
||||
@@ -106,6 +108,7 @@ describe('doc preview grant lifetime', () => {
|
||||
it('revokes on release and mints fresh afterwards', async () => {
|
||||
const request = {
|
||||
owner: { kind: 'ssh' as const, connectionId: 'ssh-1' },
|
||||
requestBase: '/d',
|
||||
root: '/d',
|
||||
entryRelativePath: 'a.html'
|
||||
}
|
||||
@@ -129,6 +132,7 @@ describe('doc preview grant lifetime', () => {
|
||||
it('leaves the entry of a later mint alone when an earlier one rejects', async () => {
|
||||
const request = {
|
||||
owner: { kind: 'ssh' as const, connectionId: 'ssh-1' },
|
||||
requestBase: '/d',
|
||||
root: '/d',
|
||||
entryRelativePath: 'a.html'
|
||||
}
|
||||
@@ -157,6 +161,7 @@ describe('doc preview grant lifetime', () => {
|
||||
mocks.mintGrant.mockRejectedValueOnce(new Error('runtime offline'))
|
||||
const request = {
|
||||
owner: { kind: 'ssh' as const, connectionId: 'ssh-1' },
|
||||
requestBase: '/d',
|
||||
root: '/d',
|
||||
entryRelativePath: 'a.html'
|
||||
}
|
||||
|
||||
@@ -23,20 +23,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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user