mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
fix(browser-preview): entry-file-only authority for root-level docs, contained chip layout, re-issued gate paths (STA-5758)
A grant whose document directory is its own request base — a doc at the workspace root, or outside any workspace — now reads nothing but the entry file until the reader approves a directory, at both the lexical and the canonical containment pass. The DNS-prefetch residual can only beacon what the page can read, and a root-level document could previously read the whole worktree silently. The identity chip's host badge overflowed the chip's layout box under squeeze (Linux CI): every row member can now shrink and truncate, verified by a width sweep in isolated Chromium down to ~120px chips. The Allow banner says what it grants: 'Allow folder', reading files in the named directory, for the life of the preview. The reliability-gate manifest command, testFiles entry, assertion refs and dated evidence naming the deleted doc-preview-external-link-bridge.test.ts are re-issued at doc-preview-external-link-confirmation.test.ts with a fresh 189/189 run; the focus-gate assertion text follows the shipped gate.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -23,17 +23,21 @@ import {
|
||||
revokeAllDocPreviewGrants
|
||||
} from './doc-preview-grant-registry'
|
||||
|
||||
// Why the fixtures approve the document directory up front: these tests exercise the transport
|
||||
// half of a read — an entry-only grant's approval flow is pinned in its own test below.
|
||||
function sshGrant(): ReturnType<typeof mintDocPreviewGrant> {
|
||||
return mintDocPreviewGrant({
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
authorizeDocPreviewDirectory(grant.id, grant.entryRelativePath)
|
||||
return grant
|
||||
}
|
||||
|
||||
function runtimeGrant(root = '/srv/repo/docs'): ReturnType<typeof mintDocPreviewGrant> {
|
||||
return mintDocPreviewGrant({
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: {
|
||||
kind: 'runtime',
|
||||
environmentId: 'env-1',
|
||||
@@ -44,6 +48,8 @@ function runtimeGrant(root = '/srv/repo/docs'): ReturnType<typeof mintDocPreview
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
authorizeDocPreviewDirectory(grant.id, grant.entryRelativePath)
|
||||
return grant
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -47,13 +47,45 @@ describe('doc preview grants', () => {
|
||||
})
|
||||
|
||||
describe('resolveDocPreviewTargetPath', () => {
|
||||
it('resolves paths inside the grant root', () => {
|
||||
// A grant whose root IS its request base — a document at the workspace root, or outside any
|
||||
// workspace — starts with the entry file alone: that directory is where secrets live, and a
|
||||
// DNS-prefetch beacon needs no click, so nothing beside the entry reads without the reader.
|
||||
it('reads only the entry document until the reader approves its directory', () => {
|
||||
const grant = mintPosixGrant()
|
||||
|
||||
expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html')
|
||||
expect(resolveDocPreviewTargetPath(grant, 'assets/logo.png')).toBeNull()
|
||||
expect(resolveDocPreviewTargetPath(grant, 'secrets.env')).toBeNull()
|
||||
|
||||
expect(authorizeDocPreviewDirectory(grant.id, 'assets/logo.png')).toBe(true)
|
||||
expect(resolveDocPreviewTargetPath(grant, 'assets/logo.png')).toBe(
|
||||
'/srv/repo/docs/assets/logo.png'
|
||||
)
|
||||
expect(resolveDocPreviewTargetPath(grant, 'secrets.env')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps silent authority over a document directory strictly inside the workspace, and only there', () => {
|
||||
const nested = mintDocPreviewGrant({
|
||||
owner: sshOwner,
|
||||
requestBase: '/srv/repo',
|
||||
root: '/srv/repo/docs',
|
||||
entryRelativePath: 'docs/report.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
expect(resolveDocPreviewTargetPath(nested, 'docs/styles.css')).toBe('/srv/repo/docs/styles.css')
|
||||
|
||||
const atWorkspaceRoot = mintDocPreviewGrant({
|
||||
owner: sshOwner,
|
||||
requestBase: '/srv/repo',
|
||||
root: '/srv/repo',
|
||||
entryRelativePath: 'report.html',
|
||||
browserPageId: 'page-2'
|
||||
})
|
||||
expect(resolveDocPreviewTargetPath(atWorkspaceRoot, 'report.html')).toBe(
|
||||
'/srv/repo/report.html'
|
||||
)
|
||||
expect(resolveDocPreviewTargetPath(atWorkspaceRoot, '.env')).toBeNull()
|
||||
expect(resolveDocPreviewTargetPath(atWorkspaceRoot, 'docs/styles.css')).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses parent traversal, absolute escapes and empty paths', () => {
|
||||
@@ -102,6 +134,7 @@ describe('resolveDocPreviewTargetPath', () => {
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
|
||||
expect(authorizeDocPreviewDirectory(windowsGrant.id, 'assets/logo.png')).toBe(true)
|
||||
expect(resolveDocPreviewTargetPath(windowsGrant, 'assets/logo.png')).toBe(
|
||||
'C:\\srv\\repo\\docs\\assets\\logo.png'
|
||||
)
|
||||
@@ -183,7 +216,7 @@ describe('resolveCanonicalDocPreviewPath', () => {
|
||||
const grant = mintPosixGrant()
|
||||
|
||||
await expect(
|
||||
resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) =>
|
||||
resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/index.html', async (path) =>
|
||||
path === grant.root ? path : '/srv/repo/docs-private/secret.html'
|
||||
)
|
||||
).resolves.toBeNull()
|
||||
@@ -193,8 +226,16 @@ describe('resolveCanonicalDocPreviewPath', () => {
|
||||
const grant = mintPosixGrant()
|
||||
|
||||
await expect(
|
||||
resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) => path)
|
||||
).resolves.toBe('/srv/repo/docs/report.html')
|
||||
resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/index.html', async (path) => path)
|
||||
).resolves.toBe('/srv/repo/docs/index.html')
|
||||
})
|
||||
|
||||
it('refuses a sibling of an entry-only document even when it canonicalizes cleanly', async () => {
|
||||
const grant = mintPosixGrant()
|
||||
|
||||
await expect(
|
||||
resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/notes.txt', async (path) => path)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an approved SSH directory inside the canonical workspace boundary', async () => {
|
||||
|
||||
@@ -23,7 +23,12 @@ export type DocPreviewGrant = {
|
||||
owner: DocPreviewOwner
|
||||
/** Directory relative preview URLs resolve against. */
|
||||
requestBase: string
|
||||
/** Containing directory of the opened document, on the owning host. */
|
||||
/**
|
||||
* Containing directory of the opened document, on the owning host. It carries silent read
|
||||
* authority only while it sits strictly inside `requestBase`: a document at the workspace root —
|
||||
* or outside any workspace, where its directory IS the request base — starts with the entry file
|
||||
* alone, because that directory is where secrets live and a DNS-prefetch beacon needs no click.
|
||||
*/
|
||||
root: string
|
||||
/** Additional directories the reader approved for this grant. */
|
||||
authorizedRoots: string[]
|
||||
@@ -173,11 +178,18 @@ function isAtOrInsideRoot(
|
||||
return candidate === root || isInsideRoot(root, candidate, flavor)
|
||||
}
|
||||
|
||||
function authorizedRoots(grant: DocPreviewGrant): string[] {
|
||||
return [grant.root, ...grant.authorizedRoots]
|
||||
function directoryAuthorityRoots(grant: DocPreviewGrant): string[] {
|
||||
return grant.root === grant.requestBase
|
||||
? [...grant.authorizedRoots]
|
||||
: [grant.root, ...grant.authorizedRoots]
|
||||
}
|
||||
|
||||
/** Resolves a request only when its directory is authorized by this grant. */
|
||||
/** The one path an entry-only grant can read before the reader approves a directory. */
|
||||
function resolveEntryAbsolutePath(grant: DocPreviewGrant): string | null {
|
||||
return resolveDocPreviewCandidatePath(grant, grant.entryRelativePath)
|
||||
}
|
||||
|
||||
/** Resolves a request only when it is the entry document or its directory is authorized. */
|
||||
export function resolveDocPreviewTargetPath(
|
||||
grant: DocPreviewGrant,
|
||||
relativePath: string
|
||||
@@ -186,8 +198,11 @@ export function resolveDocPreviewTargetPath(
|
||||
if (!resolved) {
|
||||
return null
|
||||
}
|
||||
if (resolved === resolveEntryAbsolutePath(grant)) {
|
||||
return resolved
|
||||
}
|
||||
const flavor = pathFlavorFor(grant.requestBase)
|
||||
return authorizedRoots(grant).some((root) => isInsideRoot(root, resolved, flavor))
|
||||
return directoryAuthorityRoots(grant).some((root) => isInsideRoot(root, resolved, flavor))
|
||||
? resolved
|
||||
: null
|
||||
}
|
||||
@@ -207,7 +222,7 @@ export function authorizeDocPreviewDirectory(grantId: string, relativePath: stri
|
||||
if (!isAtOrInsideRoot(grant.requestBase, directory, flavor)) {
|
||||
return false
|
||||
}
|
||||
if (!authorizedRoots(grant).some((root) => isAtOrInsideRoot(root, directory, flavor))) {
|
||||
if (!directoryAuthorityRoots(grant).some((root) => isAtOrInsideRoot(root, directory, flavor))) {
|
||||
grant.authorizedRoots.push(directory)
|
||||
canonicalRootByGrantId.delete(grant.id)
|
||||
}
|
||||
@@ -215,7 +230,10 @@ export function authorizeDocPreviewDirectory(grantId: string, relativePath: stri
|
||||
}
|
||||
|
||||
/** Why: realpath is a host round-trip, and a grant's root is fixed for its lifetime. */
|
||||
const canonicalRootByGrantId = new Map<string, Promise<{ boundary: string; roots: string[] }>>()
|
||||
const canonicalRootByGrantId = new Map<
|
||||
string,
|
||||
Promise<{ boundary: string; roots: string[]; entry: string | null }>
|
||||
>()
|
||||
|
||||
/**
|
||||
* Second containment pass for hosts where the lexical one is not enough: a symlink
|
||||
@@ -231,28 +249,31 @@ export async function resolveCanonicalDocPreviewPath(
|
||||
try {
|
||||
let canonicalRoots = canonicalRootByGrantId.get(grant.id)
|
||||
if (!canonicalRoots) {
|
||||
const entryAbsolute = resolveEntryAbsolutePath(grant)
|
||||
canonicalRoots = Promise.all([
|
||||
realpath(grant.requestBase),
|
||||
...authorizedRoots(grant).map((root) => realpath(root))
|
||||
]).then(([boundaryPath, ...rootPaths]) => {
|
||||
entryAbsolute === null ? Promise.resolve(null) : realpath(entryAbsolute),
|
||||
...directoryAuthorityRoots(grant).map((root) => realpath(root))
|
||||
]).then(([boundaryPath, entryPath, ...rootPaths]) => {
|
||||
const boundary = normalizeRootPath(boundaryPath)
|
||||
const flavor = pathFlavorFor(boundary)
|
||||
return {
|
||||
boundary,
|
||||
roots: rootPaths
|
||||
.map(normalizeRootPath)
|
||||
.filter((root) => isAtOrInsideRoot(boundary, root, flavor))
|
||||
.filter((root) => isAtOrInsideRoot(boundary, root, flavor)),
|
||||
entry: entryPath !== null && isInsideRoot(boundary, entryPath, flavor) ? entryPath : null
|
||||
}
|
||||
})
|
||||
canonicalRootByGrantId.set(grant.id, canonicalRoots)
|
||||
}
|
||||
const [{ boundary, roots }, canonicalPath] = await Promise.all([
|
||||
const [{ boundary, roots, entry }, canonicalPath] = await Promise.all([
|
||||
canonicalRoots,
|
||||
realpath(absolutePath)
|
||||
])
|
||||
const flavor = pathFlavorFor(boundary)
|
||||
return isInsideRoot(boundary, canonicalPath, flavor) &&
|
||||
roots.some((root) => isInsideRoot(root, canonicalPath, flavor))
|
||||
(canonicalPath === entry || roots.some((root) => isInsideRoot(root, canonicalPath, flavor)))
|
||||
? canonicalPath
|
||||
: null
|
||||
} catch {
|
||||
|
||||
+7
-7
@@ -241,9 +241,9 @@ describe('HtmlDocPreview failure messages', () => {
|
||||
})
|
||||
})
|
||||
|
||||
expect(container.textContent).toContain('This preview needs access to assets.')
|
||||
expect(container.textContent).toContain('This preview wants to read files in assets.')
|
||||
expect(container.textContent).toContain('Dismiss')
|
||||
expect(container.textContent).toContain('Allow once')
|
||||
expect(container.textContent).toContain('Allow folder')
|
||||
expect(grantRuntime.authorizations).toEqual([])
|
||||
})
|
||||
|
||||
@@ -258,10 +258,10 @@ describe('HtmlDocPreview failure messages', () => {
|
||||
})
|
||||
})
|
||||
|
||||
expect(container.textContent).toContain('This preview needs access to /repo.')
|
||||
expect(container.textContent).toContain('This preview wants to read files in /repo.')
|
||||
})
|
||||
|
||||
it('authorizes only after Allow once and reloads the guest', async () => {
|
||||
it('authorizes only after Allow folder and reloads the guest', async () => {
|
||||
await renderPreview(container, root)
|
||||
const reload = vi.fn()
|
||||
Object.assign(container.querySelector('webview')!, { reload })
|
||||
@@ -274,7 +274,7 @@ describe('HtmlDocPreview failure messages', () => {
|
||||
})
|
||||
})
|
||||
const allowButton = [...container.querySelectorAll('button')].find(
|
||||
(button) => button.textContent?.trim() === 'Allow once'
|
||||
(button) => button.textContent?.trim() === 'Allow folder'
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
@@ -285,7 +285,7 @@ describe('HtmlDocPreview failure messages', () => {
|
||||
{ grantId: GRANT_ID, relativePath: 'assets/app.js' }
|
||||
])
|
||||
expect(reload).toHaveBeenCalledOnce()
|
||||
expect(container.textContent).not.toContain('This preview needs access to assets.')
|
||||
expect(container.textContent).not.toContain('This preview wants to read files in assets.')
|
||||
})
|
||||
|
||||
it('does not reprompt for a dismissed directory during the grant lifetime', async () => {
|
||||
@@ -310,7 +310,7 @@ describe('HtmlDocPreview failure messages', () => {
|
||||
})
|
||||
})
|
||||
|
||||
expect(container.textContent).not.toContain('This preview needs access to assets.')
|
||||
expect(container.textContent).not.toContain('This preview wants to read files in assets.')
|
||||
expect(grantRuntime.authorizations).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
+3
-2
@@ -97,7 +97,7 @@ export function DocPreviewDirectoryAccessBanner({
|
||||
<span className="min-w-0 flex-1 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.editor.HtmlDocPreview.directoryAccessRequest',
|
||||
'This preview needs access to {{path}}.',
|
||||
'This preview wants to read files in {{path}}.',
|
||||
{ path: requestedDirectoryLabel(request.relativePath, worktreeRoot) }
|
||||
)}
|
||||
</span>
|
||||
@@ -106,7 +106,8 @@ export function DocPreviewDirectoryAccessBanner({
|
||||
</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')}
|
||||
{/* "folder", not "once": approval covers the whole directory for as long as this preview stays open */}
|
||||
{translate('auto.components.editor.HtmlDocPreview.allowDirectory', 'Allow folder')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
+4
-1
@@ -47,8 +47,11 @@ export function DocPreviewDocumentChip({
|
||||
<span className="text-muted-foreground">{identity.directoryPrefix}</span>
|
||||
<span className="text-foreground">{identity.fileName}</span>
|
||||
</span>
|
||||
{/* Why everything here can shrink: a fixed-width child of a squeezed flex row overflows
|
||||
its parent's layout box even under overflow-hidden, and this row sits beside a path
|
||||
that can be arbitrarily long in an arbitrarily narrow pane. */}
|
||||
<span className="hidden min-w-0 items-center gap-1.5 text-xs text-muted-foreground sm:flex">
|
||||
<span className="shrink-0">
|
||||
<span className="min-w-0 truncate">
|
||||
{translate(
|
||||
'auto.components.editor.HtmlDocPreview.workspaceFileChipLabel',
|
||||
'Workspace file'
|
||||
|
||||
@@ -32,8 +32,9 @@ export function buildDocPreviewGrantRequest(
|
||||
}
|
||||
const connectionId = getConnectionIdForFileFromState(state, worktreeId, filePath)
|
||||
if (connectionId) {
|
||||
// Outside a workspace there is no broader request base; the document directory is both the
|
||||
// resolution boundary and the initial authority.
|
||||
// Outside a workspace there is no broader request base: the document directory bounds
|
||||
// resolution, and main starts such a grant at the entry file alone — an out-of-workspace
|
||||
// directory is often a home directory, which is where secrets live.
|
||||
return { owner: { kind: 'ssh', connectionId }, requestBase, root, entryRelativePath }
|
||||
}
|
||||
const environmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId)
|
||||
|
||||
@@ -723,12 +723,16 @@ test('asks before a paired preview reads a sibling directory', async ({
|
||||
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(page.getByText('This preview wants to read files in preview-assets.')).toBeVisible(
|
||||
{
|
||||
timeout: 30_000
|
||||
}
|
||||
)
|
||||
await expect.poll(() => readDocPreviewRenderedText(page, '#asset-result')).toBe('blocked')
|
||||
await page.getByRole('button', { name: 'Allow once', exact: true }).click()
|
||||
await expect(page.getByText('This preview needs access to preview-assets.')).not.toBeVisible()
|
||||
await page.getByRole('button', { name: 'Allow folder', exact: true }).click()
|
||||
await expect(
|
||||
page.getByText('This preview wants to read files in preview-assets.')
|
||||
).not.toBeVisible()
|
||||
await expect
|
||||
.poll(() => readDocPreviewRenderedText(page, '#asset-result'), {
|
||||
timeout: 60_000,
|
||||
|
||||
Reference in New Issue
Block a user