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/browser-session-partition-policies.test.ts b/src/main/browser/browser-session-partition-policies.test.ts index a3667154c9b..78ce34d95fd 100644 --- a/src/main/browser/browser-session-partition-policies.test.ts +++ b/src/main/browser/browser-session-partition-policies.test.ts @@ -3,7 +3,9 @@ import type { BrowserSessionProfile } from '../../shared/browser-workspace-types const mocks = vi.hoisted(() => ({ handleGuestWillDownload: vi.fn(), - noticeDocPreviewDownloadBlocked: vi.fn() + noticeDocPreviewDownloadBlocked: vi.fn(), + clearBrowserWebAuthnAccessHandlers: vi.fn(), + installBrowserWebAuthnAccessHandlers: vi.fn() })) type WillDownloadListener = ( @@ -88,13 +90,13 @@ vi.mock('./browser-session-user-agent-mode', () => ({ })) vi.mock('./browser-webauthn-access', () => ({ allowsBrowserWebAuthnPermission: () => false, - clearBrowserWebAuthnAccessHandlers: vi.fn(), - installBrowserWebAuthnAccessHandlers: vi.fn() + clearBrowserWebAuthnAccessHandlers: mocks.clearBrowserWebAuthnAccessHandlers, + installBrowserWebAuthnAccessHandlers: mocks.installBrowserWebAuthnAccessHandlers })) type PartitionPolicyInstaller = ( profile: BrowserSessionProfile, - options?: { downloads?: 'route' | 'deny' } + options?: { downloads?: 'route' | 'deny'; permissions?: 'browser' | 'deny' } ) => void // Why imported per test rather than at the top: the installer remembers which partitions it has @@ -183,3 +185,51 @@ describe('partition download policy', () => { expect(mocks.handleGuestWillDownload).toHaveBeenCalledTimes(1) }) }) + +describe('partition permission policy', () => { + it('keeps ordinary browser partitions on the browser permission policy', async () => { + const install = await loadInstaller() + install(profileFor('persist:browsing-1')) + + expect(mocks.installBrowserWebAuthnAccessHandlers).toHaveBeenCalledWith( + sessionsByPartition.get('persist:browsing-1') + ) + expect(mocks.clearBrowserWebAuthnAccessHandlers).not.toHaveBeenCalled() + }) + + it('denies every request and check on a strict partition without WebAuthn handlers', async () => { + const install = await loadInstaller() + install(profileFor('orca-doc-preview'), { permissions: 'deny' }) + const sess = sessionsByPartition.get('orca-doc-preview') + if (!sess) { + throw new Error('Expected the preview session') + } + const requestHandler = sess.setPermissionRequestHandler.mock.calls[0]?.[0] as ( + webContents: Electron.WebContents, + permission: string, + callback: (allowed: boolean) => void + ) => void + const checkHandler = sess.setPermissionCheckHandler.mock.calls[0]?.[0] as ( + webContents: Electron.WebContents, + permission: string + ) => boolean + const displayMediaHandler = sess.setDisplayMediaRequestHandler.mock.calls[0]?.[0] as ( + request: Electron.DisplayMediaRequestHandlerHandlerRequest, + callback: (streams: { video?: Electron.WebFrameMain; audio?: 'loopback' }) => void + ) => void + + for (const permission of ['media', 'clipboard-read', 'notifications', 'fullscreen']) { + let decision: boolean | null = null + requestHandler({} as Electron.WebContents, permission, (allowed) => (decision = allowed)) + expect(decision).toBe(false) + expect(checkHandler({} as Electron.WebContents, permission)).toBe(false) + } + expect(mocks.installBrowserWebAuthnAccessHandlers).not.toHaveBeenCalled() + expect(mocks.clearBrowserWebAuthnAccessHandlers).toHaveBeenCalledWith(sess) + let displayMediaDecision: { video?: Electron.WebFrameMain; audio?: 'loopback' } | null = null + displayMediaHandler({} as Electron.DisplayMediaRequestHandlerHandlerRequest, (decision) => { + displayMediaDecision = decision + }) + expect(displayMediaDecision).toEqual({ video: undefined, audio: undefined }) + }) +}) diff --git a/src/main/browser/browser-session-partition-policies.ts b/src/main/browser/browser-session-partition-policies.ts index cf542665ae4..11f1f741e5e 100644 --- a/src/main/browser/browser-session-partition-policies.ts +++ b/src/main/browser/browser-session-partition-policies.ts @@ -57,10 +57,14 @@ function resolvePermissionNoticeUrl( /** `route` hands the item to the owning page's download flow; `deny` cancels it before it starts. */ export type BrowserPartitionDownloadPolicy = 'route' | 'deny' +export type BrowserPartitionPermissionPolicy = 'browser' | 'deny' export function installBrowserSessionPartitionPolicies( profile: BrowserSessionProfile, - options?: { downloads?: BrowserPartitionDownloadPolicy } + options?: { + downloads?: BrowserPartitionDownloadPolicy + permissions?: BrowserPartitionPermissionPolicy + } ): void { const { partition } = profile const sess = session.fromPartition(partition) @@ -75,57 +79,63 @@ export function installBrowserSessionPartitionPolicies( sess.setUserAgent(cleanUA) setupClientHintsOverride(sess, cleanUA) } - sess.setPermissionRequestHandler((webContents, permission, callback, details) => { - // Why: defer media to macOS TCC; denying at the session layer throws NotAllowedError even after the user granted Camera/Mic to the OS. - if (permission === 'media') { - // Capture before async handling; opaque frames cannot be attributed to a named site. - const rawUrl = resolvePermissionNoticeUrl(webContents, details) - void requestSystemMediaAccess( - details as Electron.MediaAccessPermissionRequest | undefined - ).then( - (granted) => { - if (!granted) { + if (options?.permissions === 'deny') { + sess.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)) + sess.setPermissionCheckHandler(() => false) + clearBrowserWebAuthnAccessHandlers(sess) + } else { + sess.setPermissionRequestHandler((webContents, permission, callback, details) => { + // Why: defer media to macOS TCC; denying at the session layer throws NotAllowedError even after the user granted Camera/Mic to the OS. + if (permission === 'media') { + // Capture before async handling; opaque frames cannot be attributed to a named site. + const rawUrl = resolvePermissionNoticeUrl(webContents, details) + void requestSystemMediaAccess( + details as Electron.MediaAccessPermissionRequest | undefined + ).then( + (granted) => { + if (!granted) { + browserManager.notifyPermissionDenied({ + guestWebContentsId: webContents.id, + permission, + rawUrl + }) + } + callback(granted) + }, + (error: unknown) => { + console.error('[permissions] Browser media access failed:', error) browserManager.notifyPermissionDenied({ guestWebContentsId: webContents.id, permission, rawUrl }) + callback(false) } - callback(granted) - }, - (error: unknown) => { - console.error('[permissions] Browser media access failed:', error) - browserManager.notifyPermissionDenied({ - guestWebContentsId: webContents.id, - permission, - rawUrl - }) - callback(false) - } - ) - return - } - const allowed = isAutoGrantedBrowserSessionPermission(permission) - if (!allowed) { - const rawUrl = resolvePermissionNoticeUrl(webContents, details) - browserManager.notifyPermissionDenied({ - guestWebContentsId: webContents.id, - permission, - rawUrl - }) - } - callback(allowed) - }) - sess.setPermissionCheckHandler((_webContents, permission, _origin, details) => { - if (permission === 'media') { - return hasSystemMediaAccess(details?.mediaType) - } - if (allowsBrowserWebAuthnPermission(permission, details)) { - return true - } - return isAutoGrantedBrowserSessionPermission(permission) - }) - installBrowserWebAuthnAccessHandlers(sess) + ) + return + } + const allowed = isAutoGrantedBrowserSessionPermission(permission) + if (!allowed) { + const rawUrl = resolvePermissionNoticeUrl(webContents, details) + browserManager.notifyPermissionDenied({ + guestWebContentsId: webContents.id, + permission, + rawUrl + }) + } + callback(allowed) + }) + sess.setPermissionCheckHandler((_webContents, permission, _origin, details) => { + if (permission === 'media') { + return hasSystemMediaAccess(details?.mediaType) + } + if (allowsBrowserWebAuthnPermission(permission, details)) { + return true + } + return isAutoGrantedBrowserSessionPermission(permission) + }) + installBrowserWebAuthnAccessHandlers(sess) + } sess.setDisplayMediaRequestHandler((_request, callback) => { callback({ video: undefined, audio: undefined }) }) diff --git a/src/main/browser/doc-preview-download-block-notice.test.ts b/src/main/browser/doc-preview-download-block-notice.test.ts index 9fc4396b329..c57998ea4e5 100644 --- a/src/main/browser/doc-preview-download-block-notice.test.ts +++ b/src/main/browser/doc-preview-download-block-notice.test.ts @@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ publishDocPreviewFailure: vi.fn(), - boundGrantIdByGuest: new Map() + boundGrantIdByGuest: new Map(), + revocationListener: null as null | ((grant: { id: string }) => void) })) vi.mock('./doc-preview-failure-notice', () => ({ @@ -11,6 +12,12 @@ vi.mock('./doc-preview-failure-notice', () => ({ vi.mock('./doc-preview-guest-policy', () => ({ readDocPreviewGuestBoundGrantId: (guest: object) => mocks.boundGrantIdByGuest.get(guest) ?? null })) +vi.mock('./doc-preview-grant-registry', () => ({ + onDocPreviewGrantRevoked: (listener: (grant: { id: string }) => void) => { + mocks.revocationListener = listener + return vi.fn() + } +})) const GRANT_ID = 'a'.repeat(32) const OTHER_GRANT_ID = 'b'.repeat(32) @@ -32,6 +39,7 @@ async function loadNotifier(): Promise<(guest: Electron.WebContents) => void> { beforeEach(() => { vi.clearAllMocks() mocks.boundGrantIdByGuest.clear() + mocks.revocationListener = null // Why per test: the module remembers which grants it has already told the reader about. vi.resetModules() vi.useFakeTimers() @@ -94,6 +102,17 @@ describe('noticeDocPreviewDownloadBlocked', () => { }) }) + it('forgets a preview as soon as its grant is revoked', async () => { + const notice = await loadNotifier() + const guest = guestBoundTo(GRANT_ID) + + notice(guest) + mocks.revocationListener?.({ id: GRANT_ID }) + notice(guest) + + expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2) + }) + // The absence half of the first test: no shell is showing this contents, so there is no preview // to put a notice on. Without the presence tests above, this would pass on a module that never // published anything at all. diff --git a/src/main/browser/doc-preview-download-block-notice.ts b/src/main/browser/doc-preview-download-block-notice.ts index 2aefb2824a7..13cebef49b7 100644 --- a/src/main/browser/doc-preview-download-block-notice.ts +++ b/src/main/browser/doc-preview-download-block-notice.ts @@ -1,4 +1,5 @@ import { publishDocPreviewFailure } from './doc-preview-failure-notice' +import { onDocPreviewGrantRevoked } from './doc-preview-grant-registry' import { readDocPreviewGuestBoundGrantId } from './doc-preview-guest-policy' /** @@ -12,6 +13,8 @@ import { readDocPreviewGuestBoundGrantId } from './doc-preview-guest-policy' const NOTICE_MIN_INTERVAL_MS = 2_000 const noticedAtByGrantId = new Map() +onDocPreviewGrantRevoked((grant) => noticedAtByGrantId.delete(grant.id)) + export function noticeDocPreviewDownloadBlocked(guest: Electron.WebContents): void { const grantId = readDocPreviewGuestBoundGrantId(guest) // Nothing to route a notice to: no shell is showing this contents as a preview. diff --git a/src/main/browser/doc-preview-file-reader.test.ts b/src/main/browser/doc-preview-file-reader.test.ts index fc707279f8f..2919d06eec9 100644 --- a/src/main/browser/doc-preview-file-reader.test.ts +++ b/src/main/browser/doc-preview-file-reader.test.ts @@ -17,19 +17,27 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader' import { docPreviewContentType, readDocPreviewFile } from './doc-preview-file-reader' -import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry' +import { + authorizeDocPreviewDirectory, + mintDocPreviewGrant, + revokeAllDocPreviewGrants +} from './doc-preview-grant-registry' +// 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', @@ -40,6 +48,8 @@ function runtimeGrant(root = '/srv/repo/docs'): ReturnType { @@ -165,6 +175,29 @@ describe('readDocPreviewFile — ssh owner', () => { expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled() }) + it('requires approval for a sibling directory before touching the SSH provider', async () => { + const grant = mintDocPreviewGrant({ + owner: { kind: 'ssh', connectionId: 'ssh-1' }, + requestBase: '/home/alice', + root: '/home/alice/docs', + entryRelativePath: 'docs/index.html', + browserPageId: 'page-1' + }) + + await expect(readDocPreviewFile(grant, 'assets/logo.png')).resolves.toMatchObject({ + ok: false, + status: 403, + reason: 'authorization-required' + }) + expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled() + + authorizeDocPreviewDirectory(grant.id, 'assets/logo.png') + mocks.readFile.mockResolvedValue({ content: 'logo', isBinary: false }) + + await expect(readDocPreviewFile(grant, 'assets/logo.png')).resolves.toMatchObject({ ok: true }) + expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/assets/logo.png') + }) + it('reports an over-cap SSH file as too large rather than unreadable', async () => { mocks.readFile.mockRejectedValue(new FileReadCapExceededError('exceeds client cap')) @@ -330,4 +363,41 @@ describe('readDocPreviewFile — paired runtime owner', () => { expect(outcome).toMatchObject({ ok: false, status: 404 }) expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled() }) + + it('requires approval for a sibling directory before touching the runtime', async () => { + const grant = mintDocPreviewGrant({ + owner: { + kind: 'runtime', + environmentId: 'env-1', + worktreeSelector: 'id:wt-1', + worktreeRoot: '/srv/repo' + }, + requestBase: '/srv/repo', + root: '/srv/repo/docs', + entryRelativePath: 'docs/index.html', + browserPageId: 'page-1' + }) + + await expect(readDocPreviewFile(grant, 'assets/app.js')).resolves.toMatchObject({ + ok: false, + status: 403, + reason: 'authorization-required' + }) + expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled() + + authorizeDocPreviewDirectory(grant.id, 'assets/app.js') + mocks.callRuntimeEnvironment.mockResolvedValue({ + ok: true, + result: { content: 'console.log(1)', truncated: false, byteLength: 14 } + }) + + await expect(readDocPreviewFile(grant, 'assets/app.js')).resolves.toMatchObject({ ok: true }) + expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith( + '/user-data', + 'env-1', + 'files.read', + { worktree: 'id:wt-1', relativePath: 'assets/app.js' }, + 15_000 + ) + }) }) diff --git a/src/main/browser/doc-preview-file-reader.ts b/src/main/browser/doc-preview-file-reader.ts index 967461a91f9..87e78c1d2e3 100644 --- a/src/main/browser/doc-preview-file-reader.ts +++ b/src/main/browser/doc-preview-file-reader.ts @@ -10,6 +10,7 @@ import { getCanonicalUserDataPath } from '../persistence' import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { resolveCanonicalDocPreviewPath, + resolveDocPreviewCandidatePath, resolveDocPreviewTargetPath, toRuntimeWorktreeRelativePath, type DocPreviewGrant @@ -153,9 +154,18 @@ export async function readDocPreviewFile( grant: DocPreviewGrant, relativePath: string ): Promise { + const candidatePath = resolveDocPreviewCandidatePath(grant, relativePath) + if (!candidatePath) { + return notFoundOutcome() + } const absolutePath = resolveDocPreviewTargetPath(grant, relativePath) if (!absolutePath) { - return notFoundOutcome() + return { + ok: false, + status: 403, + reason: 'authorization-required', + message: 'This file needs permission before the preview can read it.' + } } const contentType = docPreviewContentType(relativePath) try { diff --git a/src/main/browser/doc-preview-grant-registry.test.ts b/src/main/browser/doc-preview-grant-registry.test.ts index 8aadf9ed695..36022f6a753 100644 --- a/src/main/browser/doc-preview-grant-registry.test.ts +++ b/src/main/browser/doc-preview-grant-registry.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest' import { + authorizeDocPreviewDirectory, getDocPreviewGrant, mintDocPreviewGrant, resolveCanonicalDocPreviewPath, @@ -46,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', () => { @@ -101,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' ) @@ -118,6 +152,60 @@ describe('resolveDocPreviewTargetPath', () => { expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html') expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull() }) + + it('authorizes only the requested sibling directory', () => { + const grant = mintDocPreviewGrant({ + owner: sshOwner, + requestBase: '/srv/repo', + root: '/srv/repo/docs', + entryRelativePath: 'docs/report.html', + browserPageId: 'page-1' + }) + + expect(resolveDocPreviewTargetPath(grant, 'docs/report.html')).toBe( + '/srv/repo/docs/report.html' + ) + expect(resolveDocPreviewTargetPath(grant, 'assets/chart.js')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, '.env')).toBeNull() + + expect(authorizeDocPreviewDirectory(grant.id, 'assets/chart.js')).toBe(true) + expect(resolveDocPreviewTargetPath(grant, 'assets/chart.js')).toBe('/srv/repo/assets/chart.js') + expect(resolveDocPreviewTargetPath(grant, 'assets/theme.css')).toBe( + '/srv/repo/assets/theme.css' + ) + expect(resolveDocPreviewTargetPath(grant, '.env')).toBeNull() + }) + + it('refuses malformed authorization requests and unknown grants', () => { + const grant = mintDocPreviewGrant({ + owner: sshOwner, + requestBase: '/srv/repo', + root: '/srv/repo/docs', + entryRelativePath: 'docs/report.html', + browserPageId: 'page-1' + }) + + expect(authorizeDocPreviewDirectory(grant.id, '../outside/secret.txt')).toBe(false) + expect(authorizeDocPreviewDirectory(grant.id, 'assets\\secret.txt')).toBe(false) + expect(authorizeDocPreviewDirectory('0'.repeat(32), 'assets/chart.js')).toBe(false) + expect(grant.authorizedRoots).toEqual([]) + }) + + it('uses Windows semantics when authorizing a sibling directory', () => { + const grant = mintDocPreviewGrant({ + owner: sshOwner, + requestBase: 'C:\\srv\\repo', + root: 'C:\\srv\\repo\\docs', + entryRelativePath: 'docs/report.html', + browserPageId: 'page-1' + }) + + expect(authorizeDocPreviewDirectory(grant.id, 'assets/chart.js')).toBe(true) + expect(resolveDocPreviewTargetPath(grant, 'assets/chart.js')).toBe( + 'C:\\srv\\repo\\assets\\chart.js' + ) + expect(authorizeDocPreviewDirectory(grant.id, '../outside/secret.txt')).toBe(false) + }) }) describe('resolveCanonicalDocPreviewPath', () => { @@ -128,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() @@ -138,8 +226,39 @@ 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 () => { + const grant = mintDocPreviewGrant({ + owner: sshOwner, + requestBase: '/srv/repo', + root: '/srv/repo/docs', + entryRelativePath: 'docs/report.html', + browserPageId: 'page-1' + }) + authorizeDocPreviewDirectory(grant.id, 'assets/chart.js') + + await expect( + resolveCanonicalDocPreviewPath(grant, '/srv/repo/assets/chart.js', async (path) => { + if (path === '/srv/repo/assets') { + return '/srv/repo/assets' + } + if (path === '/srv/repo/assets/chart.js') { + return '/etc/shadow' + } + return path + }) + ).resolves.toBeNull() }) }) diff --git a/src/main/browser/doc-preview-grant-registry.ts b/src/main/browser/doc-preview-grant-registry.ts index 51237a685f1..191b7ddeb95 100644 --- a/src/main/browser/doc-preview-grant-registry.ts +++ b/src/main/browser/doc-preview-grant-registry.ts @@ -21,9 +21,18 @@ export type DocPreviewOwner = export type DocPreviewGrant = { id: string owner: DocPreviewOwner - /** Containing directory of the opened document, on the owning host. */ + /** Directory relative preview URLs resolve against. */ + requestBase: string + /** + * 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 - /** Path of the opened document relative to `root`. */ + /** Additional directories the reader approved for this grant. */ + authorizedRoots: string[] + /** Path of the opened document relative to `requestBase`. */ entryRelativePath: string /** * Browser page the reader opened this document in. Main registers the guest under it once the @@ -56,14 +65,23 @@ function normalizeRootPath(root: string): string { export function mintDocPreviewGrant(params: { owner: DocPreviewOwner + requestBase?: string root: string entryRelativePath: string browserPageId: string }): DocPreviewGrant { + const requestBase = normalizeRootPath(params.requestBase ?? params.root) + const root = normalizeRootPath(params.root) + const flavor = pathFlavorFor(requestBase) + if (!isAtOrInsideRoot(requestBase, root, flavor)) { + throw new Error('Document preview root is outside its request base') + } const grant: DocPreviewGrant = { id: randomBytes(16).toString('hex'), owner: params.owner, - root: normalizeRootPath(params.root), + requestBase, + root, + authorizedRoots: [], entryRelativePath: params.entryRelativePath.replace(/\\/g, '/'), browserPageId: params.browserPageId } @@ -126,12 +144,8 @@ function hasUnsafeSegment(segments: string[]): boolean { ) } -/** - * Resolves a request path to an absolute path on the owning host, or null when - * it would escape the grant's root. Path flavor follows the root (the owning - * host may be Windows while this client is not), never `process.platform`. - */ -export function resolveDocPreviewTargetPath( +/** Resolves a safe request inside the workspace boundary, before grant authorization. */ +export function resolveDocPreviewCandidatePath( grant: DocPreviewGrant, relativePath: string ): string | null { @@ -142,9 +156,9 @@ export function resolveDocPreviewTargetPath( if (segments.length === 0 || hasUnsafeSegment(segments)) { return null } - const flavor = pathFlavorFor(grant.root) - const resolved = flavor.normalize(flavor.join(grant.root, ...segments)) - return isInsideRoot(grant.root, resolved, flavor) ? resolved : null + const flavor = pathFlavorFor(grant.requestBase) + const resolved = flavor.normalize(flavor.join(grant.requestBase, ...segments)) + return isInsideRoot(grant.requestBase, resolved, flavor) ? resolved : null } function isInsideRoot( @@ -156,8 +170,70 @@ function isInsideRoot( return candidate.startsWith(rootPrefix) } +function isAtOrInsideRoot( + root: string, + candidate: string, + flavor: typeof posix | typeof win32 +): boolean { + return candidate === root || isInsideRoot(root, candidate, flavor) +} + +function directoryAuthorityRoots(grant: DocPreviewGrant): string[] { + return grant.root === grant.requestBase + ? [...grant.authorizedRoots] + : [grant.root, ...grant.authorizedRoots] +} + +/** 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 +): string | null { + const resolved = resolveDocPreviewCandidatePath(grant, relativePath) + if (!resolved) { + return null + } + if (resolved === resolveEntryAbsolutePath(grant)) { + return resolved + } + const flavor = pathFlavorFor(grant.requestBase) + return directoryAuthorityRoots(grant).some((root) => isInsideRoot(root, resolved, flavor)) + ? resolved + : null +} + +/** Expands a live grant to the directory containing one reader-approved request. */ +export function authorizeDocPreviewDirectory(grantId: string, relativePath: string): boolean { + const grant = grantsById.get(grantId) + if (!grant) { + return false + } + const candidate = resolveDocPreviewCandidatePath(grant, relativePath) + if (!candidate) { + return false + } + const flavor = pathFlavorFor(grant.requestBase) + const directory = normalizeRootPath(flavor.dirname(candidate)) + if (!isAtOrInsideRoot(grant.requestBase, directory, flavor)) { + return false + } + if (!directoryAuthorityRoots(grant).some((root) => isAtOrInsideRoot(root, directory, flavor))) { + grant.authorizedRoots.push(directory) + canonicalRootByGrantId.delete(grant.id) + } + return true +} + /** Why: realpath is a host round-trip, and a grant's root is fixed for its lifetime. */ -const canonicalRootByGrantId = new Map>() +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 @@ -171,14 +247,35 @@ export async function resolveCanonicalDocPreviewPath( realpath: (path: string) => Promise ): Promise { try { - let canonicalRoot = canonicalRootByGrantId.get(grant.id) - if (!canonicalRoot) { - canonicalRoot = realpath(grant.root).then(normalizeRootPath) - canonicalRootByGrantId.set(grant.id, canonicalRoot) + let canonicalRoots = canonicalRootByGrantId.get(grant.id) + if (!canonicalRoots) { + const entryAbsolute = resolveEntryAbsolutePath(grant) + canonicalRoots = Promise.all([ + realpath(grant.requestBase), + 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)), + entry: entryPath !== null && isInsideRoot(boundary, entryPath, flavor) ? entryPath : null + } + }) + canonicalRootByGrantId.set(grant.id, canonicalRoots) } - const [root, canonicalPath] = await Promise.all([canonicalRoot, realpath(absolutePath)]) - const flavor = pathFlavorFor(root) - return isInsideRoot(root, canonicalPath, flavor) ? canonicalPath : null + const [{ boundary, roots, entry }, canonicalPath] = await Promise.all([ + canonicalRoots, + realpath(absolutePath) + ]) + const flavor = pathFlavorFor(boundary) + return isInsideRoot(boundary, canonicalPath, flavor) && + (canonicalPath === entry || roots.some((root) => isInsideRoot(root, canonicalPath, flavor))) + ? canonicalPath + : null } catch { // Why: a root that no longer canonicalizes must not fall back to the lexical answer. canonicalRootByGrantId.delete(grant.id) diff --git a/src/main/browser/doc-preview-guest-policy.test.ts b/src/main/browser/doc-preview-guest-policy.test.ts index 26b117c0fc0..5813d60e110 100644 --- a/src/main/browser/doc-preview-guest-policy.test.ts +++ b/src/main/browser/doc-preview-guest-policy.test.ts @@ -270,13 +270,15 @@ describe('doc preview guest policy', () => { }) }) - it('drops a click reported while the guest is not the contents the reader is looking at', () => { + it("does not mistake Electron's false webview focus flag for an untrusted click", () => { const { guest } = boundGuest() guest.isFocused.mockReturnValue(false) reportClick(guest, 'https://example.com/docs') - expect(guest.send).not.toHaveBeenCalled() + expect(guest.send).toHaveBeenCalledExactlyOnceWith('docPreview:externalLink', { + url: 'https://example.com/docs' + }) }) it('drops a click reported by a sender that is not a preview guest', () => { diff --git a/src/main/browser/doc-preview-guest-policy.ts b/src/main/browser/doc-preview-guest-policy.ts index 0c09eb13b0f..a869cab3641 100644 --- a/src/main/browser/doc-preview-guest-policy.ts +++ b/src/main/browser/doc-preview-guest-policy.ts @@ -15,7 +15,6 @@ type PreviewHostRenderer = { type PreviewGuestRegistration = { host: PreviewHostRenderer readBoundGrantId: () => string | null - isFocused: () => boolean } /** @@ -125,8 +124,7 @@ export function installDocPreviewGuestPolicy( previewGuests.set(guest, { host, - readBoundGrantId: () => boundGrantId, - isFocused: () => guest.isFocused() + readBoundGrantId: () => boundGrantId }) const forgetGuest = (): void => { previewGuests.delete(guest) @@ -221,9 +219,9 @@ function isWebUrl(url: string): boolean { } /** - * The only way a URL leaves a preview. Every condition is load-bearing: the sender must be a live - * preview guest still bound to a grant, it must be the contents the reader is looking at, and the - * target must be the web. Anything else is dropped without a trace the document could observe. + * The only way a URL leaves a preview. Every condition is load-bearing: the isolated preload must + * report a trusted anchor click, the sender must be a live preview guest still bound to a grant, + * and the target must be the web. Anything else is dropped without a trace the document can observe. */ export function reportDocPreviewLinkClick(sender: Electron.WebContents, rawUrl: string): void { const registration = previewGuests.get(sender) @@ -234,11 +232,6 @@ export function reportDocPreviewLinkClick(sender: Electron.WebContents, rawUrl: if (boundGrantId === null || !getDocPreviewGrant(boundGrantId)) { return } - // Why focus and not just the preload's trusted-click check: that check runs inside the guest, so - // it holds only while the guest renderer does. Focus is the half main can verify for itself. - if (!registration.isFocused()) { - return - } const externalUrl = normalizeExternalBrowserUrl(rawUrl) if (!externalUrl || !isWebUrl(externalUrl)) { return diff --git a/src/main/browser/doc-preview-protocol.test.ts b/src/main/browser/doc-preview-protocol.test.ts index b87e7f9e942..32700be32fd 100644 --- a/src/main/browser/doc-preview-protocol.test.ts +++ b/src/main/browser/doc-preview-protocol.test.ts @@ -261,8 +261,8 @@ describe('installDocPreviewProtocolHandler', () => { expect(cancelled(`orca-preview://${'a'.repeat(32)}/index.html`)).toBe(false) }) - // Why: preview guests are webviews like any other, so they must not skip the deny-by-default - // permission and display-media policy every browser partition gets. + // Why: preview guests still use the shared installer for certificate, UA, permission and + // download hooks, with a stricter decision than ordinary browsing partitions. it('applies the shared browser partition policies to the preview session', () => { installDocPreviewProtocolHandler() @@ -272,14 +272,12 @@ describe('installDocPreviewProtocolHandler', () => { ) }) - // Why downloads and nothing else: the browser download flow attributes a file to the page that - // asked for it, and a previewed document is no page. Routed, it would write remote-authored bytes - // into this desktop's Downloads folder with no prompt and no tab to name as the source. - it('asks for downloads to be denied on the preview partition', () => { + it('denies downloads and ambient browser permissions on the preview partition', () => { installDocPreviewProtocolHandler() expect(mocks.installBrowserSessionPartitionPolicies).toHaveBeenCalledWith(expect.anything(), { - downloads: 'deny' + downloads: 'deny', + permissions: 'deny' }) }) }) diff --git a/src/main/browser/doc-preview-protocol.ts b/src/main/browser/doc-preview-protocol.ts index 7ed1507c5b8..fd60817ff36 100644 --- a/src/main/browser/doc-preview-protocol.ts +++ b/src/main/browser/doc-preview-protocol.ts @@ -42,7 +42,9 @@ export function isDocPreviewSession(candidate: Electron.Session): boolean { * Product decision, not a hardening default: previewed documents are agent-authored, so any * outbound request they can make is an exfiltration channel for whatever else the page can read. * Self-contained documents — inline CSS/JS/SVG and in-grant assets — render in full; a CDN - * stylesheet, font, script, or analytics beacon deliberately does not load. + * stylesheet, font, script, or analytics beacon deliberately does not load. Electron 43 still + * resolves explicit DNS-prefetch hints outside session hooks; the accepted residual is covered by + * `browser-route-dns-prefetch.electron.test.ts` and can beacon grant-readable bytes in DNS labels. */ const DOC_PREVIEW_CONTENT_SECURITY_POLICY = [ "default-src 'self'", @@ -120,12 +122,12 @@ export function installDocPreviewProtocolHandler(): void { } previewSession.protocol.handle(DOC_PREVIEW_SCHEME, handleDocPreviewRequest) // Why: the response CSP is the document's own promise to obey; this is the session refusing to - // carry the request at all, so a CSP bypass in one element type still reaches nothing. + // carry network requests even if an element bypasses CSP. DNS-prefetch does not reach this hook. previewSession.webRequest.onBeforeRequest((details, callback) => { callback({ cancel: !isAllowedDocPreviewRequestUrl(details.url) }) }) - // Why: preview guests are webviews like any other, so they inherit the same deny-by-default - // permission, display-media and user-agent policy every browser partition gets. + // Why: the shared installer still owns certificate, UA, download and permission hooks, while + // preview content receives no ambient browser/device permissions. installBrowserSessionPartitionPolicies( { id: DOC_PREVIEW_PARTITION, @@ -139,6 +141,6 @@ export function installDocPreviewProtocolHandler(): void { // page to attribute the file to, and a previewed document is not one. Routed here it would // reserve a name in this desktop's Downloads folder and write remote-authored bytes into it // with nothing in the UI naming the tab that asked, and no prompt in front of it. - { downloads: 'deny' } + { downloads: 'deny', permissions: 'deny' } ) } diff --git a/src/main/ipc/doc-preview-grant-ipc.test.ts b/src/main/ipc/doc-preview-grant-ipc.test.ts index 3a5d394c58e..453f3ac854c 100644 --- a/src/main/ipc/doc-preview-grant-ipc.test.ts +++ b/src/main/ipc/doc-preview-grant-ipc.test.ts @@ -37,6 +37,7 @@ import { revokeAllDocPreviewGrants } from '../browser/doc-preview-grant-registry' import { + DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL, DOC_PREVIEW_LINK_CLICK_CHANNEL, DOC_PREVIEW_MINT_GRANT_CHANNEL, DOC_PREVIEW_REVOKE_GRANT_CHANNEL, @@ -45,6 +46,7 @@ import { const REQUEST: DocPreviewGrantRequest = { owner: { kind: 'ssh', connectionId: 'ssh-1' }, + requestBase: '/home/alice/docs', root: '/home/alice/docs', entryRelativePath: 'index.html', browserPageId: 'doc-page-1' @@ -68,6 +70,14 @@ function revoke(grantId: string): boolean { return handler({ sender }, grantId) as boolean } +function authorize(grantId: unknown, relativePath: unknown): boolean { + const handler = mocks.handlers.get(DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL) + if (!handler) { + throw new Error('authorize handler not registered') + } + return handler({ sender }, grantId, relativePath) as boolean +} + function reportLinkClick(url: unknown): void { const listener = mocks.listeners.get(DOC_PREVIEW_LINK_CLICK_CHANNEL) if (!listener) { @@ -137,6 +147,31 @@ describe('document preview grant handlers', () => { expect(getDocPreviewGrant(result.grantId)).not.toBeNull() }) + it('lets only the trusted renderer authorize a valid requested directory', () => { + const result = mint({ + ...REQUEST, + requestBase: '/home/alice', + root: '/home/alice/docs', + entryRelativePath: 'docs/index.html' + }) + + expect(authorize(result.grantId, 'assets/app.js')).toBe(true) + expect(getDocPreviewGrant(result.grantId)?.authorizedRoots).toEqual(['/home/alice/assets']) + + mocks.isTrustedBrowserRenderer.mockReturnValue(false) + expect(authorize(result.grantId, 'secrets/token.txt')).toBe(false) + expect(getDocPreviewGrant(result.grantId)?.authorizedRoots).toEqual(['/home/alice/assets']) + }) + + it('refuses malformed directory authorization arguments', () => { + const result = mint() + + expect(authorize(result.grantId, '../secret.txt')).toBe(false) + expect(authorize(result.grantId, null)).toBe(false) + expect(authorize(null, 'assets/app.js')).toBe(false) + expect(authorize('0'.repeat(32), 'assets/app.js')).toBe(false) + }) + // Why this channel skips the trusted-renderer check: its sender is a preview guest rendering a // workspace document, which is the untrusted side by construction. The guest policy holds the // gate, so all this listener owes is the sender and a string. diff --git a/src/main/ipc/doc-preview-grant-ipc.ts b/src/main/ipc/doc-preview-grant-ipc.ts index e86ec4afe02..9b3c6636322 100644 --- a/src/main/ipc/doc-preview-grant-ipc.ts +++ b/src/main/ipc/doc-preview-grant-ipc.ts @@ -1,6 +1,7 @@ import { ipcMain } from 'electron' import { buildDocPreviewUrl, + DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL, DOC_PREVIEW_LINK_CLICK_CHANNEL, DOC_PREVIEW_MINT_GRANT_CHANNEL, DOC_PREVIEW_REVOKE_GRANT_CHANNEL @@ -8,6 +9,7 @@ import { import { browserManager } from '../browser/browser-manager' import { reportDocPreviewLinkClick } from '../browser/doc-preview-guest-policy' import { + authorizeDocPreviewDirectory, mintDocPreviewGrant, revokeDocPreviewGrant, type DocPreviewOwner @@ -16,9 +18,11 @@ import { isTrustedBrowserRenderer } from './browser-renderer-trust' export type DocPreviewGrantRequest = { owner: DocPreviewOwner + /** Directory relative preview URLs resolve against. */ + requestBase: string /** Containing directory of the opened document, on the owning host. */ root: string - /** Opened document, relative to `root`. */ + /** Opened document, relative to `requestBase`. */ entryRelativePath: string /** Browser page the reader is opening the document in; main registers the guest under it. */ browserPageId: string @@ -27,7 +31,7 @@ export type DocPreviewGrantRequest = { export type DocPreviewGrantResult = { grantId: string; url: string } function isValidGrantRequest(request: DocPreviewGrantRequest): boolean { - if (!request.root.trim() || !request.entryRelativePath.trim()) { + if (!request.requestBase.trim() || !request.root.trim() || !request.entryRelativePath.trim()) { return false } if (typeof request.browserPageId !== 'string' || !request.browserPageId.trim()) { @@ -69,6 +73,7 @@ export function registerDocPreviewGrantHandlers(): void { } const grant = mintDocPreviewGrant({ owner: request.owner, + requestBase: request.requestBase, root: request.root, entryRelativePath: request.entryRelativePath, browserPageId: request.browserPageId @@ -84,9 +89,18 @@ export function registerDocPreviewGrantHandlers(): void { isTrustedBrowserRenderer(event.sender) ? revokeDocPreviewGrant(grantId) : false ) + ipcMain.handle( + DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL, + (event, grantId: unknown, relativePath: unknown): boolean => + isTrustedBrowserRenderer(event.sender) && + typeof grantId === 'string' && + typeof relativePath === 'string' && + authorizeDocPreviewDirectory(grantId, relativePath) + ) + // Why no trusted-renderer check here: the sender is a preview guest rendering a workspace // document, which is exactly the untrusted side. `reportDocPreviewLinkClick` is the gate — a - // live bound grant, a focused guest, a web URL — and it drops everything else silently. + // live bound grant and a web URL — and it drops everything else silently. ipcMain.on(DOC_PREVIEW_LINK_CLICK_CHANNEL, (event, url: unknown) => { if (typeof url === 'string') { reportDocPreviewLinkClick(event.sender, url) diff --git a/src/main/orca-profiles/profile-project-session-state.test.ts b/src/main/orca-profiles/profile-project-session-state.test.ts index c60622ac9ba..1d9a64870c3 100644 --- a/src/main/orca-profiles/profile-project-session-state.test.ts +++ b/src/main/orca-profiles/profile-project-session-state.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { mergeWorkspaceSessions, removeRepoFromWorkspaceSession @@ -84,6 +85,57 @@ describe('profile project session state', () => { ) }) + it('rekeys document-preview ownership during project transfer', () => { + const session = { + ...getDefaultWorkspaceSession(), + browserTabsByWorktree: { + [REMOVED_WORKTREE_ID]: [ + { + id: 'browser-1', + worktreeId: REMOVED_WORKTREE_ID, + docLocation: { + kind: 'workspace-doc', + worktreeId: REMOVED_WORKTREE_ID, + filePath: '/removed/docs/report.html' + } + } + ] + }, + browserPagesByWorkspace: { + 'browser-1': [ + { + id: 'page-1', + workspaceId: 'browser-1', + worktreeId: REMOVED_WORKTREE_ID, + docLocation: { + kind: 'workspace-doc', + worktreeId: REMOVED_WORKTREE_ID, + filePath: '/removed/docs/report.html' + } + } + ] + } + } + + const result = extractSessionForTransfer( + session as unknown as WorkspaceSessionState, + REMOVED_REPO_ID, + 'repo-c' + ) + const transferredWorktreeId = 'repo-c::/removed' + + expect(result.browserTabsByWorktree?.[transferredWorktreeId]?.[0]?.docLocation).toEqual({ + kind: 'workspace-doc', + worktreeId: transferredWorktreeId, + filePath: '/removed/docs/report.html' + }) + expect(result.browserPagesByWorkspace?.['browser-1']?.[0]?.docLocation).toEqual({ + kind: 'workspace-doc', + worktreeId: transferredWorktreeId, + filePath: '/removed/docs/report.html' + }) + }) + it('prunes terminal membership authority records with a removed repo', () => { const session = { ...getDefaultWorkspaceSession(), diff --git a/src/main/orca-profiles/profile-project-session-transfer.ts b/src/main/orca-profiles/profile-project-session-transfer.ts index 0cf15a02ef2..44f83680b40 100644 --- a/src/main/orca-profiles/profile-project-session-transfer.ts +++ b/src/main/orca-profiles/profile-project-session-transfer.ts @@ -1,6 +1,11 @@ import { getDefaultWorkspaceSession } from '../../shared/constants' import type { ExecutionHostId } from '../../shared/execution-host' -import type { BrowserPage, BrowserWorkspace } from '../../shared/browser-workspace-types' +import type { + BrowserPage, + BrowserPageDocLocation, + BrowserWorkspace +} from '../../shared/browser-workspace-types' +import { remapBrowserPageDocLocation } from '../../shared/browser-page-doc-location' import type { Tab, TabGroup } from '../../shared/tab-types' import type { TerminalTab } from '../../shared/terminal-tab-types' import type { @@ -175,6 +180,9 @@ function rekeyBrowserWorkspace( return { ...structuredClone(workspace), worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, workspace.worktreeId), + ...(workspace.docLocation + ? { docLocation: rekeyBrowserDocLocation(workspace.docLocation, oldRepoId, newRepoId) } + : {}), // Why: both the session profile and the resolved partition string are // source-profile-scoped; carrying either across would point the restored // pane at a partition the target profile's allowlist rejects. @@ -186,10 +194,22 @@ function rekeyBrowserWorkspace( function rekeyBrowserPage(page: BrowserPage, oldRepoId: string, newRepoId: string): BrowserPage { return { ...structuredClone(page), - worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, page.worktreeId) + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, page.worktreeId), + ...(page.docLocation + ? { docLocation: rekeyBrowserDocLocation(page.docLocation, oldRepoId, newRepoId) } + : {}) } } +function rekeyBrowserDocLocation( + location: BrowserPageDocLocation, + oldRepoId: string, + newRepoId: string +): BrowserPageDocLocation { + const nextWorktreeId = rekeyWorktreeId(oldRepoId, newRepoId, location.worktreeId) + return remapBrowserPageDocLocation(location, location.worktreeId, nextWorktreeId) +} + function copyBrowserPages( pagesByWorkspace: Record | undefined, workspaceIds: ReadonlySet, diff --git a/src/main/persistence-cohort-and-identity-migration.test.ts b/src/main/persistence-cohort-and-identity-migration.test.ts index 9d931a1bbd4..4081672c821 100644 --- a/src/main/persistence-cohort-and-identity-migration.test.ts +++ b/src/main/persistence-cohort-and-identity-migration.test.ts @@ -243,10 +243,33 @@ describe('Store.migrateWorktreeIdentity', () => { }, activeFileIdByWorktree: { [OLD]: '/ws/cunner/a.ts' }, browserTabsByWorktree: { - [OLD]: [{ id: 'browser1', worktreeId: OLD, title: 'Browser', url: 'about:blank' }] + [OLD]: [ + { + id: 'browser1', + worktreeId: OLD, + title: 'Browser', + url: 'about:blank', + docLocation: { + kind: 'workspace-doc', + worktreeId: OLD, + filePath: '/ws/cunner/docs/report.html' + } + } + ] }, browserPagesByWorkspace: { - browser1: [{ id: 'page1', workspaceId: 'browser1', worktreeId: OLD }] + browser1: [ + { + id: 'page1', + workspaceId: 'browser1', + worktreeId: OLD, + docLocation: { + kind: 'workspace-doc', + worktreeId: OLD, + filePath: '/ws/cunner/docs/report.html' + } + } + ] }, activeBrowserTabIdByWorktree: { [OLD]: 'browser1' }, activeTabTypeByWorktree: { [OLD]: 'browser' }, @@ -327,6 +350,16 @@ describe('Store.migrateWorktreeIdentity', () => { expect(session.browserTabsByWorktree?.[OLD]).toBeUndefined() expect(session.browserTabsByWorktree?.[NEW]?.[0]?.worktreeId).toBe(NEW) expect(session.browserPagesByWorkspace?.browser1?.[0]?.worktreeId).toBe(NEW) + expect(session.browserTabsByWorktree?.[NEW]?.[0]?.docLocation).toEqual({ + kind: 'workspace-doc', + worktreeId: NEW, + filePath: '/ws/worktree-creation-spinner/docs/report.html' + }) + expect(session.browserPagesByWorkspace?.browser1?.[0]?.docLocation).toEqual({ + kind: 'workspace-doc', + worktreeId: NEW, + filePath: '/ws/worktree-creation-spinner/docs/report.html' + }) expect(session.activeBrowserTabIdByWorktree?.[NEW]).toBe('browser1') expect(session.activeTabTypeByWorktree?.[NEW]).toBe('browser') expect(session.activeWorktreeId).toBe(NEW) diff --git a/src/main/persistence/tracking-repos/worktree-identity-migration.ts b/src/main/persistence/tracking-repos/worktree-identity-migration.ts index d9451258634..2d2fa484ad6 100644 --- a/src/main/persistence/tracking-repos/worktree-identity-migration.ts +++ b/src/main/persistence/tracking-repos/worktree-identity-migration.ts @@ -1,4 +1,6 @@ import type { WorkspaceKey } from '../../../shared/folder-workspace-types' +import type { BrowserPage, BrowserWorkspace } from '../../../shared/browser-workspace-types' +import { remapBrowserPageDocLocation } from '../../../shared/browser-page-doc-location' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' import type { PersistedState } from '../../../shared/persisted-state-types' @@ -6,6 +8,7 @@ import { getWorktreeIdFromHostIdentity, isWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' +import { splitWorktreeIdForFilesystem } from '../../../shared/worktree/id' /** * Re-keys every worktreeId-keyed record in `state` from `oldWorktreeId` to `newWorktreeId`. Mutates `state` in place; @@ -35,6 +38,23 @@ export function migrateWorktreeIdentity( } const withNewWorktreeId = (value: T): T => value.worktreeId === oldWorktreeId ? { ...value, worktreeId: newWorktreeId } : value + const oldWorktreePath = splitWorktreeIdForFilesystem(oldWorktreeId)?.worktreePath + const newWorktreePath = splitWorktreeIdForFilesystem(newWorktreeId)?.worktreePath + const withNewBrowserWorktreeId = (value: T): T => { + const renamedValue = withNewWorktreeId(value) + return value.docLocation?.worktreeId === oldWorktreeId + ? { + ...renamedValue, + docLocation: remapBrowserPageDocLocation( + value.docLocation, + oldWorktreeId, + newWorktreeId, + oldWorktreePath, + newWorktreePath + ) + } + : renamedValue + } const migrateSession = (session: WorkspaceSessionState | undefined): boolean => { if (!session) { return false @@ -72,16 +92,21 @@ export function migrateWorktreeIdentity( sessionChanged = moveSessionKey(session.activeFileIdByWorktree) || sessionChanged sessionChanged = moveSessionKey(session.browserTabsByWorktree, (workspaces) => - workspaces.map(withNewWorktreeId) + workspaces.map(withNewBrowserWorktreeId) ) || sessionChanged if (session.browserPagesByWorkspace) { let pagesChanged = false const nextPagesByWorkspace = { ...session.browserPagesByWorkspace } for (const [workspaceId, pages] of Object.entries(nextPagesByWorkspace)) { - if (!pages.some((page) => page.worktreeId === oldWorktreeId)) { + if ( + !pages.some( + (page) => + page.worktreeId === oldWorktreeId || page.docLocation?.worktreeId === oldWorktreeId + ) + ) { continue } - nextPagesByWorkspace[workspaceId] = pages.map(withNewWorktreeId) + nextPagesByWorkspace[workspaceId] = pages.map(withNewBrowserWorktreeId) pagesChanged = true } if (pagesChanged) { diff --git a/src/preload/api/doc-preview-api.ts b/src/preload/api/doc-preview-api.ts index 5199a312b65..51e928096b3 100644 --- a/src/preload/api/doc-preview-api.ts +++ b/src/preload/api/doc-preview-api.ts @@ -11,6 +11,9 @@ export type DocPreviewGrantOwner = export type DocPreviewGrantRequest = { owner: DocPreviewGrantOwner + /** Directory that relative preview URLs resolve against. */ + requestBase: string + /** Initial filesystem authority; always the opened document's directory. */ root: string entryRelativePath: string /** Browser page the document is being opened in; main registers its guest under this id. */ @@ -21,6 +24,7 @@ export type DocPreviewApi = { docPreview: { mintGrant: (request: DocPreviewGrantRequest) => Promise<{ grantId: string; url: string }> revokeGrant: (grantId: string) => Promise + authorizeDirectory: (grantId: string, relativePath: string) => Promise /** External link the preview guest tried to open; the renderer turns it into a browser tab. */ onExternalLink: (callback: (payload: { url: string }) => void) => () => void /** Why the guest is showing an error body instead of the document. */ diff --git a/src/preload/doc-preview-link.ts b/src/preload/doc-preview-link.ts index f333d313836..e88e4ab7801 100644 --- a/src/preload/doc-preview-link.ts +++ b/src/preload/doc-preview-link.ts @@ -9,7 +9,7 @@ const { ipcRenderer } = require('electron') as { } // Why no contextBridge: the document must not be able to call this. The listeners live in the -// isolated world, and main still refuses any report that does not come from a focused preview guest. +// isolated world, and main still refuses any report that does not come from a live, grant-bound preview guest. installDocPreviewLinkInterception((url) => { ipcRenderer.send(PRELOAD_DOC_PREVIEW_LINK_CLICK_CHANNEL, url) }) diff --git a/src/preload/index.ts b/src/preload/index.ts index 3974d1b4140..1b75b5b56da 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -11,6 +11,7 @@ import type { import { DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, DOC_PREVIEW_LOAD_FAILURE_CHANNEL, + DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL, DOC_PREVIEW_MINT_GRANT_CHANNEL, DOC_PREVIEW_REVOKE_GRANT_CHANNEL, type DocPreviewFailure @@ -3335,6 +3336,8 @@ const api = { ipcRenderer.invoke(DOC_PREVIEW_MINT_GRANT_CHANNEL, request), revokeGrant: (grantId: string): Promise => ipcRenderer.invoke(DOC_PREVIEW_REVOKE_GRANT_CHANNEL, grantId), + authorizeDirectory: (grantId: string, relativePath: string): Promise => + ipcRenderer.invoke(DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL, grantId, relativePath), onExternalLink: (callback: (payload: { url: string }) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, payload: { url: string }): void => callback(payload) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index cdd08d323d6..903c8b81447 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -3,6 +3,7 @@ import { Toaster } from '@/components/ui/sonner' import { TooltipProvider } from '@/components/ui/tooltip' import { ConfirmationDialogProvider } from './components/confirmation-dialog' import { BrowserWebAuthnAccountDialog } from './components/browser-webauthn-account-dialog' +import { DocPreviewExternalLinkConfirmation } from './components/browser-pane/workspace-doc/doc-preview-external-link-confirmation' import { LinkRoutingPreferenceDialogProvider } from './components/link-routing-preference-dialog' import { SkillFreshnessNudge } from './components/skills/SkillFreshnessNudge' import PinnedTabCloseDialog from './components/terminal-pane/PinnedTabCloseDialog' @@ -90,6 +91,7 @@ function App(): React.JSX.Element { > + diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-reload-control.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-reload-control.tsx index fda6cadcc11..32cf8ad50e0 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-reload-control.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-reload-control.tsx @@ -71,7 +71,11 @@ export function BrowserReloadControl({ onMenuOpenChange(true) }} > - {loading ? : } + {loading ? ( + + ) : ( + + )} 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 3abd75fff59..a925431b449 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 @@ -18,7 +18,11 @@ const GRANT_ID = 'a'.repeat(32) const REMINTED_GRANT_ID = 'c'.repeat(32) const ENTRY_RELATIVE_PATH = 'doc.html' -const grantRuntime = vi.hoisted(() => ({ mints: 0, released: [] as string[] })) +const grantRuntime = vi.hoisted(() => ({ + mints: 0, + released: [] as string[], + authorizations: [] as { grantId: string; relativePath: string }[] +})) vi.mock('@/lib/doc-preview-grants', () => ({ buildDocPreviewGrantRequest: () => ({ @@ -28,6 +32,7 @@ vi.mock('@/lib/doc-preview-grants', () => ({ worktreeSelector: 'id:wt-1', worktreeRoot: '/repo' }, + requestBase: '/repo', root: '/repo/docs', entryRelativePath: ENTRY_RELATIVE_PATH }), @@ -110,8 +115,13 @@ describe('HtmlDocPreview failure messages', () => { failureListeners.length = 0 grantRuntime.mints = 0 grantRuntime.released = [] + grantRuntime.authorizations = [] ;(window as unknown as { api: unknown }).api = { docPreview: { + authorizeDirectory: (grantId: string, relativePath: string) => { + grantRuntime.authorizations.push({ grantId, relativePath }) + return Promise.resolve(true) + }, onLoadFailure: (callback: (payload: DocPreviewFailure) => void) => { failureListeners.push(callback) return () => { @@ -203,6 +213,180 @@ describe('HtmlDocPreview failure messages', () => { expect(container.textContent).not.toContain('files in this document') }) + it('caps document-authored failure rows', async () => { + await renderPreview(container, root) + + await act(async () => { + for (let index = 0; index < 75; index += 1) { + emitFailure({ + grantId: GRANT_ID, + relativePath: `assets/missing-${index}.png`, + reason: 'unreadable' + }) + } + }) + + expect(container.textContent).toContain('50 files in this document could not be loaded.') + expect(container.textContent).not.toContain('75 files in this document could not be loaded.') + }) + + it('asks before reading a directory outside the document folder', async () => { + await renderPreview(container, root) + + await act(async () => { + emitFailure({ + grantId: GRANT_ID, + relativePath: 'assets/app.js', + reason: 'authorization-required' + }) + }) + + expect(container.textContent).toContain('This preview wants to read files in assets.') + expect(container.textContent).toContain('Dismiss') + expect(container.textContent).toContain('Allow folder') + expect(grantRuntime.authorizations).toEqual([]) + }) + + it('names the full workspace root when the document asks for a root file', async () => { + await renderPreview(container, root) + + await act(async () => { + emitFailure({ + grantId: GRANT_ID, + relativePath: '.env', + reason: 'authorization-required' + }) + }) + + expect(container.textContent).toContain('This preview wants to read files in /repo.') + }) + + 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 }) + + await act(async () => { + emitFailure({ + grantId: GRANT_ID, + relativePath: 'assets/app.js', + reason: 'authorization-required' + }) + }) + const allowButton = [...container.querySelectorAll('button')].find( + (button) => button.textContent?.trim() === 'Allow folder' + ) + + await act(async () => { + allowButton?.click() + }) + + expect(grantRuntime.authorizations).toEqual([ + { grantId: GRANT_ID, relativePath: 'assets/app.js' } + ]) + expect(reload).toHaveBeenCalledOnce() + expect(container.textContent).not.toContain('This preview wants to read files in assets.') + }) + + it('batches folders blocked in one load into a single decision', async () => { + await renderPreview(container, root) + const reload = vi.fn() + Object.assign(container.querySelector('webview')!, { reload }) + + await act(async () => { + emitFailure({ + grantId: GRANT_ID, + relativePath: 'assets/app.js', + reason: 'authorization-required' + }) + emitFailure({ + grantId: GRANT_ID, + relativePath: 'assets/theme.css', + reason: 'authorization-required' + }) + emitFailure({ + grantId: GRANT_ID, + relativePath: 'data/rows.json', + reason: 'authorization-required' + }) + }) + + expect(container.textContent).toContain('This preview wants to read files in assets and data.') + const allowButton = [...container.querySelectorAll('button')].find( + (button) => button.textContent?.trim() === 'Allow 2 folders' + ) + expect(allowButton).toBeDefined() + + await act(async () => { + allowButton?.click() + }) + + // One decision grants exactly the named set, then one reload picks it all up. + expect(grantRuntime.authorizations).toEqual([ + { grantId: GRANT_ID, relativePath: 'assets/app.js' }, + { grantId: GRANT_ID, relativePath: 'data/rows.json' } + ]) + expect(reload).toHaveBeenCalledOnce() + expect(container.textContent).not.toContain('This preview wants to read files in') + }) + + it('dismisses every folder the banner named, not just the first', async () => { + await renderPreview(container, root) + + await act(async () => { + emitFailure({ + grantId: GRANT_ID, + relativePath: 'assets/app.js', + reason: 'authorization-required' + }) + emitFailure({ + grantId: GRANT_ID, + relativePath: 'data/rows.json', + reason: 'authorization-required' + }) + }) + const dismissButton = [...container.querySelectorAll('button')].find( + (button) => button.textContent?.trim() === 'Dismiss' + ) + await act(async () => { + dismissButton?.click() + emitFailure({ + grantId: GRANT_ID, + relativePath: 'data/other.json', + reason: 'authorization-required' + }) + }) + + expect(container.textContent).not.toContain('This preview wants to read files in') + expect(grantRuntime.authorizations).toEqual([]) + }) + + it('does not reprompt for a dismissed directory during the grant lifetime', async () => { + await renderPreview(container, root) + + await act(async () => { + emitFailure({ + grantId: GRANT_ID, + relativePath: 'assets/app.js', + reason: 'authorization-required' + }) + }) + const dismissButton = [...container.querySelectorAll('button')].find( + (button) => button.textContent?.trim() === 'Dismiss' + ) + await act(async () => { + dismissButton?.click() + emitFailure({ + grantId: GRANT_ID, + relativePath: 'assets/theme.css', + reason: 'authorization-required' + }) + }) + + expect(container.textContent).not.toContain('This preview wants to read files in assets.') + expect(grantRuntime.authorizations).toEqual([]) + }) + // Why: nothing rendered, so the notice strip would be a footnote on a blank page. it('replaces the asset notice with the failure panel when the document itself fails', async () => { await renderPreview(container, root) diff --git a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx index 0e545703932..d989303c9f9 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx @@ -484,9 +484,8 @@ describe('HtmlDocPreview browser chrome', () => { }) }) -// Why this is a test and not left to the pane: main answers a reported link click only from a -// focused guest, so a preview whose guest never takes focus has no route out at all — the failure -// is silent, and only the reader pressing a link ever sees it. +// Why this is a test and not left to the pane: a preview has no address bar to hand focus to the +// document, so without this its keyboard and link input can silently land outside the visible guest. describe('HtmlDocPreview guest focus', () => { let container: HTMLDivElement let root: Root diff --git a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx index 334df32c435..343c5bdb11b 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx @@ -19,6 +19,10 @@ import { selectWorktreeHostDisplayLabel } from '@/lib/execution-host-display-lab import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { openDocPreviewExternally, openDocPreviewSource } from './doc-preview-document-actions' +import { + DocPreviewDirectoryAccessBanner, + useDocPreviewDirectoryAccess +} from './doc-preview-directory-access' import { buildDocPreviewDocumentIdentity } from './doc-preview-document-identity' import { docPreviewAssetNotice, @@ -108,6 +112,7 @@ function attachDocPreviewWebview({ /** Frames a preview keeps offering focus to a guest that is still attaching. */ const GUEST_FOCUS_FRAMES = 10 +const MAX_ASSET_FAILURES = 50 export function HtmlDocPreview({ previewId, @@ -136,6 +141,14 @@ export function HtmlDocPreview({ const [downloadBlocked, setDownloadBlocked] = useState(false) const [remintCount, setRemintCount] = useState(0) const [grantId, setGrantId] = useState(null) + const { + requests: accessRequests, + busy: accessRequestBusy, + offer: offerDirectoryAccess, + reset: resetDirectoryAccess, + dismiss: dismissDirectoryAccess, + allow: allowDirectoryAccess + } = useDocPreviewDirectoryAccess({ grantId, reloadRef }) const history = useDocPreviewWebviewHistory(webviewRef) const { sync: syncHistory, reset: resetHistory } = history @@ -193,6 +206,7 @@ export function HtmlDocPreview({ setState('loading') setFailureReason(null) setAssetFailures([]) + resetDirectoryAccess() setDownloadBlocked(false) setGrantId(null) resetHistory() @@ -216,11 +230,16 @@ export function HtmlDocPreview({ setDownloadBlocked(true) return } + if (payload.reason === 'authorization-required') { + offerDirectoryAccess(payload) + return + } if (payload.relativePath === request.entryRelativePath) { setFailureReason(payload.reason) return } setAssetFailures((current) => + current.length >= MAX_ASSET_FAILURES || current.some((failure) => failure.relativePath === payload.relativePath) ? current : [...current, payload] @@ -267,12 +286,19 @@ export function HtmlDocPreview({ unsubscribeFailure?.() detach?.() } - }, [filePath, previewId, remintCount, resetHistory, syncHistory, worktreeId]) + }, [ + filePath, + offerDirectoryAccess, + previewId, + remintCount, + resetDirectoryAccess, + resetHistory, + syncHistory, + worktreeId + ]) - // Why the guest is handed focus rather than left to the press that opens a link: main answers a - // reported link click only from a focused guest, and a preview has no chrome of its own to pass - // focus on — a URL page's address bar is what hands it over. Without this the one route out of a - // preview stays shut until something else happens to focus the document. + // Why the guest is handed focus: a preview has no address bar to make the usual handoff, so a + // surfaced document would otherwise look active while its keyboard and link input land elsewhere. useEffect(() => { if (!holdsGuestFocus || state !== 'ready') { return @@ -377,6 +403,15 @@ export function HtmlDocPreview({ {notice} ))} + {accessRequests.length > 0 && !isUnavailable ? ( + + ) : null}
void) | null> +}): { + requests: DocPreviewFileFailure[] + busy: boolean + offer: (failure: DocPreviewFileFailure) => void + reset: () => void + dismiss: () => void + allow: () => Promise +} { + // Why a ref plus a version tick and not state alone: dismiss must fence the directory against + // an offer landing in the same event batch, which a state updater sees one render too late. + const requestsByDirectoryRef = useRef(new Map()) + const [, setRequestsVersion] = useState(0) + const [busy, setBusy] = useState(false) + const dismissedDirectoriesRef = useRef(new Set()) + const offer = useCallback((failure: DocPreviewFileFailure) => { + const directory = requestedDirectory(failure.relativePath) + if ( + dismissedDirectoriesRef.current.has(directory) || + requestsByDirectoryRef.current.has(directory) + ) { + return + } + requestsByDirectoryRef.current.set(directory, failure) + setRequestsVersion((version) => version + 1) + }, []) + const reset = useCallback(() => { + requestsByDirectoryRef.current = new Map() + dismissedDirectoriesRef.current.clear() + setBusy(false) + setRequestsVersion((version) => version + 1) + }, []) + const dismiss = useCallback(() => { + for (const directory of requestsByDirectoryRef.current.keys()) { + dismissedDirectoriesRef.current.add(directory) + } + requestsByDirectoryRef.current = new Map() + setRequestsVersion((version) => version + 1) + }, []) + const allow = useCallback(async () => { + const pending = [...requestsByDirectoryRef.current.values()] + if (pending.length === 0 || !grantId || busy) { + return + } + setBusy(true) + try { + for (const failure of pending) { + if (!(await window.api.docPreview.authorizeDirectory(grantId, failure.relativePath))) { + reportAuthorizationFailure() + return + } + } + requestsByDirectoryRef.current = new Map() + setRequestsVersion((version) => version + 1) + reloadRef.current?.() + } catch { + reportAuthorizationFailure() + } finally { + setBusy(false) + } + }, [busy, grantId, reloadRef]) + return { + requests: [...requestsByDirectoryRef.current.values()], + busy, + offer, + reset, + dismiss, + allow + } +} + +const MAX_NAMED_FOLDERS = 3 + +function requestedFolderSentence(labels: string[]): string { + if (labels.length === 1) { + return translate( + 'auto.components.editor.HtmlDocPreview.directoryAccessRequest', + 'This preview wants to read files in {{path}}.', + { path: labels[0] } + ) + } + const named = labels.slice(0, MAX_NAMED_FOLDERS) + const remainder = labels.length - named.length + const folders = + remainder > 0 + ? translate( + 'auto.components.editor.HtmlDocPreview.directoryAccessRequestOverflow', + '{{folders}}, and {{count}} more', + { folders: named.join(', '), count: remainder } + ) + : `${named.slice(0, -1).join(', ')} and ${named.at(-1)}` + return translate( + 'auto.components.editor.HtmlDocPreview.directoryAccessRequestMultiple', + 'This preview wants to read files in {{folders}}.', + { folders } + ) +} + +export function DocPreviewDirectoryAccessBanner({ + requests, + busy, + worktreeRoot, + onDismiss, + onAllow +}: { + requests: DocPreviewFileFailure[] + busy: boolean + worktreeRoot: string | null + onDismiss: () => void + onAllow: () => Promise +}): React.JSX.Element { + const labels = requests.map((request) => + requestedDirectoryLabel(request.relativePath, worktreeRoot) + ) + return ( +
+ + {/* The title carries every folder in full, for when the sentence truncates past three. */} + + {requestedFolderSentence(labels)} + + + +
+ ) +} 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 8ca681195f4..8fafb551725 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 @@ -35,7 +35,7 @@ export function DocPreviewDocumentChip({ type="button" onClick={() => void copyText()} aria-label={copied ? copiedLabel : copyLabel} - className="flex min-w-0 flex-1 items-center gap-2 rounded-xl border border-border bg-background px-3 py-1 text-left shadow-sm hover:bg-accent/40 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none" + className="@container flex min-w-0 flex-1 items-center gap-2 overflow-hidden rounded-xl border border-border bg-background px-3 py-1 text-left shadow-sm hover:bg-accent/40 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none" > {copied ? ( @@ -47,14 +47,21 @@ export function DocPreviewDocumentChip({ {identity.directoryPrefix} {identity.fileName} - - {translate( - 'auto.components.editor.HtmlDocPreview.workspaceFileChipLabel', - 'Workspace file' - )} + {/* Below 24rem of chip width this row hides whole rather than clipping into slivers — + 384px is what icon + label + a capped badge + a readable path stub need, so visible + implies contained. The tooltip keeps the full identity either way. */} + + + {translate( + 'auto.components.editor.HtmlDocPreview.workspaceFileChipLabel', + 'Workspace file' + )} + {identity.hostLabel ? ( - - {identity.hostLabel} + + {/* Why the inner span: text directly inside the flex pill clips both ends with no + ellipsis — text-overflow needs a non-flex text box. */} + {identity.hostLabel} ) : null} diff --git a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-external-link-confirmation.test.ts b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-external-link-confirmation.test.ts new file mode 100644 index 00000000000..9ea527417fa --- /dev/null +++ b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-external-link-confirmation.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ConfirmationDialogContextValue } from '@/components/confirmation-dialog-context' + +const mocks = vi.hoisted(() => ({ + toastError: vi.fn(), + openBrowserProfileTabInActiveWorkspace: vi.fn() +})) + +vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + openBrowserProfileTabInActiveWorkspace: mocks.openBrowserProfileTabInActiveWorkspace + }) + } +})) + +import { subscribeDocPreviewExternalLinkConfirmation } from './doc-preview-external-link-confirmation' + +function installSubscription(confirm: ConfirmationDialogContextValue): (url: string) => void { + let listener: ((payload: { url: string }) => void) | null = null + vi.stubGlobal('window', { + api: { + docPreview: { + onExternalLink: (callback: (payload: { url: string }) => void): (() => void) => { + listener = callback + return () => {} + } + } + } + }) + subscribeDocPreviewExternalLinkConfirmation(confirm) + if (!listener) { + throw new Error('external-link confirmation did not subscribe') + } + return (url) => listener?.({ url }) +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.openBrowserProfileTabInActiveWorkspace.mockResolvedValue(true) +}) + +describe('document preview external-link confirmation', () => { + it('shows the exact destination and opens only after confirmation', async () => { + const confirm = vi.fn().mockResolvedValue(true) + const emit = installSubscription(confirm) + + emit('https://example.com/docs?source=preview') + + await vi.waitFor(() => expect(confirm).toHaveBeenCalledOnce()) + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Open link to example.com?', + description: 'https://example.com/docs?source=preview' + }) + ) + await vi.waitFor(() => + expect(mocks.openBrowserProfileTabInActiveWorkspace).toHaveBeenCalledWith( + 'https://example.com/docs?source=preview', + null + ) + ) + }) + + it('opens nothing when the reader cancels', async () => { + const confirm = vi.fn().mockResolvedValue(false) + const emit = installSubscription(confirm) + + emit('https://example.com/docs') + + await vi.waitFor(() => expect(confirm).toHaveBeenCalledOnce()) + expect(mocks.openBrowserProfileTabInActiveWorkspace).not.toHaveBeenCalled() + }) + + it('surfaces a confirmed link that the browser refuses', async () => { + mocks.openBrowserProfileTabInActiveWorkspace.mockResolvedValue(false) + const emit = installSubscription( + vi.fn().mockResolvedValue(true) + ) + + emit('https://example.com/docs') + + await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) + }) +}) diff --git a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-external-link-confirmation.tsx b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-external-link-confirmation.tsx new file mode 100644 index 00000000000..f8e2f185589 --- /dev/null +++ b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-external-link-confirmation.tsx @@ -0,0 +1,75 @@ +import { useEffect } from 'react' +import { toast } from 'sonner' +import { + useConfirmationDialog, + type ConfirmationDialogContextValue +} from '@/components/confirmation-dialog-context' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' + +function displayHost(url: string): string { + try { + return new URL(url).host + } catch { + return url + } +} + +async function openConfirmedExternalLink(url: string): Promise { + try { + const opened = await useAppStore.getState().openBrowserProfileTabInActiveWorkspace(url, null) + if (opened) { + return + } + } catch { + // The same reader-facing result covers refusal and rejection. + } + toast.error( + translate( + 'auto.hooks.ipc.events.browserStateIpcBridge.docPreviewLinkFailed', + 'Could not open this link in Orca Browser.' + ) + ) +} + +export function subscribeDocPreviewExternalLinkConfirmation( + confirm: ConfirmationDialogContextValue +): () => void { + if (typeof window.api.docPreview?.onExternalLink !== 'function') { + return () => {} + } + let active = true + const unsubscribe = window.api.docPreview.onExternalLink(({ url }) => { + void confirm({ + title: translate( + 'auto.components.browserPane.workspaceDoc.externalLinkTitle', + 'Open link to {{host}}?', + { host: displayHost(url) } + ), + description: url, + descriptionClassName: 'break-all font-mono text-xs', + confirmLabel: translate( + 'auto.components.browserPane.workspaceDoc.externalLinkConfirm', + 'Open link' + ), + cancelLabel: translate( + 'auto.components.browserPane.workspaceDoc.externalLinkCancel', + 'Cancel' + ) + }).then((confirmed) => { + if (active && confirmed) { + void openConfirmedExternalLink(url) + } + }) + }) + return () => { + active = false + unsubscribe() + } +} + +export function DocPreviewExternalLinkConfirmation(): null { + const confirm = useConfirmationDialog() + useEffect(() => subscribeDocPreviewExternalLinkConfirmation(confirm), [confirm]) + return null +} diff --git a/src/renderer/src/components/confirmation-dialog-context.ts b/src/renderer/src/components/confirmation-dialog-context.ts index 8dbaf36498e..4675c181fb7 100644 --- a/src/renderer/src/components/confirmation-dialog-context.ts +++ b/src/renderer/src/components/confirmation-dialog-context.ts @@ -5,6 +5,7 @@ import { createContext, useContext } from 'react' export type ConfirmationDialogOptions = { title: string description?: string + descriptionClassName?: string confirmLabel?: string cancelLabel?: string confirmVariant?: 'default' | 'destructive' diff --git a/src/renderer/src/components/confirmation-dialog.tsx b/src/renderer/src/components/confirmation-dialog.tsx index c9303096b7a..a1670b9bb22 100644 --- a/src/renderer/src/components/confirmation-dialog.tsx +++ b/src/renderer/src/components/confirmation-dialog.tsx @@ -18,6 +18,7 @@ import { } from '@/components/confirmation-dialog-context' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' type ConfirmationDialogRequest = { id: number @@ -100,7 +101,9 @@ export function ConfirmationDialogProvider({ {displayedRequest?.options.title} {displayedRequest?.options.description ? ( // Callers pass multi-line descriptions (e.g. one path per line). - + {displayedRequest.options.description} ) : null} diff --git a/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts index c20a39a5882..4f3450dae85 100644 --- a/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts @@ -1,6 +1,4 @@ -import { toast } from 'sonner' import { rememberLiveBrowserUrl } from '@/components/browser-pane/describe-page/live-browser-url-registry' -import { translate } from '@/i18n/i18n' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { redactKagiSessionToken } from '../../../../shared/browser-url' import { useAppStore } from '../../store' @@ -107,31 +105,4 @@ export function registerBrowserStateIpcBridge( }) }) ) - // Why: the doc-preview scheme is desktop-only, so hosts without it (web client) simply have no channel. - if (typeof window.api.docPreview?.onExternalLink === 'function') { - unsubs.push( - window.api.docPreview.onExternalLink(({ url }) => { - // Why: an external link in a doc preview leaves the preview entirely — it becomes a normal - // browser tab through the same path as any other new tab, local or paired. - // Why: the click already left the preview, so a refused tab is a dead end unless it says so. - const reportLinkFailure = (): void => { - toast.error( - translate( - 'auto.hooks.ipc.events.browserStateIpcBridge.docPreviewLinkFailed', - 'Could not open this link in Orca Browser.' - ) - ) - } - void useAppStore - .getState() - .openBrowserProfileTabInActiveWorkspace(url, null) - .then((opened) => { - if (!opened) { - reportLinkFailure() - } - }) - .catch(reportLinkFailure) - }) - ) - } } diff --git a/src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts b/src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts deleted file mode 100644 index 552c54ff0dc..00000000000 --- a/src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - toastError: vi.fn(), - openBrowserProfileTabInActiveWorkspace: vi.fn() -})) - -vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) -vi.mock('@/components/browser-pane/describe-page/live-browser-url-registry', () => ({ - rememberLiveBrowserUrl: vi.fn() -})) -vi.mock('@/lib/worktree-runtime-owner', () => ({ getRuntimeEnvironmentIdForWorktree: () => null })) -vi.mock('./browser-automation-bootstrap-lease', () => ({ - acquireBrowserAutomationBootstrapLease: vi.fn() -})) -vi.mock('../../store', () => ({ - useAppStore: { - getState: () => ({ - openBrowserProfileTabInActiveWorkspace: mocks.openBrowserProfileTabInActiveWorkspace, - remoteBrowserPageHandlesByPageId: {} - }) - } -})) - -import { registerBrowserStateIpcBridge } from './browser-state-ipc-bridge' - -/** Every channel the bridge subscribes to, stubbed; only the preview link one is exercised here. */ -function installBridge(): (payload: { url: string }) => void { - let externalLinkHandler: ((payload: { url: string }) => void) | null = null - const noopSubscribe = (): (() => void) => () => {} - vi.stubGlobal('window', { - api: { - ui: { onFullscreenChanged: noopSubscribe }, - browser: new Proxy( - {}, - { - get: () => (): (() => void) => () => {} - } - ), - docPreview: { - onExternalLink: (callback: (payload: { url: string }) => void): (() => void) => { - externalLinkHandler = callback - return () => {} - } - } - } - }) - registerBrowserStateIpcBridge([], () => false) - if (!externalLinkHandler) { - throw new Error('bridge did not subscribe to the doc preview external link channel') - } - return externalLinkHandler -} - -beforeEach(() => { - vi.clearAllMocks() - mocks.openBrowserProfileTabInActiveWorkspace.mockResolvedValue(true) -}) - -describe('doc preview external links', () => { - it('routes an external link into a browser tab', async () => { - installBridge()({ url: 'https://example.com/docs' }) - await vi.waitFor(() => - expect(mocks.openBrowserProfileTabInActiveWorkspace).toHaveBeenCalledWith( - 'https://example.com/docs', - null - ) - ) - - expect(mocks.toastError).not.toHaveBeenCalled() - }) - - // Why: the click already left the preview behind, so a refused tab is a dead end unless it says - // so — the store reports that refusal by returning false, not by throwing. - it('surfaces a refused tab instead of dropping the click', async () => { - mocks.openBrowserProfileTabInActiveWorkspace.mockResolvedValue(false) - - installBridge()({ url: 'https://example.com/docs' }) - - await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) - }) - - // Why the same sentence for a rejection: to the reader a tab that threw and a tab that was refused - // are the same dead end, and an unhandled rejection would leave the press with no answer at all. - it('surfaces a tab that failed rather than refused', async () => { - mocks.openBrowserProfileTabInActiveWorkspace.mockRejectedValue(new Error('no workspace')) - - installBridge()({ url: 'https://example.com/docs' }) - - await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) - }) -}) diff --git a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts index 2d84a77c1a2..ea84f1b2811 100644 --- a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts @@ -15,7 +15,6 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [ 'browser.onNavigationUpdate', 'browser.onOpenLinkInOrcaTab', 'browser.onPaneFocus', - 'docPreview.onExternalLink', 'emulator.onAutoAttach', 'emulator.onPaneFocus', 'gh.onPRRefreshEvent', @@ -160,7 +159,6 @@ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [ 'browser.onActivateView', 'browser.onPaneFocus', 'browser.onOpenLinkInOrcaTab', - 'docPreview.onExternalLink', 'ui.onNewBrowserTab', 'ui.onNewMarkdownTab', 'ui.onNewSimulatorTab', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 53aabcd4a00..0d2880b2513 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14811,7 +14811,14 @@ "openExternallyControl": "Open with default app", "copyDocumentRelativePathControl": "Copy relative path", "openExternallyUnknownHostError": "Can't open '{{value0}}': the host that owns it is no longer known.", - "downloadBlockedNotice": "Downloads are disabled in document previews." + "downloadBlockedNotice": "Downloads are disabled in document previews.", + "directoryAuthorizationFailed": "Could not allow access to this directory.", + "directoryAccessRequest": "This preview wants to read files in {{path}}.", + "dismissAccessRequest": "Dismiss", + "allowDirectory": "Allow folder", + "directoryAccessRequestOverflow": "{{folders}}, and {{count}} more", + "directoryAccessRequestMultiple": "This preview wants to read files in {{folders}}.", + "allowDirectories": "Allow {{count}} folders" } }, "diff": { @@ -16355,6 +16362,13 @@ "description": "Nests this workspace under another in the sidebar. Does not change the base branch.", "searchPlaceholder": "Search workspaces...", "noMatches": "No matches." + }, + "browserPane": { + "workspaceDoc": { + "externalLinkTitle": "Open link to {{host}}?", + "externalLinkConfirm": "Open link", + "externalLinkCancel": "Cancel" + } } }, "i18n": { diff --git a/src/renderer/src/lib/doc-preview-grants.test.ts b/src/renderer/src/lib/doc-preview-grants.test.ts index daf2a216576..c7afa8c77b7 100644 --- a/src/renderer/src/lib/doc-preview-grants.test.ts +++ b/src/renderer/src/lib/doc-preview-grants.test.ts @@ -33,25 +33,25 @@ beforeEach(() => { }) describe('buildDocPreviewGrantRequest', () => { - // Why: SSH previews are unrestricted by design, and a document outside every workspace has no - // boundary to root a grant in. + // A document outside every workspace resolves and authorizes from its own directory. it('roots an SSH grant outside the workspace at the document directory', () => { mocks.connectionId = 'ssh-1' expect(buildDocPreviewGrantRequest(state, 'wt-1', '/home/alice/docs/report.html')).toEqual({ owner: { kind: 'ssh', connectionId: 'ssh-1' }, + requestBase: '/home/alice/docs', root: '/home/alice/docs', entryRelativePath: 'report.html' }) }) - // Why: reports keep their assets in a sibling directory, so `../assets/app.css` has to resolve. - it('roots a document inside the workspace at the workspace root', () => { + it('resolves from the workspace but authorizes only the document directory', () => { mocks.connectionId = 'ssh-1' expect(buildDocPreviewGrantRequest(state, 'wt-1', '/srv/repo/docs/report.html')).toEqual({ owner: { kind: 'ssh', connectionId: 'ssh-1' }, - root: '/srv/repo', + requestBase: '/srv/repo', + root: '/srv/repo/docs', entryRelativePath: 'docs/report.html' }) }) @@ -66,7 +66,8 @@ describe('buildDocPreviewGrantRequest', () => { worktreeSelector: 'id:wt-1', worktreeRoot: '/srv/repo' }, - root: '/srv/repo', + requestBase: '/srv/repo', + root: '/srv/repo/docs', entryRelativePath: 'docs/report.html' }) }) @@ -87,6 +88,7 @@ describe('doc preview grant lifetime', () => { it('mints once for repeated mounts of the same preview tab', async () => { const request = { owner: { kind: 'ssh' as const, connectionId: 'ssh-1' }, + requestBase: '/d', root: '/d', entryRelativePath: 'a.html' } @@ -106,6 +108,7 @@ describe('doc preview grant lifetime', () => { it('revokes on release and mints fresh afterwards', async () => { const request = { owner: { kind: 'ssh' as const, connectionId: 'ssh-1' }, + requestBase: '/d', root: '/d', entryRelativePath: 'a.html' } @@ -129,6 +132,7 @@ describe('doc preview grant lifetime', () => { it('leaves the entry of a later mint alone when an earlier one rejects', async () => { const request = { owner: { kind: 'ssh' as const, connectionId: 'ssh-1' }, + requestBase: '/d', root: '/d', entryRelativePath: 'a.html' } @@ -157,6 +161,7 @@ describe('doc preview grant lifetime', () => { mocks.mintGrant.mockRejectedValueOnce(new Error('runtime offline')) const request = { owner: { kind: 'ssh' as const, connectionId: 'ssh-1' }, + requestBase: '/d', root: '/d', entryRelativePath: 'a.html' } diff --git a/src/renderer/src/lib/doc-preview-grants.ts b/src/renderer/src/lib/doc-preview-grants.ts index a4c5bd2d530..5e2af9a7167 100644 --- a/src/renderer/src/lib/doc-preview-grants.ts +++ b/src/renderer/src/lib/doc-preview-grants.ts @@ -23,20 +23,19 @@ export function buildDocPreviewGrantRequest( filePath: string ): DocPreviewGrantLocation | null { const worktreeRoot = state.getKnownWorktreeById(worktreeId)?.path ?? null - // Why the workspace root and not the document's folder: reports keep their assets in a sibling - // directory (`../assets/app.css`), which a folder-rooted grant refuses. This is no wider than the - // channel already allows — files.read is worktree-scoped on paired hosts either way. const worktreeRelativePath = getRelativePathInsideRoot(filePath, worktreeRoot) - const root = worktreeRoot && worktreeRelativePath ? worktreeRoot : dirname(filePath) + const root = dirname(filePath) + const requestBase = worktreeRoot && worktreeRelativePath ? worktreeRoot : root const entryRelativePath = worktreeRelativePath ?? basename(filePath) - if (!root || !entryRelativePath) { + if (!requestBase || !root || !entryRelativePath) { return null } const connectionId = getConnectionIdForFileFromState(state, worktreeId, filePath) if (connectionId) { - // Why SSH keeps a document-folder root when the file sits outside the workspace: those previews - // are unrestricted by design, and there is no workspace boundary to root them in. - return { owner: { kind: 'ssh', connectionId }, root, entryRelativePath } + // Outside a workspace there is no broader request base: the document directory 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) if (!environmentId || !worktreeRoot || !worktreeRelativePath) { @@ -49,6 +48,7 @@ export function buildDocPreviewGrantRequest( worktreeSelector: toRuntimeWorktreeSelector(worktreeId), worktreeRoot }, + requestBase, root, entryRelativePath } diff --git a/src/renderer/src/store/slices/browser-workspace-doc-location.test.ts b/src/renderer/src/store/slices/browser-workspace-doc-location.test.ts index b7e28759238..d311216a807 100644 --- a/src/renderer/src/store/slices/browser-workspace-doc-location.test.ts +++ b/src/renderer/src/store/slices/browser-workspace-doc-location.test.ts @@ -264,6 +264,56 @@ describe('a browser page that shows a workspace document', () => { expect(mocks.releaseDocPreviewGrant).toHaveBeenCalledTimes(1) }) + it('revokes a document grant when only that page is closed', () => { + mocks.releaseDocPreviewGrant.mockClear() + const store = createStoreWithWorktree() + const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { + docLocation: DOC_LOCATION, + browserRuntimeEnvironmentId: null + }) + const docPageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' + store.getState().createBrowserPage(tab.id, 'https://example.com/') + + store.getState().closeBrowserPage(docPageId) + + expect(mocks.releaseDocPreviewGrant).toHaveBeenCalledExactlyOnceWith(docPageId) + expect(store.getState().browserPagesByWorkspace[tab.id]).toHaveLength(1) + }) + + it('reopens a closed document tab with its document identity', () => { + const store = createStoreWithWorktree() + const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { + docLocation: DOC_LOCATION, + browserRuntimeEnvironmentId: null + }) + + store.getState().closeBrowserTab(tab.id) + const reopened = store.getState().reopenClosedBrowserTab(WORKTREE_ID) + const page = reopened ? store.getState().browserPagesByWorkspace[reopened.id]?.[0] : undefined + + expect(page?.docLocation).toEqual(DOC_LOCATION) + expect(page?.url).toBe(ORCA_BROWSER_BLANK_URL) + expect(reopened?.docLocation).toEqual(DOC_LOCATION) + }) + + it('reopens a closed document page with its document identity', () => { + const store = createStoreWithWorktree() + const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/') + const documentPage = store.getState().createBrowserPage(tab.id, LIVE_GRANT_URL, { + docLocation: DOC_LOCATION, + browserRuntimeEnvironmentId: null + }) + if (!documentPage) { + throw new Error('Expected a document page') + } + + store.getState().closeBrowserPage(documentPage.id) + const reopened = store.getState().reopenClosedBrowserPage(tab.id) + + expect(reopened?.docLocation).toEqual(DOC_LOCATION) + expect(reopened?.url).toBe(ORCA_BROWSER_BLANK_URL) + }) + it('writes the document and not the grant to the session', () => { const store = createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 25733605b70..21394e8ff36 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -97,6 +97,8 @@ type CreateBrowserPageOptions = { activate?: boolean title?: string browserRuntimeEnvironmentId?: string | null + /** Creates a page that shows a workspace document instead of a URL. */ + docLocation?: BrowserPageDocLocation } type BrowserTabPageState = { @@ -1187,6 +1189,7 @@ export const createBrowserSlice: StateCreator = activate: true, sessionProfileId, sessionPartition, + ...(snap.docLocation ? { docLocation: snap.docLocation } : {}), targetGroupId: entryToRestore.position?.groupId }) restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, entryToRestore.position) @@ -1201,14 +1204,16 @@ export const createBrowserSlice: StateCreator = sessionProfileId, sessionPartition, targetGroupId: entryToRestore.position?.groupId, - browserRuntimeEnvironmentId: firstPage.browserRuntimeEnvironmentId + browserRuntimeEnvironmentId: firstPage.browserRuntimeEnvironmentId, + ...(firstPage.docLocation ? { docLocation: firstPage.docLocation } : {}) }) for (const p of restPages) { get().createBrowserPage(restored.id, p.url, { activate: false, title: p.title, - browserRuntimeEnvironmentId: p.browserRuntimeEnvironmentId + browserRuntimeEnvironmentId: p.browserRuntimeEnvironmentId, + ...(p.docLocation ? { docLocation: p.docLocation } : {}) }) } @@ -1288,7 +1293,9 @@ export const createBrowserSlice: StateCreator = workspace.worktreeId, url, options?.title, - options?.browserRuntimeEnvironmentId + options?.browserRuntimeEnvironmentId, + undefined, + options?.docLocation ) set((s) => { @@ -1309,6 +1316,7 @@ export const createBrowserSlice: StateCreator = s.activeBrowserTabIdByWorktree[workspace.worktreeId] === workspaceId const shouldFocusAddressBar = shouldUpdateGlobalActiveSurface && + !page.docLocation && (page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL) return { @@ -1351,6 +1359,7 @@ export const createBrowserSlice: StateCreator = closeBrowserPage: (pageId) => { let closedWorkspaceIdForLabel: string | null = null + let docPageIdToRelease: string | null = null const remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = [] set((s) => { const page = findPage(s.browserPagesByWorkspace, pageId) @@ -1362,6 +1371,7 @@ export const createBrowserSlice: StateCreator = return s } closedWorkspaceIdForLabel = page.workspaceId + docPageIdToRelease = page.docLocation ? page.id : null const currentPages = s.browserPagesByWorkspace[workspace.id] ?? [] const nextPages = currentPages.filter((entry) => entry.id !== pageId) const closedIdx = currentPages.findIndex((entry) => entry.id === pageId) @@ -1432,6 +1442,10 @@ export const createBrowserSlice: StateCreator = closeRemoteBrowserPageInOwningEnvironment(remotePage.worktreeId, remotePage.handle) } + if (docPageIdToRelease) { + releaseDocPreviewGrant(docPageIdToRelease) + } + const closedWorkspaceId = closedWorkspaceIdForLabel if (!closedWorkspaceId) { return @@ -1470,7 +1484,8 @@ export const createBrowserSlice: StateCreator = return get().createBrowserPage(workspaceId, pageToRestore.url, { title: pageToRestore.title, activate: true, - browserRuntimeEnvironmentId: pageToRestore.browserRuntimeEnvironmentId + browserRuntimeEnvironmentId: pageToRestore.browserRuntimeEnvironmentId, + ...(pageToRestore.docLocation ? { docLocation: pageToRestore.docLocation } : {}) }) }, diff --git a/src/renderer/src/store/slices/worktrees-identity-migration.test.ts b/src/renderer/src/store/slices/worktrees-identity-migration.test.ts index fc36935dd5c..7d709f33ac7 100644 --- a/src/renderer/src/store/slices/worktrees-identity-migration.test.ts +++ b/src/renderer/src/store/slices/worktrees-identity-migration.test.ts @@ -74,8 +74,32 @@ describe('migrateWorktreeIdentity', () => { [OLD]: { resultOwner: { worktreeId: OLD, runtimeEnvironmentId: 'runtime-a' } } }, activeTabIdByWorktree: { [OLD]: 'tab1' }, - browserTabsByWorktree: { [OLD]: [{ id: 'browser1', worktreeId: OLD }] }, - browserPagesByWorkspace: { browser1: [{ id: 'page1', worktreeId: OLD }] }, + browserTabsByWorktree: { + [OLD]: [ + { + id: 'browser1', + worktreeId: OLD, + docLocation: { + kind: 'workspace-doc', + worktreeId: OLD, + filePath: '/ws/cunner/docs/report.html' + } + } + ] + }, + browserPagesByWorkspace: { + browser1: [ + { + id: 'page1', + worktreeId: OLD, + docLocation: { + kind: 'workspace-doc', + worktreeId: OLD, + filePath: '/ws/cunner/docs/report.html' + } + } + ] + }, recentlyClosedBrowserTabsByWorktree: { [OLD]: [ { workspace: { id: 'closed-browser', worktreeId: OLD }, pages: [{ worktreeId: OLD }] } @@ -127,6 +151,16 @@ describe('migrateWorktreeIdentity', () => { expect(s.activeTabIdByWorktree[NEW]).toBe('tab1') expect(s.browserTabsByWorktree[NEW]?.[0]?.worktreeId).toBe(NEW) expect(s.browserPagesByWorkspace.browser1?.[0]?.worktreeId).toBe(NEW) + expect(s.browserTabsByWorktree[NEW]?.[0]?.docLocation).toEqual({ + kind: 'workspace-doc', + worktreeId: NEW, + filePath: '/ws/worktree-creation-spinner/docs/report.html' + }) + expect(s.browserPagesByWorkspace.browser1?.[0]?.docLocation).toEqual({ + kind: 'workspace-doc', + worktreeId: NEW, + filePath: '/ws/worktree-creation-spinner/docs/report.html' + }) expect(s.recentlyClosedBrowserTabsByWorktree[NEW]?.[0]?.workspace.worktreeId).toBe(NEW) expect(s.recentlyClosedBrowserTabsByWorktree[NEW]?.[0]?.pages[0]?.worktreeId).toBe(NEW) expect(s.recentlyClosedBrowserPagesByWorkspace.browser1?.[0]?.worktreeId).toBe(NEW) diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts b/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts index 1a841fbdfc9..dee9ebb7f84 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts @@ -1,4 +1,9 @@ import type { AppState } from '../../../types' +import type { + BrowserPage, + BrowserWorkspace +} from '../../../../../../shared/browser-workspace-types' +import { remapBrowserPageDocLocation } from '../../../../../../shared/browser-page-doc-location' import { splitWorktreeIdForFilesystem } from '../../../../../../shared/worktree/id' import { worktreeWorkspaceKey } from '../../../../../../shared/workspace-scope' import { getWorktreeIdFromVisitKey } from '@/lib/worktree-visit-recency' @@ -76,17 +81,34 @@ export function buildWorktreeRenameState( } const withNewWorktreeId = (value: T): T => value.worktreeId === oldWorktreeId ? { ...value, worktreeId: newWorktreeId } : value + const oldWorktreePath = splitWorktreeIdForFilesystem(oldWorktreeId)?.worktreePath + const newWorktreePath = splitWorktreeIdForFilesystem(newWorktreeId)?.worktreePath + const withNewBrowserWorktreeId = (value: T): T => { + const renamedValue = withNewWorktreeId(value) + return value.docLocation?.worktreeId === oldWorktreeId + ? { + ...renamedValue, + docLocation: remapBrowserPageDocLocation( + value.docLocation, + oldWorktreeId, + newWorktreeId, + oldWorktreePath, + newWorktreePath + ) + } + : renamedValue + } const renameValueByKey: Partial> = { tabsByWorktree: (tabs: { worktreeId: string }[]) => tabs.map(withNewWorktreeId), - browserTabsByWorktree: (workspaces: { worktreeId: string }[]) => - workspaces.map(withNewWorktreeId), + browserTabsByWorktree: (workspaces: BrowserWorkspace[]) => + workspaces.map(withNewBrowserWorktreeId), recentlyClosedBrowserTabsByWorktree: ( - snapshots: { workspace: { worktreeId: string }; pages: { worktreeId: string }[] }[] + snapshots: { workspace: BrowserWorkspace; pages: BrowserPage[] }[] ) => snapshots.map((snapshot) => ({ ...snapshot, - workspace: withNewWorktreeId(snapshot.workspace), - pages: snapshot.pages.map(withNewWorktreeId) + workspace: withNewBrowserWorktreeId(snapshot.workspace), + pages: snapshot.pages.map(withNewBrowserWorktreeId) })), fileSearchStateByWorktree: (searchState: AppState['fileSearchStateByWorktree'][string]) => ({ ...searchState, @@ -121,8 +143,6 @@ export function buildWorktreeRenameState( files.map(withNewWorktreeId) ) // Why: terminal reopen snapshots hold absolute startupCwd paths under the old folder; remap or Cmd+Shift+T respawns into a directory that no longer exists after the rename. - const oldWorktreePath = splitWorktreeIdForFilesystem(oldWorktreeId)?.worktreePath - const newWorktreePath = splitWorktreeIdForFilesystem(newWorktreeId)?.worktreePath renameKey('recentlyClosedTerminalTabsByWorktree', (snapshots: ClosedTerminalTabSnapshot[]) => oldWorktreePath && newWorktreePath ? remapClosedTerminalTabSnapshotCwds(snapshots, oldWorktreePath, newWorktreePath) @@ -137,23 +157,29 @@ export function buildWorktreeRenameState( : s.openFiles const currentBrowserPagesByWorkspace = s.browserPagesByWorkspace ?? {} const browserPagesByWorkspace = Object.values(currentBrowserPagesByWorkspace).some((pages) => - pages.some((page) => page.worktreeId === oldWorktreeId) + pages.some( + (page) => page.worktreeId === oldWorktreeId || page.docLocation?.worktreeId === oldWorktreeId + ) ) ? Object.fromEntries( Object.entries(currentBrowserPagesByWorkspace).map(([workspaceId, pages]) => [ workspaceId, - pages.map(withNewWorktreeId) + pages.map(withNewBrowserWorktreeId) ]) ) : s.browserPagesByWorkspace const currentRecentlyClosedBrowserPagesByWorkspace = s.recentlyClosedBrowserPagesByWorkspace ?? {} const recentlyClosedBrowserPagesByWorkspace = Object.values( currentRecentlyClosedBrowserPagesByWorkspace - ).some((pages) => pages.some((page) => page.worktreeId === oldWorktreeId)) + ).some((pages) => + pages.some( + (page) => page.worktreeId === oldWorktreeId || page.docLocation?.worktreeId === oldWorktreeId + ) + ) ? Object.fromEntries( Object.entries(currentRecentlyClosedBrowserPagesByWorkspace).map(([workspaceId, pages]) => [ workspaceId, - pages.map(withNewWorktreeId) + pages.map(withNewBrowserWorktreeId) ]) ) : s.recentlyClosedBrowserPagesByWorkspace diff --git a/src/shared/browser-page-doc-location.ts b/src/shared/browser-page-doc-location.ts index 3ef30c29952..97a9dcc6e27 100644 --- a/src/shared/browser-page-doc-location.ts +++ b/src/shared/browser-page-doc-location.ts @@ -3,6 +3,7 @@ import type { BrowserPageDocLocation, BrowserWorkspace } from './browser-workspace-types' +import { relativePathInsideRoot, resolveRuntimePath } from './cross-platform-path' /** * Why an explicit comparison and not object identity: the mirror rebuilds the workspace's copy of @@ -25,3 +26,26 @@ export function isWorkspaceDocSurface( ): boolean { return Boolean(surface.docLocation) } + +export function remapBrowserPageDocLocation( + location: BrowserPageDocLocation, + oldWorktreeId: string, + newWorktreeId: string, + oldWorktreePath?: string, + newWorktreePath?: string +): BrowserPageDocLocation { + if (location.worktreeId !== oldWorktreeId) { + return location + } + const relativePath = + oldWorktreePath && newWorktreePath + ? relativePathInsideRoot(oldWorktreePath, location.filePath) + : null + return { + ...location, + worktreeId: newWorktreeId, + ...(relativePath !== null && newWorktreePath + ? { filePath: resolveRuntimePath(newWorktreePath, relativePath) } + : {}) + } +} diff --git a/src/shared/doc-preview-scheme.ts b/src/shared/doc-preview-scheme.ts index 8818cded00f..8046e9b75ce 100644 --- a/src/shared/doc-preview-scheme.ts +++ b/src/shared/doc-preview-scheme.ts @@ -11,10 +11,11 @@ export const DOC_PREVIEW_PARTITION = 'orca-doc-preview' export const DOC_PREVIEW_MINT_GRANT_CHANNEL = 'docPreview:mintGrant' export const DOC_PREVIEW_REVOKE_GRANT_CHANNEL = 'docPreview:revokeGrant' +export const DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL = 'docPreview:authorizeDirectory' export const DOC_PREVIEW_EXTERNAL_LINK_CHANNEL = 'docPreview:externalLink' /** * The preview guest's preload reports a trusted anchor click here. Renderer↔main only — no paired - * client ever sees it, and main gates every report on the sender being a focused preview guest. + * client ever sees it, and main gates every report on the sender being a live, grant-bound preview guest. */ export const DOC_PREVIEW_LINK_CLICK_CHANNEL = 'docPreview:linkClick' /** The one out-of-band route from the preview's main-side fences to the shell hosting it. */ @@ -22,7 +23,11 @@ export const DOC_PREVIEW_LOAD_FAILURE_CHANNEL = 'docPreview:loadFailure' /** Why: an unreadable document still answers with a real HTTP status, so the guest paints the * handler's plain-text body instead of failing to load. The shell needs the reason out-of-band. */ -export type DocPreviewFileFailureReason = 'too-large' | 'unsupported-asset' | 'unreadable' +export type DocPreviewFileFailureReason = + | 'authorization-required' + | 'too-large' + | 'unsupported-asset' + | 'unreadable' export type DocPreviewFileFailure = { grantId: string 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 35deddd18c4..a86721741dd 100644 --- a/tests/e2e/paired-remote-html-preview-local-render.spec.ts +++ b/tests/e2e/paired-remote-html-preview-local-render.spec.ts @@ -1,4 +1,4 @@ -import { writeFileSync } from 'node:fs' +import { mkdirSync, writeFileSync } from 'node:fs' import path from 'node:path' import type { Locator, Page, TestInfo } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' @@ -37,6 +37,9 @@ const SCRIPTED_EGRESS_URL = 'https://exfil.test/?d=scripted' /** The same exfiltration, but riding a press the reader really made somewhere else in the document. */ const POST_INPUT_EGRESS_URL = 'https://exfil.test/?d=after-input' const BLANK_PAGE_URL = 'data:text/html,' +const SCOPED_FIXTURE_NAME = 'scoped-preview.html' +const SCOPED_FIXTURE_HEADING = 'scoped preview rendered' +const SCOPED_ASSET_TEXT = 'approved sibling asset loaded' type PreparedPairedClient = { client: PairedElectronClient @@ -286,6 +289,23 @@ test('renders a paired HTML doc as a document browser tab while the host gains n const pathChip = page.getByRole('button', { name: 'Copy file path', exact: true }) await expect(pathChip).toBeVisible({ timeout: 30_000 }) await expect(pathChip).toContainText(FIXTURE_NAME) + // Below 24rem of chip width the identity row hides whole instead of clipping into slivers; + // when it shows, the badge must sit inside the chip's own layout box. Which arm runs depends + // on how much width this platform's toolbar leaves the chip — both are the contract. + const hostBadge = pathChip.locator('[data-slot="badge"]') + const pathChipBox = await pathChip.boundingBox() + expect(pathChipBox).not.toBeNull() + if (await hostBadge.isVisible()) { + const hostBadgeBox = await hostBadge.boundingBox() + expect(hostBadgeBox).not.toBeNull() + expect((hostBadgeBox?.x ?? 0) + (hostBadgeBox?.width ?? 0)).toBeLessThanOrEqual( + (pathChipBox?.x ?? 0) + (pathChipBox?.width ?? 0) + 0.5 + ) + } else { + // A chip too narrow for the identity row hides it whole; a wide one must show it. 26rem of + // border-box width clears the 24rem content-box container threshold plus padding. + expect(pathChipBox?.width ?? 0).toBeLessThan(416) + } await expect(page.locator(`[data-tab-group-body-id="${sourceGroupId}"]`)).toBeVisible() await expect(page.locator(`[data-tab-group-body-id="${previewRow.groupId}"]`)).toBeVisible() @@ -553,36 +573,56 @@ test('renders a paired HTML doc as a document browser tab while the host gains n hostBrowserPages: linkBaseline.hostBrowserPages.length, routedCalls: [] }) - console.log( - `[preview-e2e] before-focus ${JSON.stringify( - await page.evaluate(() => { - const active = document.activeElement - const guest = document.querySelector( - 'webview[src^="orca-preview://"]' - ) as HTMLElement | null - const before = active?.tagName ?? null - guest?.focus() - return { before, after: document.activeElement?.tagName ?? null } - }) - )}` - ) - // Why the failure is dressed rather than left bare: "nothing routed" has three very different - // causes — the press missed the anchor, the pane had no layout, or the fences refused what the - // press reported — and the counts below are what tell them apart. Finding the guest-focus gap - // took exactly these three numbers. + const guestFocus = await page.evaluate(() => { + const active = document.activeElement + const guest = document.querySelector('webview[src^="orca-preview://"]') as HTMLElement | null + const before = active?.tagName ?? null + guest?.focus() + return { before, after: document.activeElement?.tagName ?? null } + }) + console.log(`[preview-e2e] before-focus ${JSON.stringify(guestFocus)}`) + const confirmationTitle = page.getByRole('heading', { name: 'Open link to example.com?' }) + await expect + .poll( + async () => { + if (!(await confirmationTitle.isVisible())) { + const point = await readDocPreviewElementCenter(page, '#external') + if (point) { + await page.mouse.click(point.x, point.y) + } + } + return confirmationTitle.isVisible() + }, + { + timeout: 60_000, + intervals: [2_000], + message: 'a target=_blank click never showed its destination confirmation' + } + ) + .toBe(true) + await expect(page.getByText(EXTERNAL_LINK_URL, { exact: true })).toBeVisible() + await page.getByRole('button', { name: 'Cancel', exact: true }).click() + await expect(confirmationTitle).not.toBeVisible() + const afterCancel = await readPairedHtmlPreviewInventory(page, inventoryArgs) + expect({ + routedCalls: await readPairedHtmlPreviewLinkRouting(page), + browserCount: afterCancel.clientBrowserWorkspaceCountAllWorktrees + }).toEqual({ + routedCalls: [], + browserCount: linkBaseline.clientBrowserWorkspaceCountAllWorktrees + }) + try { + const point = await readDocPreviewElementCenter(page, '#external') + if (!point) { + throw new Error('external link lost its clickable point after cancellation') + } + await page.mouse.click(point.x, point.y) + await expect(confirmationTitle).toBeVisible({ timeout: 30_000 }) + await page.getByRole('button', { name: 'Open link', exact: true }).click() await expect .poll( async () => { - const routedCalls = await readPairedHtmlPreviewLinkRouting(page) - // Why only while nothing has routed: a retry after a successful route would open a - // second tab and turn this oracle into a counter of presses. - if (routedCalls.length === 0) { - const point = await readDocPreviewElementCenter(page, '#external') - if (point) { - await page.mouse.click(point.x, point.y) - } - } const opened = await readPairedHtmlPreviewInventory(page, inventoryArgs) return { routedCalls: await readPairedHtmlPreviewLinkRouting(page), @@ -596,7 +636,7 @@ test('renders a paired HTML doc as a document browser tab while the host gains n { timeout: 60_000, intervals: [2_000], - message: 'a target=_blank click in the preview never opened an Orca browser tab' + message: 'a confirmed preview link never opened an Orca browser tab' } ) .toMatchObject({ @@ -617,6 +657,100 @@ test('renders a paired HTML doc as a document browser tab while the host gains n } }) +test('asks before a paired preview reads a sibling directory', async ({ + orcaPage, + testRepoPath +}, testInfo) => { + test.setTimeout(300_000) + const docsDirectory = path.join(testRepoPath, 'preview-docs') + const assetsDirectory = path.join(testRepoPath, 'preview-assets') + mkdirSync(docsDirectory, { recursive: true }) + mkdirSync(assetsDirectory, { recursive: true }) + writeFileSync( + path.join(docsDirectory, SCOPED_FIXTURE_NAME), + `Scoped Preview` + + `

${SCOPED_FIXTURE_HEADING}

blocked
` + + `\n` + ) + writeFileSync( + path.join(assetsDirectory, 'scoped-preview.js'), + `document.getElementById('asset-result').textContent=${JSON.stringify(SCOPED_ASSET_TEXT)}\n` + ) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + let prepared: PreparedPairedClient | null = null + try { + prepared = await preparePairedClient(offer, testInfo, 'Scoped HTML preview', testRepoPath) + const { client, worktreeId, worktreePath } = prepared + const page = client.page + const docFilePath = path.join(worktreePath, 'preview-docs', SCOPED_FIXTURE_NAME) + await page.evaluate( + ({ environmentId, filePath, relativePath, targetWorktreeId }) => { + window.__store?.getState().openFile( + { + filePath, + relativePath, + worktreeId: targetWorktreeId, + language: 'html', + runtimeEnvironmentId: environmentId, + mode: 'edit' + }, + { preview: false, focusEditor: true } + ) + }, + { + environmentId: client.environmentId, + filePath: docFilePath, + relativePath: `preview-docs/${SCOPED_FIXTURE_NAME}`, + targetWorktreeId: worktreeId + } + ) + const openPreviewToSide = page.getByRole('button', { name: 'Open Preview to the Side' }) + await expect(openPreviewToSide).toBeVisible({ timeout: 30_000 }) + await openPreviewToSide.click() + await expect + .poll(() => readDocPreviewRenderedText(page, 'h1'), { + timeout: 60_000, + message: 'the scoped preview document never rendered' + }) + .toBe(SCOPED_FIXTURE_HEADING) + + const workspace = await page.evaluate((targetWorktreeId) => { + const state = window.__store?.getState() + return (state?.browserTabsByWorktree[targetWorktreeId] ?? []).find((candidate) => + candidate.docLocation?.filePath.endsWith('/scoped-preview.html') + )?.id + }, worktreeId) + if (!workspace) { + throw new Error('scoped preview had no document browser workspace') + } + await focusBrowserWorkspace(page, worktreeId, workspace) + await page.locator(`[data-tab-id="${workspace}"]`).click() + + await expect(page.getByText('This preview 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 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, + message: 'the approved sibling asset never loaded after reload' + }) + .toBe(SCOPED_ASSET_TEXT) + } finally { + await prepared?.client.dispose() + } +}) + /** * A document tab is a browser tab, so it comes back the way one does. What it may not do is come * back holding yesterday's grant: the URL it is served over is minted fresh by the client that