fix(preview): report a target that is this gateway instead of a blank frame - #2080
Conversation
Design Review (Fable 5) — ✅ PASSAdvisory design-level review of Design is sound. Let me verify nothing unrelated is smuggled in and the fix's edge cases are honestly handled. The diff is fully accounted for: the Design gate: the problem is real (unprompted "Load preview" card for the gateway's own URL → blank box). The fix detects the condition client-side without a probe, which is the correct layer — a probe cannot observe frame refusal. The narrower Design-Verdict: PASS Correctly-scoped, root-cause client-side detection of the un-framable self-target; broader unframeable class honestly deferred rather than over-engineered. [DESIGN-REVIEWED] 696bc7a |
UX Review (Fable 5) — ✅ PASSAdvisory UX-level review of UX-Verdict: PASS A former silent dead-end now explains itself in the user's terms — cause, the URL as typed, and a working escape hatch — matching the panel's sibling states. Suggestions
[UX-REVIEWED] 696bc7a |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsNo blocking findings. FINDING — temp-screenshots/preview-self-origin/*.png — Four binary PNGs under a [OPUS-REVIEWED] 696bc7a Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
9f66696 to
e2bb012
Compare
|
Both advisory suggestions read and addressed ( Design Review — documented, as suggested.
Worth noting the case is narrower still in practice: UX Review — agreed, and deliberately left for the follow-up you suggested. The two escape-hatch shapes should converge on the new one (URL in the code block, clean button label). Not folding it in here: aligning mixed content means retiring |
e2bb012 to
467c750
Compare
467c750 to
d41c6f7
Compare
|
UX Review — suggestion taken, and it was a real defect ( You caught something I had actually seen and mis-read as reassuring: my own verification screenshots showed the URL bar reading Fixed at the root rather than at the display site. The host swap exists to keep the dashboard's host-scoped cookie out of a framed server; a self-origin target is never framed, so the swap buys no safety there and only misreports the target: /** Isolate a host only for a target the panel will actually FRAME. */
function isolateFrameTarget(url: string): string {
return isDashboardOrigin(url) ? url : isolatePreviewHost(url)
}Applied at the three sites that feed what the user sees — Pinned by a test that types the dashboard's own host (the only input that triggers the swap) and asserts all three surfaces carry the entered URL, plus a guard test that an ordinary dev server on another port is still cookie-isolated — so the exemption cannot widen. Worth recording how nearly this shipped: my first version of that test typed Design Review — no reply needed this round: your prior |
|
Design Review — accepted and deferred, filed as #2134. You're right that the broader class is still open: any reachable-but-frame-refusing target (a dev server sending The load-timeout idea is a better mechanism than the one I ruled out. My PR body says a general frameability check "would need a server-side fetch of the target to read its headers, and I did not want to add an outbound-fetch endpoint for a display affordance" — an iframe load-timeout sidesteps that entirely, with no new server surface. I hadn't considered it. Deferring rather than folding it in, for the reason you gave and one more: it needs real design work, not a few lines. Chromium fires The issue carries the mechanism, the three hazards above, and the explicit non-goal of the server-side probe, so the next person starts where this thread ended rather than re-deriving it. |
d41c6f7 to
bc8b05b
Compare
… frame The Web Preview panel's only health signal is a no-cors liveness probe that throws on connection failure. A URL that answers but cannot be DISPLAYED passes that probe, so the panel framed it and rendered an unexplained empty rectangle with no way to tell the user why. The reachable case that always hits this is the dashboard's own gateway. It answers every request with `X-Frame-Options: SAMEORIGIN` and CSP `frame-ancestors 'self'`, and `isolatePreviewHost` deliberately swaps the host to the other loopback alias for cookie isolation — so the frame is cross-origin to the dashboard by construction and always refused. Keeping the host identical to satisfy `'self'` is not an option: that is the cookie leak the swap exists to prevent. The condition is knowable client-side without a probe, so detect it and say so. `isDashboardOrigin` compares effective ports and requires BOTH sides to be loopback: reached over a tunnel or a LAN address, a loopback target is the user's own machine — an ordinary dev server — not this gateway. Wired at both entry points. A typed URL renders an explanatory state naming the target with an open-in-browser escape hatch, and `setSessionPreviewPending` refuses such a URL outright so a chat-detected mention of the gateway's own address can no longer raise a Load-preview card that leads nowhere. The test DOM moves to the gateway's real default port. happy-dom defaults to localhost:3000, which is one of the panel's own dev-server quick-picks, leaving "the dashboard" and "a dev server" indistinguishable in tests; matching production separates them.
bc8b05b to
696bc7a
Compare
|
UX Review — same concern, now properly tracked: #2589 You raised this on the first head and I answered "agreed, follow-up" — but I never filed the follow-up, so the only record was a chat reply on a superseded SHA. Re-raising it on this head is fair, and an untracked deferral is what invited it. The issue now carries the direction, the reasoning, and the scope note. Disposition unchanged, accepted-and-deferred, for the same reason as before: converging the two labels means retiring On which direction to converge, the issue records your earlier preference rather than my choice: move mixed content to the self-origin shape (URL in a Worth being explicit that the inconsistency is not introduced by this PR: mixed content already read |
… frame (kirodotdev#2080) The Web Preview panel's only health signal is a no-cors liveness probe that throws on connection failure. A URL that answers but cannot be DISPLAYED passes that probe, so the panel framed it and rendered an unexplained empty rectangle with no way to tell the user why. The reachable case that always hits this is the dashboard's own gateway. It answers every request with `X-Frame-Options: SAMEORIGIN` and CSP `frame-ancestors 'self'`, and `isolatePreviewHost` deliberately swaps the host to the other loopback alias for cookie isolation — so the frame is cross-origin to the dashboard by construction and always refused. Keeping the host identical to satisfy `'self'` is not an option: that is the cookie leak the swap exists to prevent. The condition is knowable client-side without a probe, so detect it and say so. `isDashboardOrigin` compares effective ports and requires BOTH sides to be loopback: reached over a tunnel or a LAN address, a loopback target is the user's own machine — an ordinary dev server — not this gateway. Wired at both entry points. A typed URL renders an explanatory state naming the target with an open-in-browser escape hatch, and `setSessionPreviewPending` refuses such a URL outright so a chat-detected mention of the gateway's own address can no longer raise a Load-preview card that leads nowhere. The test DOM moves to the gateway's real default port. happy-dom defaults to localhost:3000, which is one of the panel's own dev-server quick-picks, leaving "the dashboard" and "a dev server" indistinguishable in tests; matching production separates them. Co-authored-by: Kyle Seaman <kseam@dev-dsk-kseam-1b-55230d27.us-east-1.amazon.com>
Problem
The Web Preview panel showed an unexplained blank rectangle. Reported from a session whose Browser tab held
http://127.0.0.1:6776/api/hooks/agent— the POST-only inbound webhook endpoint, which is not a page at all.The panel's only health signal is a no-cors liveness probe:
That catches a dead server. It cannot catch a server that answers but cannot be displayed — a no-cors fetch to a 405, or to a page the browser refuses to frame, resolves. So
unreachablestays false, the iframe stays mounted, and the user gets a black box with no message. The panel already has this shape of state for mixed content ("detect it so we can explain … instead of rendering a silently-blank frame"); the reachable-but-unframeable case had no equivalent.Why it matters
The reachable target that always lands here is the dashboard's own gateway, and the panel offers it to the user unprompted.
detectPreviewUrlheuristically scoops any loopback URL out of assistant prose, so whenever the agent discusses webhooks, the dashboard or the gateway, the panel raises a "Load preview" card for the gateway's own URL. One click, blank box, no explanation. Nothing breaks and no data is at risk, but the panel reads as broken.Fix (symptom → root cause → change)
The gateway answers every request with
X-Frame-Options: SAMEORIGINand CSPframe-ancestors 'self', andisolatePreviewHostdeliberately swaps a same-host loopback target onto the other alias (localhost↔127.0.0.1) for cookie isolation. So a self-target is a catch-22: keeping the host identical to satisfy'self'is exactly the cookie leak the swap exists to prevent, and swapping it guarantees the frame is cross-origin and refused. It can never render, and no probe can discover that.It is, however, knowable client-side without any probe, which is what this change uses:
isDashboardOrigin(url, dashboardOrigin?)compares effective ports (so:80and an absent port agree) and requires both sides to be loopback. Both conditions carry weight — ports because the host strings legitimately differ for one and the same server after the isolation swap, and both-loopback because over a tunnel or a LAN address a loopback target is the user's own machine and must still frame normally.Wired at the two entry points:
setSessionPreviewPendingrefuses such a URL outright, so the chat-detected mention can no longer raise a card whose only outcome is a blank panel.isolateFrameTargetnarrows the cookie-isolation swap to targets the panel will actually frame. The swap protects a framed server; a self-origin target is never framed, so swapping it only misreports what the user typed — someone entering127.0.0.1:6776was toldlocalhost:6776"is the dashboard's own server" at the exact moment the panel was explaining itself.A general frameability check would need a server-side fetch to read the target's headers. That is deliberately not here: it means adding an outbound-fetch endpoint to the gateway for a display affordance. The broader reachable-but-unframeable class is tracked in #2134, with Design Review's load-timeout suggestion recorded there.
Tests
isDashboardOriginunit coverage: cross-alias same port, different port, default-port equivalence, non-loopback target, remote dashboard with a loopback target, unparseable inputhref), plus a guard that an ordinary dev server is still cookie-isolatedRevert-verified on the current base: removing the
isolateFrameTargetexemption fails 1 test; disabling the detection fails 3.Manual verification
Captured against the real stylesheet and theme tokens via an isolated harness that mounts the real
WebPreviewPanel— the target URL is built from the harness page's own origin, soisDashboardOriginfires exactly as in production rather than being stubbed.Note the URL bar and the code block both read
127.0.0.1:6809— the host the harness page was served from, not swapped tolocalhost. That is theisolateFrameTargetfix visible: before it, this state contradicted itself by naming an alias the user never typed.Control: an ordinary dev server on another port still frames normally
Test environment change
happy-domdefaults the test document tolocalhost:3000, which is one of the panel's own dev-server quick-picks — so "the dashboard" and "a dev server" were indistinguishable in tests and five existing cases began exercising the new state. The test URL now matches production (localhost:6776), which separates them and leaves every existing case meaning what it did before.i18n
Two new keys, translated across all 11 locales with
en-XAregenerated. Appended within their group so no catalog reorders (3 changed lines per file). The body is a complete sentence rather than a fragment wrapped around the URL, so no target language is pinned to English clause order.This branch was rebased across 229 commits of
main. That surfaced two things worth flagging for review:mainhad deleted fourwebPreviewPanelkeys (the browse-mirror "Let the agent act" strings) and no longer references them, so the catalogs were resolved as main's file plus only the two new keys rather than a union merge that would have resurrected dead keys; andmainadded a Korean locale during the gap, which never conflicted but needed both keys to satisfy catalog parity.