From eff1ce8a8fa920ef300c41999f8106a565fdbfcf Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 27 Aug 2026 23:05:01 -0400 Subject: [PATCH] fix(browser-preview): entry-file-only authority for root-level docs, contained chip layout, re-issued gate paths (STA-5758) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- config/reliability-gates.jsonc | 21 ++++---- .../browser/doc-preview-file-reader.test.ts | 10 +++- .../doc-preview-grant-registry.test.ts | 49 +++++++++++++++++-- .../browser/doc-preview-grant-registry.ts | 45 ++++++++++++----- .../HtmlDocPreview.failure-message.test.tsx | 14 +++--- .../doc-preview-directory-access.tsx | 5 +- .../doc-preview-document-chip.tsx | 5 +- src/renderer/src/lib/doc-preview-grants.ts | 5 +- ...d-remote-html-preview-local-render.spec.ts | 14 ++++-- 9 files changed, 123 insertions(+), 45 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 4d75e89a918..02bd0b02bc2 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2844,13 +2844,13 @@ "https://github.com/stablyai/orca/pull/14363", "https://github.com/stablyai/orca/pull/13876" ], - "invariant": "Opening one HTML preview from a paired client renders the workspace document in exactly one client-local browser tab, located by that document and served over the orca-preview scheme. The client gains exactly that one browser workspace and it is the document one — blank where a URL page carries a URL, named by the document, with the chip naming the file — while the host gains no browser page at all, neither in its own page registry nor in the tab snapshot its clients publish into. The preview occupies its own split without taking focus from the source editor; an explicit click activates it, and closing it removes only the preview. Following a document from a file link is the other half of that switch and does move the reader to it, tab group included, whether the preview is new or already open, because opening a file is a request to look at it. A document tab quit with the client comes back as the same row on a grant the relaunched client mints afresh. A preview is named by the browser page it is open in, not by a namespace of its own, and the page registry has two halves: a workspace-document guest is registered in its own map and is absent from the browsing one entirely. That absence is the fence. Page, session and profile management, agent tab enumeration and command targeting, download routing and certificate attribution all read the browsing map directly, in more places than a per-channel guard could be remembered in, so none of them can name a document page and none of them carries a guard. Browser tools the reader drives (element grab, hover describe, selection capture, the annotation viewport bridge) are the one operation that legitimately spans the halves, and they go through the single authority that reads both, keyed by the page and its hosting renderer. The halves are disjoint in both directions: browsing registration refuses a page the document half already holds, and minting a grant refuses a page the browsing half already holds, so one id can never name a surface in both. The headless backend acts on that refusal by destroying the window it had already opened rather than leaving a policy-less page behind an id nothing can drive, keeping nothing under that id for its own shutdown to hand back. Registration refuses on the same terms when the guest it was asked about is already gone. The exit door is guarded in both its halves: a preview withdraws by revoking its grant and never through the unregister channel, so a page the document half holds arriving there is refused before either the registration teardown or the grab-state disposal beside it, which would otherwise drop the intent an in-flight preview grab compares by identity and leave that grab answering ok without ever arming its guest. A bridge request whose guest does not resolve is refused without tearing down the page it named, so a misaddressed request cannot cancel a healthy page's in-flight downloads and grabs. The annotation viewport bridge resolves its guest when its serialized op actually runs rather than when the request arrived, so a cross-process navigation while it waited cannot leave the bridge installed in a retired guest while the reader looks at a new one. State main keys by a preview's page is disposed when that page's grant is revoked, which is the only signal a preview's surface is gone. A tool asking for a page whose guest has not attached yet waits for that registration and arms when it arrives, rather than answering not-ready at the reader; that wait resolves only the request already naming this page, never the worktree-wide or any-tab waits the CLI and agents use to ask for a browser tab to drive. Handing the previewed document to the reader's own machine routes on the owners its grant was minted against — the file's own connection owner and the worktree's own runtime owner, neither read from the tab's stored fields. Only a document proven to live on this machine reaches the client OS; one with a resolved remote owner is downloaded first; and one whose owner cannot be resolved at all, workspace root included, is refused with a message naming that, because the download route would otherwise read the same absolute path on the client and hand back a same-named local file under the remote document's name. A runtime-owned path that falls outside its worktree root is refused by that route itself and surfaces as a failure toast rather than a download. Nothing the document does writes a file to this machine either: the preview partition denies downloads outright instead of routing them through the browser download flow, which has no page to attribute a preview's bytes to and would otherwise reserve a name in this desktop's Downloads folder and write them there unprompted. That refusal is visible to the reader and invisible to the document: the preview's shell carries a fixed sentence saying downloads are off, published at most once per preview per interval so a document asking in a loop cannot fill Orca's chrome, while the page itself gets back exactly what it got before, which is nothing. The sentence names no file, because the document chooses the name it offers; and a refusal never takes the document away the way an entry document's own failure does, whatever it names. A preview is a browser tab, not an editor tab in a preview mode: it is named the way a browser tab is named — by the document it shows when that document declares a title, and by the file it shows when it does not — while the chip goes on naming the file and the host whatever the document calls itself. A title is refused on the same terms the url is: a document that declares none has Chromium report the grant URL as its title, and that title is stored, mirrored onto the tab and written to disk, so anything carrying the scheme falls back to the file instead. It is created by the preview action as a page located by its document, it carries the workspace-relative path copy the editor's path header owned, and closing it revokes the grant that made the document readable while a URL tab closing beside it revokes nothing. Chrome persisted by builds that made previews editor tabs is dropped on restore rather than coming back naming a surface no restore can produce, and the ordinary editor tab for the same document is left alone. A document tab is held back at the mobile publish boundary — no client holds its grant, and the wire has no tab kind for it — while an ordinary browser tab beside it still publishes. It is held back from the group projection that publishes tab order, recency and group activity as well as from the tab list itself, so no published group names a tab the phone is never sent. A browser page can be located by a workspace document instead of a URL, and the document is the whole of its stored identity. The grant and the orca-preview URL that document is served over are minted when the page mounts and replaced by a hard reload, so neither is ever written to the page's url, mirrored onto its tab, persisted or published: such a page's url is the blank URL from creation through restore, including when a session written elsewhere carries a grant URL in, and what the session carries is the worktree and path a restored page mints afresh against today's owners. Every door onto a page's url holds that line — creation, the title update, and the navigation commit alike — so a report about a document page cannot give it a URL it never had, and the title fallback and the loading affordance follow the url each door actually wrote. The mirror carries the document too, so a tab entry cannot go on naming a document its active page has left. Every guest in the app is policy-attached through one door: a workspace document takes a restricted profile there rather than a separate installer beside it, so the attachment bookkeeping that door owns — what registration refuses, and what teardown frees — covers a preview on the same terms as a browsing page, and a preview takes none of the browsing machinery that door installs. That authority answers from the moment the embedder hands the guest over rather than only after a later navigation: the guest binds to the grant it is already showing, so the tools reach the document the reader opened and not just one they navigated to. A read the host reports as truncated or over-cap is refused rather than served partially, and a document outside the paired worktree is refused with a message naming that boundary instead of a bare read failure. The rendered document reaches nothing off-machine on its own: every served response carries a self-only content security policy, the preview session cancels any request that is not in-document, subframes cannot navigate outside the grant, a guest no document has yet bound to a grant may not navigate at all, the guest gathers no ICE candidates, and an SSH path that canonicalizes outside the grant root is refused before it is read. The one route out is a link the reader presses: a trusted click on an anchor, reported by the preview's own preload from a guest that holds focus and is still bound to a live grant, leaves as an Orca browser tab rather than a native window or a dead click. The preview hands its guest that focus itself whenever it is the surface the reader is in — a browsing page gets it from the chrome around it, and a preview has no chrome to get it from — and it does so only then, so a preview mounted behind a terminal or an editor never takes the keyboard from what the reader is actually in. It offers again when the window itself takes focus back and nothing in the embedder has claimed that focus, because another app coming to the front lands focus on the embedder rather than the guest and the route out would otherwise stay shut until something remounted the pane — while the same window focus also arrives when the reader presses a tab, that being the guest's own blur returning, and taking focus back from there would fight the reader for their own click. Nothing else does. A navigation or popup the document starts by itself is swallowed whatever else is happening, including immediately after a genuine press elsewhere in the document, so a page that can read its grant cannot hand it to a browser tab; a middle click opens nothing; and a fragment link is answered inside the document. A preview attach carries the preview preload and no renderer-supplied one, and no other attach path can acquire it. A subresource the workspace will not send degrades the document to a notice naming that file, never to a failure panel over a page that rendered. A grant outlives neither the tab that owns it nor the renderer document that minted it, and only the trusted renderer can mint or revoke one. For the browser creations this gate still owns, owner-pinned creation returns the canonical host page identity before navigation readiness; delayed navigation cannot turn a created page into an unidentifiable failure or a duplicate retry. Capability rejection before host mutation must preserve the original error, issue no RPC, surface a failure toast, and remove only a caller-declared newly-created empty split. Post-create reconciliation failure requires exact rollback; ambiguous rollback rejects without local fallback.", - "oracle": "In paired Electron, write an HTML fixture that declares its own title on the host, invoke the Explorer preview action on the client, and require the document text to be readable out of the orca-preview guest before judging any absence. With that presence established, require the client to hold exactly one browser workspace more than its baseline and that workspace to be the document one: page and tab url blank, the document path mirrored onto both, the tab named by the document's title, the chip naming the file, no editor row of the retired preview species anywhere, and the guest URL carrying the orca-preview scheme. Ask the host through its own page registry as well as through the tab snapshot, and in the same run open an ordinary URL browser tab from the same client and require that one to arrive in both — the presence precondition without which “the host gained nothing” is satisfied just as well by an oracle that cannot see browser pages at all. Require the preview to sit in a group other than the source editor's while the active group and tab remain the source editor's. Then click away to the terminal, click the preview tab, require it to reactivate and still render, close it with its own X, and require the document tab to be gone while the host still holds only the URL tab and the source group, source editor and terminal survive. Quit the client with a document tab open and relaunch it on the same profile: require the same workspace row to come back, blank and named by the document, rendering the document again over a grant URL that differs from the one that was quit, with no preview-scheme or document-named page anywhere in what the host holds. Prove the halves are live by flipping one product property at a time and requiring the run to fail: publish document workspaces to the host like ordinary ones, and stop mirroring the document onto the workspace row. Have the fixture document attempt its own egress on every load — an unattended window.open and location.href to an off-machine URL, plus an inline ICE gathering probe — and require the same baseline counts and a candidate count of zero, so the document's own attempts are measured rather than assumed. Then, as a separate phase after the close oracle has already run, bring the client window to the front, press the document's heading with a real mouse event, and require the document to report that the same press drove it to attempt a second window.open and location.href while both browser counts stay at that phase's baseline and nothing routes — the case a recent-input gate cannot distinguish from the press's own effect. Only then press the target=_blank link with a real mouse event and require both a recorded routing call that returned success and a browser count above that baseline, with the preview tab still open. Drive the preload's click policy as a unit oracle over a real document: a dispatched click, a trusted press on an external anchor, an anchor reached through what it wraps, an SVG animated href, a sibling preview link, fragment and percent-encoded fragment targets, a bare hash, and a middle click. Create a browser page located by a workspace document, handing creation a live grant URL, and require its stored url, its mirrored tab url and the written session payload all to be blank with no orca-preview string anywhere in what was written, while an ordinary page created the same way keeps the URL it was given and asks for the address bar the document page never does. Parse the written page and tab through the session schema and require the document to survive both halves. Hydrate them back and require the document page to return blank and still named — including when its page row was salvaged away and only the tab's own copy remains, and when a foreign session carried a grant URL into both rows. Drive the mirror across a page switch out of the document and back, and across a repair in which the document is the only mirrored field that differs. Name a document page from its document and require the tab to take that name, name it with an empty title and require the file, name it with a live grant URL and require the file again with no preview scheme anywhere in the written session, and require an ordinary blank browser tab beside it to still be called New Tab. Dispatch a title update out of a rendered preview's own guest and require it to reach the page state while the identity chip still reads the document's workspace-relative path. Attach a browsing guest and a workspace-document guest through the same method in one run and require the browsing one to take clicked-link routing, popup handling and anti-detection while the document guest takes none of them, stays inside the grant it is showing, denies every window it asks for, and is dropped from the page-keyed document registry by the same teardown that frees its id for a later attach. Register a browsing guest and attach a workspace-document guest in one run against the real manager, require the one door to answer each page with the guest of its own half, and require the document page to be absent from the browsing map and from its enumeration. Drive both browsing registration entry points with a page the document half already holds and require them to register nothing, and drive the mint channel with a page the browsing half already holds and require it to refuse; and drive the offscreen one with a guest that is missing and with one already destroyed, requiring the same refusal. Arm a grab on a live preview target, drive the unregister channel at that same target in the window before the queued operation runs, and require the grab to reach the guest anyway — then drive the same sequence for an ordinary browser page and require its grab state to be disposed after all. Hold one viewport-bridge op open, queue a second behind it, swap the page's guest while that second op waits, and require the injection to land in the guest the page has then. Revoke a grant after a tool has run against its page and require that page's grab state to be cancelled and disposed. Ask a tool for a document page whose guest has not attached, require the request to park in the registration wait, attach the guest, and require the same request to arm on it; require a page nothing ever renders to answer not-ready once that wait elapses. With a document open, ask a tool for a browsing page id and require it to be answered by the browsing half or not at all, with the same channel reaching the document guest under the page it really renders. Drive open-externally for a document whose per-file owner is remote while the workspace-scoped owner is unresolved, for a runtime-owned worktree whose preview tab carries no runtime id of its own, and for a worktree that resolves no runtime owner while the tab still carries one, requiring the download route in each; and for an owner that cannot be resolved at all, and for an unknown workspace root while nothing names another host, requiring a refusal that neither opens nor downloads. Drive the headless backend with a page the document half already holds and require it to reject, destroy the window, and unregister nothing — then shut the backend down and require it still to have unregistered nothing. Navigate a bound preview guest at a second grant through both latch events and require it to stay on the grant it bound to. Mount a preview while a renderer drag is already in flight and require its guest to be click-through at the moment it is appended, not a turn later. Render the editor panel shell in each remaining tab mode and require the path header exactly where the surface does not already name itself. Drive the preview action and require a browser tab located by the document rather than an editor tab, require a second open of the same document to activate the tab it is already in, and require closing that tab to revoke its grant while a URL tab closed beside it revokes none. Hydrate a session carrying preview chrome from a build that made previews editor tabs and require it dropped while the ordinary editor tab for the same document survives. Publish a worktree holding a document tab and a URL tab and require only the URL tab to reach the mobile snapshot. Install the shared partition policies for a preview partition and for an ordinary browsing partition in the same run, fire each one's own will-download listener, and require the preview's to cancel while the browsing one still reaches the download router — then require the preview protocol installer to be what asks for that deny. In the same run, require the cancelled download to raise a reader-facing notice and the routed one to raise none. Drive that notice directly for a guest bound to a live grant, for repeated attempts inside and outside its interval, for two previews at once, and for a contents no preview is bound to; require the guest registry to name the bound grant for a live preview guest and nothing for a contents that is not one, has committed no document, or is gone. Drive the shell with a refusal and require one fixed sentence, still one row after three more refusals, standing beside an asset failure rather than being counted with it, gone behind the failure panel, and ignored when it names another preview's grant. Drive the main-side report gate directly for an unfocused guest, a sender that is no preview guest, a guest with no bound or a revoked grant, and non-web URLs, and drive will-attach-webview in both preload directions. Run the per-owner reader, grant-containment, scheme-admission, guest-policy, and plan-routing contracts as unit oracles, including a host-reported truncation, an over-cap binary, and an out-of-worktree paired path. Drive the reader-facing component with the payloads the reader can actually produce — the entry document fails only as truncated or unreadable, a subresource additionally as a refused format — and require the asset case to leave the guest mounted. Drive the closed-tab cleanup hook, the window installer, and the grant IPC handlers directly, requiring the grant to be released when the preview tab closes, cleared at window creation and on a cross-document main-frame navigation, and refused to any sender that is not the trusted renderer. For the browser creations this gate still owns, run the unchanged contract oracle for direct create and side-preview callers with absent status, unknown capabilities, and a mixed-version host, requiring the original unsupported error or visible toast, zero RPCs, and no retained new split; hold a real navigation response beyond the 15-second client deadline after host creation and require the first RPC to return the exact host inventory page ID, one host page, and no retry; repeat reconciliation faults against headless serve and retain the separate exact-page reconciliation rollback oracle.", + "invariant": "Opening one HTML preview from a paired client renders the workspace document in exactly one client-local browser tab, located by that document and served over the orca-preview scheme. The client gains exactly that one browser workspace and it is the document one — blank where a URL page carries a URL, named by the document, with the chip naming the file — while the host gains no browser page at all, neither in its own page registry nor in the tab snapshot its clients publish into. The preview occupies its own split without taking focus from the source editor; an explicit click activates it, and closing it removes only the preview. Following a document from a file link is the other half of that switch and does move the reader to it, tab group included, whether the preview is new or already open, because opening a file is a request to look at it. A document tab quit with the client comes back as the same row on a grant the relaunched client mints afresh. A preview is named by the browser page it is open in, not by a namespace of its own, and the page registry has two halves: a workspace-document guest is registered in its own map and is absent from the browsing one entirely. That absence is the fence. Page, session and profile management, agent tab enumeration and command targeting, download routing and certificate attribution all read the browsing map directly, in more places than a per-channel guard could be remembered in, so none of them can name a document page and none of them carries a guard. Browser tools the reader drives (element grab, hover describe, selection capture, the annotation viewport bridge) are the one operation that legitimately spans the halves, and they go through the single authority that reads both, keyed by the page and its hosting renderer. The halves are disjoint in both directions: browsing registration refuses a page the document half already holds, and minting a grant refuses a page the browsing half already holds, so one id can never name a surface in both. The headless backend acts on that refusal by destroying the window it had already opened rather than leaving a policy-less page behind an id nothing can drive, keeping nothing under that id for its own shutdown to hand back. Registration refuses on the same terms when the guest it was asked about is already gone. The exit door is guarded in both its halves: a preview withdraws by revoking its grant and never through the unregister channel, so a page the document half holds arriving there is refused before either the registration teardown or the grab-state disposal beside it, which would otherwise drop the intent an in-flight preview grab compares by identity and leave that grab answering ok without ever arming its guest. A bridge request whose guest does not resolve is refused without tearing down the page it named, so a misaddressed request cannot cancel a healthy page's in-flight downloads and grabs. The annotation viewport bridge resolves its guest when its serialized op actually runs rather than when the request arrived, so a cross-process navigation while it waited cannot leave the bridge installed in a retired guest while the reader looks at a new one. State main keys by a preview's page is disposed when that page's grant is revoked, which is the only signal a preview's surface is gone. A tool asking for a page whose guest has not attached yet waits for that registration and arms when it arrives, rather than answering not-ready at the reader; that wait resolves only the request already naming this page, never the worktree-wide or any-tab waits the CLI and agents use to ask for a browser tab to drive. Handing the previewed document to the reader's own machine routes on the owners its grant was minted against — the file's own connection owner and the worktree's own runtime owner, neither read from the tab's stored fields. Only a document proven to live on this machine reaches the client OS; one with a resolved remote owner is downloaded first; and one whose owner cannot be resolved at all, workspace root included, is refused with a message naming that, because the download route would otherwise read the same absolute path on the client and hand back a same-named local file under the remote document's name. A runtime-owned path that falls outside its worktree root is refused by that route itself and surfaces as a failure toast rather than a download. Nothing the document does writes a file to this machine either: the preview partition denies downloads outright instead of routing them through the browser download flow, which has no page to attribute a preview's bytes to and would otherwise reserve a name in this desktop's Downloads folder and write them there unprompted. That refusal is visible to the reader and invisible to the document: the preview's shell carries a fixed sentence saying downloads are off, published at most once per preview per interval so a document asking in a loop cannot fill Orca's chrome, while the page itself gets back exactly what it got before, which is nothing. The sentence names no file, because the document chooses the name it offers; and a refusal never takes the document away the way an entry document's own failure does, whatever it names. A preview is a browser tab, not an editor tab in a preview mode: it is named the way a browser tab is named — by the document it shows when that document declares a title, and by the file it shows when it does not — while the chip goes on naming the file and the host whatever the document calls itself. A title is refused on the same terms the url is: a document that declares none has Chromium report the grant URL as its title, and that title is stored, mirrored onto the tab and written to disk, so anything carrying the scheme falls back to the file instead. It is created by the preview action as a page located by its document, it carries the workspace-relative path copy the editor's path header owned, and closing it revokes the grant that made the document readable while a URL tab closing beside it revokes nothing. Chrome persisted by builds that made previews editor tabs is dropped on restore rather than coming back naming a surface no restore can produce, and the ordinary editor tab for the same document is left alone. A document tab is held back at the mobile publish boundary — no client holds its grant, and the wire has no tab kind for it — while an ordinary browser tab beside it still publishes. It is held back from the group projection that publishes tab order, recency and group activity as well as from the tab list itself, so no published group names a tab the phone is never sent. A browser page can be located by a workspace document instead of a URL, and the document is the whole of its stored identity. The grant and the orca-preview URL that document is served over are minted when the page mounts and replaced by a hard reload, so neither is ever written to the page's url, mirrored onto its tab, persisted or published: such a page's url is the blank URL from creation through restore, including when a session written elsewhere carries a grant URL in, and what the session carries is the worktree and path a restored page mints afresh against today's owners. Every door onto a page's url holds that line — creation, the title update, and the navigation commit alike — so a report about a document page cannot give it a URL it never had, and the title fallback and the loading affordance follow the url each door actually wrote. The mirror carries the document too, so a tab entry cannot go on naming a document its active page has left. Every guest in the app is policy-attached through one door: a workspace document takes a restricted profile there rather than a separate installer beside it, so the attachment bookkeeping that door owns — what registration refuses, and what teardown frees — covers a preview on the same terms as a browsing page, and a preview takes none of the browsing machinery that door installs. That authority answers from the moment the embedder hands the guest over rather than only after a later navigation: the guest binds to the grant it is already showing, so the tools reach the document the reader opened and not just one they navigated to. A read the host reports as truncated or over-cap is refused rather than served partially, and a document outside the paired worktree is refused with a message naming that boundary instead of a bare read failure. The rendered document reaches nothing off-machine on its own: every served response carries a self-only content security policy, the preview session cancels any request that is not in-document, subframes cannot navigate outside the grant, a guest no document has yet bound to a grant may not navigate at all, the guest gathers no ICE candidates, and an SSH path that canonicalizes outside the grant root is refused before it is read. The one route out is a link the reader presses: a trusted click on an anchor, reported by the preview's own preload from a guest still bound to a live grant, leaves as an Orca browser tab rather than a native window or a dead click — and only after the reader confirms the exact destination URL, so a document cannot spend a single stray press exfiltrating what it can read into a link it authored. The preview hands its guest that focus itself whenever it is the surface the reader is in — a browsing page gets it from the chrome around it, and a preview has no chrome to get it from — and it does so only then, so a preview mounted behind a terminal or an editor never takes the keyboard from what the reader is actually in. It offers again when the window itself takes focus back and nothing in the embedder has claimed that focus, because another app coming to the front lands focus on the embedder rather than the guest and the route out would otherwise stay shut until something remounted the pane — while the same window focus also arrives when the reader presses a tab, that being the guest's own blur returning, and taking focus back from there would fight the reader for their own click. Nothing else does. A navigation or popup the document starts by itself is swallowed whatever else is happening, including immediately after a genuine press elsewhere in the document, so a page that can read its grant cannot hand it to a browser tab; a middle click opens nothing; and a fragment link is answered inside the document. A preview attach carries the preview preload and no renderer-supplied one, and no other attach path can acquire it. A subresource the workspace will not send degrades the document to a notice naming that file, never to a failure panel over a page that rendered. A grant outlives neither the tab that owns it nor the renderer document that minted it, and only the trusted renderer can mint or revoke one. For the browser creations this gate still owns, owner-pinned creation returns the canonical host page identity before navigation readiness; delayed navigation cannot turn a created page into an unidentifiable failure or a duplicate retry. Capability rejection before host mutation must preserve the original error, issue no RPC, surface a failure toast, and remove only a caller-declared newly-created empty split. Post-create reconciliation failure requires exact rollback; ambiguous rollback rejects without local fallback.", + "oracle": "In paired Electron, write an HTML fixture that declares its own title on the host, invoke the Explorer preview action on the client, and require the document text to be readable out of the orca-preview guest before judging any absence. With that presence established, require the client to hold exactly one browser workspace more than its baseline and that workspace to be the document one: page and tab url blank, the document path mirrored onto both, the tab named by the document's title, the chip naming the file, no editor row of the retired preview species anywhere, and the guest URL carrying the orca-preview scheme. Ask the host through its own page registry as well as through the tab snapshot, and in the same run open an ordinary URL browser tab from the same client and require that one to arrive in both — the presence precondition without which “the host gained nothing” is satisfied just as well by an oracle that cannot see browser pages at all. Require the preview to sit in a group other than the source editor's while the active group and tab remain the source editor's. Then click away to the terminal, click the preview tab, require it to reactivate and still render, close it with its own X, and require the document tab to be gone while the host still holds only the URL tab and the source group, source editor and terminal survive. Quit the client with a document tab open and relaunch it on the same profile: require the same workspace row to come back, blank and named by the document, rendering the document again over a grant URL that differs from the one that was quit, with no preview-scheme or document-named page anywhere in what the host holds. Prove the halves are live by flipping one product property at a time and requiring the run to fail: publish document workspaces to the host like ordinary ones, and stop mirroring the document onto the workspace row. Have the fixture document attempt its own egress on every load — an unattended window.open and location.href to an off-machine URL, plus an inline ICE gathering probe — and require the same baseline counts and a candidate count of zero, so the document's own attempts are measured rather than assumed. Then, as a separate phase after the close oracle has already run, bring the client window to the front, press the document's heading with a real mouse event, and require the document to report that the same press drove it to attempt a second window.open and location.href while both browser counts stay at that phase's baseline and nothing routes — the case a recent-input gate cannot distinguish from the press's own effect. Only then press the target=_blank link with a real mouse event and require both a recorded routing call that returned success and a browser count above that baseline, with the preview tab still open. Drive the preload's click policy as a unit oracle over a real document: a dispatched click, a trusted press on an external anchor, an anchor reached through what it wraps, an SVG animated href, a sibling preview link, fragment and percent-encoded fragment targets, a bare hash, and a middle click. Create a browser page located by a workspace document, handing creation a live grant URL, and require its stored url, its mirrored tab url and the written session payload all to be blank with no orca-preview string anywhere in what was written, while an ordinary page created the same way keeps the URL it was given and asks for the address bar the document page never does. Parse the written page and tab through the session schema and require the document to survive both halves. Hydrate them back and require the document page to return blank and still named — including when its page row was salvaged away and only the tab's own copy remains, and when a foreign session carried a grant URL into both rows. Drive the mirror across a page switch out of the document and back, and across a repair in which the document is the only mirrored field that differs. Name a document page from its document and require the tab to take that name, name it with an empty title and require the file, name it with a live grant URL and require the file again with no preview scheme anywhere in the written session, and require an ordinary blank browser tab beside it to still be called New Tab. Dispatch a title update out of a rendered preview's own guest and require it to reach the page state while the identity chip still reads the document's workspace-relative path. Attach a browsing guest and a workspace-document guest through the same method in one run and require the browsing one to take clicked-link routing, popup handling and anti-detection while the document guest takes none of them, stays inside the grant it is showing, denies every window it asks for, and is dropped from the page-keyed document registry by the same teardown that frees its id for a later attach. Register a browsing guest and attach a workspace-document guest in one run against the real manager, require the one door to answer each page with the guest of its own half, and require the document page to be absent from the browsing map and from its enumeration. Drive both browsing registration entry points with a page the document half already holds and require them to register nothing, and drive the mint channel with a page the browsing half already holds and require it to refuse; and drive the offscreen one with a guest that is missing and with one already destroyed, requiring the same refusal. Arm a grab on a live preview target, drive the unregister channel at that same target in the window before the queued operation runs, and require the grab to reach the guest anyway — then drive the same sequence for an ordinary browser page and require its grab state to be disposed after all. Hold one viewport-bridge op open, queue a second behind it, swap the page's guest while that second op waits, and require the injection to land in the guest the page has then. Revoke a grant after a tool has run against its page and require that page's grab state to be cancelled and disposed. Ask a tool for a document page whose guest has not attached, require the request to park in the registration wait, attach the guest, and require the same request to arm on it; require a page nothing ever renders to answer not-ready once that wait elapses. With a document open, ask a tool for a browsing page id and require it to be answered by the browsing half or not at all, with the same channel reaching the document guest under the page it really renders. Drive open-externally for a document whose per-file owner is remote while the workspace-scoped owner is unresolved, for a runtime-owned worktree whose preview tab carries no runtime id of its own, and for a worktree that resolves no runtime owner while the tab still carries one, requiring the download route in each; and for an owner that cannot be resolved at all, and for an unknown workspace root while nothing names another host, requiring a refusal that neither opens nor downloads. Drive the headless backend with a page the document half already holds and require it to reject, destroy the window, and unregister nothing — then shut the backend down and require it still to have unregistered nothing. Navigate a bound preview guest at a second grant through both latch events and require it to stay on the grant it bound to. Mount a preview while a renderer drag is already in flight and require its guest to be click-through at the moment it is appended, not a turn later. Render the editor panel shell in each remaining tab mode and require the path header exactly where the surface does not already name itself. Drive the preview action and require a browser tab located by the document rather than an editor tab, require a second open of the same document to activate the tab it is already in, and require closing that tab to revoke its grant while a URL tab closed beside it revokes none. Hydrate a session carrying preview chrome from a build that made previews editor tabs and require it dropped while the ordinary editor tab for the same document survives. Publish a worktree holding a document tab and a URL tab and require only the URL tab to reach the mobile snapshot. Install the shared partition policies for a preview partition and for an ordinary browsing partition in the same run, fire each one's own will-download listener, and require the preview's to cancel while the browsing one still reaches the download router — then require the preview protocol installer to be what asks for that deny. In the same run, require the cancelled download to raise a reader-facing notice and the routed one to raise none. Drive that notice directly for a guest bound to a live grant, for repeated attempts inside and outside its interval, for two previews at once, and for a contents no preview is bound to; require the guest registry to name the bound grant for a live preview guest and nothing for a contents that is not one, has committed no document, or is gone. Drive the shell with a refusal and require one fixed sentence, still one row after three more refusals, standing beside an asset failure rather than being counted with it, gone behind the failure panel, and ignored when it names another preview's grant. Drive the main-side report gate directly for a sender that is no preview guest, a guest with no bound or a revoked grant, a genuine press Electron's webview focus flag misreports as unfocused, and non-web URLs; drive the reader-facing confirmation for accept, cancel, and a confirmed tab the browser refuses; and drive will-attach-webview in both preload directions. Run the per-owner reader, grant-containment, scheme-admission, guest-policy, and plan-routing contracts as unit oracles, including a host-reported truncation, an over-cap binary, and an out-of-worktree paired path. Drive the reader-facing component with the payloads the reader can actually produce — the entry document fails only as truncated or unreadable, a subresource additionally as a refused format — and require the asset case to leave the guest mounted. Drive the closed-tab cleanup hook, the window installer, and the grant IPC handlers directly, requiring the grant to be released when the preview tab closes, cleared at window creation and on a cross-document main-frame navigation, and refused to any sender that is not the trusted renderer. For the browser creations this gate still owns, run the unchanged contract oracle for direct create and side-preview callers with absent status, unknown capabilities, and a mixed-version host, requiring the original unsupported error or visible toast, zero RPCs, and no retained new split; hold a real navigation response beyond the 15-second client deadline after host creation and require the first RPC to return the exact host inventory page ID, one host page, and no retry; repeat reconciliation faults against headless serve and retain the separate exact-page reconciliation rollback oracle.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-browser.test.ts src/main/runtime/rpc/methods/browser.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/runtime/web-session-browser-placement.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/runtime/remote-server-parity.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-materialization.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-grant-registry.test.ts src/main/browser/doc-preview-file-reader.test.ts src/main/browser/doc-preview-protocol.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/browser/doc-preview-failure-notice.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/lib/doc-preview-grants.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/lib/file-preview-action-visibility.test.tsx src/main/window/main-window-webview-security.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-grant-registry.test.ts src/main/browser/doc-preview-file-reader.test.ts src/main/browser/doc-preview-protocol.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/browser/doc-preview-failure-notice.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/lib/doc-preview-grants.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/lib/file-preview-action-visibility.test.tsx src/main/window/main-window-webview-security.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx src/renderer/src/components/browser-pane/workspace-doc/doc-preview-external-link-confirmation.test.ts src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-protocol.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-download-block-notice.test.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/window/main-window-webview-security.test.ts src/main/browser/doc-preview-guest-policy.test.ts", @@ -2915,7 +2915,7 @@ "src/renderer/src/lib/doc-preview-grants.test.ts", "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", "src/renderer/src/lib/file-preview-action-visibility.test.tsx", - "src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts", + "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-external-link-confirmation.test.ts", "src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts", "src/main/ipc/doc-preview-grant-ipc.test.ts", "src/renderer/src/store/slices/tabs-hydration.test.ts", @@ -3065,9 +3065,10 @@ ] }, { - "file": "src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts", + "file": "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-external-link-confirmation.test.ts", "assertions": [ - "an external preview link becomes a browser tab, and a tab that is refused or that fails outright surfaces instead of being dropped" + "an external preview link shows the reader its exact destination and becomes a browser tab only after confirmation", + "a declined confirmation opens nothing, and a confirmed tab that is refused or fails outright surfaces instead of being dropped" ] }, { @@ -3085,7 +3086,7 @@ "the guest may only navigate within its own live grant, and not at all until a document has bound it to one", "every navigation and popup sink refuses without routing, whenever genuine input happened", "a subframe cannot navigate outside the grant, and only the main frame binds the guest to one", - "a reported click routes exactly once, and only from a focused preview guest with a live bound grant", + "a reported click routes exactly once, and only from a live preview guest with a bound grant — including when Electron's webview focus flag reads false for a genuine press", "a reported click on a non-web URL, or from a destroyed guest, routes nothing", "the guest is denied non-proxied WebRTC UDP when its policy is installed" ] @@ -3275,10 +3276,10 @@ "date": "2026-08-27", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-grant-registry.test.ts src/main/browser/doc-preview-file-reader.test.ts src/main/browser/doc-preview-protocol.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/browser/doc-preview-failure-notice.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/lib/doc-preview-grants.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/lib/file-preview-action-visibility.test.tsx src/main/window/main-window-webview-security.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-grant-registry.test.ts src/main/browser/doc-preview-file-reader.test.ts src/main/browser/doc-preview-protocol.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/browser/doc-preview-failure-notice.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/lib/doc-preview-grants.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/lib/file-preview-action-visibility.test.tsx src/main/window/main-window-webview-security.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx src/renderer/src/components/browser-pane/workspace-doc/doc-preview-external-link-confirmation.test.ts src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts", "result": "passed", - "durationSeconds": 1.37, - "summary": "174/174 over the grant, reader, protocol, guest-policy, scheme-admission, plan-routing, action-visibility, failure-message, external-link and terminal-row contracts, re-issued at the relocated preview paths. This replaces the 2026-08-26 record of the same thirteen files at 152/152, whose command named the pre-relocation paths: it established the stale-mint rejection that must not evict a reopened preview tab, the CSP header and session request filter, the SSH symlink refusal, subframe containment, truncated and over-cap reads, the unservable-asset and empty-file split, the out-of-worktree paired refusal with SSH left unrestricted, reload re-minting a stale grant, and the guest deny-only navigation sinks. The count moved with the cases added since; nothing in those files changed but their directory." + "durationSeconds": 1.63, + "summary": "189/189 over the grant, reader, protocol, guest-policy, scheme-admission, plan-routing, action-visibility, failure-message, external-link and terminal-row contracts. This replaces the 2026-08-27 record of the same command at 174/174, whose thirteenth file was the deleted doc-preview-external-link-bridge.test.ts: the STA-5758 hardening moved that behavior into the reader-facing confirmation (doc-preview-external-link-confirmation.test.ts), which now proves the exact destination is shown and nothing opens on cancel, alongside everything the retired run carried — the stale-mint rejection that must not evict a reopened preview tab, the CSP header and session request filter, the SSH symlink refusal, subframe containment, truncated and over-cap reads, the unservable-asset and empty-file split, the out-of-worktree paired refusal, and the guest deny-only navigation sinks. The count also carries the entry-only rule added on top of the hardening: a grant whose document directory is its own request base — a workspace-root or out-of-workspace document — reads nothing but the entry file until the reader approves a directory, at both the lexical and the canonical containment pass, because that directory is where secrets live and a DNS-prefetch beacon needs no click." }, { "date": "2026-08-27", diff --git a/src/main/browser/doc-preview-file-reader.test.ts b/src/main/browser/doc-preview-file-reader.test.ts index 4476acf5029..2919d06eec9 100644 --- a/src/main/browser/doc-preview-file-reader.test.ts +++ b/src/main/browser/doc-preview-file-reader.test.ts @@ -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 { - 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 { - return mintDocPreviewGrant({ + const grant = mintDocPreviewGrant({ owner: { kind: 'runtime', environmentId: 'env-1', @@ -44,6 +48,8 @@ function runtimeGrant(root = '/srv/repo/docs'): ReturnType { diff --git a/src/main/browser/doc-preview-grant-registry.test.ts b/src/main/browser/doc-preview-grant-registry.test.ts index 062ee810dfa..36022f6a753 100644 --- a/src/main/browser/doc-preview-grant-registry.test.ts +++ b/src/main/browser/doc-preview-grant-registry.test.ts @@ -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 () => { diff --git a/src/main/browser/doc-preview-grant-registry.ts b/src/main/browser/doc-preview-grant-registry.ts index 1ecfc7d0292..191b7ddeb95 100644 --- a/src/main/browser/doc-preview-grant-registry.ts +++ b/src/main/browser/doc-preview-grant-registry.ts @@ -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>() +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 { diff --git a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx index e07e1f8984e..43e83c90120 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx @@ -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([]) }) diff --git a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-directory-access.tsx b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-directory-access.tsx index 2b871ee1034..d3bca160069 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-directory-access.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-directory-access.tsx @@ -97,7 +97,7 @@ export function DocPreviewDirectoryAccessBanner({ {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) } )} @@ -106,7 +106,8 @@ export function DocPreviewDirectoryAccessBanner({ ) diff --git a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-chip.tsx b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-chip.tsx index 6897e4a1e35..67efecdbc8b 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-chip.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-chip.tsx @@ -47,8 +47,11 @@ export function DocPreviewDocumentChip({ {identity.directoryPrefix} {identity.fileName} + {/* 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. */} - + {translate( 'auto.components.editor.HtmlDocPreview.workspaceFileChipLabel', 'Workspace file' diff --git a/src/renderer/src/lib/doc-preview-grants.ts b/src/renderer/src/lib/doc-preview-grants.ts index 98583355a1d..5e2af9a7167 100644 --- a/src/renderer/src/lib/doc-preview-grants.ts +++ b/src/renderer/src/lib/doc-preview-grants.ts @@ -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) diff --git a/tests/e2e/paired-remote-html-preview-local-render.spec.ts b/tests/e2e/paired-remote-html-preview-local-render.spec.ts index 4f3d396b340..dbe2794ed78 100644 --- a/tests/e2e/paired-remote-html-preview-local-render.spec.ts +++ b/tests/e2e/paired-remote-html-preview-local-render.spec.ts @@ -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,