mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
test(canvas): isolate A2UI host fixtures
This commit is contained in:
@@ -23,6 +23,8 @@ let resolvingA2uiRoot: Promise<string | null> | null = null;
|
||||
let cachedA2uiResolvedAtMs = 0;
|
||||
const A2UI_ROOT_RETRY_NULL_AFTER_MS = 10_000;
|
||||
|
||||
type A2uiRootResolver = () => Promise<string | null>;
|
||||
|
||||
async function resolveA2uiRoot(): Promise<string | null> {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const entryDir = process.argv[1] ? path.dirname(path.resolve(process.argv[1])) : null;
|
||||
@@ -80,10 +82,10 @@ async function resolveA2uiRootReal(): Promise<string | null> {
|
||||
return resolvingA2uiRoot;
|
||||
}
|
||||
|
||||
/** Handles one HTTP request for the hosted A2UI asset surface. */
|
||||
export async function handleA2uiHttpRequest(
|
||||
async function handleA2uiHttpRequestWithRootResolver(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
resolveRootReal: A2uiRootResolver,
|
||||
): Promise<boolean> {
|
||||
const urlRaw = req.url;
|
||||
if (!urlRaw) {
|
||||
@@ -103,7 +105,7 @@ export async function handleA2uiHttpRequest(
|
||||
return true;
|
||||
}
|
||||
|
||||
const a2uiRootReal = await resolveA2uiRootReal();
|
||||
const a2uiRootReal = await resolveRootReal();
|
||||
if (!a2uiRootReal) {
|
||||
res.statusCode = 503;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
@@ -148,3 +150,22 @@ export async function handleA2uiHttpRequest(
|
||||
await result.handle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates an HTTP handler for a specific hosted A2UI asset root. */
|
||||
export function createA2uiHttpRequestHandler(params: {
|
||||
rootDir: string;
|
||||
}): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
|
||||
let rootRealPromise: Promise<string> | null = null;
|
||||
return async (req, res) => {
|
||||
rootRealPromise ??= fs.realpath(params.rootDir);
|
||||
return await handleA2uiHttpRequestWithRootResolver(req, res, async () => await rootRealPromise);
|
||||
};
|
||||
}
|
||||
|
||||
/** Handles one HTTP request for the hosted A2UI asset surface. */
|
||||
export async function handleA2uiHttpRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<boolean> {
|
||||
return await handleA2uiHttpRequestWithRootResolver(req, res, resolveA2uiRootReal);
|
||||
}
|
||||
|
||||
@@ -108,9 +108,13 @@ async function captureHandlerResponse(
|
||||
return await captureHttpResponse(handler.handleHttpRequest, url, method);
|
||||
}
|
||||
|
||||
async function captureA2uiResponse(url: string, method = "GET"): Promise<CapturedResponse> {
|
||||
const { handleA2uiHttpRequest } = await import("./a2ui.js");
|
||||
return await captureHttpResponse(handleA2uiHttpRequest, url, method);
|
||||
async function captureA2uiFixtureResponse(
|
||||
rootDir: string,
|
||||
url: string,
|
||||
method = "GET",
|
||||
): Promise<CapturedResponse> {
|
||||
const { createA2uiHttpRequestHandler } = await import("./a2ui.js");
|
||||
return await captureHttpResponse(createA2uiHttpRequestHandler({ rootDir }), url, method);
|
||||
}
|
||||
|
||||
describe("canvas host", () => {
|
||||
@@ -439,46 +443,56 @@ describe("canvas host", () => {
|
||||
});
|
||||
|
||||
it("serves A2UI scaffold and blocks traversal/symlink escapes", async () => {
|
||||
const a2uiRoot = path.resolve(process.cwd(), "extensions/canvas/src/host/a2ui");
|
||||
const bundlePath = path.join(a2uiRoot, "a2ui.bundle.js");
|
||||
const a2uiRoot = await createCaseDir();
|
||||
const nestedAssetDir = path.join(a2uiRoot, "assets", "demo");
|
||||
const linkName = `test-link-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`;
|
||||
const linkPath = path.join(a2uiRoot, linkName);
|
||||
let createdBundle = false;
|
||||
|
||||
try {
|
||||
await fs.stat(bundlePath);
|
||||
} catch {
|
||||
await fs.writeFile(bundlePath, "window.openclawA2UI = {};", "utf8");
|
||||
createdBundle = true;
|
||||
}
|
||||
|
||||
await fs.mkdir(nestedAssetDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(a2uiRoot, "index.html"),
|
||||
`<openclaw-a2ui-host></openclaw-a2ui-host>
|
||||
<script>openclawCanvasA2UIAction</script>`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(path.join(a2uiRoot, "a2ui.bundle.js"), "window.openclawA2UI = {};", "utf8");
|
||||
await fs.writeFile(path.join(nestedAssetDir, "sample.txt"), "nested asset", "utf8");
|
||||
await fs.symlink(path.join(process.cwd(), "package.json"), linkPath);
|
||||
|
||||
try {
|
||||
const res = await captureA2uiResponse(`${A2UI_PATH}/`);
|
||||
const res = await captureA2uiFixtureResponse(a2uiRoot, `${A2UI_PATH}/`);
|
||||
const html = res.body;
|
||||
expect(res.status).toBe(200);
|
||||
expect(html).toContain("openclaw-a2ui-host");
|
||||
expect(html).toContain("openclawCanvasA2UIAction");
|
||||
|
||||
const bundleRes = await captureA2uiResponse(`${A2UI_PATH}/a2ui.bundle.js`);
|
||||
const bundleRes = await captureA2uiFixtureResponse(a2uiRoot, `${A2UI_PATH}/a2ui.bundle.js`);
|
||||
const js = bundleRes.body;
|
||||
expect(bundleRes.status).toBe(200);
|
||||
expect(js).toContain("openclawA2UI");
|
||||
const traversalRes = await captureA2uiResponse(`${A2UI_PATH}/%2e%2e%2fpackage.json`);
|
||||
|
||||
const assetRes = await captureA2uiFixtureResponse(
|
||||
a2uiRoot,
|
||||
`${A2UI_PATH}/assets/demo/sample.txt`,
|
||||
);
|
||||
expect(assetRes.status).toBe(200);
|
||||
expect(assetRes.headers["content-type"]).toBe("text/plain");
|
||||
expect(assetRes.body).toBe("nested asset");
|
||||
|
||||
const traversalRes = await captureA2uiFixtureResponse(
|
||||
a2uiRoot,
|
||||
`${A2UI_PATH}/%2e%2e%2fpackage.json`,
|
||||
);
|
||||
expect(traversalRes.status).toBe(404);
|
||||
expect(traversalRes.body).toBe("not found");
|
||||
const malformedRes = await captureA2uiResponse(`${A2UI_PATH}/%E0%A4%A`);
|
||||
const malformedRes = await captureA2uiFixtureResponse(a2uiRoot, `${A2UI_PATH}/%E0%A4%A`);
|
||||
expect(malformedRes.status).toBe(404);
|
||||
expect(malformedRes.body).toBe("not found");
|
||||
const symlinkRes = await captureA2uiResponse(`${A2UI_PATH}/${linkName}`);
|
||||
const symlinkRes = await captureA2uiFixtureResponse(a2uiRoot, `${A2UI_PATH}/${linkName}`);
|
||||
expect(symlinkRes.status).toBe(404);
|
||||
expect(symlinkRes.body).toBe("not found");
|
||||
} finally {
|
||||
await fs.rm(linkPath, { force: true });
|
||||
if (createdBundle) {
|
||||
await fs.rm(bundlePath, { force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user