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:
@@ -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.
|
||||
|
||||
@@ -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,路径仍在服务端重新解析,`..` 与符号链接逃逸照旧拒绝。
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user