fix(frontend): ignore literal base tags in HTML artifact previews (#6176)

* fix(frontend): ignore literal base tags in HTML artifact previews

* fix(frontend): address HTML preview base review feedback

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Daoyuan Li 2026-10-03 01:35:55 -07:00 committed by GitHub
parent 3095027d79
commit c84b32205d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 139 additions and 59 deletions

View File

@ -1902,6 +1902,8 @@ regular files require a matching ETag; date-form `If-Range` requests receive the
CSV and TSV artifacts open as tables in the artifact panel and in a separate window. The preview preserves text values (including leading zeros), supports an optional header row, and pages through up to 200 rows and 50 columns from the initial sample. Long or multiline cells can be opened and copied in full. Switch to source to inspect or edit the file; downloads and separate windows use the saved version.
HTML artifact previews resolve relative assets from the artifact directory unless the document contains a real `<base>` element. Base-tag examples in comments or script text do not change that resolution.
If the sample cuts a CRLF line ending in half, the preview keeps the earlier complete rows and omits the incomplete final record, including when its last field is quoted.
Text artifacts are streamed with HTTP byte-range support. The Web UI initially

View File

@ -15,6 +15,11 @@ Only Markdown destinations decode once; relative images match decoded names
against raw artifact paths before encoding.
File-type detection uses the basename so extensionless `Dockerfile` and
`Makefile` artifacts remain recognizable under nested or dotted directories.
HTML preview base detection skips DOM construction without case-insensitive `base`
text; otherwise an inert template distinguishes real tags from comments, scripts
and nested templates without rewriting source. Tests: `preview-base.dom.test.ts`
(happy-dom); textarea/title RCDATA needs real-browser checks due to parser gaps.
Other preview helpers retain the node test environment.
Language-map membership checks only own properties; inherited names such as
`constructor` and `__proto__` must keep the unknown-file download fallback.

View File

@ -175,10 +175,20 @@ export function appendHtmlPreviewBaseHref(
url?: string,
currentHref = globalThis.location?.href ?? "http://localhost/",
) {
if (!url || /<base\s/i.exec(content)) {
if (!url) {
return content;
}
// A real base tag requires this text; avoid constructing a DOM for other reports.
if (/base/i.test(content)) {
// Template contents are inert: parse tags without running scripts or loading assets.
const template = document.createElement("template");
template.innerHTML = content;
if (template.content.querySelector("base")) {
return content;
}
}
const baseHref = htmlBaseHref(url, currentHref);
const baseElement = `<base href="${escapeHtmlAttribute(baseHref)}">`;
// "(?:\s[^>]*)?" keeps the tag-name boundary so `<header>` (a common

View File

@ -0,0 +1,121 @@
import { expect, rs, test } from "@rstest/core";
import {
appendHtmlPreviewBaseHref,
appendHtmlPreviewScrollRestoration,
} from "@/core/artifacts/preview";
const ARTIFACT_URL = "/api/threads/thread-1/artifacts/report/index.html";
const CURRENT_HREF = "https://deerflow.example/workspace/chats/thread-1";
const BASE_ELEMENT =
'<base href="https://deerflow.example/api/threads/thread-1/artifacts/report/">';
// Textarea/title RCDATA is checked in a real browser: happy-dom misparses it here.
test("skips DOM construction when HTML contains no base text", () => {
const html = `<!doctype html><html><head></head><body>${"<p>Report</p>".repeat(1024)}<img src="chart.png"></body></html>`;
const createElement = rs.spyOn(document, "createElement");
try {
expect(appendHtmlPreviewBaseHref(html, ARTIFACT_URL, CURRENT_HREF)).toBe(
html.replace("<head>", `<head>${BASE_ELEMENT}`),
);
expect(createElement).not.toHaveBeenCalled();
} finally {
createElement.mockRestore();
}
});
test.each([
["comment", '<!-- <base href="/"> is disabled -->'],
[
"JSON script",
'<script type="application/json">{"example":"<base href=\\"/\\">"}</script>',
],
["template", '<template><base href="/"></template>'],
])("ignores base-tag text in a %s", (_name, literal) => {
const html = `<!doctype html><html><head>${literal}</head><body><img src="chart.png"></body></html>`;
expect(appendHtmlPreviewBaseHref(html, ARTIFACT_URL, CURRENT_HREF)).toBe(
html.replace("<head>", `<head>${BASE_ELEMENT}`),
);
});
test("ignores a commented base in a fragment", () => {
const html = '<!-- <base href="/"> --><img src="chart.png">';
expect(appendHtmlPreviewBaseHref(html, ARTIFACT_URL, CURRENT_HREF)).toBe(
`${BASE_ELEMENT}${html}`,
);
});
test("preserves a real base element and the original HTML", () => {
const html =
'<!doctype html><html><head><BASE HREF="https://assets.example/"></head><body><img src="chart.png"></body></html>';
expect(appendHtmlPreviewBaseHref(html, ARTIFACT_URL, CURRENT_HREF)).toBe(
html,
);
});
test("leaves HTML unchanged without an artifact URL", () => {
const html = '<!-- <base href="/"> --><img src="chart.png">';
expect(appendHtmlPreviewBaseHref(html)).toBe(html);
});
test("preserves existing head elements when injecting scroll restoration", () => {
const html =
'<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="script-src \'none\'"></head><body><main>content</main></body></html>';
const result = appendHtmlPreviewScrollRestoration(
appendHtmlPreviewBaseHref(
html,
"/demo/threads/thread-1/user-data/outputs/report.html?download=true",
"http://localhost/workspace/chats/thread-1",
),
"/artifact-fixtures/report.html",
);
expect(result).toContain(
'<base href="http://localhost/demo/threads/thread-1/user-data/outputs/">',
);
expect(
result.indexOf("data-deerflow-artifact-scroll-restoration"),
).toBeLessThan(
result.indexOf(
'<base href="http://localhost/demo/threads/thread-1/user-data/outputs/">',
),
);
});
test("does not mistake <header> for <head> when injecting the base href", () => {
// Agent-generated fragments often have no <head> but open with <header>;
// the base must then be prepended so assets before the tag resolve too.
const html =
'<img src="logo.png"><header class="top">Report</header><main>content</main>';
const result = appendHtmlPreviewBaseHref(
html,
"/demo/threads/thread-1/user-data/outputs/report.html",
"http://localhost/workspace/chats/thread-1",
);
expect(result).toBe(
'<base href="http://localhost/demo/threads/thread-1/user-data/outputs/">' +
html,
);
});
test("injects the base href inside an attributed <head> tag", () => {
const html =
'<!doctype html><html><head lang="en"><meta charset="utf-8"></head><body>x</body></html>';
const result = appendHtmlPreviewBaseHref(
html,
"/demo/threads/thread-1/user-data/outputs/report.html",
"http://localhost/workspace/chats/thread-1",
);
expect(result).toContain(
'<head lang="en"><base href="http://localhost/demo/threads/thread-1/user-data/outputs/">',
);
});

View File

@ -1,7 +1,6 @@
import { expect, test } from "@rstest/core";
import {
appendHtmlPreviewBaseHref,
appendHtmlPreviewScrollRestoration,
buildWriteFileDraftContent,
createHtmlPreviewScrollKey,
@ -258,63 +257,6 @@ test("injects scroll restoration at the start of the HTML head", () => {
);
});
test("preserves existing head elements when injecting scroll restoration", () => {
const html =
'<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="script-src \'none\'"></head><body><main>content</main></body></html>';
const result = appendHtmlPreviewScrollRestoration(
appendHtmlPreviewBaseHref(
html,
"/demo/threads/thread-1/user-data/outputs/report.html?download=true",
"http://localhost/workspace/chats/thread-1",
),
ARTIFACT_PATH,
);
expect(result).toContain(
'<base href="http://localhost/demo/threads/thread-1/user-data/outputs/">',
);
expect(
result.indexOf("data-deerflow-artifact-scroll-restoration"),
).toBeLessThan(
result.indexOf(
'<base href="http://localhost/demo/threads/thread-1/user-data/outputs/">',
),
);
});
test("does not mistake <header> for <head> when injecting the base href", () => {
// Agent-generated fragments often have no <head> but open with <header>;
// the base must then be prepended so assets before the tag resolve too.
const html =
'<img src="logo.png"><header class="top">Report</header><main>content</main>';
const result = appendHtmlPreviewBaseHref(
html,
"/demo/threads/thread-1/user-data/outputs/report.html",
"http://localhost/workspace/chats/thread-1",
);
expect(result).toBe(
'<base href="http://localhost/demo/threads/thread-1/user-data/outputs/">' +
html,
);
});
test("injects the base href inside an attributed <head> tag", () => {
const html =
'<!doctype html><html><head lang="en"><meta charset="utf-8"></head><body>x</body></html>';
const result = appendHtmlPreviewBaseHref(
html,
"/demo/threads/thread-1/user-data/outputs/report.html",
"http://localhost/workspace/chats/thread-1",
);
expect(result).toContain(
'<head lang="en"><base href="http://localhost/demo/threads/thread-1/user-data/outputs/">',
);
});
test("does not duplicate HTML scroll restoration script", () => {
const html = appendHtmlPreviewScrollRestoration(
"<html><body>x</body></html>",