Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
/**
* Canvas CLI metadata entrypoint used for lightweight command discovery.
*/
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
export default definePluginEntry({
id: "canvas",
name: "Canvas",
description: "Experimental Canvas control and A2UI rendering surfaces for paired nodes.",
register(api) {
api.registerNodeCliFeature(() => {}, {
descriptors: [
{
name: "canvas",
description: "Capture or render canvas content from a paired node",
hasSubcommands: true,
},
],
});
},
});

View File

@@ -0,0 +1,138 @@
// Canvas tests cover index plugin behavior.
import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { beforeEach, describe, expect, it, vi } from "vitest";
import canvasPlugin from "./index.js";
const mocks = vi.hoisted(() => {
const httpHandler = {
handleHttpRequest: vi.fn(async () => true),
handleUpgrade: vi.fn(async () => true),
close: vi.fn(async () => {}),
};
const toolExecute = vi.fn(async () => ({ content: [{ type: "text", text: "ok" }] }));
return {
httpHandler,
createCanvasHttpRouteHandler: vi.fn(() => httpHandler),
resolveCanvasHttpPathToLocalPath: vi.fn(() => "/tmp/canvas-asset"),
createDefaultCanvasCliDependencies: vi.fn(() => ({ deps: true })),
registerNodesCanvasCommands: vi.fn(),
toolExecute,
createCanvasTool: vi.fn(() => ({
label: "Canvas",
name: "canvas",
description: "Canvas",
parameters: {},
execute: toolExecute,
})),
};
});
vi.mock("./src/http-route.js", () => ({
createCanvasHttpRouteHandler: mocks.createCanvasHttpRouteHandler,
}));
vi.mock("./src/documents.js", () => ({
resolveCanvasHttpPathToLocalPath: mocks.resolveCanvasHttpPathToLocalPath,
}));
vi.mock("./src/cli.js", () => ({
createDefaultCanvasCliDependencies: mocks.createDefaultCanvasCliDependencies,
registerNodesCanvasCommands: mocks.registerNodesCanvasCommands,
}));
vi.mock("./src/tool.js", () => ({
createCanvasTool: mocks.createCanvasTool,
}));
function registerCanvas() {
const routes: Array<Parameters<OpenClawPluginApi["registerHttpRoute"]>[0]> = [];
const services: Array<Parameters<OpenClawPluginApi["registerService"]>[0]> = [];
const resolvers: Array<Parameters<OpenClawPluginApi["registerHostedMediaResolver"]>[0]> = [];
const tools: Array<Parameters<OpenClawPluginApi["registerTool"]>[0]> = [];
const cliFeatures: Array<{
registrar: Parameters<OpenClawPluginApi["registerNodeCliFeature"]>[0];
opts: Parameters<OpenClawPluginApi["registerNodeCliFeature"]>[1];
}> = [];
canvasPlugin.register?.(
createTestPluginApi({
id: "canvas",
name: "Canvas",
config: {},
registerHttpRoute: (route) => routes.push(route),
registerService: (service) => services.push(service),
registerHostedMediaResolver: (resolver) => resolvers.push(resolver),
registerTool: (tool) => tools.push(tool),
registerNodeCliFeature: (registrar, opts) => cliFeatures.push({ registrar, opts }),
registerNodeInvokePolicy: vi.fn(),
}),
);
return { routes, services, resolvers, tools, cliFeatures };
}
describe("Canvas plugin entry", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("defers Canvas host implementation until a registered route is used", async () => {
const { routes, services } = registerCanvas();
expect(routes).toHaveLength(3);
expect(services).toHaveLength(1);
expect(mocks.createCanvasHttpRouteHandler).not.toHaveBeenCalled();
await services[0]?.stop?.({} as never);
expect(mocks.createCanvasHttpRouteHandler).not.toHaveBeenCalled();
await routes[0]?.handler({ url: "/__openclaw__/canvas" } as never, {} as never);
expect(mocks.createCanvasHttpRouteHandler).toHaveBeenCalledTimes(1);
expect(mocks.httpHandler.handleHttpRequest).toHaveBeenCalledTimes(1);
await services[0]?.stop?.({} as never);
expect(mocks.httpHandler.close).toHaveBeenCalledTimes(1);
});
it("defers Canvas resolver, CLI, and tool implementations until use", async () => {
const { resolvers, tools, cliFeatures } = registerCanvas();
expect(resolvers).toHaveLength(1);
expect(tools).toHaveLength(1);
expect(cliFeatures).toHaveLength(1);
expect(mocks.resolveCanvasHttpPathToLocalPath).not.toHaveBeenCalled();
expect(mocks.createDefaultCanvasCliDependencies).not.toHaveBeenCalled();
expect(mocks.createCanvasTool).not.toHaveBeenCalled();
await expect(resolvers[0]?.("/__openclaw__/canvas/documents/id/index.html")).resolves.toBe(
"/tmp/canvas-asset",
);
expect(mocks.resolveCanvasHttpPathToLocalPath).toHaveBeenCalledTimes(1);
await cliFeatures[0]?.registrar({
program: {} as never,
parentPath: ["nodes"],
config: {},
workspaceDir: undefined,
logger: { info() {}, warn() {}, error() {}, debug() {} },
});
expect(mocks.createDefaultCanvasCliDependencies).toHaveBeenCalledTimes(1);
expect(mocks.registerNodesCanvasCommands).toHaveBeenCalledTimes(1);
const toolFactory = tools[0];
expect(typeof toolFactory).toBe("function");
const tool = (toolFactory as Exclude<typeof toolFactory, AnyAgentTool>)({
config: {},
workspaceDir: "/tmp/workspace",
});
expect(Array.isArray(tool)).toBe(false);
expect((tool as AnyAgentTool).name).toBe("canvas");
expect(mocks.createCanvasTool).not.toHaveBeenCalled();
await (tool as AnyAgentTool).execute("tool-call", { action: "hide" });
expect(mocks.createCanvasTool).toHaveBeenCalledWith({
config: {},
workspaceDir: "/tmp/workspace",
});
expect(mocks.toolExecute).toHaveBeenCalledWith("tool-call", { action: "hide" });
});
});

147
extensions/canvas/index.ts Normal file
View File

@@ -0,0 +1,147 @@
/**
* Canvas plugin entrypoint for node canvas control, hosted A2UI routes, and
* node CLI registration.
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import type { Duplex } from "node:stream";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { definePluginEntry, type AnyAgentTool } from "openclaw/plugin-sdk/plugin-entry";
import { canvasConfigSchema, isCanvasHostEnabled } from "./src/config.js";
import { A2UI_PATH, CANVAS_HOST_PATH, CANVAS_WS_PATH } from "./src/host/a2ui-shared.js";
import { CanvasToolSchema } from "./src/tool-schema.js";
const CANVAS_NODE_COMMANDS = [
"canvas.present",
"canvas.hide",
"canvas.navigate",
"canvas.eval",
"canvas.snapshot",
"canvas.a2ui.push",
"canvas.a2ui.pushJSONL",
"canvas.a2ui.reset",
];
function createLazyCanvasTool(params: {
config?: OpenClawConfig;
workspaceDir?: string;
}): AnyAgentTool {
const loadTool = createLazyRuntimeModule(() =>
import("./src/tool.js").then(({ createCanvasTool }) =>
createCanvasTool({
config: params.config,
workspaceDir: params.workspaceDir,
}),
),
);
return {
label: "Canvas",
name: "canvas",
description:
"Control node canvases (present/hide/navigate/eval/snapshot/A2UI). Use snapshot to capture the rendered UI.",
parameters: CanvasToolSchema,
execute: async (...args: Parameters<AnyAgentTool["execute"]>) =>
await (await loadTool()).execute(...args),
};
}
export default definePluginEntry({
id: "canvas",
name: "Canvas",
description: "Experimental Canvas control and A2UI rendering surfaces for paired nodes.",
configSchema: canvasConfigSchema,
reload: {
restartPrefixes: ["plugins.enabled", "plugins.allow", "plugins.deny", "plugins.entries.canvas"],
},
register(api) {
if (isCanvasHostEnabled(api.config)) {
const httpRouteHandlerLoader = createLazyRuntimeModule(() =>
import("./src/http-route.js").then(({ createCanvasHttpRouteHandler }) =>
createCanvasHttpRouteHandler({
config: api.config,
pluginConfig: api.pluginConfig,
runtime: {
log: (...args) => api.logger.info(args.map(String).join(" ")),
error: (...args) => api.logger.error(args.map(String).join(" ")),
exit: (code) => {
throw new Error(`canvas host requested process exit ${code}`);
},
},
}),
),
);
const loadHttpRouteHandler = httpRouteHandlerLoader;
const handleHttpRequest = async (req: IncomingMessage, res: ServerResponse) =>
await (await loadHttpRouteHandler()).handleHttpRequest(req, res);
const handleUpgrade = async (req: IncomingMessage, socket: Duplex, head: Buffer) =>
await (await loadHttpRouteHandler()).handleUpgrade(req, socket, head);
const nodeCapability = { surface: "canvas" };
api.registerHttpRoute({
path: A2UI_PATH,
auth: "plugin",
match: "prefix",
nodeCapability,
handler: handleHttpRequest,
});
api.registerHttpRoute({
path: CANVAS_HOST_PATH,
auth: "plugin",
match: "prefix",
nodeCapability,
handler: handleHttpRequest,
});
api.registerHttpRoute({
path: CANVAS_WS_PATH,
auth: "plugin",
match: "exact",
nodeCapability,
handler: handleHttpRequest,
handleUpgrade,
});
api.registerService({
id: "canvas-host",
start: () => {},
stop: async () => {
const httpRouteHandler = await httpRouteHandlerLoader.peek();
await httpRouteHandler?.close();
},
});
const loadResolveCanvasHttpPathToLocalPath = createLazyRuntimeModule(() =>
import("./src/documents.js").then(
({ resolveCanvasHttpPathToLocalPath }) => resolveCanvasHttpPathToLocalPath,
),
);
api.registerHostedMediaResolver(async (mediaUrl) => {
return (await loadResolveCanvasHttpPathToLocalPath())(mediaUrl);
});
}
api.registerNodeInvokePolicy({
commands: CANVAS_NODE_COMMANDS,
defaultPlatforms: ["ios", "android", "macos", "windows", "unknown"],
foregroundRestrictedOnIos: true,
handle: (ctx) => ctx.invokeNode(),
});
api.registerTool((ctx) =>
createLazyCanvasTool({
config: ctx.runtimeConfig ?? ctx.config,
workspaceDir: ctx.workspaceDir,
}),
);
api.registerNodeCliFeature(
async ({ program }) => {
const { createDefaultCanvasCliDependencies, registerNodesCanvasCommands } =
await import("./src/cli.js");
registerNodesCanvasCommands(program, createDefaultCanvasCliDependencies());
},
{
descriptors: [
{
name: "canvas",
description: "Capture or render canvas content from a paired node",
hasSubcommands: true,
},
],
},
);
},
});

View File

@@ -0,0 +1,41 @@
{
"id": "canvas",
"activation": {
"onStartup": true
},
"enabledByDefault": true,
"name": "Canvas",
"description": "Experimental Canvas control and A2UI rendering surfaces for paired nodes.",
"skills": ["./skills"],
"contracts": {
"tools": ["canvas"]
},
"configContracts": {
"compatibilityMigrationPaths": ["canvasHost"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"host": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"root": {
"type": "string"
},
"port": {
"type": "integer",
"minimum": 1
},
"liveReload": {
"type": "boolean"
}
}
}
}
}
}

View File

@@ -0,0 +1,27 @@
{
"name": "@openclaw/canvas-plugin",
"version": "2026.6.11",
"private": true,
"description": "OpenClaw Canvas plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"dependencies": {
"@a2ui/lit": "0.10.1",
"@lit/context": "1.1.6",
"chokidar": "5.0.0",
"lit": "3.3.3",
"typebox": "1.3.3",
"ws": "8.21.0"
},
"openclaw": {
"extensions": [
"./index.ts"
],
"assetScripts": {
"build": "node scripts/bundle-a2ui.mjs",
"copy": "node scripts/copy-a2ui.mjs"
}
}
}

View File

@@ -0,0 +1,46 @@
/**
* Runtime API exports for Canvas plugin host, document, CLI, and capability
* helpers.
*/
export {
canvasConfigSchema,
isCanvasHostEnabled,
isCanvasPluginEnabled,
parseCanvasPluginConfig,
resolveCanvasHostConfig,
type CanvasHostConfig,
type CanvasPluginConfig,
} from "./src/config.js";
export {
A2UI_PATH,
CANVAS_HOST_PATH,
CANVAS_WS_PATH,
handleA2uiHttpRequest,
} from "./src/host/a2ui.js";
export {
createCanvasHostHandler,
startCanvasHost,
type CanvasHostHandler,
type CanvasHostServer,
} from "./src/host/server.js";
export {
buildCanvasDocumentEntryUrl,
createCanvasDocument,
resolveCanvasDocumentAssets,
resolveCanvasDocumentDir,
resolveCanvasHttpPathToLocalPath,
} from "./src/documents.js";
export {
registerNodesCanvasCommands,
type CanvasCliDependencies,
type CanvasNodesRpcOpts,
} from "./src/cli.js";
export { canvasSnapshotTempPath, parseCanvasSnapshotPayload } from "./src/cli-helpers.js";
export {
buildCanvasScopedHostUrl,
CANVAS_CAPABILITY_PATH_PREFIX,
CANVAS_CAPABILITY_TTL_MS,
mintCanvasCapabilityToken,
normalizeCanvasScopedUrl,
} from "./src/capability.js";
export { resolveCanvasHostUrl } from "./src/host-url.js";

View File

@@ -0,0 +1,4 @@
export declare function isBundleHashInputPath(filePath: string, repoRoot?: string): boolean;
export declare function getLocalRolldownCliCandidates(repoRoot?: string): string[];
export declare function getBundleHashRepoInputPaths(repoRoot?: string): string[];
export declare function compareNormalizedPaths(left: string, right: string): number;

View File

@@ -0,0 +1,235 @@
#!/usr/bin/env node
/**
* Bundles the Canvas A2UI web app and writes a hash for tracked inputs.
*/
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { resolvePnpmRunner } from "./pnpm-runner.mjs";
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const rootDir = path.resolve(pluginDir, "../..");
const require = createRequire(import.meta.url);
const hashFile =
process.env.OPENCLAW_A2UI_BUNDLE_HASH_FILE ??
path.join(pluginDir, "src", "host", "a2ui", ".bundle.hash");
const outputFile =
process.env.OPENCLAW_A2UI_BUNDLE_OUT ??
path.join(pluginDir, "src", "host", "a2ui", "a2ui.bundle.js");
const a2uiAppDir = path.join(pluginDir, "src", "host", "a2ui-app");
const repoInputPaths = getBundleHashRepoInputPaths(rootDir);
const relativeRepoInputPaths = repoInputPaths.map((inputPath) =>
normalizePath(path.relative(rootDir, inputPath)),
);
function fail(message) {
console.error(message);
console.error("A2UI bundling failed. Re-run with: pnpm canvas:a2ui:bundle");
console.error("If this persists, verify pnpm deps and try again.");
process.exit(1);
}
async function pathExists(targetPath) {
try {
await fs.stat(targetPath);
return true;
} catch {
return false;
}
}
function normalizePath(filePath) {
return filePath.split(path.sep).join("/");
}
/** Returns whether a path should participate in the A2UI bundle input hash. */
export function isBundleHashInputPath(filePath, repoRoot = rootDir) {
return Boolean(filePath && repoRoot);
}
/** Returns local Rolldown CLI candidates for the current install layout. */
export function getLocalRolldownCliCandidates(repoRoot = rootDir) {
return [
path.join(repoRoot, "node_modules", "rolldown", "bin", "cli.mjs"),
path.join(repoRoot, "node_modules", ".pnpm", "node_modules", "rolldown", "bin", "cli.mjs"),
path.join(
repoRoot,
"node_modules",
".pnpm",
"rolldown@1.0.0-rc.12",
"node_modules",
"rolldown",
"bin",
"cli.mjs",
),
];
}
/** Returns repository paths that define the A2UI bundle hash inputs. */
export function getBundleHashRepoInputPaths(repoRoot = rootDir) {
return [
path.join(repoRoot, "package.json"),
path.join(repoRoot, "pnpm-lock.yaml"),
path.join(repoRoot, "extensions", "canvas", "src", "host", "a2ui-app"),
];
}
/** Compares paths after normalizing separators to POSIX slashes. */
export function compareNormalizedPaths(left, right) {
const normalizedLeft = normalizePath(left);
const normalizedRight = normalizePath(right);
if (normalizedLeft < normalizedRight) {
return -1;
}
if (normalizedLeft > normalizedRight) {
return 1;
}
return 0;
}
async function walkFiles(entryPath, files) {
if (!isBundleHashInputPath(entryPath)) {
return;
}
const stat = await fs.stat(entryPath);
if (!stat.isDirectory()) {
files.push(entryPath);
return;
}
const entries = await fs.readdir(entryPath);
for (const entry of entries) {
await walkFiles(path.join(entryPath, entry), files);
}
}
function listTrackedInputFiles() {
const result = spawnSync("git", ["ls-files", "--", ...relativeRepoInputPaths], {
cwd: rootDir,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
return null;
}
const trackedFiles = result.stdout
.split("\n")
.filter(Boolean)
.map((filePath) => path.join(rootDir, filePath))
.filter((filePath) => existsSync(filePath))
.filter((filePath) => isBundleHashInputPath(filePath));
return trackedFiles;
}
async function computeHash() {
let files = listTrackedInputFiles();
if (!files) {
files = [];
for (const inputPath of getBundleHashRepoInputPaths(rootDir)) {
await walkFiles(inputPath, files);
}
}
files = [...new Set(files)].toSorted(compareNormalizedPaths);
const hash = createHash("sha256");
for (const filePath of files) {
hash.update(normalizePath(path.relative(rootDir, filePath)));
hash.update("\0");
hash.update(await fs.readFile(filePath));
hash.update("\0");
}
return hash.digest("hex");
}
function runStep(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: rootDir,
env: process.env,
stdio: "inherit",
...options,
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
function runPnpm(pnpmArgs) {
const runner = resolvePnpmRunner({
pnpmArgs,
nodeExecPath: process.execPath,
npmExecPath: process.env.npm_execpath,
comSpec: process.env.ComSpec,
platform: process.platform,
});
runStep(runner.command, runner.args, {
shell: runner.shell,
windowsVerbatimArguments: runner.windowsVerbatimArguments,
});
}
async function main() {
const hasAppDir = await pathExists(a2uiAppDir);
const hasOutputFile = await pathExists(outputFile);
let hasA2uiPackage = true;
try {
require.resolve("@a2ui/lit");
require.resolve("@a2ui/lit/ui");
} catch {
hasA2uiPackage = false;
}
if (!hasA2uiPackage || !hasAppDir) {
if (hasOutputFile) {
console.log("A2UI package missing; keeping prebuilt bundle.");
return;
}
if (process.env.OPENCLAW_SPARSE_PROFILE || process.env.OPENCLAW_A2UI_SKIP_MISSING === "1") {
console.error(
"A2UI package missing; skipping bundle because OPENCLAW_A2UI_SKIP_MISSING=1 or OPENCLAW_SPARSE_PROFILE is set.",
);
return;
}
fail(`A2UI package missing and no prebuilt bundle found at: ${outputFile}`);
}
const currentHash = await computeHash();
if (await pathExists(hashFile)) {
const previousHash = (await fs.readFile(hashFile, "utf8")).trim();
if (previousHash === currentHash && hasOutputFile) {
console.log("A2UI bundle up to date; skipping.");
return;
}
}
const localRolldownCliCandidates = getLocalRolldownCliCandidates(rootDir);
const localRolldownCli = (
await Promise.all(
localRolldownCliCandidates.map(async (candidate) =>
(await pathExists(candidate)) ? candidate : null,
),
)
).find(Boolean);
if (localRolldownCli) {
runStep(process.execPath, [
localRolldownCli,
"-c",
path.join(a2uiAppDir, "rolldown.config.mjs"),
]);
} else {
runPnpm(["-s", "exec", "rolldown", "-c", path.join(a2uiAppDir, "rolldown.config.mjs")]);
}
await fs.writeFile(hashFile, `${currentHash}\n`, "utf8");
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main().catch(
/** @param {unknown} error */ (error) => {
fail(error instanceof Error ? error.message : String(error));
},
);
}

View File

@@ -0,0 +1,61 @@
// Canvas tests cover bundle a2ui plugin behavior.
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
compareNormalizedPaths,
getBundleHashRepoInputPaths,
getLocalRolldownCliCandidates,
isBundleHashInputPath,
} from "./bundle-a2ui.mjs";
describe("scripts/bundle-a2ui.mjs", () => {
it("uses package metadata and plugin-owned A2UI sources as bundle hash inputs", () => {
const repoRoot = path.resolve("repo-root");
const inputPaths = getBundleHashRepoInputPaths(repoRoot);
expect(inputPaths).toContain(path.join(repoRoot, "package.json"));
expect(inputPaths).toContain(path.join(repoRoot, "pnpm-lock.yaml"));
expect(inputPaths).toContain(
path.join(repoRoot, "extensions", "canvas", "src", "host", "a2ui-app"),
);
expect(inputPaths).not.toContain(path.join(repoRoot, "vendor", "a2ui", "renderers", "lit"));
expect(isBundleHashInputPath(path.join(repoRoot, "package.json"), repoRoot)).toBe(true);
});
it("prefers the installed rolldown CLI over a network dlx fallback", () => {
const repoRoot = path.resolve("repo-root");
expect(getLocalRolldownCliCandidates(repoRoot)[0]).toBe(
path.join(repoRoot, "node_modules", "rolldown", "bin", "cli.mjs"),
);
});
it("sorts hash inputs without locale-dependent collation", () => {
const paths = ["repo/Z.ts", "repo/a.ts", "repo/ä.ts", "repo/A.ts"];
expect([...paths].toSorted(compareNormalizedPaths)).toEqual([
"repo/A.ts",
"repo/Z.ts",
"repo/a.ts",
"repo/ä.ts",
]);
});
it("keeps unrelated package metadata out of bundle hash inputs", () => {
const repoRoot = path.resolve("repo-root");
const inputPaths = getBundleHashRepoInputPaths(repoRoot);
expect(inputPaths).not.toContain(path.join(repoRoot, "ui", "package.json"));
expect(inputPaths).not.toContain(path.join(repoRoot, "packages", "plugin-sdk", "package.json"));
});
it("keeps local node_modules state out of bundle hash inputs", () => {
const repoRoot = process.cwd();
const inputPaths = getBundleHashRepoInputPaths(repoRoot);
expect(inputPaths).not.toContain(path.join(repoRoot, "node_modules", "lit", "package.json"));
expect(inputPaths).not.toContain(
path.join(repoRoot, "ui", "node_modules", "lit", "package.json"),
);
});
});

View File

@@ -0,0 +1 @@
export declare function copyA2uiAssets(params: { srcDir: string; outDir: string }): Promise<void>;

View File

@@ -0,0 +1,75 @@
#!/usr/bin/env node
/**
* Copies bundled Canvas A2UI assets into the dist host asset directory.
*/
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const rootDir = path.resolve(pluginDir, "../..");
function getA2uiPaths(env = process.env) {
const srcDir = env.OPENCLAW_A2UI_SRC_DIR ?? path.join(pluginDir, "src", "host", "a2ui");
const outDir = env.OPENCLAW_A2UI_OUT_DIR ?? path.join(rootDir, "dist", "canvas-host", "a2ui");
return { srcDir, outDir };
}
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"));
await fs.stat(path.join(srcDir, "a2ui.bundle.js"));
} catch (err) {
const message = 'Missing A2UI bundle assets. Run "pnpm canvas:a2ui:bundle" and retry.';
if (skipMissing) {
console.warn(
`${message} Skipping copy because OPENCLAW_A2UI_SKIP_MISSING=1 or OPENCLAW_SPARSE_PROFILE is set.`,
);
return;
}
throw new Error(message, { cause: err });
}
await fs.mkdir(path.dirname(outDir), { recursive: true });
await fs.rm(outDir, { recursive: true, force: true });
await fs.cp(srcDir, outDir, { recursive: true });
}
async function main() {
const { srcDir, outDir } = getA2uiPaths();
await copyA2uiAssets({ srcDir, outDir });
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
main().catch(
/** @param {unknown} err */ (err) => {
console.error(String(err));
process.exit(1);
},
);
}

View File

@@ -0,0 +1,122 @@
// Canvas tests cover copy a2ui plugin behavior.
import fs from "node:fs/promises";
import path from "node:path";
import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { copyA2uiAssets } from "./copy-a2ui.mjs";
const ORIGINAL_SKIP_MISSING = process.env.OPENCLAW_A2UI_SKIP_MISSING;
const ORIGINAL_SPARSE_PROFILE = process.env.OPENCLAW_SPARSE_PROFILE;
describe("canvas a2ui copy", () => {
beforeEach(() => {
delete process.env.OPENCLAW_A2UI_SKIP_MISSING;
delete process.env.OPENCLAW_SPARSE_PROFILE;
});
afterEach(() => {
if (ORIGINAL_SKIP_MISSING === undefined) {
delete process.env.OPENCLAW_A2UI_SKIP_MISSING;
} else {
process.env.OPENCLAW_A2UI_SKIP_MISSING = ORIGINAL_SKIP_MISSING;
}
if (ORIGINAL_SPARSE_PROFILE === undefined) {
delete process.env.OPENCLAW_SPARSE_PROFILE;
} else {
process.env.OPENCLAW_SPARSE_PROFILE = ORIGINAL_SPARSE_PROFILE;
}
});
async function withA2uiFixture(run: (dir: string) => Promise<void>) {
await withTempWorkspace(
{ rootDir: resolvePreferredOpenClawTmpDir(), prefix: "openclaw-a2ui-" },
async ({ dir }) => await run(dir),
);
}
it("throws a helpful error when assets are missing", async () => {
await withA2uiFixture(async (dir) => {
await expect(
copyA2uiAssets({ srcDir: path.join(dir, "src"), outDir: path.join(dir, "out") }),
).rejects.toThrow('Run "pnpm canvas:a2ui:bundle"');
});
});
it("skips missing assets when OPENCLAW_A2UI_SKIP_MISSING=1", async () => {
await withA2uiFixture(async (dir) => {
process.env.OPENCLAW_A2UI_SKIP_MISSING = "1";
await expect(
copyA2uiAssets({ srcDir: path.join(dir, "src"), outDir: path.join(dir, "out") }),
).resolves.toBeUndefined();
});
});
it("skips missing assets when OPENCLAW_SPARSE_PROFILE is set", async () => {
await withA2uiFixture(async (dir) => {
process.env.OPENCLAW_SPARSE_PROFILE = "core";
await expect(
copyA2uiAssets({ srcDir: path.join(dir, "src"), outDir: path.join(dir, "out") }),
).resolves.toBeUndefined();
});
});
it("copies bundled assets to dist", async () => {
await withA2uiFixture(async (dir) => {
const srcDir = path.join(dir, "src");
const outDir = path.join(dir, "dist");
await fs.mkdir(srcDir, { recursive: true });
await fs.writeFile(path.join(srcDir, "index.html"), "<html></html>", "utf8");
await fs.writeFile(path.join(srcDir, "a2ui.bundle.js"), "console.log(1);", "utf8");
await copyA2uiAssets({ srcDir, outDir });
await expect(fs.readFile(path.join(outDir, "index.html"), "utf8")).resolves.toBe(
"<html></html>",
);
await expect(fs.readFile(path.join(outDir, "a2ui.bundle.js"), "utf8")).resolves.toBe(
"console.log(1);",
);
});
});
it("copies nested bundled assets and removes stale output", async () => {
await withA2uiFixture(async (dir) => {
const srcDir = path.join(dir, "src");
const outDir = path.join(dir, "dist");
const nestedAssetDir = path.join(srcDir, "assets", "demo");
await fs.mkdir(nestedAssetDir, { recursive: true });
await fs.mkdir(outDir, { recursive: true });
await fs.writeFile(path.join(srcDir, "index.html"), "<html></html>", "utf8");
await fs.writeFile(path.join(srcDir, "a2ui.bundle.js"), "console.log(1);", "utf8");
await fs.writeFile(path.join(nestedAssetDir, "sample.txt"), "nested-asset", "utf8");
await fs.writeFile(path.join(outDir, "stale.txt"), "stale-output", "utf8");
await copyA2uiAssets({ srcDir, outDir });
await expect(
fs.readFile(path.join(outDir, "assets", "demo", "sample.txt"), "utf8"),
).resolves.toBe("nested-asset");
await expect(fs.stat(path.join(outDir, "stale.txt"))).rejects.toMatchObject({
code: "ENOENT",
});
});
});
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"), "<html></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(
"<html></html>",
);
await expect(copyA2uiAssets({ srcDir, outDir: path.join(srcDir, "dist") })).rejects.toThrow(
"must not overlap",
);
});
});
});

View File

@@ -0,0 +1,19 @@
export interface PnpmRunnerParams {
comSpec?: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
nodeArgs?: string[];
nodeExecPath?: string;
npmExecPath?: string;
platform?: string;
pnpmArgs?: string[];
}
export interface PnpmRunnerSpec {
args: string[];
command: string;
shell: false;
windowsVerbatimArguments?: true;
}
export function resolvePnpmRunner(params?: PnpmRunnerParams): PnpmRunnerSpec;

View File

@@ -0,0 +1,197 @@
/**
* Cross-platform pnpm command resolver used by Canvas build scripts.
*/
import { accessSync, closeSync, constants, openSync, readSync, statSync } from "node:fs";
import path from "node:path";
const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>%\r\n]/;
const PNPM_EXECUTABLE_RE = /^pnpm(?:-cli)?(?:\.(?:[cm]?js|cmd|exe))?$/;
const NODE_RUNNABLE_EXTENSIONS = new Set([".js", ".cjs", ".mjs"]);
function inspectExecutablePath(value) {
const basename = value.split(/[/\\]/).at(-1) ?? value;
const extension = basename.match(/(\.[^.]+)$/u)?.[1]?.toLowerCase() ?? "";
return { basename: basename.toLowerCase(), extension };
}
function isPnpmExecPath(value) {
return PNPM_EXECUTABLE_RE.test(inspectExecutablePath(value).basename);
}
function hasScriptShebang(value) {
let fd;
try {
fd = openSync(value, "r");
const header = Buffer.alloc(2);
return (
readSync(fd, header, 0, header.length, 0) === header.length &&
header[0] === 0x23 &&
header[1] === 0x21
);
} catch {
return false;
} finally {
if (fd !== undefined) {
closeSync(fd);
}
}
}
function isExecutableFile(value) {
try {
if (!statSync(value).isFile()) {
return false;
}
accessSync(value, constants.X_OK);
return true;
} catch {
return false;
}
}
function isFile(value) {
try {
return statSync(value).isFile();
} catch {
return false;
}
}
function resolvePathEnvKey(env) {
return Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH";
}
function findExecutableOnPath(command, envPath, platform, env, cwd) {
if (typeof envPath !== "string" || envPath.length === 0) {
return undefined;
}
const extensions =
platform === "win32"
? (env[Object.keys(env).find((key) => key.toLowerCase() === "pathext") ?? "PATHEXT"] ??
".COM;.EXE;.BAT;.CMD")
.split(";")
.filter(Boolean)
.map((extension) => extension.toLowerCase())
: [""];
const pathImpl = platform === "win32" ? path.win32 : path;
const pathDelimiter = platform === "win32" ? ";" : path.delimiter;
for (const directory of envPath.split(pathDelimiter)) {
if (!directory) {
continue;
}
const resolvedDirectory = pathImpl.isAbsolute(directory)
? directory
: pathImpl.resolve(cwd, directory);
for (const extension of extensions) {
const candidate = pathImpl.join(resolvedDirectory, `${command}${extension}`);
if ((platform === "win32" ? isFile(candidate) : isExecutableFile(candidate))) {
return candidate;
}
}
}
return undefined;
}
function isNodeRunnablePnpmExecPath(value) {
if (!isPnpmExecPath(value)) {
return false;
}
const { extension } = inspectExecutablePath(value);
if (NODE_RUNNABLE_EXTENSIONS.has(extension)) {
return isFile(value);
}
if (extension.length > 0) {
return false;
}
return hasScriptShebang(value);
}
function escapeForCmdExe(arg) {
if (WINDOWS_UNSAFE_CMD_CHARS_RE.test(arg)) {
throw new Error(`unsafe Windows cmd.exe argument detected: ${JSON.stringify(arg)}`);
}
const escaped = arg.replace(/\^/g, "^^");
if (!escaped.includes(" ") && !escaped.includes('"')) {
return escaped;
}
return `"${escaped.replace(/"/g, '""')}"`;
}
function buildCmdExeCommandLine(command, args) {
return [escapeForCmdExe(command), ...args.map(escapeForCmdExe)].join(" ");
}
function windowsCmdSpec(command, args, comSpec) {
return {
args: ["/d", "/s", "/c", buildCmdExeCommandLine(command, args)],
command: comSpec,
shell: false,
windowsVerbatimArguments: true,
};
}
function resolveConfiguredPnpmExec(params) {
const npmExecPath = params.npmExecPath ?? process.env.npm_execpath;
if (typeof npmExecPath !== "string" || npmExecPath.length === 0 || !isPnpmExecPath(npmExecPath)) {
return undefined;
}
if (isNodeRunnablePnpmExecPath(npmExecPath)) {
return {
args: [...(params.nodeArgs ?? []), npmExecPath, ...(params.pnpmArgs ?? [])],
command: params.nodeExecPath ?? process.execPath,
shell: false,
};
}
const { extension } = inspectExecutablePath(npmExecPath);
if ((params.platform ?? process.platform) !== "win32") {
return extension.length === 0 && isExecutableFile(npmExecPath)
? { args: params.pnpmArgs ?? [], command: npmExecPath, shell: false }
: undefined;
}
if (extension === ".exe") {
return { args: params.pnpmArgs ?? [], command: npmExecPath, shell: false };
}
if (extension === ".cmd") {
return windowsCmdSpec(
npmExecPath,
params.pnpmArgs ?? [],
params.comSpec ?? process.env.ComSpec ?? "cmd.exe",
);
}
return undefined;
}
/** Resolves a safe pnpm command spec for Unix, Windows, and npm_execpath launches. */
export function resolvePnpmRunner(params = {}) {
const configured = resolveConfiguredPnpmExec(params);
if (configured) {
return configured;
}
const pnpmArgs = params.pnpmArgs ?? [];
const platform = params.platform ?? process.platform;
const env = params.env ?? process.env;
const envPath = env[platform === "win32" ? resolvePathEnvKey(env) : "PATH"];
const cwd = params.cwd ?? process.cwd();
const pnpmPath = findExecutableOnPath("pnpm", envPath, platform, env, cwd);
if (pnpmPath) {
return platform === "win32"
? windowsCmdSpec(pnpmPath, pnpmArgs, params.comSpec ?? process.env.ComSpec ?? "cmd.exe")
: { args: pnpmArgs, command: pnpmPath, shell: false };
}
const corepackPath = findExecutableOnPath("corepack", envPath, platform, env, cwd);
if (corepackPath) {
const args = ["pnpm", ...pnpmArgs];
return platform === "win32"
? windowsCmdSpec(corepackPath, args, params.comSpec ?? process.env.ComSpec ?? "cmd.exe")
: { args, command: corepackPath, shell: false };
}
if (platform === "win32") {
return windowsCmdSpec("pnpm.cmd", pnpmArgs, params.comSpec ?? process.env.ComSpec ?? "cmd.exe");
}
return { args: pnpmArgs, command: "pnpm", shell: false };
}

View File

@@ -0,0 +1,133 @@
// Canvas tests cover pnpm runner plugin behavior.
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolvePnpmRunner } from "./pnpm-runner.mjs";
describe("canvas pnpm runner", () => {
const posixIt = process.platform === "win32" ? it.skip : it;
it("executes native pnpm binaries from npm_execpath directly on non-Windows", () => {
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-"));
const npmExecPath = path.join(tempDir, "pnpm");
writeFileSync(npmExecPath, Buffer.from([0xcf, 0xfa, 0xed, 0xfe]));
chmodSync(npmExecPath, 0o755);
try {
expect(
resolvePnpmRunner({
env: { PATH: "" },
npmExecPath,
platform: "darwin",
pnpmArgs: ["exec", "rolldown", "-c"],
}),
).toEqual({
args: ["exec", "rolldown", "-c"],
command: npmExecPath,
shell: false,
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
posixIt("falls back to bare pnpm when native npm_execpath is not executable", () => {
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-"));
const npmExecPath = path.join(tempDir, "pnpm");
writeFileSync(npmExecPath, Buffer.from([0xcf, 0xfa, 0xed, 0xfe]));
chmodSync(npmExecPath, 0o644);
try {
expect(
resolvePnpmRunner({
env: { PATH: "" },
npmExecPath,
platform: "darwin",
pnpmArgs: ["exec", "rolldown", "-c"],
}),
).toEqual({
args: ["exec", "rolldown", "-c"],
command: "pnpm",
shell: false,
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
posixIt("uses Corepack when pnpm is not directly available on PATH", () => {
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-corepack-"));
const corepackPath = path.join(tempDir, "corepack");
writeFileSync(corepackPath, "#!/bin/sh\nexit 0\n");
chmodSync(corepackPath, 0o755);
try {
expect(
resolvePnpmRunner({
env: { PATH: tempDir },
npmExecPath: "",
platform: "darwin",
pnpmArgs: ["exec", "rolldown", "-c"],
}),
).toEqual({
args: ["pnpm", "exec", "rolldown", "-c"],
command: corepackPath,
shell: false,
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
posixIt("ignores a missing pnpm JS npm_execpath before checking PATH", () => {
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-missing-"));
const corepackPath = path.join(tempDir, "corepack");
writeFileSync(corepackPath, "#!/bin/sh\nexit 0\n");
chmodSync(corepackPath, 0o755);
try {
expect(
resolvePnpmRunner({
env: { PATH: tempDir },
npmExecPath: path.join(tempDir, "missing-pnpm.mjs"),
platform: "darwin",
pnpmArgs: ["exec", "rolldown", "-c"],
}),
).toEqual({
args: ["pnpm", "exec", "rolldown", "-c"],
command: corepackPath,
shell: false,
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
posixIt("prefers a direct pnpm executable over Corepack", () => {
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-path-"));
const pnpmPath = path.join(tempDir, "pnpm");
const corepackPath = path.join(tempDir, "corepack");
writeFileSync(pnpmPath, "#!/bin/sh\nexit 0\n");
writeFileSync(corepackPath, "#!/bin/sh\nexit 0\n");
chmodSync(pnpmPath, 0o755);
chmodSync(corepackPath, 0o755);
try {
expect(
resolvePnpmRunner({
env: { PATH: tempDir },
npmExecPath: "",
platform: "darwin",
pnpmArgs: ["exec", "rolldown", "-c"],
}),
).toEqual({
args: ["exec", "rolldown", "-c"],
command: pnpmPath,
shell: false,
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,14 @@
/**
* Canvas setup entrypoint that exposes config migrations.
*/
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { migrateLegacyCanvasHostConfig } from "./src/config-migration.js";
export default definePluginEntry({
id: "canvas",
name: "Canvas Setup",
description: "Lightweight Canvas setup hooks",
register(api) {
api.registerConfigMigration((config) => migrateLegacyCanvasHostConfig(config));
},
});

View File

@@ -0,0 +1,78 @@
---
name: canvas
description: "Present HTML on connected OpenClaw node canvases, navigate/eval/snapshot, and debug canvas host URLs."
metadata: { "openclaw": { "emoji": "🖼️" } }
---
# Canvas
Use canvas to show HTML on connected Mac/iOS/Android nodes.
## Model
- Canvas host serves files from `plugins.entries.canvas.config.host.root`.
- Canvas routes live on the Gateway HTTP port (`gateway.port`, default `18789`).
- Node bridge sends canvas URLs to connected node apps.
- Node apps render URLs in a WebView.
- Host name follows `gateway.bind`: loopback local only, LAN IP for LAN, Tailscale host for tailnet, auto picks best route.
- Localhost URLs only work for a node on the same machine.
- Paired nodes normally receive node-scoped `pluginSurfaceUrls.canvas` capability URLs; prefer those when available.
## Config
Active config: `$OPENCLAW_CONFIG_PATH` or `~/.openclaw/openclaw.json`.
```json
{
"plugins": {
"entries": {
"canvas": {
"config": {
"host": {
"enabled": true,
"root": "~/.openclaw/canvas",
"liveReload": true
}
}
}
}
},
"gateway": { "bind": "auto" }
}
```
## Actions
- `present`: show canvas, optional URL.
- `hide`: hide canvas.
- `navigate`: open new URL.
- `eval`: run JavaScript in current canvas.
- `snapshot`: capture screenshot.
## Workflow
1. Ensure Canvas plugin host is enabled.
2. Put HTML/CSS/JS under `plugins.entries.canvas.config.host.root` or the default state canvas dir.
3. Use a route reachable by the target node.
4. Present the hosted URL: `/__openclaw__/canvas/<file>.html`.
5. Use `snapshot` when the user needs proof.
## URL shape
```text
http://<gateway-host>:<gateway.port>/__openclaw__/canvas/index.html
http://<gateway-host>:<gateway.port>/__openclaw__/canvas/games/snake.html
```
Path mapping:
- `/__openclaw__/canvas/index.html` -> `<canvas host root>/index.html`
- `/__openclaw__/canvas/games/snake.html` -> `<canvas host root>/games/snake.html`
## Troubleshooting
- Node sees localhost but is remote: fix `gateway.bind` or public URL, regenerate URL.
- LAN node cannot load: verify same network, firewall, Gateway port, and auth/capability URL.
- Tailnet node cannot load: verify Tailscale status and advertised host.
- Blank page: open URL locally, check browser console, then snapshot node.
- Live reload missing: verify `liveReload` and file write under root.

View File

@@ -0,0 +1,95 @@
/**
* A2UI JSONL helpers for Canvas text rendering and validation.
*/
const A2UI_ACTION_KEYS = [
"beginRendering",
"surfaceUpdate",
"dataModelUpdate",
"deleteSurface",
"createSurface",
] as const;
/** Supported A2UI message dialects accepted by the Canvas host. */
export type A2UIVersion = "v0.8" | "v0.9";
/** Builds a minimal A2UI JSONL payload that renders text in a single surface. */
export function buildA2UITextJsonl(text: string) {
const surfaceId = "main";
const rootId = "root";
const textId = "text";
const payloads = [
{
surfaceUpdate: {
surfaceId,
components: [
{
id: rootId,
component: { Column: { children: { explicitList: [textId] } } },
},
{
id: textId,
component: {
Text: { text: { literalString: text }, usageHint: "body" },
},
},
],
},
},
{ beginRendering: { surfaceId, root: rootId } },
];
return payloads.map((payload) => JSON.stringify(payload)).join("\n");
}
/** Validates A2UI JSONL and returns the detected dialect/version metadata. */
export function validateA2UIJsonl(jsonl: string) {
const lines = jsonl.split(/\r?\n/);
const errors: string[] = [];
let sawV08 = false;
let sawV09 = false;
let messageCount = 0;
lines.forEach((line, idx) => {
const trimmed = line.trim();
if (!trimmed) {
return;
}
messageCount += 1;
let obj: unknown;
try {
obj = JSON.parse(trimmed) as unknown;
} catch (err) {
errors.push(`line ${idx + 1}: ${String(err)}`);
return;
}
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
errors.push(`line ${idx + 1}: expected JSON object`);
return;
}
const record = obj as Record<string, unknown>;
const actionKeys = A2UI_ACTION_KEYS.filter((key) => key in record);
if (actionKeys.length !== 1) {
errors.push(
`line ${idx + 1}: expected exactly one action key (${A2UI_ACTION_KEYS.join(", ")})`,
);
return;
}
if (actionKeys[0] === "createSurface") {
sawV09 = true;
} else {
sawV08 = true;
}
});
if (messageCount === 0) {
errors.push("no JSONL messages found");
}
if (sawV08 && sawV09) {
errors.push("mixed A2UI v0.8 and v0.9 messages in one file");
}
if (errors.length > 0) {
throw new Error(`Invalid A2UI JSONL:\n- ${errors.join("\n- ")}`);
}
const version: A2UIVersion = sawV09 ? "v0.9" : "v0.8";
return { version, messageCount };
}

View File

@@ -0,0 +1,34 @@
/**
* Canvas capability-token helpers for scoped hosted node URLs.
*/
import {
buildPluginNodeCapabilityScopedHostUrl,
DEFAULT_PLUGIN_NODE_CAPABILITY_TTL_MS,
mintPluginNodeCapabilityToken,
normalizePluginNodeCapabilityScopedUrl,
PLUGIN_NODE_CAPABILITY_PATH_PREFIX,
type NormalizedPluginNodeCapabilityUrl,
} from "openclaw/plugin-sdk/gateway-runtime";
/** Path prefix used for Canvas capability-scoped gateway routes. */
export const CANVAS_CAPABILITY_PATH_PREFIX = PLUGIN_NODE_CAPABILITY_PATH_PREFIX;
/** Default Canvas capability token TTL in milliseconds. */
export const CANVAS_CAPABILITY_TTL_MS = DEFAULT_PLUGIN_NODE_CAPABILITY_TTL_MS;
/** Normalized Canvas capability-scoped URL shape. */
export type NormalizedCanvasScopedUrl = NormalizedPluginNodeCapabilityUrl;
/** Creates a new opaque Canvas capability token. */
export function mintCanvasCapabilityToken(): string {
return mintPluginNodeCapabilityToken();
}
/** Builds a Canvas host URL scoped by the supplied capability token. */
export function buildCanvasScopedHostUrl(baseUrl: string, capability: string): string | undefined {
return buildPluginNodeCapabilityScopedHostUrl(baseUrl, capability);
}
/** Normalizes and validates a Canvas capability-scoped URL. */
export function normalizeCanvasScopedUrl(rawUrl: string): NormalizedCanvasScopedUrl {
return normalizePluginNodeCapabilityScopedUrl(rawUrl);
}

View File

@@ -0,0 +1,63 @@
// Canvas tests cover cli helpers plugin behavior.
import { describe, expect, it } from "vitest";
import {
canvasSnapshotTempPath,
normalizeCanvasSnapshotFileExtension,
parseCanvasSnapshotPayload,
} from "./cli-helpers.js";
describe("canvas CLI helpers", () => {
it("parses canvas.snapshot payload", () => {
expect(parseCanvasSnapshotPayload({ format: "png", base64: "aGk=" })).toEqual({
format: "png",
base64: "aGk=",
});
});
it("rejects invalid canvas.snapshot payload", () => {
expect(() => parseCanvasSnapshotPayload({ format: "png" })).toThrow(
/invalid canvas\.snapshot payload/i,
);
});
it.each([{ base64: "aGk=" }, { format: 42, base64: "aGk=" }])(
"rejects invalid canvas.snapshot format fields",
(payload) => {
expect(() => parseCanvasSnapshotPayload(payload)).toThrow(
/invalid canvas\.snapshot payload/i,
);
},
);
it.each(["/../../target.sh", "../target.sh", "png/../../target.sh", "image/png", ""])(
"rejects unsafe canvas.snapshot formats from responses: %s",
(format) => {
expect(() => parseCanvasSnapshotPayload({ format, base64: "aGk=" })).toThrow(
/invalid canvas\.snapshot payload/i,
);
},
);
it("normalizes supported snapshot file extensions", () => {
expect(normalizeCanvasSnapshotFileExtension("png")).toBe("png");
expect(normalizeCanvasSnapshotFileExtension(".jpeg")).toBe("jpg");
expect(normalizeCanvasSnapshotFileExtension(" JPG ")).toBe("jpg");
});
it("rejects unsafe snapshot temp path parts", () => {
expect(() =>
canvasSnapshotTempPath({
tmpDir: "/tmp/openclaw-canvas-test",
id: "snapshot",
ext: "/../../target.sh",
}),
).toThrow(/invalid canvas\.snapshot format/i);
expect(() =>
canvasSnapshotTempPath({
tmpDir: "/tmp/openclaw-canvas-test",
id: "../../snapshot",
ext: "png",
}),
).toThrow(/invalid canvas snapshot id/i);
});
});

View File

@@ -0,0 +1,74 @@
/**
* Shared Canvas CLI helpers for snapshot payload parsing and temp paths.
*/
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import * as path from "node:path";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/security-runtime";
import { asRecord, readStringValue } from "openclaw/plugin-sdk/string-coerce-runtime";
type CanvasSnapshotPayload = {
format: CanvasSnapshotFormat;
base64: string;
};
type CanvasSnapshotFormat = "png" | "jpg" | "jpeg";
type CanvasSnapshotFileExtension = "png" | "jpg";
function normalizeCanvasSnapshotFormat(value: string | undefined): CanvasSnapshotFormat | null {
const format = value?.trim().toLowerCase() ?? "";
if (format === "png" || format === "jpg" || format === "jpeg") {
return format;
}
return null;
}
/** Normalizes Canvas snapshot output extensions, mapping jpeg to jpg. */
export function normalizeCanvasSnapshotFileExtension(value: string): CanvasSnapshotFileExtension {
const format = normalizeCanvasSnapshotFormat(value.startsWith(".") ? value.slice(1) : value);
if (!format) {
throw new Error("invalid canvas.snapshot format");
}
return format === "jpeg" ? "jpg" : format;
}
/** Parses the node.invoke canvas.snapshot payload shape. */
export function parseCanvasSnapshotPayload(value: unknown): CanvasSnapshotPayload {
const obj = asRecord(value);
const format = normalizeCanvasSnapshotFormat(readStringValue(obj.format));
const base64 = readStringValue(obj.base64);
if (!format || !base64) {
throw new Error("invalid canvas.snapshot payload");
}
return { format, base64 };
}
function resolveCliName(): string {
return "openclaw";
}
function resolveCanvasSnapshotId(id: string): string {
if (!/^[A-Za-z0-9_-]+$/.test(id)) {
throw new Error("invalid canvas snapshot id");
}
return id;
}
function resolveTempPathParts(opts: { ext: string; tmpDir?: string; id?: string }) {
const tmpDir = opts.tmpDir ?? resolvePreferredOpenClawTmpDir();
if (!opts.tmpDir) {
fs.mkdirSync(tmpDir, { recursive: true, mode: 0o700 });
}
return {
tmpDir,
id: resolveCanvasSnapshotId(opts.id ?? randomUUID()),
ext: `.${normalizeCanvasSnapshotFileExtension(opts.ext)}`,
};
}
/** Builds a safe temp path for a Canvas snapshot output file. */
export function canvasSnapshotTempPath(opts: { ext: string; tmpDir?: string; id?: string }) {
const { tmpDir, id, ext } = resolveTempPathParts(opts);
const cliName = resolveCliName();
return path.join(tmpDir, `${cliName}-canvas-snapshot-${id}${ext}`);
}

View File

@@ -0,0 +1,256 @@
// Canvas tests cover cli plugin behavior.
import { Command } from "commander";
import { describe, expect, it, vi } from "vitest";
import {
createDefaultCanvasCliDependencies,
registerNodesCanvasCommands,
type CanvasCliDependencies,
} from "./cli.js";
function createCanvasCliDeps() {
const writtenFiles: Array<{ filePath: string; base64: string }> = [];
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`exit ${code}`);
}),
writeJson: vi.fn(),
};
const deps: CanvasCliDependencies = {
defaultRuntime: runtime,
nodesCallOpts: (cmd) =>
cmd
.option("--url <url>", "Gateway WebSocket URL")
.option("--token <token>", "Gateway token")
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--json", "Output JSON", false),
runNodesCommand: async (_label, action) => {
await action();
},
getNodesTheme: () => ({ ok: (value) => value }),
parseTimeoutMs: (raw) => (typeof raw === "string" ? Number.parseInt(raw, 10) : undefined),
resolveNodeId: async (opts) => opts.node ?? "ios-node",
buildNodeInvokeParams: ({ nodeId, command, params, timeoutMs }) => ({
nodeId,
command,
params,
...(typeof timeoutMs === "number" ? { timeoutMs } : {}),
}),
callGatewayCli: vi.fn(async () => ({
payload: {
format: "png",
base64: "aGk=",
},
})),
writeBase64ToFile: async (filePath, base64) => {
writtenFiles.push({ filePath, base64 });
},
shortenHomePath: (filePath) => filePath,
};
return { deps, runtime, writtenFiles };
}
function createCanvasCliDepsWithDefaultParsers() {
const baseDeps = createDefaultCanvasCliDependencies();
const harness = createCanvasCliDeps();
return {
...harness,
deps: {
...baseDeps,
defaultRuntime: harness.runtime,
nodesCallOpts: harness.deps.nodesCallOpts,
runNodesCommand: harness.deps.runNodesCommand,
getNodesTheme: harness.deps.getNodesTheme,
resolveNodeId: harness.deps.resolveNodeId,
buildNodeInvokeParams: harness.deps.buildNodeInvokeParams,
callGatewayCli: harness.deps.callGatewayCli,
writeBase64ToFile: harness.deps.writeBase64ToFile,
shortenHomePath: harness.deps.shortenHomePath,
},
};
}
describe("canvas CLI", () => {
it("registers under nodes and captures a snapshot media path", async () => {
const program = new Command();
program.exitOverride();
const nodes = program.command("nodes");
const { deps, runtime, writtenFiles } = createCanvasCliDeps();
registerNodesCanvasCommands(nodes, deps);
await program.parseAsync(["nodes", "canvas", "snapshot", "--node", "ios-node"], {
from: "user",
});
expect(deps.callGatewayCli).toHaveBeenCalledTimes(1);
expect(deps.callGatewayCli).toHaveBeenCalledWith(
"node.invoke",
{
node: "ios-node",
format: "jpg",
timeout: "10000",
json: false,
invokeTimeout: "20000",
},
{
nodeId: "ios-node",
command: "canvas.snapshot",
params: {
format: "jpeg",
maxWidth: undefined,
quality: undefined,
},
timeoutMs: 20000,
},
);
expect(writtenFiles).toHaveLength(1);
const [writtenFile] = writtenFiles;
if (!writtenFile) {
throw new Error("Expected canvas snapshot file");
}
expect(writtenFile.filePath).toMatch(/openclaw-canvas-snapshot-.*\.png$/);
expect(writtenFile.base64).toBe("aGk=");
expect(runtime.log).toHaveBeenCalledTimes(1);
const savedPath = runtime.log.mock.calls[0]?.[0];
expect(savedPath?.startsWith("MEDIA:")).toBe(false);
expect(savedPath?.endsWith(".png")).toBe(true);
});
it("rejects node-controlled snapshot formats before writing", async () => {
const program = new Command();
program.exitOverride();
const nodes = program.command("nodes");
const { deps, writtenFiles } = createCanvasCliDeps();
vi.mocked(deps.callGatewayCli).mockResolvedValueOnce({
payload: {
format: "/../../target.sh",
base64: "aGk=",
},
});
registerNodesCanvasCommands(nodes, deps);
await expect(
program.parseAsync(["nodes", "canvas", "snapshot", "--node", "ios-node"], {
from: "user",
}),
).rejects.toThrow(/invalid canvas\.snapshot payload/i);
expect(writtenFiles).toHaveLength(0);
});
it("rejects unsupported snapshot formats before invoking the node", async () => {
const program = new Command();
program.exitOverride();
const nodes = program.command("nodes");
const { deps, writtenFiles } = createCanvasCliDeps();
registerNodesCanvasCommands(nodes, deps);
await expect(
program.parseAsync(["nodes", "canvas", "snapshot", "--node", "ios-node", "--format", "gif"], {
from: "user",
}),
).rejects.toThrow(/invalid format: gif/i);
expect(deps.callGatewayCli).not.toHaveBeenCalled();
expect(writtenFiles).toHaveLength(0);
});
it.each([
["--max-width", "640px", "--max-width must be a positive integer."],
["--quality", "0.8x", "--quality must be a number."],
["--quality", "-0.1", "--quality must be between 0 and 1."],
["--quality", "5", "--quality must be between 0 and 1."],
])("rejects partial numeric snapshot %s values", async (flag, value, message) => {
const program = new Command();
program.exitOverride();
const nodes = program.command("nodes");
const { deps } = createCanvasCliDeps();
registerNodesCanvasCommands(nodes, deps);
await expect(
program.parseAsync(["nodes", "canvas", "snapshot", "--node", "ios-node", flag, value], {
from: "user",
}),
).rejects.toThrow(message);
expect(deps.callGatewayCli).not.toHaveBeenCalled();
});
it.each(["0", "1"])("accepts snapshot --quality boundary value %s", async (quality) => {
const program = new Command();
program.exitOverride();
const nodes = program.command("nodes");
const { deps } = createCanvasCliDeps();
registerNodesCanvasCommands(nodes, deps);
await program.parseAsync(
["nodes", "canvas", "snapshot", "--node", "ios-node", "--quality", quality],
{
from: "user",
},
);
expect(deps.callGatewayCli).toHaveBeenCalledWith(
"node.invoke",
expect.any(Object),
expect.objectContaining({
params: expect.objectContaining({
quality: Number(quality),
}),
}),
);
});
it.each([
["snapshot"],
["present"],
["hide"],
["navigate", "https://example.com"],
["eval", "1 + 1"],
["a2ui", "push", "--text", "hello"],
["a2ui", "reset"],
])("rejects invalid %s invoke timeouts before invoking the node", async (...args) => {
const program = new Command();
program.exitOverride();
const nodes = program.command("nodes");
const { deps } = createCanvasCliDepsWithDefaultParsers();
deps.resolveNodeId = vi.fn(async () => {
throw new Error("resolveNodeId should not be called");
});
registerNodesCanvasCommands(nodes, deps);
await expect(
program.parseAsync(
["nodes", "canvas", ...args, "--node", "ios-node", "--invoke-timeout", "20ms"],
{
from: "user",
},
),
).rejects.toThrow("--invoke-timeout must be a positive integer.");
expect(deps.resolveNodeId).not.toHaveBeenCalled();
expect(deps.callGatewayCli).not.toHaveBeenCalled();
});
it.each([
["--x", "1x"],
["--y", "2px"],
["--width", "800wide"],
["--height", "600tall"],
])("rejects partial numeric present %s values", async (flag, value) => {
const program = new Command();
program.exitOverride();
const nodes = program.command("nodes");
const { deps } = createCanvasCliDeps();
registerNodesCanvasCommands(nodes, deps);
await expect(
program.parseAsync(["nodes", "canvas", "present", "--node", "ios-node", flag, value], {
from: "user",
}),
).rejects.toThrow(`${flag} must be a number.`);
expect(deps.callGatewayCli).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,482 @@
/**
* Canvas node CLI command registration and runtime dependency wiring.
*/
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import type { Command } from "commander";
import { runCommandWithRuntime, theme } from "openclaw/plugin-sdk/cli-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
callGatewayFromCli,
resolveNodeFromNodeList,
type NodeMatchCandidate,
} from "openclaw/plugin-sdk/gateway-runtime";
import {
parseStrictFiniteNumber,
parseStrictPositiveInteger,
} from "openclaw/plugin-sdk/number-runtime";
import { defaultRuntime } from "openclaw/plugin-sdk/runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { shortenHomePath } from "openclaw/plugin-sdk/text-utility-runtime";
import { buildA2UITextJsonl, validateA2UIJsonl } from "./a2ui-jsonl.js";
import { canvasSnapshotTempPath, parseCanvasSnapshotPayload } from "./cli-helpers.js";
/** Runtime output surface used by Canvas CLI commands. */
export type CanvasCliRuntime = {
log: (message: string) => void;
error: (message: string) => void;
exit: (code: number) => void;
writeJson: (value: unknown) => void;
};
/** Parent node/gateway options consumed by Canvas CLI commands. */
export type CanvasNodesRpcOpts = {
url?: string;
token?: string;
timeout?: string;
json?: boolean;
node?: string;
invokeTimeout?: string;
target?: string;
x?: string;
y?: string;
width?: string;
height?: string;
js?: string;
jsonl?: string;
text?: string;
format?: string;
maxWidth?: string;
quality?: string;
};
/** Dependency bundle used to keep Canvas CLI commands testable. */
export type CanvasCliDependencies = {
defaultRuntime: CanvasCliRuntime;
nodesCallOpts: (cmd: Command, defaults?: { timeoutMs?: number }) => Command;
runNodesCommand: (label: string, action: () => Promise<void>) => Promise<void> | void;
getNodesTheme: () => { ok: (value: string) => string };
parseTimeoutMs: (raw: unknown) => number | undefined;
resolveNodeId: (opts: CanvasNodesRpcOpts, query: string) => Promise<string>;
buildNodeInvokeParams: (params: {
nodeId: string;
command: string;
params?: Record<string, unknown>;
timeoutMs?: number;
}) => Record<string, unknown>;
callGatewayCli: (
method: string,
opts: CanvasNodesRpcOpts,
params?: unknown,
callOpts?: { transportTimeoutMs?: number },
) => Promise<unknown>;
writeBase64ToFile: (filePath: string, base64: string) => Promise<unknown>;
shortenHomePath: (filePath: string) => string;
};
type CanvasNodeCandidate = NodeMatchCandidate;
type CanvasSnapshotRequestFormat = "png" | "jpeg";
function parseCanvasSnapshotRequestFormat(raw: unknown): CanvasSnapshotRequestFormat {
const format = normalizeLowercaseStringOrEmpty(normalizeOptionalString(raw) ?? "jpg");
switch (format) {
case "png":
return "png";
case "jpg":
case "jpeg":
return "jpeg";
default:
throw new Error(`invalid format: ${String(raw)} (expected png|jpg|jpeg)`);
}
}
function parseTimeoutMs(raw: unknown): number | undefined {
if (raw === undefined || raw === null) {
return undefined;
}
const parsed = parseStrictPositiveInteger(raw);
if (parsed === undefined) {
throw new Error("--invoke-timeout must be a positive integer.");
}
return parsed;
}
function parseCanvasPositiveIntOption(raw: string | undefined, flag: string): number | undefined {
if (!raw) {
return undefined;
}
const parsed = parseStrictPositiveInteger(raw);
if (parsed === undefined) {
throw new Error(`${flag} must be a positive integer.`);
}
return parsed;
}
function parseCanvasFiniteNumberOption(raw: string | undefined, flag: string): number | undefined {
if (!raw) {
return undefined;
}
const parsed = parseStrictFiniteNumber(raw);
if (parsed === undefined) {
throw new Error(`${flag} must be a number.`);
}
return parsed;
}
function parseCanvasSnapshotQualityOption(raw: string | undefined): number | undefined {
const parsed = parseCanvasFiniteNumberOption(raw, "--quality");
if (parsed !== undefined && (parsed < 0 || parsed > 1)) {
throw new Error("--quality must be between 0 and 1.");
}
return parsed;
}
function parseNodeCandidates(raw: unknown): CanvasNodeCandidate[] {
const payload =
raw && typeof raw === "object" ? (raw as { nodes?: unknown; paired?: unknown }) : {};
const list = Array.isArray(payload.nodes)
? payload.nodes
: Array.isArray(payload.paired)
? payload.paired
: [];
return list
.map((entry) => {
if (!entry || typeof entry !== "object") {
return null;
}
const node = entry as {
nodeId?: unknown;
displayName?: unknown;
remoteIp?: unknown;
connected?: unknown;
clientId?: unknown;
};
if (typeof node.nodeId !== "string") {
return null;
}
const candidate: CanvasNodeCandidate = { nodeId: node.nodeId };
if (typeof node.displayName === "string") {
candidate.displayName = node.displayName;
}
if (typeof node.remoteIp === "string") {
candidate.remoteIp = node.remoteIp;
}
if (typeof node.connected === "boolean") {
candidate.connected = node.connected;
}
if (typeof node.clientId === "string") {
candidate.clientId = node.clientId;
}
return candidate;
})
.filter((entry): entry is CanvasNodeCandidate => entry !== null);
}
function unauthorizedHintForMessage(message: string): string | null {
const haystack = normalizeLowercaseStringOrEmpty(message);
if (
haystack.includes("unauthorizedclient") ||
haystack.includes("bridge client is not authorized") ||
haystack.includes("unsigned bridge clients are not allowed")
) {
return [
"peekaboo bridge rejected the client.",
"sign the peekaboo CLI (TeamID Y5PE65HELJ) or launch the host with",
"PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1 for local dev.",
].join(" ");
}
return null;
}
/** Creates the default Canvas CLI dependency bundle backed by the OpenClaw gateway CLI. */
export function createDefaultCanvasCliDependencies(): CanvasCliDependencies {
const nodesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) =>
cmd
.option(
"--url <url>",
"Gateway WebSocket URL (defaults to gateway.remote.url when configured)",
)
.option("--token <token>", "Gateway token (if required)")
.option("--timeout <ms>", "Timeout in ms", String(defaults?.timeoutMs ?? 10_000))
.option("--json", "Output JSON", false);
const callGatewayCli: CanvasCliDependencies["callGatewayCli"] = async (
method,
opts,
params,
callOpts,
) => {
const timeout = String(callOpts?.transportTimeoutMs ?? opts.timeout ?? 10_000);
return await callGatewayFromCli(method, { ...opts, timeout }, params, {
progress: opts.json !== true,
});
};
return {
defaultRuntime,
nodesCallOpts,
runNodesCommand: (label, action) =>
runCommandWithRuntime(defaultRuntime, action, (err) => {
const message = formatErrorMessage(err);
defaultRuntime.error(theme.error(`nodes ${label} failed: ${message}`));
const hint = unauthorizedHintForMessage(message);
if (hint) {
defaultRuntime.error(theme.warn(hint));
}
defaultRuntime.exit(1);
}),
getNodesTheme: () => ({ ok: theme.success }),
parseTimeoutMs,
resolveNodeId: async (opts, query) => {
let raw: unknown;
try {
raw = await callGatewayCli("node.list", opts, {});
} catch {
raw = await callGatewayCli("node.pair.list", opts, {});
}
return resolveNodeFromNodeList(parseNodeCandidates(raw), query).nodeId;
},
buildNodeInvokeParams: ({ nodeId, command, params, timeoutMs }) => ({
nodeId,
command,
params,
idempotencyKey: randomUUID(),
...(typeof timeoutMs === "number" && Number.isFinite(timeoutMs) ? { timeoutMs } : {}),
}),
callGatewayCli,
writeBase64ToFile: async (filePath, base64) =>
await fs.writeFile(filePath, Buffer.from(base64, "base64")),
shortenHomePath,
};
}
async function invokeCanvas(
deps: CanvasCliDependencies,
opts: CanvasNodesRpcOpts,
command: string,
params?: Record<string, unknown>,
) {
const timeoutMs = deps.parseTimeoutMs(opts.invokeTimeout);
const nodeId = await deps.resolveNodeId(opts, normalizeOptionalString(opts.node) ?? "");
return await deps.callGatewayCli(
"node.invoke",
opts,
deps.buildNodeInvokeParams({
nodeId,
command,
params,
timeoutMs: typeof timeoutMs === "number" ? timeoutMs : undefined,
}),
);
}
/** Registers Canvas subcommands under the nodes CLI command group. */
export function registerNodesCanvasCommands(nodes: Command, deps: CanvasCliDependencies) {
const canvas = nodes
.command("canvas")
.description("Capture or render canvas content from a paired node");
deps.nodesCallOpts(
canvas
.command("snapshot")
.description("Capture a canvas snapshot (prints the saved path)")
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
.option("--format <png|jpg|jpeg>", "Image format", "jpg")
.option("--max-width <px>", "Max width in px (optional)")
.option("--quality <0-1>", "JPEG quality (optional)")
.option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 20000)", "20000")
.action(async (opts: CanvasNodesRpcOpts) => {
await deps.runNodesCommand("canvas snapshot", async () => {
const format = parseCanvasSnapshotRequestFormat(opts.format);
const maxWidth = parseCanvasPositiveIntOption(opts.maxWidth, "--max-width");
const quality = parseCanvasSnapshotQualityOption(opts.quality);
const raw = await invokeCanvas(deps, opts, "canvas.snapshot", {
format,
maxWidth: Number.isFinite(maxWidth) ? maxWidth : undefined,
quality: Number.isFinite(quality) ? quality : undefined,
});
const res = typeof raw === "object" && raw !== null ? (raw as { payload?: unknown }) : {};
const payload = parseCanvasSnapshotPayload(res.payload);
const filePath = canvasSnapshotTempPath({
ext: payload.format === "jpeg" ? "jpg" : payload.format,
});
await deps.writeBase64ToFile(filePath, payload.base64);
if (opts.json) {
deps.defaultRuntime.writeJson({ file: { path: filePath, format: payload.format } });
return;
}
deps.defaultRuntime.log(deps.shortenHomePath(filePath));
});
}),
{ timeoutMs: 60_000 },
);
deps.nodesCallOpts(
canvas
.command("present")
.description("Show the canvas (optionally with a target URL/path)")
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
.option("--target <urlOrPath>", "Target URL/path (optional)")
.option("--x <px>", "Placement x coordinate")
.option("--y <px>", "Placement y coordinate")
.option("--width <px>", "Placement width")
.option("--height <px>", "Placement height")
.option("--invoke-timeout <ms>", "Node invoke timeout in ms")
.action(async (opts: CanvasNodesRpcOpts) => {
await deps.runNodesCommand("canvas present", async () => {
const placement = {
x: parseCanvasFiniteNumberOption(opts.x, "--x"),
y: parseCanvasFiniteNumberOption(opts.y, "--y"),
width: parseCanvasFiniteNumberOption(opts.width, "--width"),
height: parseCanvasFiniteNumberOption(opts.height, "--height"),
};
const params: Record<string, unknown> = {};
if (opts.target) {
params.url = opts.target;
}
if (
Number.isFinite(placement.x) ||
Number.isFinite(placement.y) ||
Number.isFinite(placement.width) ||
Number.isFinite(placement.height)
) {
params.placement = placement;
}
await invokeCanvas(deps, opts, "canvas.present", params);
if (!opts.json) {
const { ok } = deps.getNodesTheme();
deps.defaultRuntime.log(ok("canvas present ok"));
}
});
}),
);
deps.nodesCallOpts(
canvas
.command("hide")
.description("Hide the canvas")
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
.option("--invoke-timeout <ms>", "Node invoke timeout in ms")
.action(async (opts: CanvasNodesRpcOpts) => {
await deps.runNodesCommand("canvas hide", async () => {
await invokeCanvas(deps, opts, "canvas.hide", undefined);
if (!opts.json) {
const { ok } = deps.getNodesTheme();
deps.defaultRuntime.log(ok("canvas hide ok"));
}
});
}),
);
deps.nodesCallOpts(
canvas
.command("navigate")
.description("Navigate the canvas to a URL")
.argument("<url>", "Target URL/path")
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
.option("--invoke-timeout <ms>", "Node invoke timeout in ms")
.action(async (url: string, opts: CanvasNodesRpcOpts) => {
await deps.runNodesCommand("canvas navigate", async () => {
await invokeCanvas(deps, opts, "canvas.navigate", { url });
if (!opts.json) {
const { ok } = deps.getNodesTheme();
deps.defaultRuntime.log(ok("canvas navigate ok"));
}
});
}),
);
deps.nodesCallOpts(
canvas
.command("eval")
.description("Evaluate JavaScript in the canvas")
.argument("[js]", "JavaScript to evaluate")
.option("--js <code>", "JavaScript to evaluate")
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
.option("--invoke-timeout <ms>", "Node invoke timeout in ms")
.action(async (jsArg: string | undefined, opts: CanvasNodesRpcOpts) => {
await deps.runNodesCommand("canvas eval", async () => {
const js = opts.js ?? jsArg;
if (!js) {
throw new Error("missing --js or <js>");
}
const raw = await invokeCanvas(deps, opts, "canvas.eval", {
javaScript: js,
});
if (opts.json) {
deps.defaultRuntime.writeJson(raw);
return;
}
const payload =
typeof raw === "object" && raw !== null
? (raw as { payload?: { result?: string } }).payload
: undefined;
if (payload?.result) {
deps.defaultRuntime.log(payload.result);
} else {
const { ok } = deps.getNodesTheme();
deps.defaultRuntime.log(ok("canvas eval ok"));
}
});
}),
);
const a2ui = canvas.command("a2ui").description("Render A2UI content on the canvas");
deps.nodesCallOpts(
a2ui
.command("push")
.description("Push A2UI JSONL to the canvas")
.option("--jsonl <path>", "Path to JSONL payload")
.option("--text <text>", "Render a quick A2UI text payload")
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
.option("--invoke-timeout <ms>", "Node invoke timeout in ms")
.action(async (opts: CanvasNodesRpcOpts) => {
await deps.runNodesCommand("canvas a2ui push", async () => {
const hasJsonl = Boolean(opts.jsonl);
const hasText = typeof opts.text === "string";
if (hasJsonl === hasText) {
throw new Error("provide exactly one of --jsonl or --text");
}
const jsonl = hasText
? buildA2UITextJsonl(opts.text ?? "")
: await fs.readFile(String(opts.jsonl), "utf8");
const { version, messageCount } = validateA2UIJsonl(jsonl);
if (version === "v0.9") {
throw new Error(
"Detected A2UI v0.9 JSONL (createSurface). OpenClaw currently supports v0.8 only.",
);
}
await invokeCanvas(deps, opts, "canvas.a2ui.pushJSONL", { jsonl });
if (!opts.json) {
const { ok } = deps.getNodesTheme();
deps.defaultRuntime.log(
ok(
`canvas a2ui push ok (v0.8, ${messageCount} message${messageCount === 1 ? "" : "s"})`,
),
);
}
});
}),
);
deps.nodesCallOpts(
a2ui
.command("reset")
.description("Reset A2UI renderer state")
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
.option("--invoke-timeout <ms>", "Node invoke timeout in ms")
.action(async (opts: CanvasNodesRpcOpts) => {
await deps.runNodesCommand("canvas a2ui reset", async () => {
await invokeCanvas(deps, opts, "canvas.a2ui.reset", undefined);
if (!opts.json) {
const { ok } = deps.getNodesTheme();
deps.defaultRuntime.log(ok("canvas a2ui reset ok"));
}
});
}),
);
}

View File

@@ -0,0 +1,82 @@
// Canvas tests cover config migration plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, test } from "vitest";
import { migrateLegacyCanvasHostConfig } from "./config-migration.js";
describe("migrateLegacyCanvasHostConfig", () => {
test("moves legacy canvasHost into the Canvas plugin config", () => {
const result = migrateLegacyCanvasHostConfig({
canvasHost: {
enabled: false,
root: "~/canvas",
liveReload: false,
},
} as OpenClawConfig);
if (!result) {
throw new Error("expected Canvas config migration result");
}
expect(result.changes).toEqual(["migrated canvasHost to plugins.entries.canvas.config.host"]);
expect(result.config).toEqual({
plugins: {
entries: {
canvas: {
config: {
host: {
enabled: false,
root: "~/canvas",
liveReload: false,
},
},
},
},
},
});
});
test("preserves plugin-owned Canvas host values when both shapes exist", () => {
const result = migrateLegacyCanvasHostConfig({
canvasHost: {
enabled: false,
root: "~/legacy-canvas",
liveReload: false,
},
plugins: {
entries: {
canvas: {
enabled: true,
config: {
host: {
root: "~/plugin-canvas",
},
},
},
},
},
} as OpenClawConfig);
if (!result) {
throw new Error("expected Canvas config migration result");
}
expect(result.config).toEqual({
plugins: {
entries: {
canvas: {
enabled: true,
config: {
host: {
enabled: false,
root: "~/plugin-canvas",
liveReload: false,
},
},
},
},
},
});
});
test("ignores configs without legacy canvasHost", () => {
expect(migrateLegacyCanvasHostConfig({} as OpenClawConfig)).toBeNull();
});
});

View File

@@ -0,0 +1,51 @@
/**
* Canvas config migration from legacy root canvasHost config to plugin config.
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { asOptionalRecord as readRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
type MutableRecord = Record<string, unknown>;
function mergeHostConfig(params: {
legacyHost: MutableRecord;
existingHost: MutableRecord | undefined;
}): MutableRecord {
return Object.assign({}, params.legacyHost, params.existingHost);
}
/** Migrates root canvasHost config into plugins.entries.canvas.config.host. */
export function migrateLegacyCanvasHostConfig(config: OpenClawConfig): {
config: OpenClawConfig;
changes: string[];
} | null {
const legacyHost = readRecord((config as { canvasHost?: unknown }).canvasHost);
if (!legacyHost) {
return null;
}
const plugins = structuredClone(readRecord(config.plugins) ?? {});
const entries = readRecord(plugins.entries) ?? {};
const canvasEntry = readRecord(entries.canvas) ?? {};
const canvasConfig = readRecord(canvasEntry.config) ?? {};
const existingHost = readRecord(canvasConfig.host);
entries.canvas = {
...canvasEntry,
config: {
...canvasConfig,
host: mergeHostConfig({
legacyHost,
existingHost,
}),
},
};
plugins.entries = entries;
const next = { ...config, plugins } as OpenClawConfig & { canvasHost?: unknown };
delete next.canvasHost;
return {
config: next,
changes: ["migrated canvasHost to plugins.entries.canvas.config.host"],
};
}

View File

@@ -0,0 +1,88 @@
// Canvas tests cover config plugin behavior.
import { afterEach, describe, expect, it } from "vitest";
import {
isCanvasHostEnabled,
isCanvasPluginEnabled,
parseCanvasPluginConfig,
resolveCanvasHostConfig,
} from "./config.js";
describe("Canvas plugin config", () => {
const originalSkipCanvasHost = process.env.OPENCLAW_SKIP_CANVAS_HOST;
afterEach(() => {
if (originalSkipCanvasHost === undefined) {
delete process.env.OPENCLAW_SKIP_CANVAS_HOST;
} else {
process.env.OPENCLAW_SKIP_CANVAS_HOST = originalSkipCanvasHost;
}
});
it("parses host config from the plugin entry", () => {
expect(
parseCanvasPluginConfig({
host: {
enabled: false,
root: "~/canvas",
port: 18793,
liveReload: false,
ignored: true,
},
}),
).toEqual({
host: {
enabled: false,
root: "~/canvas",
port: 18793,
liveReload: false,
},
});
});
it("resolves host config from the plugin entry only", () => {
expect(
resolveCanvasHostConfig({
config: {
plugins: {
entries: {
canvas: {
config: {
host: {
enabled: false,
root: "/plugin",
liveReload: false,
},
},
},
},
},
},
}),
).toEqual({
enabled: false,
root: "/plugin",
liveReload: false,
});
});
it("disables the host when the bundled Canvas plugin is disabled", () => {
const config = {
plugins: {
entries: {
canvas: {
enabled: false,
},
},
},
};
expect(isCanvasPluginEnabled(config)).toBe(false);
expect(isCanvasHostEnabled(config)).toBe(false);
});
it("honors truthy skip-canvas env values before host registration", () => {
for (const value of ["1", "true", " yes ", "ON"]) {
process.env.OPENCLAW_SKIP_CANVAS_HOST = value;
expect(isCanvasHostEnabled()).toBe(false);
}
});
});

View File

@@ -0,0 +1,127 @@
/**
* Canvas plugin config parsing, enablement, and schema metadata.
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
normalizePluginsConfig,
resolveEffectiveEnableState,
resolvePluginConfigObject,
} from "openclaw/plugin-sdk/plugin-config-runtime";
import { isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env";
import {
asBoolean as readBoolean,
isRecord,
readStringValue as readString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
/** Host-server configuration for Canvas and A2UI assets. */
export type CanvasHostConfig = {
enabled?: boolean;
root?: string;
port?: number;
liveReload?: boolean;
};
/** Canvas plugin configuration shape. */
export type CanvasPluginConfig = {
host?: CanvasHostConfig;
};
type CanvasPluginConfigSchema = {
parse: (value: unknown) => CanvasPluginConfig;
uiHints: Record<string, { label: string; help?: string; advanced?: boolean }>;
};
function readPositiveInteger(value: unknown): number | undefined {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
}
function parseCanvasHostConfig(value: unknown): CanvasHostConfig | undefined {
if (!isRecord(value)) {
return undefined;
}
return {
...(readBoolean(value.enabled) !== undefined ? { enabled: readBoolean(value.enabled) } : {}),
...(readString(value.root) !== undefined ? { root: readString(value.root) } : {}),
...(readPositiveInteger(value.port) !== undefined
? { port: readPositiveInteger(value.port) }
: {}),
...(readBoolean(value.liveReload) !== undefined
? { liveReload: readBoolean(value.liveReload) }
: {}),
};
}
/** Parses raw Canvas plugin config into a typed, normalized shape. */
export function parseCanvasPluginConfig(value: unknown): CanvasPluginConfig {
if (!isRecord(value)) {
return {};
}
const host = parseCanvasHostConfig(value.host);
return host ? { host } : {};
}
/** Returns whether the bundled Canvas plugin is effectively enabled. */
export function isCanvasPluginEnabled(config?: OpenClawConfig): boolean {
if (!config) {
return true;
}
return resolveEffectiveEnableState({
id: "canvas",
origin: "bundled",
config: normalizePluginsConfig(config.plugins),
rootConfig: config,
enabledByDefault: true,
}).enabled;
}
/** Resolves Canvas host config from plugin config or root config. */
export function resolveCanvasHostConfig(params: {
config?: OpenClawConfig;
pluginConfig?: Record<string, unknown>;
}): CanvasHostConfig {
const pluginConfig =
params.pluginConfig ?? resolvePluginConfigObject(params.config, "canvas") ?? {};
const parsedPluginConfig = parseCanvasPluginConfig(pluginConfig);
return parsedPluginConfig.host ?? {};
}
/** Returns whether the Canvas hosted route/server surface should be active. */
export function isCanvasHostEnabled(config?: OpenClawConfig): boolean {
if (isTruthyEnvValue(process.env.OPENCLAW_SKIP_CANVAS_HOST)) {
return false;
}
if (!isCanvasPluginEnabled(config)) {
return false;
}
return resolveCanvasHostConfig({ config }).enabled !== false;
}
/** Config schema metadata for Canvas plugin settings. */
export const canvasConfigSchema: CanvasPluginConfigSchema = {
parse: parseCanvasPluginConfig,
uiHints: {
host: {
label: "Canvas Host",
help: "Serves local Canvas and A2UI files for paired nodes.",
advanced: true,
},
"host.enabled": {
label: "Canvas Host Enabled",
advanced: true,
},
"host.root": {
label: "Canvas Host Root Directory",
help: "Directory to serve. Defaults to the OpenClaw state canvas directory.",
advanced: true,
},
"host.port": {
label: "Canvas Host Port",
advanced: true,
},
"host.liveReload": {
label: "Canvas Host Live Reload",
advanced: true,
},
},
};

View File

@@ -0,0 +1,265 @@
// Canvas tests cover documents plugin behavior.
import { mkdtemp, mkdir, writeFile, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
buildCanvasDocumentEntryUrl,
createCanvasDocument,
resolveCanvasDocumentAssets,
resolveCanvasDocumentDir,
resolveCanvasHttpPathToLocalPath,
} from "./documents.js";
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map(async (dir) => {
await import("node:fs/promises").then((fs) => fs.rm(dir, { recursive: true, force: true }));
}),
);
});
describe("canvas documents", () => {
it("builds entry urls for materialized path documents under managed storage", async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-"));
tempDirs.push(stateDir);
const workspaceDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-workspace-"));
tempDirs.push(workspaceDir);
await mkdir(path.join(workspaceDir, "player"), { recursive: true });
await writeFile(path.join(workspaceDir, "player/index.html"), "<div>ok</div>", "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(document.id, { stateDir })).toContain(stateDir);
});
it("normalizes nested local entrypoint urls", () => {
const url = buildCanvasDocumentEntryUrl("cv_example", "collection.media/index.html");
expect(url).toBe("/__openclaw__/canvas/documents/cv_example/collection.media/index.html");
});
it("encodes special characters in hosted entrypoint path segments", () => {
const url = buildCanvasDocumentEntryUrl("cv_example", "bundle#1/entry%20point?.html");
expect(url).toBe(
"/__openclaw__/canvas/documents/cv_example/bundle%231/entry%2520point%3F.html",
);
});
it("materializes inline html bundles as index documents", async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-"));
tempDirs.push(stateDir);
const document = await createCanvasDocument(
{
kind: "html_bundle",
title: "Preview",
entrypoint: {
type: "html",
value:
"<!doctype html><html><head><style>.demo{color:red}</style></head><body><div class='demo'>Front</div></body></html>",
},
},
{ stateDir },
);
const indexHtml = await import("node:fs/promises").then((fs) =>
fs.readFile(
path.join(resolveCanvasDocumentDir(document.id, { stateDir }), "index.html"),
"utf8",
),
);
expect(indexHtml).toContain("<div class='demo'>Front</div>");
expect(indexHtml).toContain("<style>.demo{color:red}</style>");
expect(document.title).toBe("Preview");
expect(document.entryUrl).toBe(`/__openclaw__/canvas/documents/${document.id}/index.html`);
});
it("reuses a supplied stable document id by replacing the prior materialized view", async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-"));
tempDirs.push(stateDir);
const first = await createCanvasDocument(
{
id: "status-card",
kind: "html_bundle",
entrypoint: { type: "html", value: "<div>first</div>" },
},
{ stateDir },
);
const second = await createCanvasDocument(
{
id: "status-card",
kind: "html_bundle",
entrypoint: { type: "html", value: "<div>second</div>" },
},
{ stateDir },
);
expect(first.id).toBe("status-card");
expect(second.id).toBe("status-card");
const indexHtml = await import("node:fs/promises").then((fs) =>
fs.readFile(
path.join(resolveCanvasDocumentDir(second.id, { stateDir }), "index.html"),
"utf8",
),
);
expect(indexHtml).toContain("second");
expect(indexHtml).not.toContain("first");
});
it("exposes stable managed asset urls for copied canvas assets", async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-"));
tempDirs.push(stateDir);
const workspaceDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-workspace-"));
tempDirs.push(workspaceDir);
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:
'<audio controls><source src="collection.media/audio.mp3" type="audio/mpeg" /></audio>',
},
assets: [
{
logicalPath: "collection.media/audio.mp3",
sourcePath: "collection.media/audio.mp3",
contentType: "audio/mpeg",
},
],
},
{ stateDir, workspaceDir },
);
expect(resolveCanvasDocumentAssets(document, { stateDir })).toEqual([
{
logicalPath: "collection.media/audio.mp3",
contentType: "audio/mpeg",
localPath: path.join(
resolveCanvasDocumentDir(document.id, { stateDir }),
"collection.media/audio.mp3",
),
url: `/__openclaw__/canvas/documents/${document.id}/collection.media/audio.mp3`,
},
]);
expect(
resolveCanvasDocumentAssets(document, {
baseUrl: "http://127.0.0.1:19003",
stateDir,
}),
).toEqual([
{
logicalPath: "collection.media/audio.mp3",
contentType: "audio/mpeg",
localPath: path.join(
resolveCanvasDocumentDir(document.id, { stateDir }),
"collection.media/audio.mp3",
),
url: `http://127.0.0.1:19003/__openclaw__/canvas/documents/${document.id}/collection.media/audio.mp3`,
},
]);
});
it("wraps local pdf documents in an index viewer page", async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-"));
tempDirs.push(stateDir);
const workspaceDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-workspace-"));
tempDirs.push(workspaceDir);
await writeFile(path.join(workspaceDir, "demo.pdf"), "%PDF-1.4", "utf8");
const document = await createCanvasDocument(
{
kind: "document",
entrypoint: {
type: "path",
value: "demo.pdf",
},
},
{ stateDir, workspaceDir },
);
expect(document.entryUrl).toBe(`/__openclaw__/canvas/documents/${document.id}/index.html`);
const indexHtml = await readFile(
path.join(resolveCanvasDocumentDir(document.id, { stateDir }), "index.html"),
"utf8",
);
expect(indexHtml).toContain('type="application/pdf"');
expect(indexHtml).toContain('data="demo.pdf"');
});
it("wraps remote pdf urls in an index viewer page", async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-"));
tempDirs.push(stateDir);
const document = await createCanvasDocument(
{
kind: "document",
entrypoint: {
type: "url",
value: "https://example.com/demo.pdf",
},
},
{ stateDir },
);
expect(document.entryUrl).toBe(`/__openclaw__/canvas/documents/${document.id}/index.html`);
const indexHtml = await readFile(
path.join(resolveCanvasDocumentDir(document.id, { stateDir }), "index.html"),
"utf8",
);
expect(indexHtml).toContain('type="application/pdf"');
expect(indexHtml).toContain('data="https://example.com/demo.pdf"');
});
it("rejects traversal-style document ids in hosted canvas paths", async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-"));
tempDirs.push(stateDir);
expect(
resolveCanvasHttpPathToLocalPath(
"/__openclaw__/canvas/documents/../collection.media/index.html",
{ stateDir },
),
).toBeNull();
});
it("rejects malformed encoded hosted canvas document paths", async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-"));
tempDirs.push(stateDir);
const documentId = "cv_malformed";
const documentDir = resolveCanvasDocumentDir(documentId, { stateDir });
await mkdir(documentDir, { recursive: true });
await writeFile(path.join(documentDir, "%E0%A4%A.html"), "literal-percent-name", "utf8");
expect(
resolveCanvasHttpPathToLocalPath(
`/__openclaw__/canvas/documents/${documentId}/%E0%A4%A.html`,
{ stateDir },
),
).toBeNull();
expect(
resolveCanvasHttpPathToLocalPath(
`/__openclaw__/canvas/documents/${documentId}/%25E0%25A4%25A.html`,
{ stateDir },
),
).toBe(path.join(documentDir, "%E0%A4%A.html"));
});
});

View File

@@ -0,0 +1,351 @@
/**
* Canvas document materialization helpers for hosted HTML, media, documents,
* and asset manifests.
*/
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { root as fsRoot, sanitizeUntrustedFileName } from "openclaw/plugin-sdk/security-runtime";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { resolveUserPath } from "openclaw/plugin-sdk/text-utility-runtime";
import { CANVAS_HOST_PATH } from "./host/a2ui.js";
type CanvasDocumentKind = "html_bundle" | "url_embed" | "document" | "image" | "video_asset";
type CanvasDocumentAsset = {
logicalPath: string;
sourcePath: string;
contentType?: string;
};
type CanvasDocumentEntrypoint =
| { type: "html"; value: string }
| { type: "path"; value: string }
| { type: "url"; value: string };
type CanvasDocumentCreateInput = {
id?: string;
kind: CanvasDocumentKind;
title?: string;
preferredHeight?: number;
entrypoint?: CanvasDocumentEntrypoint;
assets?: CanvasDocumentAsset[];
surface?: "assistant_message" | "tool_card" | "sidebar";
};
type CanvasDocumentManifest = {
id: string;
kind: CanvasDocumentKind;
title?: string;
preferredHeight?: number;
createdAt: string;
entryUrl: string;
localEntrypoint?: string;
externalUrl?: string;
surface?: "assistant_message" | "tool_card" | "sidebar";
assets: Array<{
logicalPath: string;
contentType?: string;
}>;
};
type CanvasDocumentResolvedAsset = {
logicalPath: string;
contentType?: string;
url: string;
localPath: string;
};
const CANVAS_DOCUMENTS_DIR_NAME = "documents";
function isPdfPathLike(value: string): boolean {
return /\.pdf(?:[?#].*)?$/i.test(value.trim());
}
function buildPdfWrapper(url: string): string {
const escaped = escapeHtml(url);
return `<!doctype html><html><body style="margin:0;background:#e5e7eb;"><object data="${escaped}" type="application/pdf" style="width:100%;height:100vh;border:0;"><iframe src="${escaped}" style="width:100%;height:100vh;border:0;"></iframe><p style="padding:16px;font:14px system-ui,sans-serif;">Unable to render PDF preview. <a href="${escaped}" target="_blank" rel="noopener noreferrer">Open PDF</a>.</p></object></body></html>`;
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function normalizeLogicalPath(value: string): string {
const normalized = value.replaceAll("\\", "/").replace(/^\/+/, "");
const parts = normalized.split("/").filter(Boolean);
if (
parts.length === 0 ||
parts.some(
(part) => part === "." || part === ".." || part.includes(":") || hasControlCharacter(part),
)
) {
throw new Error("canvas document logicalPath invalid");
}
return parts.join("/");
}
function hasControlCharacter(value: string): boolean {
for (const char of value) {
const code = char.charCodeAt(0);
if (code < 0x20 || code === 0x7f) {
return true;
}
}
return false;
}
function canvasDocumentId(): string {
return `cv_${randomUUID().replaceAll("-", "")}`;
}
function normalizeCanvasDocumentId(value: string): string {
const normalized = value.trim();
if (
!normalized ||
normalized === "." ||
normalized === ".." ||
!/^[A-Za-z0-9._-]+$/.test(normalized)
) {
throw new Error("canvas document id invalid");
}
return normalized;
}
function resolveCanvasRootDir(rootDir?: string, stateDir = resolveStateDir()): string {
const resolved = rootDir?.trim() ? resolveUserPath(rootDir) : path.join(stateDir, "canvas");
return path.resolve(resolved);
}
function resolveCanvasDocumentsDir(rootDir?: string, stateDir = resolveStateDir()): string {
return path.join(resolveCanvasRootDir(rootDir, stateDir), CANVAS_DOCUMENTS_DIR_NAME);
}
/** Resolves the on-disk directory for one Canvas document id. */
export function resolveCanvasDocumentDir(
documentId: string,
options?: { rootDir?: string; stateDir?: string },
): string {
return path.join(resolveCanvasDocumentsDir(options?.rootDir, options?.stateDir), documentId);
}
/** Builds the hosted URL path for a Canvas document entrypoint. */
export function buildCanvasDocumentEntryUrl(documentId: string, entrypoint: string): string {
const normalizedEntrypoint = normalizeLogicalPath(entrypoint);
const encodedEntrypoint = normalizedEntrypoint
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
return `${CANVAS_HOST_PATH}/${CANVAS_DOCUMENTS_DIR_NAME}/${encodeURIComponent(documentId)}/${encodedEntrypoint}`;
}
function buildCanvasDocumentAssetUrl(documentId: string, logicalPath: string): string {
return buildCanvasDocumentEntryUrl(documentId, logicalPath);
}
/** Maps a Canvas hosted document URL path back to a local file path. */
export function resolveCanvasHttpPathToLocalPath(
requestPath: string,
options?: { rootDir?: string; stateDir?: string },
): string | null {
const trimmed = requestPath.trim();
const prefix = `${CANVAS_HOST_PATH}/${CANVAS_DOCUMENTS_DIR_NAME}/`;
if (!trimmed.startsWith(prefix)) {
return null;
}
const pathWithoutQuery = trimmed.replace(/[?#].*$/, "");
const relative = pathWithoutQuery.slice(prefix.length);
const segments: string[] = [];
for (const segment of relative.split("/")) {
if (!segment) {
continue;
}
try {
segments.push(decodeURIComponent(segment));
} catch {
return null;
}
}
if (segments.length < 2) {
return null;
}
const [rawDocumentId, ...entrySegments] = segments;
try {
const documentId = normalizeCanvasDocumentId(rawDocumentId);
const normalizedEntrypoint = normalizeLogicalPath(entrySegments.join("/"));
const documentsDir = path.resolve(
resolveCanvasDocumentsDir(options?.rootDir, options?.stateDir),
);
const candidatePath = path.resolve(
resolveCanvasDocumentDir(documentId, options),
normalizedEntrypoint,
);
if (
!(candidatePath === documentsDir || candidatePath.startsWith(`${documentsDir}${path.sep}`))
) {
return null;
}
return candidatePath;
} catch {
return null;
}
}
type CanvasDocumentRoot = Awaited<ReturnType<typeof fsRoot>>;
async function writeManifest(
root: CanvasDocumentRoot,
manifest: CanvasDocumentManifest,
): Promise<void> {
await root.writeJson("manifest.json", manifest, { space: 2 });
}
async function copyAssets(
root: CanvasDocumentRoot,
assets: CanvasDocumentAsset[] | undefined,
workspaceDir: string,
): Promise<CanvasDocumentManifest["assets"]> {
const copied: CanvasDocumentManifest["assets"] = [];
for (const asset of assets ?? []) {
const logicalPath = normalizeLogicalPath(asset.logicalPath);
const sourcePath = asset.sourcePath.startsWith("~")
? resolveUserPath(asset.sourcePath)
: path.isAbsolute(asset.sourcePath)
? path.resolve(asset.sourcePath)
: path.resolve(workspaceDir, asset.sourcePath);
await root.copyIn(logicalPath, sourcePath);
copied.push({
logicalPath,
...(asset.contentType ? { contentType: asset.contentType } : {}),
});
}
return copied;
}
async function materializeEntrypoint(
rootDir: string,
root: CanvasDocumentRoot,
input: CanvasDocumentCreateInput,
workspaceDir: string,
): Promise<Pick<CanvasDocumentManifest, "entryUrl" | "localEntrypoint" | "externalUrl">> {
const entrypoint = input.entrypoint;
if (!entrypoint) {
throw new Error("canvas document entrypoint required");
}
if (entrypoint.type === "html") {
const fileName = "index.html";
await root.write(fileName, entrypoint.value);
return {
localEntrypoint: fileName,
entryUrl: buildCanvasDocumentEntryUrl(path.basename(rootDir), fileName),
};
}
if (entrypoint.type === "url") {
if (input.kind === "document" && isPdfPathLike(entrypoint.value)) {
const fileName = "index.html";
await root.write(fileName, buildPdfWrapper(entrypoint.value));
return {
localEntrypoint: fileName,
externalUrl: entrypoint.value,
entryUrl: buildCanvasDocumentEntryUrl(path.basename(rootDir), fileName),
};
}
return {
externalUrl: entrypoint.value,
entryUrl: entrypoint.value,
};
}
const resolvedPath = entrypoint.value.startsWith("~")
? resolveUserPath(entrypoint.value)
: path.isAbsolute(entrypoint.value)
? path.resolve(entrypoint.value)
: path.resolve(workspaceDir, entrypoint.value);
if (input.kind === "image" || input.kind === "video_asset") {
const copiedName = sanitizeUntrustedFileName(path.basename(resolvedPath), "asset");
await root.copyIn(copiedName, resolvedPath);
const wrapper =
input.kind === "image"
? `<!doctype html><html><body style="margin:0;background:#0f172a;display:flex;align-items:center;justify-content:center;"><img src="${escapeHtml(copiedName)}" style="max-width:100%;max-height:100vh;object-fit:contain;" /></body></html>`
: `<!doctype html><html><body style="margin:0;background:#0f172a;"><video src="${escapeHtml(copiedName)}" controls autoplay style="width:100%;height:100vh;object-fit:contain;background:#000;"></video></body></html>`;
await root.write("index.html", wrapper);
return {
localEntrypoint: "index.html",
entryUrl: buildCanvasDocumentEntryUrl(path.basename(rootDir), "index.html"),
};
}
const fileName = sanitizeUntrustedFileName(path.basename(resolvedPath), "document");
await root.copyIn(fileName, resolvedPath);
if (input.kind === "document" && isPdfPathLike(fileName)) {
await root.write("index.html", buildPdfWrapper(fileName));
return {
localEntrypoint: "index.html",
entryUrl: buildCanvasDocumentEntryUrl(path.basename(rootDir), "index.html"),
};
}
return {
localEntrypoint: fileName,
entryUrl: buildCanvasDocumentEntryUrl(path.basename(rootDir), fileName),
};
}
/** Creates a Canvas document directory, copies assets, and writes its manifest. */
export async function createCanvasDocument(
input: CanvasDocumentCreateInput,
options?: { stateDir?: string; workspaceDir?: string; canvasRootDir?: string },
): Promise<CanvasDocumentManifest> {
const workspaceDir = options?.workspaceDir ?? process.cwd();
const id = input.id?.trim() ? normalizeCanvasDocumentId(input.id) : canvasDocumentId();
const rootDir = resolveCanvasDocumentDir(id, {
stateDir: options?.stateDir,
rootDir: options?.canvasRootDir,
});
await fs.rm(rootDir, { recursive: true, force: true }).catch(() => undefined);
await fs.mkdir(rootDir, { recursive: true });
const root = await fsRoot(rootDir);
const assets = await copyAssets(root, input.assets, workspaceDir);
const entry = await materializeEntrypoint(rootDir, root, input, workspaceDir);
const manifest: CanvasDocumentManifest = {
id,
kind: input.kind,
...(input.title?.trim() ? { title: input.title.trim() } : {}),
...(typeof input.preferredHeight === "number"
? { preferredHeight: input.preferredHeight }
: {}),
...(input.surface ? { surface: input.surface } : {}),
createdAt: new Date().toISOString(),
entryUrl: entry.entryUrl,
...(entry.localEntrypoint ? { localEntrypoint: entry.localEntrypoint } : {}),
...(entry.externalUrl ? { externalUrl: entry.externalUrl } : {}),
assets,
};
await writeManifest(root, manifest);
return manifest;
}
/** Resolves manifest assets to local paths and hosted URLs. */
export function resolveCanvasDocumentAssets(
manifest: CanvasDocumentManifest,
options?: { baseUrl?: string; stateDir?: string; canvasRootDir?: string },
): CanvasDocumentResolvedAsset[] {
const baseUrl = options?.baseUrl?.trim().replace(/\/+$/, "");
const documentDir = resolveCanvasDocumentDir(manifest.id, {
stateDir: options?.stateDir,
rootDir: options?.canvasRootDir,
});
return manifest.assets.map((asset) => ({
logicalPath: asset.logicalPath,
...(asset.contentType ? { contentType: asset.contentType } : {}),
localPath: path.join(documentDir, asset.logicalPath),
url: baseUrl
? `${baseUrl}${buildCanvasDocumentAssetUrl(manifest.id, asset.logicalPath)}`
: buildCanvasDocumentAssetUrl(manifest.id, asset.logicalPath),
}));
}

View File

@@ -0,0 +1,77 @@
// Canvas tests cover host url plugin behavior.
import { describe, expect, it } from "vitest";
import { resolveCanvasHostUrl } from "./host-url.js";
describe("resolveCanvasHostUrl", () => {
it.each([
{
name: "returns undefined when no canvas port is available",
params: {},
expected: undefined,
},
{
name: "returns undefined when only a loopback host override is available",
params: { canvasPort: 3000, hostOverride: "127.0.0.1" },
expected: undefined,
},
{
name: "prefers non-loopback host overrides and preserves explicit ports",
params: {
canvasPort: 3000,
hostOverride: " canvas.openclaw.ai ",
requestHost: "gateway.local:9000",
localAddress: "192.168.1.10",
},
expected: "http://canvas.openclaw.ai:3000",
},
{
name: "falls back from rejected loopback overrides to request hosts",
params: {
canvasPort: 3000,
hostOverride: "127.0.0.1",
requestHost: "example.com:8443",
},
expected: "http://example.com:3000",
},
{
name: "maps proxied default gateway ports to request-host ports",
params: {
canvasPort: 18789,
requestHost: "gateway.example.com:9443",
forwardedProto: "https",
},
expected: "https://gateway.example.com:9443",
},
{
name: "maps proxied default gateway ports to scheme defaults",
params: {
canvasPort: 18789,
requestHost: "gateway.example.com",
forwardedProto: ["https", "http"],
},
expected: "https://gateway.example.com:443",
},
{
name: "uses http scheme defaults without forwarded proto",
params: {
canvasPort: 18789,
requestHost: "gateway.example.com",
},
expected: "http://gateway.example.com:80",
},
{
name: "brackets ipv6 hosts and can fall back to local addresses",
params: {
canvasPort: 3000,
requestHost: "not a host",
localAddress: "2001:db8::1",
scheme: "https",
},
expected: "https://[2001:db8::1]:3000",
},
])("$name", ({ params, expected }) => {
expect(resolveCanvasHostUrl(params as Parameters<typeof resolveCanvasHostUrl>[0])).toBe(
expected,
);
});
});

View File

@@ -0,0 +1,19 @@
/**
* Canvas hosted-surface URL resolver.
*/
import {
resolveHostedPluginSurfaceUrl,
type HostedPluginSurfaceUrlParams,
} from "openclaw/plugin-sdk/gateway-runtime";
type CanvasHostUrlParams = Omit<HostedPluginSurfaceUrlParams, "port"> & {
canvasPort?: number;
};
/** Resolves the externally visible Canvas host URL for a gateway/plugin surface. */
export function resolveCanvasHostUrl(params: CanvasHostUrlParams) {
return resolveHostedPluginSurfaceUrl({
...params,
port: params.canvasPort,
});
}

View File

@@ -0,0 +1,596 @@
/**
* Canvas A2UI browser bootstrap that installs theme overrides and native bridge
* helpers.
*/
import { v0_8 } from "@a2ui/lit";
import { ContextProvider } from "@lit/context";
import { themeContext } from "@openclaw/a2ui-theme-context";
import { html, css, LitElement, unsafeCSS } from "lit";
import "@a2ui/lit/ui";
import { repeat } from "lit/directives/repeat.js";
const modalStyles = css`
dialog {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 24px;
border: none;
background: rgba(5, 8, 16, 0.65);
backdrop-filter: blur(6px);
display: grid;
place-items: center;
}
dialog::backdrop {
background: rgba(5, 8, 16, 0.65);
backdrop-filter: blur(6px);
}
`;
const modalElement = customElements.get("a2ui-modal");
if (modalElement && Array.isArray(modalElement.styles)) {
modalElement.styles = [...modalElement.styles, modalStyles];
}
const appendComponentStyles = (tagName, extraStyles) => {
const component = customElements.get(tagName);
if (!component) {
return;
}
const current = component.styles;
if (!current) {
component.styles = [extraStyles];
return;
}
component.styles = Array.isArray(current) ? [...current, extraStyles] : [current, extraStyles];
};
appendComponentStyles(
"a2ui-row",
css`
@media (max-width: 860px) {
section {
flex-wrap: wrap;
align-content: flex-start;
}
::slotted(*) {
flex: 1 1 100%;
min-width: 100%;
width: 100%;
max-width: 100%;
}
}
`,
);
appendComponentStyles(
"a2ui-column",
css`
:host {
min-width: 0;
}
section {
min-width: 0;
}
`,
);
appendComponentStyles(
"a2ui-card",
css`
:host {
min-width: 0;
}
section {
min-width: 0;
}
`,
);
const emptyClasses = () => ({});
const textHintStyles = () => ({ h1: {}, h2: {}, h3: {}, h4: {}, h5: {}, body: {}, caption: {} });
const isAndroid = /Android/i.test(globalThis.navigator?.userAgent ?? "");
const cardShadow = isAndroid ? "0 2px 10px rgba(0,0,0,.18)" : "0 10px 30px rgba(0,0,0,.35)";
const buttonShadow = isAndroid
? "0 2px 10px rgba(6, 182, 212, 0.14)"
: "0 10px 25px rgba(6, 182, 212, 0.18)";
const statusShadow = isAndroid
? "0 2px 10px rgba(0, 0, 0, 0.18)"
: "0 10px 24px rgba(0, 0, 0, 0.25)";
const statusBlur = isAndroid ? "10px" : "14px";
const postNativeMessage = (handler, payload) => {
Reflect.apply(handler.postMessage, handler, [payload]);
};
const openclawTheme = {
components: {
AudioPlayer: emptyClasses(),
Button: emptyClasses(),
Card: emptyClasses(),
Column: emptyClasses(),
CheckBox: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
DateTimeInput: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
Divider: emptyClasses(),
Image: {
all: emptyClasses(),
icon: emptyClasses(),
avatar: emptyClasses(),
smallFeature: emptyClasses(),
mediumFeature: emptyClasses(),
largeFeature: emptyClasses(),
header: emptyClasses(),
},
Icon: emptyClasses(),
List: emptyClasses(),
Modal: { backdrop: emptyClasses(), element: emptyClasses() },
MultipleChoice: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
Row: emptyClasses(),
Slider: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
Tabs: {
container: emptyClasses(),
element: emptyClasses(),
controls: { all: emptyClasses(), selected: emptyClasses() },
},
Text: {
all: emptyClasses(),
h1: emptyClasses(),
h2: emptyClasses(),
h3: emptyClasses(),
h4: emptyClasses(),
h5: emptyClasses(),
caption: emptyClasses(),
body: emptyClasses(),
},
TextField: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
Video: emptyClasses(),
},
elements: {
a: emptyClasses(),
audio: emptyClasses(),
body: emptyClasses(),
button: emptyClasses(),
h1: emptyClasses(),
h2: emptyClasses(),
h3: emptyClasses(),
h4: emptyClasses(),
h5: emptyClasses(),
iframe: emptyClasses(),
input: emptyClasses(),
p: emptyClasses(),
pre: emptyClasses(),
textarea: emptyClasses(),
video: emptyClasses(),
},
markdown: {
p: [],
h1: [],
h2: [],
h3: [],
h4: [],
h5: [],
ul: [],
ol: [],
li: [],
a: [],
strong: [],
em: [],
},
additionalStyles: {
Card: {
background: "linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.03))",
border: "1px solid rgba(255,255,255,.09)",
borderRadius: "14px",
padding: "14px",
boxShadow: cardShadow,
},
Modal: {
background: "rgba(12, 16, 24, 0.92)",
border: "1px solid rgba(255,255,255,.12)",
borderRadius: "16px",
padding: "16px",
boxShadow: "0 30px 80px rgba(0,0,0,.6)",
width: "min(520px, calc(100vw - 48px))",
},
Column: { gap: "10px" },
Row: { gap: "10px", alignItems: "center" },
Divider: { opacity: "0.25" },
Button: {
background: "linear-gradient(135deg, #22c55e 0%, #06b6d4 100%)",
border: "0",
borderRadius: "12px",
padding: "10px 14px",
color: "#071016",
fontWeight: "650",
cursor: "pointer",
boxShadow: buttonShadow,
},
Text: {
...textHintStyles(),
h1: { fontSize: "20px", fontWeight: "750", margin: "0 0 6px 0" },
h2: { fontSize: "16px", fontWeight: "700", margin: "0 0 6px 0" },
body: { fontSize: "13px", lineHeight: "1.4" },
caption: { opacity: "0.8" },
},
TextField: { display: "grid", gap: "6px" },
Image: { borderRadius: "12px" },
},
};
class OpenClawA2UIHost extends LitElement {
static properties = {
surfaces: { state: true },
pendingAction: { state: true },
toast: { state: true },
};
#processor = v0_8.Data.createSignalA2uiMessageProcessor();
themeProvider = new ContextProvider(this, {
context: themeContext,
initialValue: openclawTheme,
});
surfaces = [];
pendingAction = null;
toast = null;
#statusListener = null;
static styles = css`
:host {
display: block;
height: 100%;
position: relative;
box-sizing: border-box;
padding: var(--openclaw-a2ui-inset-top, 0px) var(--openclaw-a2ui-inset-right, 0px)
var(--openclaw-a2ui-inset-bottom, 0px) var(--openclaw-a2ui-inset-left, 0px);
}
#surfaces {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
height: 100%;
overflow: auto;
padding-bottom: var(--openclaw-a2ui-scroll-pad-bottom, 0px);
}
.status {
position: absolute;
left: 50%;
transform: translateX(-50%);
top: var(--openclaw-a2ui-status-top, 12px);
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.92);
font:
13px/1.2 system-ui,
-apple-system,
BlinkMacSystemFont,
"Roboto",
sans-serif;
pointer-events: none;
backdrop-filter: blur(${unsafeCSS(statusBlur)});
-webkit-backdrop-filter: blur(${unsafeCSS(statusBlur)});
box-shadow: ${unsafeCSS(statusShadow)};
z-index: 5;
}
.toast {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: var(--openclaw-a2ui-toast-bottom, 12px);
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.92);
font:
13px/1.2 system-ui,
-apple-system,
BlinkMacSystemFont,
"Roboto",
sans-serif;
pointer-events: none;
backdrop-filter: blur(${unsafeCSS(statusBlur)});
-webkit-backdrop-filter: blur(${unsafeCSS(statusBlur)});
box-shadow: ${unsafeCSS(statusShadow)};
z-index: 5;
}
.toast.error {
border-color: rgba(255, 109, 109, 0.35);
color: rgba(255, 223, 223, 0.98);
}
.empty {
position: absolute;
left: 50%;
transform: translateX(-50%);
top: var(--openclaw-a2ui-empty-top, var(--openclaw-a2ui-status-top, 12px));
text-align: center;
opacity: 0.8;
padding: 10px 12px;
pointer-events: none;
}
.empty-title {
font-weight: 700;
margin-bottom: 6px;
}
.spinner {
width: 12px;
height: 12px;
border-radius: 999px;
border: 2px solid rgba(255, 255, 255, 0.25);
border-top-color: rgba(255, 255, 255, 0.92);
animation: spin 0.75s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
`;
connectedCallback() {
super.connectedCallback();
const api = {
applyMessages: (messages) => this.applyMessages(messages),
reset: () => this.reset(),
getSurfaces: () => Array.from(this.#processor.getSurfaces().keys()),
};
globalThis.openclawA2UI = api;
this.addEventListener("a2uiaction", (evt) => this.#handleA2UIAction(evt));
this.#statusListener = (evt) => this.#handleActionStatus(evt);
for (const eventName of ["openclaw:a2ui-action-status"]) {
globalThis.addEventListener(eventName, this.#statusListener);
}
this.#syncSurfaces();
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.#statusListener) {
for (const eventName of ["openclaw:a2ui-action-status"]) {
globalThis.removeEventListener(eventName, this.#statusListener);
}
this.#statusListener = null;
}
}
#makeActionId() {
return (
globalThis.crypto?.randomUUID?.() ??
`a2ui_${Date.now()}_${Math.random().toString(16).slice(2)}`
);
}
#setToast(text, kind = "ok", timeoutMs = 1400) {
const toast = { text, kind, expiresAt: Date.now() + timeoutMs };
this.toast = toast;
this.requestUpdate();
setTimeout(() => {
if (this.toast === toast) {
this.toast = null;
this.requestUpdate();
}
}, timeoutMs + 30);
}
#handleActionStatus(evt) {
const detail = evt?.detail ?? null;
if (!detail || typeof detail.id !== "string") {
return;
}
if (!this.pendingAction || this.pendingAction.id !== detail.id) {
return;
}
if (detail.ok) {
this.pendingAction = { ...this.pendingAction, phase: "sent", sentAt: Date.now() };
} else {
const msg = typeof detail.error === "string" && detail.error ? detail.error : "send failed";
this.pendingAction = { ...this.pendingAction, phase: "error", error: msg };
this.#setToast(`Failed: ${msg}`, "error", 4500);
}
this.requestUpdate();
}
#handleA2UIAction(evt) {
const payload = evt?.detail ?? evt?.payload ?? null;
if (!payload || payload.eventType !== "a2ui.action") {
return;
}
const action = payload.action;
const name = action?.name;
if (!name) {
return;
}
const sourceComponentId = payload.sourceComponentId ?? "";
const surfaces = this.#processor.getSurfaces();
let surfaceId = null;
let sourceNode = null;
for (const [sid, surface] of surfaces.entries()) {
const node = surface?.components?.get?.(sourceComponentId) ?? null;
if (node) {
surfaceId = sid;
sourceNode = node;
break;
}
}
const context = {};
const ctxItems = Array.isArray(action?.context) ? action.context : [];
for (const item of ctxItems) {
const key = item?.key;
const value = item?.value ?? null;
if (!key || !value) {
continue;
}
if (typeof value.path === "string") {
const resolved = sourceNode
? this.#processor.getData(sourceNode, value.path, surfaceId ?? undefined)
: null;
context[key] = resolved;
continue;
}
if (Object.hasOwn(value, "literalString")) {
context[key] = value.literalString ?? "";
continue;
}
if (Object.hasOwn(value, "literalNumber")) {
context[key] = value.literalNumber ?? 0;
continue;
}
if (Object.hasOwn(value, "literalBoolean")) {
context[key] = value.literalBoolean ?? false;
continue;
}
}
const actionId = this.#makeActionId();
this.pendingAction = { id: actionId, name, phase: "sending", startedAt: Date.now() };
this.requestUpdate();
const userAction = {
id: actionId,
name,
surfaceId: surfaceId ?? "main",
sourceComponentId,
timestamp: new Date().toISOString(),
...(Object.keys(context).length ? { context } : {}),
};
globalThis["__openclawLastA2UIAction"] = userAction;
const handler =
globalThis.webkit?.messageHandlers?.openclawCanvasA2UIAction ??
globalThis.openclawCanvasA2UIAction;
if (handler?.postMessage) {
try {
// WebKit message handlers support structured objects; Android's JS interface expects strings.
if (handler === globalThis.openclawCanvasA2UIAction) {
postNativeMessage(handler, JSON.stringify({ userAction }));
} else {
postNativeMessage(handler, { userAction });
}
} catch (e) {
const msg = String(e?.message ?? e);
this.pendingAction = {
id: actionId,
name,
phase: "error",
startedAt: Date.now(),
error: msg,
};
this.#setToast(`Failed: ${msg}`, "error", 4500);
}
} else {
this.pendingAction = {
id: actionId,
name,
phase: "error",
startedAt: Date.now(),
error: "missing native bridge",
};
this.#setToast("Failed: missing native bridge", "error", 4500);
}
}
applyMessages(messages) {
if (!Array.isArray(messages)) {
throw new Error("A2UI: expected messages array");
}
this.#processor.processMessages(messages);
this.#syncSurfaces();
if (this.pendingAction?.phase === "sent") {
this.#setToast(`Updated: ${this.pendingAction.name}`, "ok", 1100);
this.pendingAction = null;
}
this.requestUpdate();
return { ok: true, surfaces: this.surfaces.map(([id]) => id) };
}
reset() {
this.#processor.clearSurfaces();
this.#syncSurfaces();
this.pendingAction = null;
this.requestUpdate();
return { ok: true };
}
#syncSurfaces() {
this.surfaces = Array.from(this.#processor.getSurfaces().entries());
}
render() {
if (this.surfaces.length === 0) {
return html`<div class="empty">
<div class="empty-title">Canvas (A2UI)</div>
</div>`;
}
const statusText =
this.pendingAction?.phase === "sent"
? `Working: ${this.pendingAction.name}`
: this.pendingAction?.phase === "sending"
? `Sending: ${this.pendingAction.name}`
: this.pendingAction?.phase === "error"
? `Failed: ${this.pendingAction.name}`
: "";
return html` ${this.pendingAction && this.pendingAction.phase !== "error"
? html`<div class="status">
<div class="spinner"></div>
<div>${statusText}</div>
</div>`
: ""}
${this.toast
? html`<div class="toast ${this.toast.kind === "error" ? "error" : ""}">
${this.toast.text}
</div>`
: ""}
<section id="surfaces">
${repeat(
this.surfaces,
([surfaceId]) => surfaceId,
([surfaceId, surface]) => html`<a2ui-surface
.surfaceId=${surfaceId}
.surface=${surface}
.processor=${this.#processor}
></a2ui-surface>`,
)}
</section>`;
}
}
if (!customElements.get("openclaw-a2ui-host")) {
customElements.define("openclaw-a2ui-host", OpenClawA2UIHost);
}

View File

@@ -0,0 +1,68 @@
/**
* Rolldown config for bundling the Canvas A2UI app into a single browser asset.
*/
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "../../../../..");
const require = createRequire(import.meta.url);
const uiRoot = path.resolve(repoRoot, "ui");
const fromHere = (p) => path.resolve(here, p);
const outputFile = process.env.OPENCLAW_A2UI_BUNDLE_OUT
? path.resolve(process.env.OPENCLAW_A2UI_BUNDLE_OUT)
: path.resolve(here, "..", "a2ui", "a2ui.bundle.js");
const a2uiLitIndex = require.resolve("@a2ui/lit");
const a2uiLitUi = require.resolve("@a2ui/lit/ui");
const a2uiThemeContext = path.resolve(path.dirname(a2uiLitUi), "context/theme.js");
const uiNodeModules = path.resolve(uiRoot, "node_modules");
const repoNodeModules = path.resolve(repoRoot, "node_modules");
function resolveUiDependency(moduleId) {
const candidates = [
path.resolve(uiNodeModules, moduleId),
path.resolve(repoNodeModules, moduleId),
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
const fallbackCandidates = candidates.join(", ");
throw new Error(
`A2UI bundle config cannot resolve ${moduleId}. Checked: ${fallbackCandidates}. ` +
"Keep dependency installed in ui workspace or repo root before bundling.",
);
}
export default {
input: fromHere("bootstrap.js"),
experimental: {
attachDebugInfo: "none",
},
treeshake: false,
resolve: {
alias: {
"@a2ui/lit": a2uiLitIndex,
"@a2ui/lit/ui": a2uiLitUi,
"@openclaw/a2ui-theme-context": a2uiThemeContext,
"@lit/context": resolveUiDependency("@lit/context"),
"@lit/context/": resolveUiDependency("@lit/context/"),
"@lit-labs/signals": resolveUiDependency("@lit-labs/signals"),
"@lit-labs/signals/": resolveUiDependency("@lit-labs/signals/"),
lit: resolveUiDependency("lit"),
"lit/": resolveUiDependency("lit/"),
"signal-utils/": resolveUiDependency("signal-utils/"),
},
},
output: {
file: outputFile,
format: "esm",
codeSplitting: false,
sourcemap: false,
},
};

View File

@@ -0,0 +1,80 @@
/**
* Shared A2UI/Canvas host paths and live-reload injection helpers.
*/
import { lowercasePreservingWhitespace } from "openclaw/plugin-sdk/string-coerce-runtime";
/** Hosted path prefix for bundled A2UI assets. */
export const A2UI_PATH = "/__openclaw__/a2ui";
/** Hosted path prefix for Canvas document/static assets. */
export const CANVAS_HOST_PATH = "/__openclaw__/canvas";
/** Hosted WebSocket path for Canvas live reload. */
export const CANVAS_WS_PATH = "/__openclaw__/ws";
/** Returns whether a URL path targets the hosted A2UI asset surface. */
export function isA2uiPath(pathname: string): boolean {
return pathname === A2UI_PATH || pathname.startsWith(`${A2UI_PATH}/`);
}
/** Injects Canvas bridge helpers and live-reload WebSocket code into HTML. */
export function injectCanvasLiveReload(html: string): string {
const snippet = `
<script>
(() => {
// Cross-platform action bridge helper.
// Works on:
// - iOS: window.webkit.messageHandlers.openclawCanvasA2UIAction.postMessage(...)
// - Android: window.openclawCanvasA2UIAction.postMessage(...)
const handlerNames = ["openclawCanvasA2UIAction"];
function postToNode(payload) {
try {
const raw = typeof payload === "string" ? payload : JSON.stringify(payload);
for (const name of handlerNames) {
const iosHandler = globalThis.webkit?.messageHandlers?.[name];
if (iosHandler && typeof iosHandler.postMessage === "function") {
iosHandler.postMessage(raw);
return true;
}
const androidHandler = globalThis[name];
if (androidHandler && typeof androidHandler.postMessage === "function") {
// Important: call as a method on the interface object (binding matters on Android WebView).
androidHandler.postMessage(raw);
return true;
}
}
} catch {}
return false;
}
function sendUserAction(userAction) {
const id =
(userAction && typeof userAction.id === "string" && userAction.id.trim()) ||
(globalThis.crypto?.randomUUID?.() ?? String(Date.now()));
const action = { ...userAction, id };
return postToNode({ userAction: action });
}
globalThis.OpenClaw = globalThis.OpenClaw ?? {};
globalThis.OpenClaw.postMessage = postToNode;
globalThis.OpenClaw.sendUserAction = sendUserAction;
globalThis.openclawPostMessage = postToNode;
globalThis.openclawSendUserAction = sendUserAction;
try {
const cap = new URLSearchParams(location.search).get("oc_cap");
const proto = location.protocol === "https:" ? "wss" : "ws";
const capQuery = cap ? "?oc_cap=" + encodeURIComponent(cap) : "";
const ws = new WebSocket(proto + "://" + location.host + ${JSON.stringify(CANVAS_WS_PATH)} + capQuery);
ws.onmessage = (ev) => {
if (String(ev.data || "") === "reload") location.reload();
};
} catch {}
})();
</script>
`.trim();
const idx = lowercasePreservingWhitespace(html).lastIndexOf("</body>");
if (idx >= 0) {
return `${html.slice(0, idx)}\n${snippet}\n${html.slice(idx)}`;
}
return `${html}\n${snippet}\n`;
}

View File

@@ -0,0 +1,171 @@
/**
* HTTP handler for serving bundled A2UI assets through Canvas host routes.
*/
import fs from "node:fs/promises";
import type { IncomingMessage, ServerResponse } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { detectMime } from "openclaw/plugin-sdk/media-mime";
import { lowercasePreservingWhitespace } from "openclaw/plugin-sdk/string-coerce-runtime";
import { A2UI_PATH, injectCanvasLiveReload, isA2uiPath } from "./a2ui-shared.js";
import { resolveFileWithinRoot } from "./file-resolver.js";
export {
A2UI_PATH,
CANVAS_HOST_PATH,
CANVAS_WS_PATH,
injectCanvasLiveReload,
isA2uiPath,
} from "./a2ui-shared.js";
let cachedA2uiRootReal: string | null | undefined;
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;
const candidates = [
// Running from source (bun) or a copied dist asset chunk.
path.resolve(here, "a2ui"),
// Running from dist root chunk (common launchd path).
path.resolve(here, "canvas-host/a2ui"),
// Entry path fallbacks (helps when cwd is not the repo root).
...(entryDir
? [path.resolve(entryDir, "a2ui"), path.resolve(entryDir, "canvas-host/a2ui")]
: []),
// Running from dist without copied assets (fallback to source).
path.resolve(here, "../../extensions/canvas/src/host/a2ui"),
path.resolve(here, "../extensions/canvas/src/host/a2ui"),
// Running from repo root.
path.resolve(process.cwd(), "extensions/canvas/src/host/a2ui"),
path.resolve(process.cwd(), "dist/canvas-host/a2ui"),
];
if (process.execPath) {
candidates.unshift(path.resolve(path.dirname(process.execPath), "a2ui"));
}
for (const dir of candidates) {
try {
const indexPath = path.join(dir, "index.html");
const bundlePath = path.join(dir, "a2ui.bundle.js");
await fs.stat(indexPath);
await fs.stat(bundlePath);
return dir;
} catch {
// try next
}
}
return null;
}
async function resolveA2uiRootReal(): Promise<string | null> {
const nowMs = Date.now();
if (
cachedA2uiRootReal !== undefined &&
(cachedA2uiRootReal !== null || nowMs - cachedA2uiResolvedAtMs < A2UI_ROOT_RETRY_NULL_AFTER_MS)
) {
return cachedA2uiRootReal;
}
if (!resolvingA2uiRoot) {
resolvingA2uiRoot = (async () => {
const root = await resolveA2uiRoot();
cachedA2uiRootReal = root ? await fs.realpath(root) : null;
cachedA2uiResolvedAtMs = Date.now();
resolvingA2uiRoot = null;
return cachedA2uiRootReal;
})();
}
return resolvingA2uiRoot;
}
async function handleA2uiHttpRequestWithRootResolver(
req: IncomingMessage,
res: ServerResponse,
resolveRootReal: A2uiRootResolver,
): Promise<boolean> {
const urlRaw = req.url;
if (!urlRaw) {
return false;
}
const url = new URL(urlRaw, "http://localhost");
const basePath = isA2uiPath(url.pathname) ? A2UI_PATH : undefined;
if (!basePath) {
return false;
}
if (req.method !== "GET" && req.method !== "HEAD") {
res.statusCode = 405;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Method Not Allowed");
return true;
}
const a2uiRootReal = await resolveRootReal();
if (!a2uiRootReal) {
res.statusCode = 503;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("A2UI assets not found");
return true;
}
const rel = url.pathname.slice(basePath.length);
const result = await resolveFileWithinRoot(a2uiRootReal, rel || "/");
if (!result) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("not found");
return true;
}
try {
const lower = lowercasePreservingWhitespace(result.realPath);
const mime =
lower.endsWith(".html") || lower.endsWith(".htm")
? "text/html"
: ((await detectMime({ filePath: result.realPath })) ?? "application/octet-stream");
res.setHeader("Cache-Control", "no-store");
if (req.method === "HEAD") {
res.setHeader("Content-Type", mime === "text/html" ? "text/html; charset=utf-8" : mime);
res.end();
return true;
}
if (mime === "text/html") {
const buf = await result.handle.readFile({ encoding: "utf8" });
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(injectCanvasLiveReload(buf));
return true;
}
res.setHeader("Content-Type", mime);
res.end(await result.handle.readFile());
return true;
} finally {
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);
}

View File

@@ -0,0 +1,311 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenClaw Canvas</title>
<script>
(() => {
const normalizeLower = (value) => {
const trimmed = String(value || "").trim();
return trimmed.toLocaleLowerCase();
};
try {
const params = new URLSearchParams(window.location.search);
const platform = normalizeLower(params.get("platform"));
if (platform) {
document.documentElement.dataset.platform = platform;
return;
}
if (/android/i.test(navigator.userAgent || "")) {
document.documentElement.dataset.platform = "android";
}
} catch (_) {}
})();
</script>
<style>
:root {
color-scheme: dark;
}
@media (prefers-reduced-motion: reduce) {
body::before,
body::after {
animation: none !important;
}
}
html,
body {
height: 100%;
margin: 0;
}
body {
font:
14px system-ui,
-apple-system,
BlinkMacSystemFont,
"Roboto",
sans-serif;
background:
radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.18), rgba(0, 0, 0, 0) 55%),
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.14), rgba(0, 0, 0, 0) 60%),
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.1), rgba(0, 0, 0, 0) 60%),
#000;
color: #e5e7eb;
overflow: hidden;
}
:root[data-platform="android"] body {
background:
radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.62), rgba(0, 0, 0, 0) 55%),
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.52), rgba(0, 0, 0, 0) 60%),
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.48), rgba(0, 0, 0, 0) 60%),
#0b1328;
}
body::before {
content: "";
position: fixed;
inset: -20%;
background:
repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0.03) 0,
rgba(255, 255, 255, 0.03) 1px,
transparent 1px,
transparent 48px
),
repeating-linear-gradient(
90deg,
rgba(255, 255, 255, 0.03) 0,
rgba(255, 255, 255, 0.03) 1px,
transparent 1px,
transparent 48px
);
transform: translate3d(0, 0, 0) rotate(-7deg);
will-change: transform, opacity;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
opacity: 0.45;
pointer-events: none;
animation: openclaw-grid-drift 140s ease-in-out infinite alternate;
}
:root[data-platform="android"] body::before {
opacity: 0.8;
}
body::after {
content: "";
position: fixed;
inset: -35%;
background:
radial-gradient(900px 700px at 30% 30%, rgba(42, 113, 255, 0.16), rgba(0, 0, 0, 0) 60%),
radial-gradient(800px 650px at 70% 35%, rgba(255, 0, 138, 0.12), rgba(0, 0, 0, 0) 62%),
radial-gradient(900px 800px at 55% 75%, rgba(0, 209, 255, 0.1), rgba(0, 0, 0, 0) 62%);
filter: blur(28px);
opacity: 0.52;
will-change: transform, opacity;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
transform: translate3d(0, 0, 0);
pointer-events: none;
animation: openclaw-glow-drift 110s ease-in-out infinite alternate;
}
:root[data-platform="android"] body::after {
opacity: 0.85;
}
@supports (mix-blend-mode: screen) {
body::after {
mix-blend-mode: screen;
}
}
@supports not (mix-blend-mode: screen) {
body::after {
opacity: 0.7;
}
}
@keyframes openclaw-grid-drift {
0% {
transform: translate3d(-12px, 8px, 0) rotate(-7deg);
opacity: 0.4;
}
50% {
transform: translate3d(10px, -7px, 0) rotate(-6.6deg);
opacity: 0.56;
}
100% {
transform: translate3d(-8px, 6px, 0) rotate(-7.2deg);
opacity: 0.42;
}
}
@keyframes openclaw-glow-drift {
0% {
transform: translate3d(-18px, 12px, 0) scale(1.02);
opacity: 0.4;
}
50% {
transform: translate3d(14px, -10px, 0) scale(1.05);
opacity: 0.52;
}
100% {
transform: translate3d(-10px, 8px, 0) scale(1.03);
opacity: 0.43;
}
}
canvas {
position: fixed;
inset: 0;
display: block;
width: 100vw;
height: 100vh;
touch-action: none;
z-index: 1;
}
:root[data-platform="android"] #openclaw-canvas {
background:
radial-gradient(1100px 800px at 20% 15%, rgba(42, 113, 255, 0.78), rgba(0, 0, 0, 0) 58%),
radial-gradient(900px 650px at 82% 28%, rgba(255, 0, 138, 0.66), rgba(0, 0, 0, 0) 62%),
radial-gradient(1000px 900px at 60% 88%, rgba(0, 209, 255, 0.58), rgba(0, 0, 0, 0) 62%),
#141c33;
}
#openclaw-status {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
flex-direction: column;
padding: 24px;
box-sizing: border-box;
pointer-events: none;
z-index: 3;
}
#openclaw-status .card {
width: min(560px, 88vw);
text-align: left;
padding: 14px 16px 12px;
border-radius: 16px;
background: linear-gradient(140deg, rgba(23, 24, 35, 0.78), rgba(18, 19, 28, 0.55));
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow:
0 16px 46px rgba(0, 0, 0, 0.52),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
-webkit-backdrop-filter: blur(18px) saturate(140%);
backdrop-filter: blur(18px) saturate(140%);
}
#openclaw-status .title {
font:
600 12px/1.2 -apple-system,
BlinkMacSystemFont,
"SF Pro Text",
system-ui,
sans-serif;
letter-spacing: 0.45px;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.7);
}
#openclaw-status .subtitle {
margin-top: 8px;
font:
500 13px/1.45 -apple-system,
BlinkMacSystemFont,
"SF Pro Text",
system-ui,
sans-serif;
color: rgba(255, 255, 255, 0.9);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
openclaw-a2ui-host {
display: block;
height: 100%;
position: fixed;
inset: 0;
z-index: 4;
--openclaw-a2ui-inset-top: 28px;
--openclaw-a2ui-inset-right: 0px;
--openclaw-a2ui-inset-bottom: 0px;
--openclaw-a2ui-inset-left: 0px;
--openclaw-a2ui-scroll-pad-bottom: 0px;
--openclaw-a2ui-status-top: calc(50% - 18px);
--openclaw-a2ui-empty-top: 18px;
}
</style>
</head>
<body>
<canvas id="openclaw-canvas"></canvas>
<div id="openclaw-status" role="status" aria-live="polite">
<section class="card">
<div class="title" id="openclaw-status-title">Ready</div>
<div class="subtitle" id="openclaw-status-subtitle">Waiting for agent</div>
</section>
</div>
<openclaw-a2ui-host></openclaw-a2ui-host>
<script src="a2ui.bundle.js"></script>
<script>
(() => {
const canvas = document.getElementById("openclaw-canvas");
const ctx = canvas.getContext("2d");
const statusEl = document.getElementById("openclaw-status");
const titleEl = document.getElementById("openclaw-status-title");
const subtitleEl = document.getElementById("openclaw-status-subtitle");
const debugStatusEnabledByQuery = (() => {
try {
const params = new URLSearchParams(window.location.search);
const raw = params.get("debugStatus") ?? params.get("debug");
if (!raw) return false;
const normalized = normalizeLower(raw);
return normalized === "1" || normalized === "true" || normalized === "yes";
} catch (_) {
return false;
}
})();
let debugStatusEnabled = debugStatusEnabledByQuery;
function resize() {
const dpr = window.devicePixelRatio || 1;
const w = Math.max(1, Math.floor(window.innerWidth * dpr));
const h = Math.max(1, Math.floor(window.innerHeight * dpr));
canvas.width = w;
canvas.height = h;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener("resize", resize);
resize();
const setDebugStatusEnabled = (enabled) => {
debugStatusEnabled = !!enabled;
if (!statusEl) return;
if (!debugStatusEnabled) {
statusEl.style.display = "none";
}
};
if (statusEl && !debugStatusEnabled) {
statusEl.style.display = "none";
}
window.__openclaw = {
canvas,
ctx,
setDebugStatusEnabled,
setStatus: (title, subtitle) => {
if (!statusEl || !debugStatusEnabled) return;
if (!title && !subtitle) {
statusEl.style.display = "none";
return;
}
statusEl.style.display = "flex";
if (titleEl && typeof title === "string") titleEl.textContent = title;
if (subtitleEl && typeof subtitle === "string") subtitleEl.textContent = subtitle;
if (!debugStatusEnabled) {
clearTimeout(window.__statusTimeout);
window.__statusTimeout = setTimeout(() => {
statusEl.style.display = "none";
}, 3000);
} else {
clearTimeout(window.__statusTimeout);
}
},
};
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,75 @@
// Canvas tests cover file resolver plugin behavior.
import fs from "node:fs/promises";
import path from "node:path";
import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path";
import { describe, expect, it } from "vitest";
import { normalizeUrlPath, resolveFileWithinRoot } from "./file-resolver.js";
type ResolvedFile = NonNullable<Awaited<ReturnType<typeof resolveFileWithinRoot>>>;
async function withCanvasTemp<T>(prefix: string, run: (dir: string) => Promise<T>): Promise<T> {
return await withTempWorkspace(
{ rootDir: resolvePreferredOpenClawTmpDir(), prefix },
async ({ dir }) => await run(dir),
);
}
function expectResolvedFile(
result: Awaited<ReturnType<typeof resolveFileWithinRoot>>,
): ResolvedFile {
if (result === null) {
throw new Error("Expected resolved file within root");
}
expect(typeof result.handle.close).toBe("function");
expect(typeof result.handle.readFile).toBe("function");
return result;
}
describe("resolveFileWithinRoot", () => {
it("normalizes URL paths", () => {
expect(normalizeUrlPath("/nested/../file.txt")).toBe("/file.txt");
expect(normalizeUrlPath("plain.txt")).toBe("/plain.txt");
});
it("opens directory index files through the fs-safe root", async () => {
await withCanvasTemp("openclaw-canvas-resolver-", async (root) => {
await fs.mkdir(path.join(root, "docs"), { recursive: true });
await fs.writeFile(path.join(root, "docs", "index.html"), "<h1>docs</h1>");
const result = await resolveFileWithinRoot(root, "/docs");
const resolved = expectResolvedFile(result);
try {
await expect(resolved.handle.readFile({ encoding: "utf8" })).resolves.toBe("<h1>docs</h1>");
} finally {
await resolved.handle.close().catch(() => {});
}
});
});
it("rejects traversal paths", async () => {
await withCanvasTemp("openclaw-canvas-resolver-", async (root) => {
await fs.writeFile(path.join(root, "outside.txt"), "inside-root", "utf8");
await expect(resolveFileWithinRoot(root, "/../outside.txt")).resolves.toBeNull();
await expect(resolveFileWithinRoot(root, "/%2e%2e%2foutside.txt")).resolves.toBeNull();
});
});
it("rejects malformed URL encoding as a missing file", async () => {
await withCanvasTemp("openclaw-canvas-resolver-", async (root) => {
await expect(resolveFileWithinRoot(root, "/%E0%A4%A")).resolves.toBeNull();
});
});
it.runIf(process.platform !== "win32")("rejects symlink entries", async () => {
await withCanvasTemp("openclaw-canvas-resolver-", async (root) => {
await withCanvasTemp("openclaw-canvas-resolver-outside-", async (outside) => {
const target = path.join(outside, "outside.html");
const link = path.join(root, "link.html");
await fs.writeFile(target, "outside");
await fs.symlink(target, link);
await expect(resolveFileWithinRoot(root, "/link.html")).resolves.toBeNull();
});
});
});
});

View File

@@ -0,0 +1,94 @@
/**
* Safe file resolution helpers for Canvas-hosted static assets.
*/
import path from "node:path";
import { root as fsRoot, FsSafeError } from "openclaw/plugin-sdk/security-runtime";
type CanvasOpenResult = Awaited<ReturnType<Awaited<ReturnType<typeof fsRoot>>["open"]>>;
/** Normalizes a decoded URL path into a leading-slash POSIX path. */
export function normalizeUrlPath(rawPath: string): string {
const decoded = decodeURIComponent(rawPath || "/");
const normalized = path.posix.normalize(decoded);
return normalized.startsWith("/") ? normalized : `/${normalized}`;
}
function pathEscapesRoot(decodedPath: string): boolean {
let depth = 0;
for (const segment of decodedPath.split("/")) {
if (segment === "" || segment === ".") {
continue;
}
if (segment === "..") {
if (depth === 0) {
return true;
}
depth--;
continue;
}
depth++;
}
return false;
}
function tryNormalizeUrlPath(rawPath: string): string | null {
let decoded: string;
try {
decoded = decodeURIComponent(rawPath || "/");
} catch {
return null;
}
if (pathEscapesRoot(decoded)) {
return null;
}
const normalized = path.posix.normalize(decoded);
return normalized.startsWith("/") ? normalized : `/${normalized}`;
}
/** Opens a Canvas-hosted file only when the request stays inside the root. */
export async function resolveFileWithinRoot(
rootReal: string,
urlPath: string,
): Promise<CanvasOpenResult | null> {
const normalized = tryNormalizeUrlPath(urlPath);
if (normalized === null) {
return null;
}
const rel = normalized.replace(/^\/+/, "");
if (rel.split("/").some((p) => p === "..")) {
return null;
}
const root = await fsRoot(rootReal);
const tryOpen = async (relative: string) => {
try {
return await root.open(relative);
} catch (err) {
if (err instanceof FsSafeError) {
return null;
}
throw err;
}
};
if (normalized.endsWith("/")) {
return await tryOpen(path.posix.join(rel, "index.html"));
}
try {
const st = await root.stat(rel);
if (st.isSymbolicLink) {
return null;
}
if (st.isDirectory) {
return await tryOpen(path.posix.join(rel, "index.html"));
}
} catch (err) {
if (err instanceof FsSafeError) {
return null;
}
throw err;
}
return await tryOpen(rel);
}

View File

@@ -0,0 +1,34 @@
// Canvas tests cover server.state dir plugin behavior.
import fs from "node:fs/promises";
import path from "node:path";
import { defaultRuntime } from "openclaw/plugin-sdk/runtime-env";
import { withStateDirEnv } from "openclaw/plugin-sdk/test-env";
import { beforeAll, describe, expect, it } from "vitest";
describe("canvas host state dir defaults", () => {
let createCanvasHostHandler: typeof import("./server.js").createCanvasHostHandler;
beforeAll(async () => {
({ createCanvasHostHandler } = await import("./server.js"));
});
it("uses OPENCLAW_STATE_DIR for the default canvas root", async () => {
await withStateDirEnv("openclaw-canvas-state-", async ({ stateDir }) => {
const handler = await createCanvasHostHandler({
runtime: defaultRuntime,
allowInTests: true,
});
try {
const expectedRoot = await fs.realpath(path.join(stateDir, "canvas"));
const actualRoot = await fs.realpath(handler.rootDir);
expect(actualRoot).toBe(expectedRoot);
const indexPath = path.join(expectedRoot, "index.html");
const indexContents = await fs.readFile(indexPath, "utf8");
expect(indexContents).toContain("OpenClaw Canvas");
} finally {
await handler.close();
}
});
});
});

View File

@@ -0,0 +1,498 @@
// Canvas tests cover server plugin behavior.
import fs from "node:fs/promises";
import type { IncomingMessage } from "node:http";
import os from "node:os";
import path from "node:path";
import type { Duplex } from "node:stream";
import { defaultRuntime } from "openclaw/plugin-sdk/runtime-env";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
A2UI_PATH,
CANVAS_HOST_PATH,
CANVAS_WS_PATH,
injectCanvasLiveReload,
} from "./a2ui-shared.js";
type MockWatcher = {
on: (event: string, cb: (...args: unknown[]) => void) => MockWatcher;
close: () => Promise<void>;
__emit: (event: string, ...args: unknown[]) => void;
};
type TrackingWebSocket = {
sent: string[];
on: (event: string, cb: () => void) => TrackingWebSocket;
send: (message: string) => void;
};
type CapturedResponse = {
handled: boolean;
status: number;
headers: Record<string, number | string | string[]>;
body: string;
bodyBytes: Buffer;
};
type HttpRequestHandler = (
req: IncomingMessage,
res: import("node:http").ServerResponse,
) => boolean | Promise<boolean>;
function createMockWatcherState() {
const watchers: MockWatcher[] = [];
const createWatcher = () => {
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
const api: MockWatcher = {
on: (event: string, cb: (...args: unknown[]) => void) => {
const list = handlers.get(event) ?? [];
list.push(cb);
handlers.set(event, list);
return api;
},
close: async () => {},
__emit: (event: string, ...args: unknown[]) => {
for (const cb of handlers.get(event) ?? []) {
cb(...args);
}
},
};
watchers.push(api);
return api;
};
return {
watchers,
watchFactory: () => createWatcher(),
};
}
async function captureHttpResponse(
handleRequest: HttpRequestHandler,
url: string,
method = "GET",
): Promise<CapturedResponse> {
const response: CapturedResponse = {
handled: false,
status: 200,
headers: {},
body: "",
bodyBytes: Buffer.alloc(0),
};
const res = {
statusCode: 200,
setHeader(name: string, value: number | string | readonly string[]) {
const headerValue: number | string | string[] =
typeof value === "object" ? [...value] : value;
response.headers[name.toLowerCase()] = headerValue;
return this;
},
end(chunk?: string | Buffer) {
response.status = this.statusCode;
response.bodyBytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk ?? "");
response.body = response.bodyBytes.toString("utf8");
return this;
},
};
response.handled = await handleRequest(
{ method, url } as IncomingMessage,
res as import("node:http").ServerResponse,
);
response.status = res.statusCode;
return response;
}
async function captureHandlerResponse(
handler: Pick<import("./server.js").CanvasHostHandler, "handleHttpRequest">,
url: string,
method = "GET",
): Promise<CapturedResponse> {
return await captureHttpResponse(handler.handleHttpRequest, 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", () => {
const quietRuntime = {
...defaultRuntime,
log: (..._args: Parameters<typeof console.log>) => {},
};
let createCanvasHostHandler: typeof import("./server.js").createCanvasHostHandler;
let startCanvasHost: typeof import("./server.js").startCanvasHost;
let canvasLiveReloadMaxInboundMessageBytes = 0;
let WebSocketServerClass: typeof import("ws").WebSocketServer;
let watcherState: ReturnType<typeof createMockWatcherState>;
let fixtureRoot = "";
let fixtureCount = 0;
const createCaseDir = async () => {
const dir = path.join(fixtureRoot, `case-${fixtureCount++}`);
await fs.mkdir(dir, { recursive: true });
return dir;
};
const createTestCanvasHostHandler = async (
rootDir: string,
options: Partial<Parameters<typeof createCanvasHostHandler>[0]> = {},
) =>
await createCanvasHostHandler({
runtime: quietRuntime,
rootDir,
basePath: CANVAS_HOST_PATH,
allowInTests: true,
watchFactory: watcherState.watchFactory as unknown as Parameters<
typeof createCanvasHostHandler
>[0]["watchFactory"],
webSocketServerClass: WebSocketServerClass,
...options,
});
beforeAll(async () => {
vi.doUnmock("undici");
vi.doMock("node:timers", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:timers")>();
return {
...actual,
setTimeout: ((callback: (...args: unknown[]) => void, delay?: number, ...args: unknown[]) =>
actual.setTimeout(
callback,
delay === 12 ? 0 : delay,
...args,
)) as typeof actual.setTimeout,
};
});
vi.resetModules();
const serverModule = await import("./server.js");
({ createCanvasHostHandler, startCanvasHost } = serverModule);
canvasLiveReloadMaxInboundMessageBytes =
serverModule.CANVAS_LIVE_RELOAD_MAX_INBOUND_MESSAGE_BYTES;
const wsModule = await vi.importActual<typeof import("ws")>("ws");
WebSocketServerClass = wsModule.WebSocketServer;
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-fixtures-"));
});
beforeEach(() => {
vi.useRealTimers();
watcherState = createMockWatcherState();
});
afterAll(async () => {
vi.doUnmock("node:timers");
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
it("injects live reload script", () => {
const out = injectCanvasLiveReload("<html><body>Hello</body></html>");
expect(out).toContain(CANVAS_WS_PATH);
expect(out).toContain("location.reload");
expect(out).toContain("openclawCanvasA2UIAction");
expect(out).toContain("openclawSendUserAction");
});
it("creates a default index.html when missing", async () => {
const dir = await createCaseDir();
const handler = await createTestCanvasHostHandler(dir);
try {
const response = await captureHandlerResponse(handler, `${CANVAS_HOST_PATH}/`);
expect(response.status).toBe(200);
expect(response.body).toContain("Interactive test page");
expect(response.body).toContain("openclawSendUserAction");
expect(response.body).toContain(CANVAS_WS_PATH);
expect(response.body).toContain('document.createElement("span")');
expect(response.body).not.toContain("statusEl.innerHTML");
} finally {
await handler.close();
}
});
it("skips live reload injection when disabled", async () => {
const dir = await createCaseDir();
await fs.writeFile(path.join(dir, "index.html"), "<html><body>no-reload</body></html>", "utf8");
const handler = await createTestCanvasHostHandler(dir, { liveReload: false });
try {
const response = await captureHandlerResponse(handler, `${CANVAS_HOST_PATH}/`);
expect(response.status).toBe(200);
expect(response.body).toContain("no-reload");
expect(response.body).not.toContain(CANVAS_WS_PATH);
const wsResponse = await captureHandlerResponse(handler, CANVAS_WS_PATH);
expect(wsResponse.status).toBe(404);
} finally {
await handler.close();
}
});
it("caps live reload WebSocket inbound payloads", async () => {
const dir = await createCaseDir();
const constructorOptions: unknown[] = [];
let connectionHandler: ((socket: TrackingWebSocket) => void) | undefined;
class CapturingWebSocketServer {
on(event: string, cb: (socket: TrackingWebSocket) => void) {
if (event === "connection") {
connectionHandler = cb;
}
return this;
}
close(cb?: () => void) {
cb?.();
}
constructor(options: unknown) {
constructorOptions.push(options);
}
}
const handler = await createTestCanvasHostHandler(dir, {
webSocketServerClass:
CapturingWebSocketServer as unknown as typeof import("ws").WebSocketServer,
});
try {
expect(constructorOptions[0]).toMatchObject({
noServer: true,
maxPayload: canvasLiveReloadMaxInboundMessageBytes,
});
const socketHandlers: string[] = [];
const socket: TrackingWebSocket = {
sent: [],
on: (event) => {
socketHandlers.push(event);
return socket;
},
send: vi.fn(),
};
expect(connectionHandler).toBeDefined();
connectionHandler?.(socket);
expect(socketHandlers).toEqual(expect.arrayContaining(["error", "close"]));
} finally {
await handler.close();
}
});
it("falls back to the default mount when the configured base path is malformed", async () => {
const dir = await createCaseDir();
await fs.writeFile(path.join(dir, "index.html"), "<html><body>fallback</body></html>", "utf8");
const handler = await createTestCanvasHostHandler(dir, { basePath: "/%E0%A4%A" });
try {
const response = await captureHandlerResponse(handler, `${CANVAS_HOST_PATH}/`);
expect(response.status).toBe(200);
expect(response.body).toContain("fallback");
} finally {
await handler.close();
}
});
it("serves canvas content from the mounted base path and reuses handlers without double close", async () => {
const dir = await createCaseDir();
await fs.writeFile(path.join(dir, "index.html"), "<html><body>v1</body></html>", "utf8");
const handler = await createTestCanvasHostHandler(dir);
const originalClose = handler.close;
const closeSpy = vi.fn(async () => originalClose());
try {
const response = await captureHandlerResponse(handler, `${CANVAS_HOST_PATH}/`);
expect(response.status).toBe(200);
expect(response.body).toContain("v1");
expect(response.body).toContain(CANVAS_WS_PATH);
const malformed = await captureHandlerResponse(handler, `${CANVAS_HOST_PATH}/%E0%A4%A`);
expect(malformed.status).toBe(404);
expect(malformed.body).toBe("not found");
const miss = await captureHandlerResponse(handler, "/");
expect(miss.handled).toBe(false);
handler.close = closeSpy;
const hosted = await startCanvasHost({
runtime: quietRuntime,
handler,
ownsHandler: false,
port: 0,
listenHost: "127.0.0.1",
allowInTests: true,
});
try {
expect(hosted.port).toBeGreaterThan(0);
} finally {
await hosted.close();
expect(closeSpy).not.toHaveBeenCalled();
}
} finally {
await originalClose();
}
});
it("broadcasts reload on file changes", async () => {
const dir = await createCaseDir();
const index = path.join(dir, "index.html");
await fs.writeFile(index, "<html><body>v1</body></html>", "utf8");
let resolveReload: (() => void) | undefined;
const reloadSent = new Promise<void>((resolve) => {
resolveReload = resolve;
});
const watcherStart = watcherState.watchers.length;
const TrackingWebSocketServerClass = class TrackingWebSocketServer {
static latestInstance: { connectionCount: number } | undefined;
static latestSocket: TrackingWebSocket | undefined;
connectionCount = 0;
readonly handlers = new Map<string, Array<(...args: unknown[]) => void>>();
on(event: string, cb: (...args: unknown[]) => void) {
const list = this.handlers.get(event) ?? [];
list.push(cb);
this.handlers.set(event, list);
return this;
}
emit(event: string, ...args: unknown[]) {
for (const cb of this.handlers.get(event) ?? []) {
cb(...args);
}
}
handleUpgrade(
req: IncomingMessage,
socket: Duplex,
head: Buffer,
cb: (ws: TrackingWebSocket) => void,
) {
void req;
void socket;
void head;
const closeHandlers: Array<() => void> = [];
const ws: TrackingWebSocket = {
sent: [],
on: (event, handler) => {
if (event === "close") {
closeHandlers.push(handler);
}
return ws;
},
send: (message: string) => {
ws.sent.push(message);
if (message === "reload") {
if (!resolveReload) {
throw new Error("Expected Canvas reload resolver to be initialized");
}
resolveReload();
}
},
};
TrackingWebSocketServerClass.latestSocket = ws;
cb(ws);
}
close(cb?: (err?: Error) => void) {
cb?.();
}
constructor(..._args: unknown[]) {
TrackingWebSocketServerClass.latestInstance = this;
this.on("connection", () => {
this.connectionCount += 1;
});
}
};
const handler = await createTestCanvasHostHandler(dir, {
webSocketServerClass:
TrackingWebSocketServerClass as unknown as typeof import("ws").WebSocketServer,
});
try {
const watcher = watcherState.watchers[watcherStart];
if (!watcher) {
throw new Error("expected Canvas host watcher");
}
const upgraded = handler.handleUpgrade(
{ url: CANVAS_WS_PATH } as IncomingMessage,
{} as Duplex,
Buffer.alloc(0),
);
expect(upgraded).toBe(true);
const latestServer = TrackingWebSocketServerClass.latestInstance;
if (!latestServer) {
throw new Error("expected Canvas host websocket server");
}
expect(latestServer.connectionCount).toBe(1);
const ws = TrackingWebSocketServerClass.latestSocket;
if (!ws) {
throw new Error("expected Canvas host websocket");
}
await fs.writeFile(index, "<html><body>v2</body></html>", "utf8");
watcher["__emit"]("all", "change", index);
await reloadSent;
expect(ws.sent[0]).toBe("reload");
} finally {
await handler.close();
}
});
it("serves A2UI scaffold and blocks traversal/symlink escapes", async () => {
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);
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 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 captureA2uiFixtureResponse(a2uiRoot, `${A2UI_PATH}/a2ui.bundle.js`);
const js = bundleRes.body;
expect(bundleRes.status).toBe(200);
expect(js).toContain("openclawA2UI");
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 captureA2uiFixtureResponse(a2uiRoot, `${A2UI_PATH}/%E0%A4%A`);
expect(malformedRes.status).toBe(404);
expect(malformedRes.body).toBe("not found");
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 });
}
});
});

View File

@@ -0,0 +1,561 @@
/**
* Canvas host server and static-file/live-reload handler implementation.
*/
import * as fsSync from "node:fs";
import fs from "node:fs/promises";
import http, { type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { createRequire } from "node:module";
import type { Socket } from "node:net";
import path from "node:path";
import type { Duplex } from "node:stream";
import {
clearTimeout as clearNativeTimeout,
setTimeout as scheduleNativeTimeout,
} from "node:timers";
import chokidar from "chokidar";
import { detectMime } from "openclaw/plugin-sdk/media-mime";
import { isTruthyEnvValue, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import {
lowercasePreservingWhitespace,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { ensureDir, resolveUserPath } from "openclaw/plugin-sdk/text-utility-runtime";
import { type WebSocket, WebSocketServer } from "ws";
import {
CANVAS_HOST_PATH,
CANVAS_WS_PATH,
injectCanvasLiveReload,
isA2uiPath,
} from "./a2ui-shared.js";
import { normalizeUrlPath, resolveFileWithinRoot } from "./file-resolver.js";
export const CANVAS_LIVE_RELOAD_MAX_INBOUND_MESSAGE_BYTES = 64 * 1024;
type ChokidarWatch = typeof import("chokidar").watch;
/** Options for Canvas host creation. */
export type CanvasHostOpts = {
runtime: RuntimeEnv;
rootDir?: string;
port?: number;
listenHost?: string;
allowInTests?: boolean;
liveReload?: boolean;
watchFactory?: typeof chokidar.watch;
webSocketServerClass?: typeof WebSocketServer;
};
/** Options for starting a standalone Canvas host HTTP server. */
export type CanvasHostServerOpts = CanvasHostOpts & {
handler?: CanvasHostHandler;
ownsHandler?: boolean;
};
/** Running Canvas host server handle. */
export type CanvasHostServer = {
port: number;
rootDir: string;
close: () => Promise<void>;
};
/** Options for creating only the Canvas host request handler. */
export type CanvasHostHandlerOpts = {
runtime: RuntimeEnv;
rootDir?: string;
basePath?: string;
allowInTests?: boolean;
liveReload?: boolean;
watchFactory?: typeof chokidar.watch;
webSocketServerClass?: typeof WebSocketServer;
};
/** Canvas host handler for HTTP requests, WebSocket upgrades, and teardown. */
export type CanvasHostHandler = {
rootDir: string;
basePath: string;
handleHttpRequest: (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
handleUpgrade: (req: IncomingMessage, socket: Duplex, head: Buffer) => boolean;
close: () => Promise<void>;
};
function defaultIndexHTML() {
return `<!doctype html>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenClaw Canvas</title>
<style>
html, body { height: 100%; margin: 0; background: #000; color: #fff; font: 16px/1.4 -apple-system, BlinkMacSystemFont, system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif; }
.wrap { min-height: 100%; display: grid; place-items: center; padding: 24px; }
.card { width: min(720px, 100%); background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.10); border-radius: 16px; padding: 18px 18px 14px; }
.title { display: flex; align-items: baseline; gap: 10px; }
h1 { margin: 0; font-size: 22px; letter-spacing: 0.2px; }
.sub { opacity: 0.75; font-size: 13px; }
.row { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 14px; }
button { appearance: none; border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.10); color: #fff; padding: 10px 12px; border-radius: 12px; font-weight: 600; cursor: pointer; }
button:active { transform: translateY(1px); }
.ok { color: #24e08a; }
.bad { color: #ff5c5c; }
.log { margin-top: 14px; opacity: 0.85; font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; white-space: pre-wrap; background: rgba(0,0,0,0.35); border: 1px solid rgba(255,255,255,0.08); padding: 10px; border-radius: 12px; }
</style>
<div class="wrap">
<div class="card">
<div class="title">
<h1>OpenClaw Canvas</h1>
<div class="sub">Interactive test page (auto-reload enabled)</div>
</div>
<div class="row">
<button id="btn-hello">Hello</button>
<button id="btn-time">Time</button>
<button id="btn-photo">Photo</button>
<button id="btn-dalek">Dalek</button>
</div>
<div id="status" class="sub" style="margin-top: 10px;"></div>
<div id="log" class="log">Ready.</div>
</div>
</div>
<script>
(() => {
const logEl = document.getElementById("log");
const statusEl = document.getElementById("status");
const log = (msg) => { logEl.textContent = String(msg); };
const hasIOS = () =>
!!(
window.webkit &&
window.webkit.messageHandlers &&
window.webkit.messageHandlers.openclawCanvasA2UIAction
);
const hasAndroid = () =>
!!(
(window.openclawCanvasA2UIAction &&
typeof window.openclawCanvasA2UIAction.postMessage === "function")
);
const hasHelper = () => typeof window.openclawSendUserAction === "function";
const helperReady = hasHelper();
statusEl.textContent = "";
statusEl.appendChild(document.createTextNode("Bridge: "));
const bridgeStatus = document.createElement("span");
bridgeStatus.className = helperReady ? "ok" : "bad";
bridgeStatus.textContent = helperReady ? "ready" : "missing";
statusEl.appendChild(bridgeStatus);
statusEl.appendChild(
document.createTextNode(
" · iOS=" + (hasIOS() ? "yes" : "no") + " · Android=" + (hasAndroid() ? "yes" : "no"),
),
);
const onStatus = (ev) => {
const d = ev && ev.detail || {};
log("Action status: id=" + (d.id || "?") + " ok=" + String(!!d.ok) + (d.error ? (" error=" + d.error) : ""));
};
window.addEventListener("openclaw:a2ui-action-status", onStatus);
function send(name, sourceComponentId) {
if (!hasHelper()) {
log("No action bridge found. Ensure you're viewing this on an iOS/Android OpenClaw node canvas.");
return;
}
const sendUserAction =
typeof window.openclawSendUserAction === "function"
? window.openclawSendUserAction
: undefined;
const ok = sendUserAction({
name,
surfaceId: "main",
sourceComponentId,
context: { t: Date.now() },
});
log(ok ? ("Sent action: " + name) : ("Failed to send action: " + name));
}
document.getElementById("btn-hello").onclick = () => send("hello", "demo.hello");
document.getElementById("btn-time").onclick = () => send("time", "demo.time");
document.getElementById("btn-photo").onclick = () => send("photo", "demo.photo");
document.getElementById("btn-dalek").onclick = () => send("dalek", "demo.dalek");
})();
</script>
`;
}
function isDisabledByEnv() {
if (isTruthyEnvValue(process.env.OPENCLAW_SKIP_CANVAS_HOST)) {
return true;
}
if (process.env.NODE_ENV === "test") {
return true;
}
if (process.env.VITEST) {
return true;
}
return false;
}
function normalizeBasePath(rawPath: string | undefined) {
const trimmed = (rawPath ?? CANVAS_HOST_PATH).trim();
let normalized: string;
try {
normalized = normalizeUrlPath(trimmed || CANVAS_HOST_PATH);
} catch {
normalized = normalizeUrlPath(CANVAS_HOST_PATH);
}
if (normalized === "/") {
return "/";
}
return normalized.replace(/\/+$/, "");
}
async function prepareCanvasRoot(rootDir: string) {
await ensureDir(rootDir);
const rootReal = await fs.realpath(rootDir);
try {
const indexPath = path.join(rootReal, "index.html");
await fs.stat(indexPath);
} catch {
try {
await fs.writeFile(path.join(rootReal, "index.html"), defaultIndexHTML(), "utf8");
} catch {
// ignore; we'll still serve the "missing file" message if needed.
}
}
return rootReal;
}
function resolveDefaultCanvasRoot(): string {
const candidates = [path.join(resolveStateDir(), "canvas")];
const existing = candidates.find((dir) => {
try {
return fsSync.statSync(dir).isDirectory();
} catch {
return false;
}
});
return existing ?? candidates[0];
}
function resolveDefaultWatchFactory(): ChokidarWatch {
const importedWatch = (chokidar as { watch?: ChokidarWatch } | undefined)?.watch;
if (typeof importedWatch === "function") {
return importedWatch.bind(chokidar);
}
const require = createRequire(import.meta.url);
const runtime = require("chokidar") as
| { watch?: ChokidarWatch; default?: { watch?: ChokidarWatch } }
| undefined;
if (runtime && typeof runtime.watch === "function") {
return runtime.watch.bind(runtime);
}
if (runtime?.default && typeof runtime.default.watch === "function") {
return runtime.default.watch.bind(runtime.default);
}
throw new Error("chokidar.watch unavailable");
}
/** Creates a Canvas static-file handler with optional live reload. */
export async function createCanvasHostHandler(
opts: CanvasHostHandlerOpts,
): Promise<CanvasHostHandler> {
const basePath = normalizeBasePath(opts.basePath);
if (isDisabledByEnv() && opts.allowInTests !== true) {
return {
rootDir: "",
basePath,
handleHttpRequest: async () => false,
handleUpgrade: () => false,
close: async () => {},
};
}
const rootDir = resolveUserPath(opts.rootDir ?? resolveDefaultCanvasRoot());
const rootReal = await prepareCanvasRoot(rootDir);
const liveReload = opts.liveReload !== false;
const testMode = opts.allowInTests === true;
const reloadDebounceMs = testMode ? 12 : 75;
const writeStabilityThresholdMs = testMode ? 12 : 75;
const writePollIntervalMs = testMode ? 5 : 10;
const WebSocketServerClass = opts.webSocketServerClass ?? WebSocketServer;
const wss = liveReload
? new WebSocketServerClass({
noServer: true,
// Live reload clients never need to send application payloads; cap frames
// before ws buffers oversized input on this long-lived upgrade route.
maxPayload: CANVAS_LIVE_RELOAD_MAX_INBOUND_MESSAGE_BYTES,
})
: null;
const sockets = new Set<WebSocket>();
if (wss) {
wss.on("connection", (ws) => {
sockets.add(ws);
// ws emits error for maxPayload rejections; close handles final cleanup.
ws.on("error", () => {
sockets.delete(ws);
});
ws.on("close", () => sockets.delete(ws));
});
}
let debounce: NodeJS.Timeout | null = null;
const broadcastReload = () => {
if (!liveReload) {
return;
}
for (const ws of sockets) {
try {
ws.send("reload");
} catch {
// ignore
}
}
};
const scheduleReload = () => {
if (debounce) {
clearNativeTimeout(debounce);
}
debounce = scheduleNativeTimeout(() => {
debounce = null;
broadcastReload();
}, reloadDebounceMs);
if (!testMode) {
debounce.unref?.();
}
};
let watcherClosed = false;
const watchFactory = opts.watchFactory ?? resolveDefaultWatchFactory();
const watcher = liveReload
? watchFactory(rootReal, {
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: writeStabilityThresholdMs,
pollInterval: writePollIntervalMs,
},
usePolling: testMode,
ignored: [
/(^|[\\/])\../, // dotfiles
/(^|[\\/])node_modules([\\/]|$)/,
],
})
: null;
watcher?.on("all", () => scheduleReload());
watcher?.on("error", (err) => {
if (watcherClosed) {
return;
}
watcherClosed = true;
opts.runtime.error(
`Canvas host watcher error: ${String(err)} (live reload disabled; consider plugins.entries.canvas.config.host.liveReload=false or a smaller plugins.entries.canvas.config.host.root)`,
);
void watcher.close().catch(() => {});
});
const handleUpgrade = (req: IncomingMessage, socket: Duplex, head: Buffer) => {
if (!wss) {
return false;
}
const url = new URL(req.url ?? "/", "http://localhost");
if (url.pathname !== CANVAS_WS_PATH) {
return false;
}
wss.handleUpgrade(req, socket as Socket, head, (ws) => {
wss.emit("connection", ws, req);
});
return true;
};
const handleHttpRequest = async (req: IncomingMessage, res: ServerResponse) => {
const urlRaw = req.url;
if (!urlRaw) {
return false;
}
try {
const url = new URL(urlRaw, "http://localhost");
if (url.pathname === CANVAS_WS_PATH) {
res.statusCode = liveReload ? 426 : 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(liveReload ? "upgrade required" : "not found");
return true;
}
let urlPath = url.pathname;
if (basePath !== "/") {
if (urlPath !== basePath && !urlPath.startsWith(`${basePath}/`)) {
return false;
}
urlPath = urlPath === basePath ? "/" : urlPath.slice(basePath.length) || "/";
}
if (req.method !== "GET" && req.method !== "HEAD") {
res.statusCode = 405;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Method Not Allowed");
return true;
}
const opened = await resolveFileWithinRoot(rootReal, urlPath);
if (!opened) {
if (urlPath === "/" || urlPath.endsWith("/")) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(
`<!doctype html><meta charset="utf-8" /><title>OpenClaw Canvas</title><pre>Missing file.\nCreate ${rootDir}/index.html</pre>`,
);
return true;
}
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("not found");
return true;
}
const { handle, realPath } = opened;
let data: Buffer;
try {
data = await handle.readFile();
} finally {
await handle.close().catch(() => {});
}
const lower = lowercasePreservingWhitespace(realPath);
const mime =
lower.endsWith(".html") || lower.endsWith(".htm")
? "text/html"
: ((await detectMime({ filePath: realPath })) ?? "application/octet-stream");
res.setHeader("Cache-Control", "no-store");
if (mime === "text/html") {
const html = data.toString("utf8");
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(liveReload ? injectCanvasLiveReload(html) : html);
return true;
}
res.setHeader("Content-Type", mime);
res.end(data);
return true;
} catch (err) {
opts.runtime.error(`Canvas host request failed: ${String(err)}`);
res.statusCode = 500;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("error");
return true;
}
};
return {
rootDir,
basePath,
handleHttpRequest,
handleUpgrade,
close: async () => {
if (debounce) {
clearNativeTimeout(debounce);
}
watcherClosed = true;
await watcher?.close().catch(() => {});
for (const ws of sockets) {
try {
ws.terminate?.();
} catch {
// ignore
}
}
if (wss) {
await new Promise<void>((resolve) => {
wss.close(() => resolve());
});
}
},
};
}
/** Starts a standalone loopback Canvas host HTTP server. */
export async function startCanvasHost(opts: CanvasHostServerOpts): Promise<CanvasHostServer> {
if (isDisabledByEnv() && opts.allowInTests !== true) {
return { port: 0, rootDir: "", close: async () => {} };
}
const handler =
opts.handler ??
(await createCanvasHostHandler({
runtime: opts.runtime,
rootDir: opts.rootDir,
basePath: CANVAS_HOST_PATH,
allowInTests: opts.allowInTests,
liveReload: opts.liveReload,
watchFactory: opts.watchFactory,
webSocketServerClass: opts.webSocketServerClass,
}));
const ownsHandler = opts.ownsHandler ?? opts.handler === undefined;
const bindHost = normalizeOptionalString(opts.listenHost) || "127.0.0.1";
const server: Server = http.createServer((req, res) => {
if (lowercasePreservingWhitespace(req.headers.upgrade ?? "") === "websocket") {
return;
}
void (async () => {
if (req.url && isA2uiPath(new URL(req.url, "http://localhost").pathname)) {
const { handleA2uiHttpRequest } = await import("./a2ui.js");
if (await handleA2uiHttpRequest(req, res)) {
return;
}
}
if (await handler.handleHttpRequest(req, res)) {
return;
}
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
})().catch((err: unknown) => {
opts.runtime.error(`Canvas host request failed: ${String(err)}`);
res.statusCode = 500;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("error");
});
});
server.on("upgrade", (req, socket, head) => {
if (handler.handleUpgrade(req, socket, head)) {
return;
}
socket.destroy();
});
const listenPort =
typeof opts.port === "number" && Number.isFinite(opts.port) && opts.port > 0 ? opts.port : 0;
await new Promise<void>((resolve, reject) => {
const onError = (err: NodeJS.ErrnoException) => {
server.off("listening", onListening);
reject(err);
};
const onListening = () => {
server.off("error", onError);
resolve();
};
server.once("error", onError);
server.once("listening", onListening);
server.listen(listenPort, bindHost);
});
const addr = server.address();
const boundPort = typeof addr === "object" && addr ? addr.port : 0;
opts.runtime.log(
`canvas host listening on http://${bindHost}:${boundPort} (root ${handler.rootDir})`,
);
return {
port: boundPort,
rootDir: handler.rootDir,
close: async () => {
if (ownsHandler) {
await handler.close();
}
await new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
},
};
}

View File

@@ -0,0 +1,78 @@
/**
* Canvas HTTP route adapter that lazily starts the host handler for plugin
* routed requests.
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import type { Duplex } from "node:stream";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { isCanvasHostEnabled, resolveCanvasHostConfig } from "./config.js";
import { A2UI_PATH, CANVAS_HOST_PATH, CANVAS_WS_PATH, handleA2uiHttpRequest } from "./host/a2ui.js";
import { createCanvasHostHandler, type CanvasHostHandler } from "./host/server.js";
/** Canvas route handler shape registered with the plugin HTTP router. */
export type CanvasHttpRouteHandler = {
handleHttpRequest: (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
handleUpgrade: (req: IncomingMessage, socket: Duplex, head: Buffer) => Promise<boolean>;
close: () => Promise<void>;
};
/** Creates a lazily initialized Canvas HTTP/WebSocket route handler. */
export function createCanvasHttpRouteHandler(params: {
config: OpenClawConfig;
pluginConfig?: Record<string, unknown>;
runtime: RuntimeEnv;
allowInTests?: boolean;
}): CanvasHttpRouteHandler {
let hostHandlerPromise: Promise<CanvasHostHandler | null> | null = null;
const loadHostHandler = async (): Promise<CanvasHostHandler | null> => {
if (!isCanvasHostEnabled(params.config)) {
return null;
}
hostHandlerPromise ??= (async () => {
const hostConfig = resolveCanvasHostConfig({
config: params.config,
pluginConfig: params.pluginConfig,
});
const handler = await createCanvasHostHandler({
runtime: params.runtime,
rootDir: hostConfig.root,
basePath: CANVAS_HOST_PATH,
allowInTests: params.allowInTests,
liveReload: hostConfig.liveReload,
});
return handler.rootDir ? handler : null;
})();
return hostHandlerPromise;
};
return {
async handleHttpRequest(req, res) {
const handler = await loadHostHandler();
if (!handler) {
return false;
}
const url = new URL(req.url ?? "/", "http://localhost");
if (url.pathname === A2UI_PATH || url.pathname.startsWith(`${A2UI_PATH}/`)) {
return handleA2uiHttpRequest(req, res);
}
return handler.handleHttpRequest(req, res);
},
async handleUpgrade(req, socket, head) {
const handler = await loadHostHandler();
if (!handler) {
return false;
}
const url = new URL(req.url ?? "/", "http://localhost");
if (url.pathname !== CANVAS_WS_PATH) {
return false;
}
return handler.handleUpgrade(req, socket, head);
},
async close() {
const handler = hostHandlerPromise ? await hostHandlerPromise : null;
await handler?.close();
hostHandlerPromise = null;
},
};
}

View File

@@ -0,0 +1,46 @@
/**
* Agent-facing Canvas tool schema and allowed action/format enums.
*/
import {
optionalFiniteNumberSchema,
optionalNonNegativeIntegerSchema,
optionalPositiveIntegerSchema,
stringEnum,
} from "openclaw/plugin-sdk/channel-actions";
import { Type } from "typebox";
/** Agent tool actions supported by the Canvas plugin. */
export const CANVAS_ACTIONS = [
"present",
"hide",
"navigate",
"eval",
"snapshot",
"a2ui_push",
"a2ui_reset",
] as const;
/** Snapshot formats accepted by the Canvas tool. */
export const CANVAS_SNAPSHOT_FORMATS = ["png", "jpg", "jpeg"] as const;
/** TypeBox schema for the model-facing Canvas tool arguments. */
export const CanvasToolSchema = Type.Object({
action: stringEnum(CANVAS_ACTIONS),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: optionalPositiveIntegerSchema(),
node: Type.Optional(Type.String()),
target: Type.Optional(Type.String()),
x: optionalFiniteNumberSchema(),
y: optionalFiniteNumberSchema(),
width: optionalFiniteNumberSchema(),
height: optionalFiniteNumberSchema(),
url: Type.Optional(Type.String()),
javaScript: Type.Optional(Type.String()),
outputFormat: Type.Optional(stringEnum(CANVAS_SNAPSHOT_FORMATS)),
maxWidth: optionalPositiveIntegerSchema(),
quality: optionalFiniteNumberSchema({ minimum: 0, maximum: 1 }),
delayMs: optionalNonNegativeIntegerSchema(),
jsonl: Type.Optional(Type.String()),
jsonlPath: Type.Optional(Type.String()),
});

View File

@@ -0,0 +1,181 @@
// Canvas tests cover tool plugin behavior.
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createCanvasTool } from "./tool.js";
const mocks = vi.hoisted(() => ({
callGatewayTool: vi.fn(),
imageResultFromFile: vi.fn(async (params) => ({ content: [], details: params })),
listNodes: vi.fn(async () => []),
resolveNodeIdFromList: vi.fn(() => "node-1"),
}));
vi.mock("openclaw/plugin-sdk/agent-harness-runtime", () => ({
callGatewayTool: mocks.callGatewayTool,
listNodes: mocks.listNodes,
resolveNodeIdFromList: mocks.resolveNodeIdFromList,
}));
vi.mock("openclaw/plugin-sdk/channel-actions", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/channel-actions")>()),
imageResultFromFile: mocks.imageResultFromFile,
}));
describe("Canvas tool", () => {
let tempRoot: string | undefined;
beforeEach(() => {
mocks.callGatewayTool.mockReset();
mocks.imageResultFromFile.mockClear();
mocks.listNodes.mockClear();
mocks.listNodes.mockResolvedValue([]);
mocks.resolveNodeIdFromList.mockClear();
mocks.resolveNodeIdFromList.mockReturnValue("node-1");
});
afterEach(async () => {
if (tempRoot) {
await rm(tempRoot, { recursive: true, force: true });
tempRoot = undefined;
}
});
it.skipIf(process.platform === "win32")(
"rejects jsonlPath symlinks that resolve outside the workspace",
async () => {
tempRoot = await mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-tool-"));
const workspaceDir = path.join(tempRoot, "workspace");
await mkdir(workspaceDir);
const outsidePath = path.join(tempRoot, "outside.jsonl");
await writeFile(outsidePath, '{"secret":true}\n');
await symlink(outsidePath, path.join(workspaceDir, "events.jsonl"));
const tool = createCanvasTool({ workspaceDir });
await expect(
tool.execute("tool-call-1", {
action: "a2ui_push",
jsonlPath: "events.jsonl",
}),
).rejects.toThrow("jsonlPath outside workspace");
expect(mocks.callGatewayTool).not.toHaveBeenCalled();
},
);
it("applies configured image limits to canvas snapshots", async () => {
mocks.callGatewayTool.mockResolvedValue({
payload: {
format: "png",
base64: Buffer.from("not-a-real-png").toString("base64"),
},
});
const tool = createCanvasTool({
config: {
agents: {
defaults: {
imageMaxDimensionPx: 1600.9,
},
},
},
});
await tool.execute("tool-call-1", { action: "snapshot" });
expect(mocks.imageResultFromFile).toHaveBeenCalledTimes(1);
const imageResultParams = mocks.imageResultFromFile.mock.calls[0]?.[0] as
| {
label?: string;
path?: string;
details?: unknown;
imageSanitization?: unknown;
}
| undefined;
expect(imageResultParams?.label).toBe("canvas:snapshot");
expect(imageResultParams?.path).toMatch(/openclaw-canvas-snapshot-.*\.png$/);
expect(imageResultParams?.details).toEqual({ format: "png" });
expect(imageResultParams?.imageSanitization).toEqual({ maxDimensionPx: 1600 });
});
it("normalizes numeric string params before invoking node canvas commands", async () => {
mocks.callGatewayTool.mockResolvedValue({
payload: {
format: "png",
base64: Buffer.from("not-a-real-png").toString("base64"),
},
});
const tool = createCanvasTool();
await tool.execute("tool-call-1", {
action: "present",
timeoutMs: "1500",
x: "10.5",
y: "-2",
width: "640",
height: "480",
});
expect(mocks.callGatewayTool).toHaveBeenLastCalledWith(
"node.invoke",
{ timeoutMs: 1500 },
expect.objectContaining({
command: "canvas.present",
params: {
placement: {
x: 10.5,
y: -2,
width: 640,
height: 480,
},
},
}),
);
await tool.execute("tool-call-2", {
action: "snapshot",
maxWidth: "800",
quality: "0.75",
});
expect(mocks.callGatewayTool).toHaveBeenLastCalledWith(
"node.invoke",
{},
expect.objectContaining({
command: "canvas.snapshot",
params: {
format: "png",
maxWidth: 800,
quality: 0.75,
},
}),
);
});
it("rejects malformed numeric canvas params before invoking node commands", async () => {
const tool = createCanvasTool();
await expect(
tool.execute("tool-call-1", {
action: "snapshot",
maxWidth: "800px",
}),
).rejects.toThrow("maxWidth must be a positive integer");
expect(mocks.callGatewayTool).not.toHaveBeenCalled();
});
it("rejects node-controlled snapshot formats before creating image results", async () => {
mocks.callGatewayTool.mockResolvedValue({
payload: {
format: "/../../target.sh",
base64: Buffer.from("not-a-real-png").toString("base64"),
},
});
const tool = createCanvasTool();
await expect(tool.execute("tool-call-1", { action: "snapshot" })).rejects.toThrow(
/invalid canvas\.snapshot payload/i,
);
expect(mocks.imageResultFromFile).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,221 @@
/**
* Agent-facing Canvas tool implementation for node canvas commands and
* snapshots.
*/
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import {
callGatewayTool,
listNodes,
resolveNodeIdFromList,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
imageResultFromFile,
jsonResult,
readStringParam,
} from "openclaw/plugin-sdk/channel-actions";
import { readFiniteNumberParam, readPositiveIntegerParam } from "openclaw/plugin-sdk/param-readers";
import type { AnyAgentTool, OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { normalizeCanvasSnapshotFileExtension, parseCanvasSnapshotPayload } from "./cli-helpers.js";
import { CanvasToolSchema } from "./tool-schema.js";
type CanvasToolOptions = {
config?: OpenClawConfig;
workspaceDir?: string;
};
type CanvasImageSanitizationLimits = {
maxDimensionPx?: number;
};
function readGatewayCallOptions(params: Record<string, unknown>) {
return {
gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }),
gatewayToken: readStringParam(params, "gatewayToken", { trim: false }),
timeoutMs: readPositiveIntegerParam(params, "timeoutMs"),
};
}
async function resolveNodeId(
opts: ReturnType<typeof readGatewayCallOptions>,
query?: string,
allowDefault = false,
): Promise<string> {
return resolveNodeIdFromList(await listNodes(opts), query, allowDefault);
}
async function writeBase64ToTempFile(params: { base64: string; ext: string }): Promise<string> {
const dir = resolvePreferredOpenClawTmpDir();
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
const ext = `.${normalizeCanvasSnapshotFileExtension(params.ext)}`;
const filePath = path.join(dir, `openclaw-canvas-snapshot-${randomUUID()}${ext}`);
await fs.writeFile(filePath, Buffer.from(params.base64, "base64"));
return filePath;
}
function isPathInsideRoot(root: string, candidate: string): boolean {
const relative = path.relative(root, candidate);
return (
relative === "" || (relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative))
);
}
async function readJsonlFromPath(jsonlPath: string, workspaceDir?: string): Promise<string> {
const trimmed = jsonlPath.trim();
if (!trimmed) {
return "";
}
const workspaceRoot = path.resolve(workspaceDir ?? process.cwd());
const resolved = path.resolve(workspaceRoot, trimmed);
const [workspaceReal, resolvedReal] = await Promise.all([
fs.realpath(workspaceRoot),
fs.realpath(resolved),
]);
if (!isPathInsideRoot(workspaceReal, resolvedReal)) {
throw new Error("jsonlPath outside workspace");
}
return await fs.readFile(resolvedReal, "utf8");
}
function resolveCanvasImageSanitizationLimits(
config?: OpenClawConfig,
): CanvasImageSanitizationLimits {
const configured = config?.agents?.defaults?.imageMaxDimensionPx;
if (typeof configured !== "number" || !Number.isFinite(configured)) {
return {};
}
return { maxDimensionPx: Math.max(1, Math.floor(configured)) };
}
/** Creates the model-facing Canvas tool used to invoke paired node canvas commands. */
export function createCanvasTool(options?: CanvasToolOptions): AnyAgentTool {
const imageSanitization = resolveCanvasImageSanitizationLimits(options?.config);
return {
label: "Canvas",
name: "canvas",
description:
"Control node canvases (present/hide/navigate/eval/snapshot/A2UI). Use snapshot to capture the rendered UI.",
parameters: CanvasToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true });
const gatewayOpts = readGatewayCallOptions(params);
const nodeId = await resolveNodeId(
gatewayOpts,
readStringParam(params, "node", { trim: true }),
true,
);
const invoke = async (command: string, invokeParams?: Record<string, unknown>) =>
await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command,
params: invokeParams,
idempotencyKey: randomUUID(),
});
switch (action) {
case "present": {
const placement = {
x: readFiniteNumberParam(params, "x"),
y: readFiniteNumberParam(params, "y"),
width: readFiniteNumberParam(params, "width"),
height: readFiniteNumberParam(params, "height"),
};
const invokeParams: Record<string, unknown> = {};
const presentTarget =
readStringParam(params, "target", { trim: true }) ??
readStringParam(params, "url", { trim: true });
if (presentTarget) {
invokeParams.url = presentTarget;
}
if (
Number.isFinite(placement.x) ||
Number.isFinite(placement.y) ||
Number.isFinite(placement.width) ||
Number.isFinite(placement.height)
) {
invokeParams.placement = placement;
}
await invoke("canvas.present", invokeParams);
return jsonResult({ ok: true });
}
case "hide":
await invoke("canvas.hide", undefined);
return jsonResult({ ok: true });
case "navigate": {
const url =
readStringParam(params, "url", { trim: true }) ??
readStringParam(params, "target", { required: true, trim: true, label: "url" });
await invoke("canvas.navigate", { url });
return jsonResult({ ok: true });
}
case "eval": {
const javaScript = readStringParam(params, "javaScript", {
required: true,
});
const raw = (await invoke("canvas.eval", { javaScript })) as {
payload?: { result?: string };
};
const result = raw?.payload?.result;
if (result) {
return {
content: [{ type: "text", text: result }],
details: { result },
};
}
return jsonResult({ ok: true });
}
case "snapshot": {
const formatRaw =
typeof params.outputFormat === "string" && params.outputFormat.trim()
? params.outputFormat.trim().toLowerCase()
: "png";
const format = formatRaw === "jpg" || formatRaw === "jpeg" ? "jpeg" : "png";
const maxWidth = readPositiveIntegerParam(params, "maxWidth");
const quality = readFiniteNumberParam(params, "quality", {
min: 0,
max: 1,
});
const raw = (await invoke("canvas.snapshot", {
format,
maxWidth,
quality,
})) as { payload?: unknown };
const payload = parseCanvasSnapshotPayload(raw?.payload);
const filePath = await writeBase64ToTempFile({
base64: payload.base64,
ext: payload.format === "jpeg" ? "jpg" : payload.format,
});
return await imageResultFromFile({
label: "canvas:snapshot",
path: filePath,
details: { format: payload.format },
imageSanitization,
});
}
case "a2ui_push": {
const jsonl =
typeof params.jsonl === "string" && params.jsonl.trim()
? params.jsonl
: typeof params.jsonlPath === "string" && params.jsonlPath.trim()
? await readJsonlFromPath(params.jsonlPath, options?.workspaceDir)
: "";
if (!jsonl.trim()) {
throw new Error("jsonl or jsonlPath required");
}
await invoke("canvas.a2ui.pushJSONL", { jsonl });
return jsonResult({ ok: true });
}
case "a2ui_reset":
await invoke("canvas.a2ui.reset", undefined);
return jsonResult({ ok: true });
default:
throw new Error(`Unknown action: ${action}`);
}
},
};
}