docs: document canvas plugin

This commit is contained in:
Peter Steinberger
2026-06-04 08:07:38 -04:00
parent 18ecb82034
commit 4726aaa08c
24 changed files with 137 additions and 0 deletions

View File

@@ -1,3 +1,6 @@
/**
* Canvas CLI metadata entrypoint used for lightweight command discovery.
*/
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
export default definePluginEntry({

View File

@@ -1,3 +1,7 @@
/**
* 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";

View File

@@ -1,3 +1,7 @@
/**
* Runtime API exports for Canvas plugin host, document, CLI, and capability
* helpers.
*/
export {
canvasConfigSchema,
isCanvasHostEnabled,

View File

@@ -1,4 +1,7 @@
#!/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";
@@ -42,10 +45,12 @@ 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"),
@@ -63,6 +68,7 @@ export function getLocalRolldownCliCandidates(repoRoot = rootDir) {
];
}
/** Returns repository paths that define the A2UI bundle hash inputs. */
export function getBundleHashRepoInputPaths(repoRoot = rootDir) {
return [
path.join(repoRoot, "package.json"),
@@ -71,10 +77,12 @@ export function getBundleHashRepoInputPaths(repoRoot = rootDir) {
];
}
/** Returns A2UI bundle hash input paths. */
export function getBundleHashInputPaths(repoRoot = rootDir) {
return getBundleHashRepoInputPaths(repoRoot);
}
/** Compares paths after normalizing separators to POSIX slashes. */
export function compareNormalizedPaths(left, right) {
const normalizedLeft = normalizePath(left);
const normalizedRight = normalizePath(right);

View File

@@ -1,4 +1,7 @@
#!/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";
@@ -17,6 +20,7 @@ function shouldSkipMissingA2uiAssets(env = process.env) {
return env.OPENCLAW_A2UI_SKIP_MISSING === "1" || Boolean(env.OPENCLAW_SPARSE_PROFILE);
}
/** Copies A2UI assets, optionally tolerating missing bundles in sparse builds. */
export async function copyA2uiAssets({ srcDir, outDir }) {
const skipMissing = shouldSkipMissingA2uiAssets(process.env);
try {

View File

@@ -1,3 +1,6 @@
/**
* Cross-platform pnpm command resolver used by Canvas build scripts.
*/
import { accessSync, closeSync, constants, openSync, readSync, statSync } from "node:fs";
const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>%\r\n]/;
@@ -117,6 +120,7 @@ function resolveConfiguredPnpmExec(params) {
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) {

View File

@@ -1,3 +1,6 @@
/**
* Canvas setup entrypoint that exposes config migrations.
*/
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { migrateLegacyCanvasHostConfig } from "./src/config-migration.js";

View File

@@ -1,3 +1,6 @@
/**
* A2UI JSONL helpers for Canvas text rendering and validation.
*/
const A2UI_ACTION_KEYS = [
"beginRendering",
"surfaceUpdate",
@@ -6,8 +9,10 @@ const A2UI_ACTION_KEYS = [
"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";
@@ -35,6 +40,7 @@ export function buildA2UITextJsonl(text: string) {
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[] = [];

View File

@@ -1,3 +1,6 @@
/**
* Canvas capability-token helpers for scoped hosted node URLs.
*/
import {
buildPluginNodeCapabilityScopedHostUrl,
DEFAULT_PLUGIN_NODE_CAPABILITY_TTL_MS,
@@ -7,19 +10,25 @@ import {
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

@@ -1,3 +1,6 @@
/**
* 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";
@@ -20,6 +23,7 @@ function normalizeCanvasSnapshotFormat(value: string | undefined): CanvasSnapsho
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) {
@@ -28,6 +32,7 @@ export function normalizeCanvasSnapshotFileExtension(value: string): CanvasSnaps
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));
@@ -61,6 +66,7 @@ function resolveTempPathParts(opts: { ext: string; tmpDir?: string; id?: string
};
}
/** 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();

View File

@@ -1,3 +1,6 @@
/**
* 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";
@@ -21,6 +24,7 @@ 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;
@@ -28,6 +32,7 @@ export type CanvasCliRuntime = {
writeJson: (value: unknown) => void;
};
/** Parent node/gateway options consumed by Canvas CLI commands. */
export type CanvasNodesRpcOpts = {
url?: string;
token?: string;
@@ -48,6 +53,7 @@ export type CanvasNodesRpcOpts = {
quality?: string;
};
/** Dependency bundle used to keep Canvas CLI commands testable. */
export type CanvasCliDependencies = {
defaultRuntime: CanvasCliRuntime;
nodesCallOpts: (cmd: Command, defaults?: { timeoutMs?: number }) => Command;
@@ -173,6 +179,7 @@ function unauthorizedHintForMessage(message: string): string | null {
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
@@ -252,6 +259,7 @@ async function invokeCanvas(
);
}
/** Registers Canvas subcommands under the nodes CLI command group. */
export function registerNodesCanvasCommands(nodes: Command, deps: CanvasCliDependencies) {
const canvas = nodes
.command("canvas")

View File

@@ -1,3 +1,6 @@
/**
* 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";
@@ -10,6 +13,7 @@ function mergeHostConfig(params: {
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[];

View File

@@ -1,3 +1,6 @@
/**
* Canvas plugin config parsing, enablement, and schema metadata.
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
normalizePluginsConfig,
@@ -11,6 +14,7 @@ import {
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;
@@ -18,6 +22,7 @@ export type CanvasHostConfig = {
liveReload?: boolean;
};
/** Canvas plugin configuration shape. */
export type CanvasPluginConfig = {
host?: CanvasHostConfig;
};
@@ -47,6 +52,7 @@ function parseCanvasHostConfig(value: unknown): CanvasHostConfig | undefined {
};
}
/** Parses raw Canvas plugin config into a typed, normalized shape. */
export function parseCanvasPluginConfig(value: unknown): CanvasPluginConfig {
if (!isRecord(value)) {
return {};
@@ -55,6 +61,7 @@ export function parseCanvasPluginConfig(value: unknown): CanvasPluginConfig {
return host ? { host } : {};
}
/** Returns whether the bundled Canvas plugin is effectively enabled. */
export function isCanvasPluginEnabled(config?: OpenClawConfig): boolean {
if (!config) {
return true;
@@ -68,6 +75,7 @@ export function isCanvasPluginEnabled(config?: OpenClawConfig): boolean {
}).enabled;
}
/** Resolves Canvas host config from plugin config or root config. */
export function resolveCanvasHostConfig(params: {
config?: OpenClawConfig;
pluginConfig?: Record<string, unknown>;
@@ -78,6 +86,7 @@ export function resolveCanvasHostConfig(params: {
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;
@@ -88,6 +97,7 @@ export function isCanvasHostEnabled(config?: OpenClawConfig): boolean {
return resolveCanvasHostConfig({ config }).enabled !== false;
}
/** Config schema metadata for Canvas plugin settings. */
export const canvasConfigSchema: CanvasPluginConfigSchema = {
parse: parseCanvasPluginConfig,
uiHints: {

View File

@@ -1,3 +1,7 @@
/**
* 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";
@@ -122,6 +126,7 @@ function resolveCanvasDocumentsDir(rootDir?: string, stateDir = resolveStateDir(
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 },
@@ -129,6 +134,7 @@ export function resolveCanvasDocumentDir(
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
@@ -142,6 +148,7 @@ function buildCanvasDocumentAssetUrl(documentId: string, logicalPath: string): s
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 },
@@ -289,6 +296,7 @@ async function materializeEntrypoint(
};
}
/** Creates a Canvas document directory, copies assets, and writes its manifest. */
export async function createCanvasDocument(
input: CanvasDocumentCreateInput,
options?: { stateDir?: string; workspaceDir?: string; canvasRootDir?: string },
@@ -322,6 +330,7 @@ export async function createCanvasDocument(
return manifest;
}
/** Resolves manifest assets to local paths and hosted URLs. */
export function resolveCanvasDocumentAssets(
manifest: CanvasDocumentManifest,
options?: { baseUrl?: string; stateDir?: string; canvasRootDir?: string },

View File

@@ -1,3 +1,6 @@
/**
* Canvas hosted-surface URL resolver.
*/
import {
resolveHostedPluginSurfaceUrl,
type HostedPluginSurfaceUrlParams,
@@ -7,6 +10,7 @@ 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,

View File

@@ -1,3 +1,7 @@
/**
* 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";

View File

@@ -1,3 +1,6 @@
/**
* 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";

View File

@@ -1,15 +1,23 @@
/**
* 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>

View File

@@ -1,3 +1,6 @@
/**
* 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";
@@ -77,6 +80,7 @@ async function resolveA2uiRootReal(): Promise<string | null> {
return resolvingA2uiRoot;
}
/** Handles one HTTP request for the hosted A2UI asset surface. */
export async function handleA2uiHttpRequest(
req: IncomingMessage,
res: ServerResponse,

View File

@@ -1,8 +1,12 @@
/**
* 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);
@@ -41,6 +45,7 @@ function tryNormalizeUrlPath(rawPath: string): string | null {
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,

View File

@@ -1,3 +1,6 @@
/**
* 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";
@@ -29,6 +32,7 @@ import { normalizeUrlPath, resolveFileWithinRoot } from "./file-resolver.js";
type ChokidarWatch = typeof import("chokidar").watch;
/** Options for Canvas host creation. */
export type CanvasHostOpts = {
runtime: RuntimeEnv;
rootDir?: string;
@@ -40,17 +44,20 @@ export type CanvasHostOpts = {
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;
@@ -61,6 +68,7 @@ export type CanvasHostHandlerOpts = {
webSocketServerClass?: typeof WebSocketServer;
};
/** Canvas host handler for HTTP requests, WebSocket upgrades, and teardown. */
export type CanvasHostHandler = {
rootDir: string;
basePath: string;
@@ -244,6 +252,7 @@ function resolveDefaultWatchFactory(): ChokidarWatch {
throw new Error("chokidar.watch unavailable");
}
/** Creates a Canvas static-file handler with optional live reload. */
export async function createCanvasHostHandler(
opts: CanvasHostHandlerOpts,
): Promise<CanvasHostHandler> {
@@ -451,6 +460,7 @@ export async function createCanvasHostHandler(
};
}
/** 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 () => {} };

View File

@@ -1,3 +1,7 @@
/**
* 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";
@@ -6,12 +10,14 @@ 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>;

View File

@@ -1,3 +1,6 @@
/**
* Agent-facing Canvas tool schema and allowed action/format enums.
*/
import {
optionalFiniteNumberSchema,
optionalNonNegativeIntegerSchema,
@@ -6,6 +9,7 @@ import {
} from "openclaw/plugin-sdk/channel-actions";
import { Type } from "typebox";
/** Agent tool actions supported by the Canvas plugin. */
export const CANVAS_ACTIONS = [
"present",
"hide",
@@ -16,8 +20,10 @@ export const CANVAS_ACTIONS = [
"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()),

View File

@@ -1,3 +1,7 @@
/**
* 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";
@@ -85,6 +89,7 @@ function resolveCanvasImageSanitizationLimits(
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 {