diff --git a/extensions/canvas/scripts/copy-a2ui.mjs b/extensions/canvas/scripts/copy-a2ui.mjs index 616a4cb0c88e..3979e71b75f4 100644 --- a/extensions/canvas/scripts/copy-a2ui.mjs +++ b/extensions/canvas/scripts/copy-a2ui.mjs @@ -20,8 +20,27 @@ function shouldSkipMissingA2uiAssets(env = process.env) { return env.OPENCLAW_A2UI_SKIP_MISSING === "1" || Boolean(env.OPENCLAW_SPARSE_PROFILE); } +function isRelativeWithin(relPath) { + return ( + relPath === "" || + (relPath !== ".." && !relPath.startsWith(`..${path.sep}`) && !path.isAbsolute(relPath)) + ); +} + +function pathsOverlap(leftDir, rightDir) { + const left = path.resolve(leftDir); + const right = path.resolve(rightDir); + return ( + isRelativeWithin(path.relative(left, right)) || isRelativeWithin(path.relative(right, left)) + ); +} + /** Copies A2UI assets, optionally tolerating missing bundles in sparse builds. */ export async function copyA2uiAssets({ srcDir, outDir }) { + if (pathsOverlap(srcDir, outDir)) { + throw new Error("A2UI source and output directories must not overlap."); + } + const skipMissing = shouldSkipMissingA2uiAssets(process.env); try { await fs.stat(path.join(srcDir, "index.html")); diff --git a/extensions/canvas/scripts/copy-a2ui.test.ts b/extensions/canvas/scripts/copy-a2ui.test.ts index 813129dad9e6..aae0756dc224 100644 --- a/extensions/canvas/scripts/copy-a2ui.test.ts +++ b/extensions/canvas/scripts/copy-a2ui.test.ts @@ -37,9 +37,9 @@ describe("canvas a2ui copy", () => { it("throws a helpful error when assets are missing", async () => { await withA2uiFixture(async (dir) => { - await expect(copyA2uiAssets({ srcDir: dir, outDir: path.join(dir, "out") })).rejects.toThrow( - 'Run "pnpm canvas:a2ui:bundle"', - ); + await expect( + copyA2uiAssets({ srcDir: path.join(dir, "src"), outDir: path.join(dir, "out") }), + ).rejects.toThrow('Run "pnpm canvas:a2ui:bundle"'); }); }); @@ -47,7 +47,7 @@ describe("canvas a2ui copy", () => { await withA2uiFixture(async (dir) => { process.env.OPENCLAW_A2UI_SKIP_MISSING = "1"; await expect( - copyA2uiAssets({ srcDir: dir, outDir: path.join(dir, "out") }), + copyA2uiAssets({ srcDir: path.join(dir, "src"), outDir: path.join(dir, "out") }), ).resolves.toBeUndefined(); }); }); @@ -56,7 +56,7 @@ describe("canvas a2ui copy", () => { await withA2uiFixture(async (dir) => { process.env.OPENCLAW_SPARSE_PROFILE = "core"; await expect( - copyA2uiAssets({ srcDir: dir, outDir: path.join(dir, "out") }), + copyA2uiAssets({ srcDir: path.join(dir, "src"), outDir: path.join(dir, "out") }), ).resolves.toBeUndefined(); }); }); @@ -102,4 +102,21 @@ describe("canvas a2ui copy", () => { }); }); }); + + it("rejects overlapping source and output directories before cleaning output", async () => { + await withA2uiFixture(async (dir) => { + const srcDir = path.join(dir, "src"); + await fs.mkdir(srcDir, { recursive: true }); + await fs.writeFile(path.join(srcDir, "index.html"), "", "utf8"); + await fs.writeFile(path.join(srcDir, "a2ui.bundle.js"), "console.log(1);", "utf8"); + + await expect(copyA2uiAssets({ srcDir, outDir: srcDir })).rejects.toThrow("must not overlap"); + await expect(fs.readFile(path.join(srcDir, "index.html"), "utf8")).resolves.toBe( + "", + ); + await expect(copyA2uiAssets({ srcDir, outDir: path.join(srcDir, "dist") })).rejects.toThrow( + "must not overlap", + ); + }); + }); });