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:
Jinwoo-H
2026-08-28 00:28:10 -04:00
parent c72b5e450c
commit eff1ce8a8f
9 changed files with 123 additions and 45 deletions
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 () => {
+33 -12
View File
@@ -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 {
@@ -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([])
})
@@ -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>
)
@@ -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'
+3 -2
View 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,