// Core Canvas document storage and URL contract coverage. import { link, mkdir, readFile, rename, symlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { createCanvasDocument, readCanvasDocumentHtmlSource, resolveCanvasDocumentsDir, resolveCanvasHttpPathToLocalPath, } from "./documents.js"; const tempDirs = useAutoCleanupTempDirTracker(afterEach); function resolveCanvasDocumentDir(stateDir: string, documentId: string): string { return path.join(resolveCanvasDocumentsDir(stateDir), documentId); } describe("canvas documents", () => { it.skipIf(process.platform === "win32").each(["document", "manifest.json", "index.html"])( "rejects a symlinked %s when reading widget HTML", async (target) => { const stateDir = tempDirs.make("openclaw-canvas-links-"); const outsideDir = tempDirs.make("openclaw-canvas-outside-"); const document = await createCanvasDocument( { kind: "html_bundle", entrypoint: { type: "html", value: "

outside

" } }, { stateDir }, ); const documentDir = resolveCanvasDocumentDir(stateDir, document.id); const original = target === "document" ? documentDir : path.join(documentDir, target); const outside = path.join(outsideDir, target); await rename(original, outside); await symlink(outside, original, target === "document" ? "dir" : "file"); await expect(readCanvasDocumentHtmlSource(document.id, { stateDir })).rejects.toMatchObject({ code: target === "document" ? "outside-workspace" : "symlink", }); }, ); it.each(["manifest.json", "index.html"])( "rejects a hardlinked %s when reading widget HTML", async (target) => { const stateDir = tempDirs.make("openclaw-canvas-hardlinks-"); const document = await createCanvasDocument( { kind: "html_bundle", entrypoint: { type: "html", value: "

aliased

" } }, { stateDir }, ); await link( path.join(resolveCanvasDocumentDir(stateDir, document.id), target), path.join(stateDir, `alias-${target}`), ); await expect(readCanvasDocumentHtmlSource(document.id, { stateDir })).rejects.toMatchObject({ code: "hardlink", }); }, ); it("bounds HTML reads by bytes while independently allowing the manifest", async () => { const stateDir = tempDirs.make("openclaw-canvas-bounded-"); const document = await createCanvasDocument( { kind: "html_bundle", entrypoint: { type: "html", value: "éééé" } }, { stateDir }, ); await expect( readCanvasDocumentHtmlSource(document.id, { stateDir, maxBytes: 7 }), ).rejects.toMatchObject({ code: "too-large" }); await expect( readCanvasDocumentHtmlSource(document.id, { stateDir, maxBytes: 8 }), ).resolves.toEqual({ html: "éééé" }); }); it("rejects oversized manifests before parsing them", async () => { const stateDir = tempDirs.make("openclaw-canvas-manifest-"); const document = await createCanvasDocument( { kind: "html_bundle", entrypoint: { type: "html", value: "

small

" } }, { stateDir }, ); const manifestPath = path.join( resolveCanvasDocumentDir(stateDir, document.id), "manifest.json", ); await writeFile( manifestPath, JSON.stringify({ ...document, title: "x".repeat(2 * 1024 * 1024) }), ); await expect(readCanvasDocumentHtmlSource(document.id, { stateDir })).rejects.toMatchObject({ code: "too-large", }); }); it("builds entry urls for materialized path documents under managed storage", async () => { const stateDir = tempDirs.make("openclaw-canvas-documents-"); const workspaceDir = tempDirs.make("openclaw-canvas-documents-workspace-"); await mkdir(path.join(workspaceDir, "player"), { recursive: true }); await writeFile(path.join(workspaceDir, "player/index.html"), "
ok
", "utf8"); const document = await createCanvasDocument( { kind: "html_bundle", entrypoint: { type: "path", value: "player/index.html" }, }, { stateDir, workspaceDir }, ); expect(document.entryUrl).toContain("/__openclaw__/canvas/documents/"); expect(document.localEntrypoint).toBe("index.html"); expect(resolveCanvasDocumentDir(stateDir, document.id)).toContain(stateDir); }); it("materializes inline html bundles as index documents", async () => { const stateDir = tempDirs.make("openclaw-canvas-documents-"); const document = await createCanvasDocument( { kind: "html_bundle", title: "Preview", entrypoint: { type: "html", value: "
Front
", }, }, { stateDir }, ); const indexHtml = await readFile( path.join(resolveCanvasDocumentDir(stateDir, document.id), "index.html"), "utf8", ); expect(indexHtml).toContain("
Front
"); expect(indexHtml).toContain(""); expect(document.title).toBe("Preview"); expect(document.entryUrl).toBe(`/__openclaw__/canvas/documents/${document.id}/index.html`); await expect(readCanvasDocumentHtmlSource(document.id, { stateDir })).resolves.toEqual({ html: indexHtml, }); }); it("reports the document sandbox policy alongside board source bytes", async () => { const stateDir = tempDirs.make("openclaw-canvas-documents-"); const document = await createCanvasDocument( { kind: "html_bundle", entrypoint: { type: "html", value: "" }, cspSandbox: "scripts", }, { stateDir }, ); await expect(readCanvasDocumentHtmlSource(document.id, { stateDir })).resolves.toEqual({ html: "", cspSandbox: "scripts", }); }); it("reuses a supplied stable id by replacing the prior materialized view", async () => { const stateDir = tempDirs.make("openclaw-canvas-documents-"); const first = await createCanvasDocument( { id: "status-card", kind: "html_bundle", entrypoint: { type: "html", value: "
first
" }, }, { stateDir }, ); const second = await createCanvasDocument( { id: "status-card", kind: "html_bundle", entrypoint: { type: "html", value: "
second
" }, }, { stateDir }, ); expect(first.id).toBe("status-card"); expect(second.id).toBe("status-card"); const indexHtml = await readFile( path.join(resolveCanvasDocumentDir(stateDir, second.id), "index.html"), "utf8", ); expect(indexHtml).toContain("second"); expect(indexHtml).not.toContain("first"); }); it("copies declared assets into managed storage", async () => { const stateDir = tempDirs.make("openclaw-canvas-documents-"); const workspaceDir = tempDirs.make("openclaw-canvas-documents-workspace-"); await mkdir(path.join(workspaceDir, "collection.media"), { recursive: true }); await writeFile(path.join(workspaceDir, "collection.media/audio.mp3"), "audio", "utf8"); const document = await createCanvasDocument( { kind: "html_bundle", entrypoint: { type: "html", value: "" }, assets: [ { logicalPath: "collection.media/audio.mp3", sourcePath: "collection.media/audio.mp3", contentType: "audio/mpeg", }, ], }, { stateDir, workspaceDir }, ); expect(document.assets).toEqual([ { logicalPath: "collection.media/audio.mp3", contentType: "audio/mpeg" }, ]); await expect( readFile( path.join(resolveCanvasDocumentDir(stateDir, document.id), "collection.media/audio.mp3"), "utf8", ), ).resolves.toBe("audio"); }); it("wraps local and remote PDF documents in index viewer pages", async () => { const stateDir = tempDirs.make("openclaw-canvas-documents-"); const workspaceDir = tempDirs.make("openclaw-canvas-documents-workspace-"); await writeFile(path.join(workspaceDir, "demo.pdf"), "%PDF-1.4", "utf8"); const localDocument = await createCanvasDocument( { kind: "document", entrypoint: { type: "path", value: "demo.pdf" } }, { stateDir, workspaceDir }, ); const remoteDocument = await createCanvasDocument( { kind: "document", entrypoint: { type: "url", value: "https://example.com/demo.pdf" }, }, { stateDir }, ); const localHtml = await readFile( path.join(resolveCanvasDocumentDir(stateDir, localDocument.id), "index.html"), "utf8", ); const remoteHtml = await readFile( path.join(resolveCanvasDocumentDir(stateDir, remoteDocument.id), "index.html"), "utf8", ); expect(localHtml).toContain('data="demo.pdf"'); expect(remoteHtml).toContain('data="https://example.com/demo.pdf"'); }); it("rejects traversal and malformed encoded hosted paths", async () => { const stateDir = tempDirs.make("openclaw-canvas-documents-"); expect( resolveCanvasHttpPathToLocalPath( "/__openclaw__/canvas/documents/../collection.media/index.html", { stateDir }, ), ).toBeNull(); const documentDir = resolveCanvasDocumentDir(stateDir, "cv_malformed"); await mkdir(documentDir, { recursive: true }); await writeFile(path.join(documentDir, "%E0%A4%A.html"), "literal-percent-name", "utf8"); expect( resolveCanvasHttpPathToLocalPath( "/__openclaw__/canvas/documents/cv_malformed/%E0%A4%A.html", { stateDir }, ), ).toBeNull(); expect( resolveCanvasHttpPathToLocalPath( "/__openclaw__/canvas/documents/cv_malformed/%25E0%25A4%25A.html", { stateDir }, ), ).toBe(path.join(documentDir, "%E0%A4%A.html")); }); });