fix(web): use the separate-origin preview for in-app HTML rendering (#59)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-26 14:52:25 +08:00
committed by GitHub
parent 3f868ac385
commit 6b8b631e37
8 changed files with 340 additions and 49 deletions
+3 -2
View File
@@ -168,11 +168,11 @@ Workspace files may be Agent-generated, so `GET /files/content` treats them as u
| `preview=1` | the real type (`text/html`, `image/svg+xml`, …) | `inline` | `sandbox allow-scripts allow-popups allow-modals allow-forms`, sent only for `.html` / `.htm` / `.svg` |
| `download=1` | the real type | `attachment` | — |
The filename always rides along as `filename*=UTF-8''` with percent-encoding. `preview=1` renders inside the Files panel's sandboxed iframe, and is also the fallback for "open in a new tab" when no separate preview origin is available: the document keeps its real type and does render and run, but the sandbox deliberately omits `allow-same-origin`, so it lands in an opaque origin and can reach neither this origin's cookies nor the API. That isolation is also why `localStorage`, `document.cookie` and third-party embeds do not work there.
The filename always rides along as `filename*=UTF-8''` with percent-encoding. `preview=1` is where the preview redirect falls back when no separate preview origin is available: the document keeps its real type and does render and run, but the sandbox deliberately omits `allow-same-origin`, so it lands in an opaque origin and can reach neither this origin's cookies nor the API. That isolation is also why `localStorage`, `document.cookie` and third-party embeds do not work there.
### Preview on a separate origin
"Open in a new tab" goes through `GET /files/preview-redirect?path=`, which authenticates the caller, then mints a short-lived HMAC token and 302s to a **different origin**:
Both the Files panel's rendered HTML view (an iframe) and "open in a new tab" go through `GET /files/preview-redirect?path=`, which authenticates the caller, then mints a short-lived HMAC token and 302s to a **different origin**:
```text
GET /api/sessions/:sessionId/files/preview-redirect?path=index.html
@@ -181,6 +181,7 @@ GET /preview/<token>/<relative path> (unauthenticated; the token is th
```
- **Why a separate origin.** The page needs a real origin to have working storage, cookies and third-party embeds — but it must not be the app's origin, or Agent-written HTML would run with the session cookie. Locally the app is canonicalized onto `localhost` and previews are served from `127.0.0.1`; cookies are keyed by host and ignore port, so those are separate cookie jars while a second port would not be. Otherwise `PENGUIN_PREVIEW_ORIGIN` applies; with neither (a wildcard or non-loopback bind, or the variable unset), the redirect falls back to the same-origin sandbox above and `previewIsolated` on `GET /api/me` reports `false` so the UI can say so first.
- **In-app rendering rides the same URL.** The Files panel embeds the redirect URL in an iframe sandboxed with `allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads` — `allow-same-origin` grants the preview origin's identity, not the App's, so this stays strictly tighter than the sandbox-free new tab. Without a separate preview origin the panel instead falls back to inline `srcdoc` rendering (`allow-scripts` only, plus an in-memory storage shim), where relative subresources cannot load. Note that some browsers partition or block storage inside a cross-site iframe, so a page may behave slightly differently in the panel than in the top-level tab.
- **The preview host serves only `/preview/*`.** It is the same process as the app, so it answers `/api` with `401` and `302`s every other route to the canonical app host. A session cookie is therefore never set or honored on the preview host, and Agent HTML there cannot reach the API same-origin. (For a deployed `PENGUIN_PREVIEW_ORIGIN`, the reverse proxy must enforce the equivalent: route only `/preview/*` to the app on that origin.)
- **Path-based, not a query parameter**, so a page's relative subresources (`app.js`, `style.css`, images) resolve against the document and load under the same token.
- **The token binds the Session, the preview host and an expiry.** The host binding is load-bearing: the same process also answers on the app origin, so `/preview/...` refuses to serve there — otherwise it would be a same-origin XSS. Access is read-only and scoped to that Session's Workspace, and the path is re-resolved server-side, so `..` and symlink escapes are rejected as before.
+3 -2
View File
@@ -168,11 +168,11 @@ Workspace 文件可能由 Agent 生成,`GET /files/content` 一律按不可信
| `preview=1` | 真实类型(`text/html`、`image/svg+xml` 等) | `inline` | `sandbox allow-scripts allow-popups allow-modals allow-forms`,仅对 `.html` / `.htm` / `.svg` 下发 |
| `download=1` | 真实类型 | `attachment` | 无 |
文件名始终以 `filename*=UTF-8''` 形式携带(百分号编码)。`preview=1` 用于 Files 面板内的沙箱 iframe 渲染,同时也是“新页面打开”在没有独立预览源时的回退:文档保留真实类型,可以正常渲染并执行脚本,但沙箱刻意不含 `allow-same-origin`,因此它落在一个不透明源里,既拿不到本源的 Cookie,也调不动 API。这份隔离也正是那里 `localStorage`、`document.cookie` 与第三方 embed 全都不可用的原因。
文件名始终以 `filename*=UTF-8''` 形式携带(百分号编码)。`preview=1` 是预览跳转在没有独立预览源时的回退目标:文档保留真实类型,可以正常渲染并执行脚本,但沙箱刻意不含 `allow-same-origin`,因此它落在一个不透明源里,既拿不到本源的 Cookie,也调不动 API。这份隔离也正是那里 `localStorage`、`document.cookie` 与第三方 embed 全都不可用的原因。
### 独立源预览
“新页面打开”走 `GET /files/preview-redirect?path=`:先鉴权,再签发一枚短时效 HMAC 令牌,然后 302 跳转到**另一个源**:
Files 面板内的 HTML 渲染视图(iframe)与“新页面打开”都走 `GET /files/preview-redirect?path=`:先鉴权,再签发一枚短时效 HMAC 令牌,然后 302 跳转到**另一个源**:
```text
GET /api/sessions/:sessionId/files/preview-redirect?path=index.html
@@ -181,6 +181,7 @@ GET /preview/<token>/<相对路径> (不鉴权,令牌即凭证
```
- **为什么要独立源。** 页面需要一个真实的源,才能有可用的 storage、Cookie 与第三方 embed;但它不能是应用自己的源,否则 Agent 写出来的 HTML 就带着会话 Cookie 在跑。本地把 App 固定在规范主机 `localhost`,预览用 `127.0.0.1`——Cookie 按主机划分且不区分端口,所以这两者天然是两个 Cookie jar,而只换端口做不到。其余情况用 `PENGUIN_PREVIEW_ORIGIN`;两者都没有时(通配或非回环绑定,或变量未设)回退到上面的同源沙箱,并由 `GET /api/me` 的 `previewIsolated` 返回 `false`,界面据此提前说明。
- **面板内渲染共用同一 URL。** Files 面板把该跳转 URL 嵌入 iframe,沙箱为 `allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads`——`allow-same-origin` 赋予的是预览源而非应用源的身份,因此仍严格紧于不带沙箱的新标签页。没有独立预览源时,面板回退为内联 `srcdoc` 渲染(仅 `allow-scripts`,附内存版 storage 垫片),相对子资源在那里无法加载。另注意部分浏览器会对跨站 iframe 内的 storage 做分区或屏蔽,页面在面板内的行为可能与顶层标签页略有差异。
- **预览主机只服务 `/preview/*`。** 它与 App 是同一个进程,故其 `/api` 一律 401,其余路径一律 302 回规范 App 主机——会话 Cookie 因此永远不会落在预览主机上,也不被其接受,那里的 Agent HTML 无法同源调用 API。(部署 `PENGUIN_PREVIEW_ORIGIN` 时,反向代理须做等价保证:该源上只把 `/preview/*` 路由到 App。)
- **路径式而非查询参数**,页面里的相对子资源(`app.js`、`style.css`、图片)才能相对文档解析,并在同一个令牌下加载。
- **令牌绑定 Session、预览主机与过期时间。** 其中主机绑定是承重的:同一个进程也在应用源上应答,因此 `/preview/...` 在应用源上一律拒绝服务——否则那就是一个同源 XSS。权限只读、限定该 Session 的 Workspace,路径仍在服务端重新解析,`..` 与符号链接逃逸照旧拒绝。
+15 -4
View File
@@ -151,7 +151,8 @@ export function requestAuthority(requestUrl: string, hostHeader: string | undefi
/**
* Where previews for this request should be served from: the configured origin when
* PENGUIN_PREVIEW_ORIGIN is set, otherwise the loopback counterpart of the host the
* caller is using. Null when neither applies — the caller degrades to the same-origin
* caller is using. Null when neither applies — or when the configured origin's host is
* the request's own host (no boundary) — and the caller degrades to the same-origin
* sandbox rather than silently serving unisolated content.
*
* The port comes from **this server's own binding**, never from the request. Those differ
@@ -169,13 +170,23 @@ export function resolvePreviewTarget(
configuredOrigin: string | null,
serverBind: { host: string; port: number },
): { origin: string; host: string } | null {
const requestHost = hostOnly(requestAuthority(requestUrl, hostHeader)).toLowerCase();
if (configuredOrigin) {
const url = new URL(configuredOrigin);
// A configured origin that lands on the very host this request is using is no
// boundary at all: "isolated" previews would be same-origin with the App, and the
// allow-same-origin iframe the UI mounts for them would hand Agent HTML the App's
// origin. Such a misconfiguration degrades to null — previewIsolated reports false
// and the redirect falls back to the same-origin sandbox — instead of claiming an
// isolation that does not exist. Host-only comparison on purpose: cookies ignore
// ports, so a port difference would not separate anything either.
if (url.hostname.toLowerCase() === requestHost) return null;
return { origin: url.origin, host: url.hostname };
}
const raw = requestAuthority(requestUrl, hostHeader);
if (raw === "") return null;
const counterpart = loopbackCounterpart(hostOnly(raw));
if (requestHost === "") return null;
// The loopback counterpart differs from the request host by construction, so the
// same-host guard above is only needed for the configured branch.
const counterpart = loopbackCounterpart(requestHost);
if (!counterpart) return null;
if (!loopbackHostRoles(serverBind.host)) return null;
@@ -128,6 +128,39 @@ describe("preview origin derivation", () => {
resolvePreviewTarget("http://192.168.1.5:7364/x", "192.168.1.5:7364", null, bind),
).toBeNull();
});
it("refuses a configured origin that matches the App request's host", () => {
// PENGUIN_PREVIEW_ORIGIN pointing back at the host the App is being used on is no
// boundary: "isolated" previews would be same-origin with the App (and the UI would
// mount an allow-same-origin iframe onto them). Must degrade to null so
// previewIsolated reports false and the safe same-origin sandbox engages.
expect(
resolvePreviewTarget(
"https://app.example.com/x",
"app.example.com",
"https://app.example.com",
bind,
),
).toBeNull();
// Port and case differences don't rescue it: cookies ignore ports, hosts ignore case.
expect(
resolvePreviewTarget(
"https://app.example.com/x",
"app.example.com:8443",
"https://APP.example.com:9443",
bind,
),
).toBeNull();
// A genuinely different host on the same registrable domain is still accepted.
expect(
resolvePreviewTarget(
"https://app.example.com/x",
"app.example.com",
"https://preview.app.example.com",
bind,
),
).toEqual({ origin: "https://preview.app.example.com", host: "preview.app.example.com" });
});
});
describe("preview route", () => {
+7 -4
View File
@@ -215,9 +215,12 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
await seg.hover();
await expect(main.locator("button.bg-amber-50").first()).toBeVisible();
// --- files: HTML preview (scripts run in sandbox, localStorage shim) + path hidden ---
// Script uses localStorage: without the shim it would throw SecurityError (opaque origin);
// with the shim it runs and appends #shim-ok.
// --- files: HTML preview (isolated separate-origin iframe, scripts + storage) + path hidden ---
// run.sh binds 127.0.0.1 and the spec browses on localhost, so previewIsolated=true and the
// in-app rendered view loads via /files/preview-redirect on the separate preview origin
// (127.0.0.1) — a real origin where localStorage works natively. The #shim-ok marker (id kept
// from the old srcDoc-plus-shim path, which now only engages when no preview origin exists)
// proves the page's script ran and storage was writable.
const html = Buffer.from(
"<html><body><h1>Hello E2E</h1><script>localStorage.setItem('e2e','1');" +
"document.body.insertAdjacentHTML('beforeend','<p id=\\'shim-ok\\'>'+localStorage.getItem('e2e')+'</p>')" +
@@ -234,7 +237,7 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
const fileNode = page.getByText("demo.html").first();
await expect(fileNode).toBeVisible();
await fileNode.click();
// Rendered iframe present; the localStorage script ran (shim) → #shim-ok appended.
// Rendered iframe present; the localStorage script ran on the preview origin → #shim-ok appended.
await expect(page.locator("iframe")).toBeVisible();
await expect(page.frameLocator("iframe").locator("#shim-ok")).toHaveText("1");
// The list and the preview are mutually exclusive: the toolbar only appears after
+90
View File
@@ -0,0 +1,90 @@
/**
* Regression: switching conversations while a Files-panel HTML preview is open must
* return the panel to the NEW session's file list — not resurrect the old session's
* preview against the new session.
*
* The trap this guards: the panel stays mounted across a sidebar switch (client-side
* navigation, `session` prop flips), and the openRequest locate effect also re-runs when
* previewPath's identity changes with session.sessionId. The parent clears openRequest
* only after child effects, so without a handled-once guard the old request replays
* against the new session — with the isolated iframe that committed a preview-redirect
* URL for a file the new Workspace doesn't have, i.e. a raw 404 page in the panel.
*/
import { test, expect } from "@playwright/test";
import { provisionAndLogin } from "./auth.mjs";
const BASE = process.env.BASE_URL;
const MOCK = process.env.MOCK_URL;
const U = "e2eswitch";
const P = "password123";
test("switching conversations closes the old session's HTML preview and shows the new session's list", async ({
page,
}) => {
// --- seed via API: one project, two sessions; demo.html exists only in A ---
await provisionAndLogin(page.request, U, P);
const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
const projectId = projects.projects[0].projectId;
const put = await page.request.put(`${BASE}/api/projects/${projectId}/models`, {
data: {
defaultModel: { provider: "custom", modelId: "claude-4-8" },
models: [
{
provider: "custom",
modelId: "claude-4-8",
apiKey: "sk-mock",
baseUrl: MOCK,
contextWindow: 200000,
pricing: { cacheRead: 1, cacheWrite: 5, output: 10 },
},
],
},
});
expect(put.ok(), "put models").toBeTruthy();
const mkSession = async () =>
(
await (
await page.request.post(`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`, {
data: {},
})
).json()
).session.sessionId;
const sidA = await mkSession();
const sidB = await mkSession();
const html = Buffer.from("<html><body><h1>Session A page</h1></body></html>").toString("base64");
const up = await page.request.put(`${BASE}/api/sessions/${sidA}/files/content?path=demo.html`, {
data: { dataBase64: html },
});
expect(up.ok(), "upload html").toBeTruthy();
// --- in A: produce the files card and open the preview through it ---
// The card click goes through browsePath -> openRequest, which is the state the bug
// needs (a list click would not arm it).
await page.goto(`${BASE}/chat/${sidA}`);
const ta = page.getByPlaceholder(/输入消息/);
await ta.waitFor();
await ta.fill("files card test");
await page.getByRole("button", { name: "发送" }).click();
await expect(page.getByText(/Report generated/)).toBeVisible();
const cardRow = page.locator('button[title="demo.html"]').filter({ hasText: "点击预览" });
await cardRow.click();
await expect(page.frameLocator("iframe").getByText("Session A page")).toBeVisible();
// --- switch to B via the sidebar (client-side navigation, the panel stays mounted) ---
const sidebar = page.getByRole("complementary");
// Wait for A's generated title so "新对话" uniquely identifies B.
await expect(sidebar.getByText("Configure Tailwind theme")).toBeVisible();
await sidebar.getByText("新对话").first().click();
await expect(page).toHaveURL(new RegExp(sidB));
// The panel must be back on the (empty) list of B: no preview header, no iframe kept
// or re-created for A's demo.html.
await expect(page.getByRole("button", { name: "返回列表" })).toHaveCount(0);
await expect(page.locator("iframe")).toHaveCount(0);
await expect(page.getByText("空目录")).toBeVisible();
// --- switching back to A keeps the panel usable: the list shows A's file again ---
await sidebar.getByText("Configure Tailwind theme").first().click();
await expect(page).toHaveURL(new RegExp(sidA));
await expect(page.getByText("demo.html").first()).toBeVisible();
});
@@ -171,8 +171,17 @@ interface Preview {
/** Content for kind=text/md/html (may be truncated). */
content?: string;
truncated?: boolean;
/** Bumped on every previewPath call; keys the isolated HTML iframe so re-opening the
* same path remounts it and refetches fresh content (its src alone would not change). */
nonce: number;
}
/** Monotonic counter behind Preview.nonce. Doubles as the staleness guard: previewPath
* captures its value up front and publishes only while still the latest, so two rapid
* calls can't have the slower loser overwrite the winner. Module scope is fine — the
* docked panel and the drawer never mount WorkspaceBrowser at the same time. */
let previewSeq = 0;
export function WorkspaceBrowser({
session,
openRequest,
@@ -191,8 +200,10 @@ export function WorkspaceBrowser({
/** Callback when entering file preview (used by the mobile Sheet to raise its snap point to full). */
onPreviewOpen?: () => void;
}) {
// Whether "open in new tab" lands on a separate origin; false downgrades it to the
// same-origin sandbox, which the link flags rather than failing silently in the page.
// Whether the HTML preview lands on a separate origin. True routes both the in-app
// rendered view and "open in new tab" through the preview origin; false downgrades
// the new tab to the same-origin sandbox (which the link flags rather than failing
// silently in the page) and the in-app rendered view to the srcDoc fallback.
const { previewIsolated } = useAuth();
const [path, setPath] = useState("");
const [data, setData] = useState<WorkspaceFilesResponse | null>(null);
@@ -207,6 +218,14 @@ export function WorkspaceBrowser({
const [showPath, setShowPath] = useState(false);
/** HTML / Markdown preview: rendered view (HTML via sandboxed iframe, Markdown via md-body) / source toggle. */
const [richView, setRichView] = useState<"rendered" | "source">("rendered");
/** Error from the lazy source fetch of an isolated HTML preview: scoped to the source
* view — the rendered iframe keeps working no matter what happens to this fetch. */
const [sourceError, setSourceError] = useState<string | null>(null);
/** Mirror for previewPath: reading previewIsolated through a ref keeps the callback's
* identity stable when /api/me refreshes it (previewPath drives the openRequest locate
* effect, which must not replay on an auth-context change). */
const previewIsolatedRef = useRef(previewIsolated);
previewIsolatedRef.current = previewIsolated;
useEffect(() => {
let cancelled = false;
@@ -224,12 +243,22 @@ export function WorkspaceBrowser({
};
}, [session.sessionId, path, reloadTick]);
// Returns to the root directory and clears the preview when the Session changes.
useEffect(() => {
// Session switched: back to the root directory with no preview. Reset during render
// (React's documented "adjust state when a prop changes" pattern), not in an effect —
// an effect-based reset lets one frame commit in which the old session's preview
// renders against the new session, flipping the isolated iframe's src to the new
// session + the old path and firing a doomed request. Bumping previewSeq also
// invalidates any in-flight previewPath from the old session (its present() guard
// fails), so a late result can't resurrect old-session content after the reset.
const [renderedSessionId, setRenderedSessionId] = useState(session.sessionId);
if (renderedSessionId !== session.sessionId) {
setRenderedSessionId(session.sessionId);
setPath("");
setPreview(null);
setData(null);
}, [session.sessionId]);
setSourceError(null);
previewSeq++;
}
// Edge-triggered refresh on the panel's hidden -> visible transition (doesn't count the initial mount: mounting itself already fetches once).
const prevActive = useRef(active);
@@ -251,26 +280,43 @@ export function WorkspaceBrowser({
? filePath.slice(filePath.lastIndexOf("/") + 1)
: filePath;
const ext = extOf(name);
const nonce = ++previewSeq;
/** Publishes this call's result unless a newer previewPath call has started since:
* two rapid calls interleave across the await, and the late loser must not
* overwrite the winner's preview. */
const present = (p: Preview) => {
if (nonce === previewSeq) setPreview(p);
};
setRichView("rendered");
setSourceError(null);
if (IMAGE_EXTS.has(ext)) {
setPreview({ path: filePath, name, kind: "image" });
present({ path: filePath, name, kind: "image", nonce });
return;
}
// PDF: the server returns it inline as application/pdf, embedded directly in an iframe and rendered by the browser.
if (ext === "pdf") {
setPreview({ path: filePath, name, kind: "pdf" });
present({ path: filePath, name, kind: "pdf", nonce });
return;
}
const isHtml = HTML_EXTS.has(ext);
const isMd = ext === "md";
if (!isHtml && !TEXT_EXTS.has(ext)) {
setPreview({ path: filePath, name, kind: "unsupported" });
present({ path: filePath, name, kind: "unsupported", nonce });
return;
}
// Isolated HTML: the rendered view is an iframe onto the preview origin and needs no
// text here, so the iframe mounts with no upfront fetch — a large file isn't
// downloaded twice, and a transient fetch failure can't downgrade a page the iframe
// would serve fine. The source text is fetched lazily on the first Source toggle
// (see the effect below).
if (isHtml && previewIsolatedRef.current) {
present({ path: filePath, name, kind: "html", nonce });
return;
}
try {
// The server downgrades html/svg served inline to text/plain (a same-origin XSS
// defense); this fetches the raw content back, and the HTML rendered view is placed in a
// sandboxed iframe (without allow-scripts), so scripts don't execute.
// defense); this fetches the raw content back for text/Markdown previews and for
// the srcDoc fallback rendered view of non-isolated HTML.
const res = await fetch(api.workspaceFileUrl(session.sessionId, filePath), {
credentials: "same-origin",
});
@@ -280,27 +326,75 @@ export function WorkspaceBrowser({
// Oversized Markdown defaults to the source view (benefiting from the unhighlighted
// highlight=false path): feeding the whole block to remark for parsing is a one-time
// main-thread cost; the user can still manually switch to "rendered view" as an informed choice.
if (isMd && full.length > HIGHLIGHT_LIMIT) setRichView("source");
setPreview({
if (isMd && full.length > HIGHLIGHT_LIMIT && nonce === previewSeq) setRichView("source");
present({
path: filePath,
name,
kind: isHtml ? "html" : isMd ? "md" : "text",
content: truncated ? full.slice(0, TEXT_PREVIEW_LIMIT) : full,
truncated,
nonce,
});
} catch {
setPreview({ path: filePath, name, kind: "unsupported" });
present({ path: filePath, name, kind: "unsupported", nonce });
}
},
[session.sessionId],
);
// Lazy source fetch for isolated HTML previews: previewPath mounted the iframe without
// downloading the text, so the first switch to the Source view fetches it here (as does
// the rare case of previewIsolated flipping to false with such a preview open, which
// strands the srcDoc fallback without content). Failure sets sourceError and touches
// nothing else — a broken source fetch must not take down the rendered view.
useEffect(() => {
if (preview?.kind !== "html" || preview.content !== undefined) return;
if (richView !== "source" && previewIsolated) return;
const target = preview.path;
let cancelled = false;
setSourceError(null);
void (async () => {
try {
const res = await fetch(api.workspaceFileUrl(session.sessionId, target), {
credentials: "same-origin",
});
if (!res.ok) throw new Error(String(res.status));
const full = await res.text();
if (cancelled) return;
const truncated = full.length > TEXT_PREVIEW_LIMIT;
// Functional update with its own guard (not `present`): this must only fill the
// still-current, still-contentless HTML preview for the same path, never revive
// a preview the user has since navigated away from.
setPreview((p) =>
p !== null && p.kind === "html" && p.path === target && p.content === undefined
? { ...p, content: truncated ? full.slice(0, TEXT_PREVIEW_LIMIT) : full, truncated }
: p,
);
} catch {
if (!cancelled) setSourceError(S.files.loadFailed);
}
})();
return () => {
cancelled = true;
};
}, [preview, richView, previewIsolated, session.sessionId]);
// External navigation command (clicking a file chip in a message / a file card): navigates to
// the directory and previews the target path. Also refreshes the list: the target is most
// likely a file the Agent just wrote, so the cached list is very likely stale; and when it's
// the same directory, setPath is a same-value no-op that won't trigger the fetch effect, so it must be explicitly bumped.
//
// Each openRequest object is handled exactly once (the ref guard): this effect also
// re-runs when previewPath's identity changes with session.sessionId, at which point
// openRequest is still the OLD session's request — the parent clears it only after
// child effects. Replaying it against the new session would resurrect the preview the
// session-switch reset just cleared, for isolated HTML as a committed iframe onto a
// path the new Workspace doesn't have (a raw 404 page). browsePath creates a fresh
// object per click, so re-clicking the same file still re-triggers.
const handledOpenRequest = useRef<{ path: string } | null>(null);
useEffect(() => {
if (!openRequest) return;
if (!openRequest || handledOpenRequest.current === openRequest) return;
handledOpenRequest.current = openRequest;
const target = openRequest.path;
const dir = target.includes("/") ? target.slice(0, target.lastIndexOf("/")) : "";
setPath(dir);
@@ -463,16 +557,49 @@ export function WorkspaceBrowser({
className="h-full min-h-[60vh] w-full rounded-md border border-gray-200 dark:border-gray-800"
/>
) : preview.kind === "html" && richView === "rendered" ? (
// sandbox allows scripts but **without allow-same-origin**: the iframe has an opaque
// origin, so scripts can run to fully render the page, yet can't read the app's
// same-origin cookies / DOM (an XSS defense). The storage shim is injected to avoid
// a SecurityError when a script accesses localStorage from an opaque origin.
<iframe
srcDoc={withStorageShim(preview.content ?? "")}
title={preview.name}
sandbox="allow-scripts"
className="h-full min-h-[60vh] w-full rounded-md border border-gray-200 bg-white dark:border-gray-800"
/>
previewIsolated ? (
// Same URL and serving path as "open in new tab": the app-origin redirect mints
// a token and 302s to the separate preview origin, where the document has a real
// base URL — relative subresources (<img src="foo.png">, app.js, style.css)
// resolve and load, and storage works, exactly as in the new-page preview.
// allow-same-origin is safe here precisely because the document IS on a separate
// origin: it grants the preview origin's identity, not the app's, so the frame
// still can't reach the app's cookies or DOM. Popups stay sandboxed (no
// allow-popups-to-escape-sandbox); allow-downloads keeps download links inside
// the page working, as they do in the new tab. The key remounts the iframe on
// every previewPath call — its src alone wouldn't change when the same file is
// re-opened after the Agent rewrote it.
<iframe
key={preview.nonce}
src={api.workspaceFilePreviewUrl(session.sessionId, preview.path)}
title={preview.name}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads"
className="h-full min-h-[60vh] w-full rounded-md border border-gray-200 bg-white dark:border-gray-800"
/>
) : preview.content === undefined ? (
// Only reachable when previewIsolated flipped to false after an isolated
// preview mounted without content: the lazy source effect is already fetching
// it, and the srcDoc fallback renders once it lands.
sourceError !== null ? (
<p className="text-sm text-red-600 dark:text-red-400">{sourceError}</p>
) : (
<SkeletonList rows={6} />
)
) : (
// No separate preview origin: srcDoc fallback. sandbox allows scripts but
// **without allow-same-origin**: the iframe has an opaque origin, so scripts can
// run to fully render the page, yet can't read the app's same-origin cookies /
// DOM (an XSS defense). The storage shim is injected to avoid a SecurityError
// when a script accesses localStorage from an opaque origin. srcdoc has no real
// base URL, so relative subresources cannot resolve here — that's what the
// isolated branch above fixes.
<iframe
srcDoc={withStorageShim(preview.content)}
title={preview.name}
sandbox="allow-scripts"
className="h-full min-h-[60vh] w-full rounded-md border border-gray-200 bg-white dark:border-gray-800"
/>
)
) : preview.kind === "md" && richView === "rendered" ? (
// Markdown's default rendered view: uses the same md-body layout as message bodies
// (ReactMarkdown outputs pure static HTML with no script execution surface, so no iframe sandbox is needed).
@@ -533,19 +660,30 @@ export function WorkspaceBrowser({
)}
</>
) : preview.kind === "text" || preview.kind === "html" || preview.kind === "md" ? (
// The source view reuses the message stream's CodeBlock: Shiki dual-theme
// highlighting + language label + copy button, no line wrapping, horizontal scroll
// instead (wrapping code is a disaster for readability, see the old mobile styling).
<>
<CodeBlock
language={langForExt(extOf(preview.name))}
code={preview.content ?? ""}
highlight={(preview.content?.length ?? 0) <= HIGHLIGHT_LIMIT}
/>
{preview.truncated && (
<p className="mt-1 text-xs text-gray-400">… {S.files.previewTruncated}</p>
)}
</>
preview.content === undefined ? (
// Isolated HTML reaches the Source view before its lazy fetch lands: show a
// skeleton (or the fetch's own error) — toggling back to Rendered is
// unaffected, and re-entering Source retries the fetch.
sourceError !== null ? (
<p className="text-sm text-red-600 dark:text-red-400">{sourceError}</p>
) : (
<SkeletonList rows={6} />
)
) : (
// The source view reuses the message stream's CodeBlock: Shiki dual-theme
// highlighting + language label + copy button, no line wrapping, horizontal scroll
// instead (wrapping code is a disaster for readability, see the old mobile styling).
<>
<CodeBlock
language={langForExt(extOf(preview.name))}
code={preview.content}
highlight={preview.content.length <= HIGHLIGHT_LIMIT}
/>
{preview.truncated && (
<p className="mt-1 text-xs text-gray-400">… {S.files.previewTruncated}</p>
)}
</>
)
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">{S.files.previewUnsupported}</p>
)}
+14
View File
@@ -65,6 +65,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const login = useCallback(async (userId: string, password: string) => {
const res = await api.login({ userId, password });
setUser(res.user);
// previewIsolated only rides on GET /api/me, and the mount-time fetch ran before
// this session existed — without a refetch, a deployment with no separate preview
// origin would keep the optimistic `true` after a UI login (navigation is
// client-side, so nothing else re-asks) and the Files panel would take the isolated
// preview path it can't actually serve. Never fail the login over it: the session
// cookie is already set, so a transient /me error just leaves the default in place
// until the next refresh.
try {
const me = await api.getMe();
setUser(me.user);
setPreviewIsolated(me.previewIsolated);
} catch {
// Login itself succeeded; keep the optimistic default.
}
}, []);
const logout = useCallback(async () => {