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
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:
95
extensions/canvas/src/a2ui-jsonl.ts
Normal file
95
extensions/canvas/src/a2ui-jsonl.ts
Normal 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 };
|
||||
}
|
||||
34
extensions/canvas/src/capability.ts
Normal file
34
extensions/canvas/src/capability.ts
Normal 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);
|
||||
}
|
||||
63
extensions/canvas/src/cli-helpers.test.ts
Normal file
63
extensions/canvas/src/cli-helpers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
74
extensions/canvas/src/cli-helpers.ts
Normal file
74
extensions/canvas/src/cli-helpers.ts
Normal 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}`);
|
||||
}
|
||||
256
extensions/canvas/src/cli.test.ts
Normal file
256
extensions/canvas/src/cli.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
482
extensions/canvas/src/cli.ts
Normal file
482
extensions/canvas/src/cli.ts
Normal 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"));
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
82
extensions/canvas/src/config-migration.test.ts
Normal file
82
extensions/canvas/src/config-migration.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
51
extensions/canvas/src/config-migration.ts
Normal file
51
extensions/canvas/src/config-migration.ts
Normal 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"],
|
||||
};
|
||||
}
|
||||
88
extensions/canvas/src/config.test.ts
Normal file
88
extensions/canvas/src/config.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
127
extensions/canvas/src/config.ts
Normal file
127
extensions/canvas/src/config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
265
extensions/canvas/src/documents.test.ts
Normal file
265
extensions/canvas/src/documents.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
351
extensions/canvas/src/documents.ts
Normal file
351
extensions/canvas/src/documents.ts
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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),
|
||||
}));
|
||||
}
|
||||
77
extensions/canvas/src/host-url.test.ts
Normal file
77
extensions/canvas/src/host-url.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
19
extensions/canvas/src/host-url.ts
Normal file
19
extensions/canvas/src/host-url.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
596
extensions/canvas/src/host/a2ui-app/bootstrap.js
vendored
Normal file
596
extensions/canvas/src/host/a2ui-app/bootstrap.js
vendored
Normal 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);
|
||||
}
|
||||
68
extensions/canvas/src/host/a2ui-app/rolldown.config.mjs
Normal file
68
extensions/canvas/src/host/a2ui-app/rolldown.config.mjs
Normal 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,
|
||||
},
|
||||
};
|
||||
80
extensions/canvas/src/host/a2ui-shared.ts
Normal file
80
extensions/canvas/src/host/a2ui-shared.ts
Normal 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`;
|
||||
}
|
||||
171
extensions/canvas/src/host/a2ui.ts
Normal file
171
extensions/canvas/src/host/a2ui.ts
Normal 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);
|
||||
}
|
||||
311
extensions/canvas/src/host/a2ui/index.html
Normal file
311
extensions/canvas/src/host/a2ui/index.html
Normal 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>
|
||||
75
extensions/canvas/src/host/file-resolver.test.ts
Normal file
75
extensions/canvas/src/host/file-resolver.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
94
extensions/canvas/src/host/file-resolver.ts
Normal file
94
extensions/canvas/src/host/file-resolver.ts
Normal 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);
|
||||
}
|
||||
34
extensions/canvas/src/host/server.state-dir.test.ts
Normal file
34
extensions/canvas/src/host/server.state-dir.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
498
extensions/canvas/src/host/server.test.ts
Normal file
498
extensions/canvas/src/host/server.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
561
extensions/canvas/src/host/server.ts
Normal file
561
extensions/canvas/src/host/server.ts
Normal 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()));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
78
extensions/canvas/src/http-route.ts
Normal file
78
extensions/canvas/src/http-route.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
46
extensions/canvas/src/tool-schema.ts
Normal file
46
extensions/canvas/src/tool-schema.ts
Normal 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()),
|
||||
});
|
||||
181
extensions/canvas/src/tool.test.ts
Normal file
181
extensions/canvas/src/tool.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
221
extensions/canvas/src/tool.ts
Normal file
221
extensions/canvas/src/tool.ts
Normal 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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user