mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-16 09:38:41 +00:00
* fix(artifacts): serve SHA-256 via ETag so preview/edit work on non-secure contexts crypto.subtle is only available in secure contexts (HTTPS or localhost). The frontend fell back to it to compute an artifact's SHA-256 when the Gateway did not return one, which threw on http://<lan-ip>:<port> and broke both artifact preview and inline editing (issue #4864). - Gateway now returns the real SHA-256 as an ETag header for inline text and active-content artifact responses (and skill-archive members). - Frontend prefers the ETag and only computes a hash as a last resort, falling back gracefully (FNV-1a) instead of throwing when crypto.subtle is missing. * fix(artifacts): address PR review feedback for #4864 - Cache SHA-256 digests by (path, mtime_ns, size) so the many small Range requests a browser issues while scrubbing/paginating a preview do not each re-hash a potentially huge artifact from scratch (performance). - Gate inline editing on a real 64-hex revision: hasRevision requires sha256.length === 64, so the FNV-1a fallback on non-secure origins keeps preview working but no longer 422s on save (contract). - Anchor and lowercase the ETag regex and accept the weak W/ prefix gzip emits, so uppercase hex and longer digests (sha-384/512) can't masquerade as sha-256. - Cover the forced-download ETag on the backend and add frontend tests for weak-ETag parsing and the non-secure-context FNV fallback. Feedback from reviewer willem-bd on PR #4865. * style: fix ruff format and prettier issues - test_artifacts_router.py: collapse two over-split client.get() calls to satisfy ruff format (line-length 240) - loader.ts / artifact-file-detail.tsx / loader.test.ts: apply prettier formatting and restore LF line endings * style: fix ruff format and prettier issues - test_artifacts_router.py: collapse two over-split client.get() calls to satisfy ruff format (line-length 240) - loader.ts / artifact-file-detail.tsx / loader.test.ts: apply prettier formatting and restore LF line endings * style: fix ruff format and prettier issues - test_artifacts_router.py: collapse two over-split client.get() calls to satisfy ruff format (line-length 240) - loader.ts / artifact-file-detail.tsx / loader.test.ts: apply prettier formatting and restore LF line endings * style: fix ruff format and prettier issues - test_artifacts_router.py: collapse two over-split client.get() calls to satisfy ruff format (line-length 240) - loader.ts / artifact-file-detail.tsx / loader.test.ts: apply prettier formatting and restore LF line endings * style: reformat artifact-file-detail.tsx for prettier with tailwind class ordering * fix: invalidate SHA-256 cache after artifact edit Clear the LRU cache after os.replace() so the next preview request computes the new digest. Edits are rare, so clearing the whole 256-entry cache costs nothing (addressing PR review comment #5). * fix(artifacts): skip ETag for oversized files + CRLF->LF + cache invalidation (#4865) * fix(loader): use real empty-content SHA-256 for empty 416 range (#4865) * test(artifacts): assert oversized artifacts carry no SHA-256 ETag (#4865) * style(frontend): format long sha256 constant (prettier) * test(backend): fix oversized-artifact ETag assertions and formatting (ruff) * test(backend): keep oversized-payload line within ruff 240-col config
193 lines
5.5 KiB
TypeScript
193 lines
5.5 KiB
TypeScript
import { afterEach, describe, expect, it, rs } from "@rstest/core";
|
|
|
|
import {
|
|
ARTIFACT_PREVIEW_MAX_BYTES,
|
|
loadArtifactContent,
|
|
} from "@/core/artifacts/loader";
|
|
|
|
describe("loadArtifactContent", () => {
|
|
afterEach(() => {
|
|
rs.restoreAllMocks();
|
|
rs.unstubAllGlobals();
|
|
});
|
|
|
|
it("uses the server content revision when available", async () => {
|
|
rs.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
new Response("content", {
|
|
status: 200,
|
|
headers: { ETag: `"${"a".repeat(64)}"` },
|
|
}),
|
|
);
|
|
|
|
const loaded = await loadArtifactContent({
|
|
filepath: "/mnt/user-data/outputs/report.md",
|
|
threadId: "thread-1",
|
|
});
|
|
|
|
expect(loaded.sha256).toBe("a".repeat(64));
|
|
});
|
|
|
|
it("computes a revision for a complete response without a SHA-256 ETag", async () => {
|
|
rs.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
new Response("content", {
|
|
status: 200,
|
|
headers: { ETag: '"starlette-file-etag"' },
|
|
}),
|
|
);
|
|
|
|
const loaded = await loadArtifactContent({
|
|
filepath: "/mnt/user-data/outputs/page.html",
|
|
threadId: "thread-1",
|
|
});
|
|
|
|
expect(loaded.sha256).toBe(
|
|
"ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73",
|
|
);
|
|
});
|
|
|
|
it("requests only the preview byte budget and reports truncation", async () => {
|
|
const bytes = new TextEncoder().encode("preview");
|
|
const fetchMock = rs.fn(async (_url: string, init?: RequestInit) => {
|
|
expect(new Headers(init?.headers).get("Range")).toBe(
|
|
`bytes=0-${ARTIFACT_PREVIEW_MAX_BYTES - 1}`,
|
|
);
|
|
expect(init?.credentials).toBe("include");
|
|
return new Response(bytes, {
|
|
status: 206,
|
|
headers: {
|
|
"Content-Range": `bytes 0-${bytes.length - 1}/2000000`,
|
|
},
|
|
});
|
|
});
|
|
rs.stubGlobal("fetch", fetchMock);
|
|
|
|
const result = await loadArtifactContent({
|
|
filepath: "/mnt/user-data/outputs/large.txt",
|
|
threadId: "thread-1",
|
|
});
|
|
|
|
expect(result.content).toBe("preview");
|
|
expect(result.truncated).toBe(true);
|
|
expect(result.totalBytes).toBe(2_000_000);
|
|
expect(result.sha256).toBeUndefined();
|
|
});
|
|
|
|
it("loads and revisions the full file only when explicitly requested", async () => {
|
|
const fetchMock = rs.fn(async (_url: string, init?: RequestInit) => {
|
|
expect(new Headers(init?.headers).has("Range")).toBe(false);
|
|
return new Response("complete", {
|
|
status: 200,
|
|
headers: { "Content-Length": "8" },
|
|
});
|
|
});
|
|
rs.stubGlobal("fetch", fetchMock);
|
|
|
|
const result = await loadArtifactContent({
|
|
filepath: "/mnt/user-data/outputs/large.txt",
|
|
threadId: "thread-1",
|
|
full: true,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
content: "complete",
|
|
truncated: false,
|
|
totalBytes: 8,
|
|
sha256:
|
|
"eebbf6457e46a7f63acdf9b97390f790ba443d60cfa44b607da7e5c40aa1cc1d",
|
|
});
|
|
});
|
|
|
|
it("does not render a replacement character for a split UTF-8 code point", async () => {
|
|
const emojiBytes = new TextEncoder().encode("abc😀");
|
|
const partial = emojiBytes.slice(0, -2);
|
|
rs.stubGlobal(
|
|
"fetch",
|
|
rs.fn(
|
|
async () =>
|
|
new Response(partial, {
|
|
status: 206,
|
|
headers: {
|
|
"Content-Range": `bytes 0-${partial.length - 1}/${emojiBytes.length + 10}`,
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
|
|
const result = await loadArtifactContent({
|
|
filepath: "/mnt/user-data/outputs/unicode.txt",
|
|
threadId: "thread-1",
|
|
});
|
|
|
|
expect(result.content).toBe("abc");
|
|
});
|
|
|
|
it("treats an unsatisfied range on an empty file as empty content", async () => {
|
|
rs.stubGlobal(
|
|
"fetch",
|
|
rs.fn(
|
|
async () =>
|
|
new Response(null, {
|
|
status: 416,
|
|
headers: { "Content-Range": "bytes */0" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const result = await loadArtifactContent({
|
|
filepath: "/mnt/user-data/outputs/empty.txt",
|
|
threadId: "thread-1",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
content: "",
|
|
truncated: false,
|
|
totalBytes: 0,
|
|
sha256:
|
|
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
});
|
|
});
|
|
|
|
it("parses a weak (W/) SHA-256 ETag returned by a gzipped response", async () => {
|
|
rs.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
new Response("content", {
|
|
status: 200,
|
|
headers: { ETag: `W/"${"b".repeat(64)}"` },
|
|
}),
|
|
);
|
|
|
|
const loaded = await loadArtifactContent({
|
|
filepath: "/mnt/user-data/outputs/report.md",
|
|
threadId: "thread-1",
|
|
});
|
|
|
|
expect(loaded.sha256).toBe("b".repeat(64));
|
|
});
|
|
|
|
it("resolves without throwing when crypto.subtle is unavailable (non-secure context)", async () => {
|
|
rs.stubGlobal("crypto", { subtle: undefined } as unknown as Crypto);
|
|
|
|
const bytes = new TextEncoder().encode("complete");
|
|
rs.stubGlobal(
|
|
"fetch",
|
|
rs.fn(async (_url: string, init?: RequestInit) => {
|
|
expect(new Headers(init?.headers).has("Range")).toBe(false);
|
|
return new Response(bytes, {
|
|
status: 200,
|
|
headers: { "Content-Length": String(bytes.length) },
|
|
});
|
|
}),
|
|
);
|
|
|
|
const loaded = await loadArtifactContent({
|
|
filepath: "/mnt/user-data/outputs/non-secure.html",
|
|
threadId: "thread-1",
|
|
full: true,
|
|
});
|
|
|
|
// FNV-1a fallback keeps preview working and returns a string; because it
|
|
// is not a 64-hex digest the UI treats it as non-editable (no 422 on save).
|
|
expect(typeof loaded.sha256).toBe("string");
|
|
expect(loaded.sha256).toHaveLength(8);
|
|
});
|
|
});
|