refactor(canvas)!: retire A2UI widgets and content-kind hooks (#182109)

* refactor(canvas)!: retire A2UI widgets and content-kind hooks

Remove both renderers, their routes and build pipeline, and the generic
registered board-widget API. Preserve HTML, native plugin widgets, and MCP Apps.

Doctor takes a fresh standard backup before deleting stored A2UI widgets and
records the count and HTML replacement guidance. The candidate Doctor owns this
repair so existing update drivers can continue through the migration.

Advance the closed board schema to protocol v5 with v4 node/probe negotiation,
and preserve v4 pairing metadata in the client fallback. Calls using the retired
show_widget kind receive actionable argument-validation guidance.

BREAKING CHANGE: A2UI widgets and registerBoardWidgetContentKind are removed;
stored A2UI widgets are deleted after backup during Doctor/update. General
clients must support protocol v5.

* test: align retirement proof with protocol v5

* fix(protocol): preserve v4 node features through the v5 cutover

* fix(doctor): preserve retired third-party widget sources

* test(ui): settle context meter before measuring pixels
This commit is contained in:
Peter Steinberger 2026-10-23 21:52:01 -07:00 • committed by GitHub
parent e90948d26e
commit 6f88c26dcf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
173 changed files with 1357 additions and 6443 deletions

View file

@ -30,7 +30,7 @@ Variation from the default completeness process:
- Approvals and Remote Execution: Exec approvals, Plugin approvals, Node exec approvals, Approved node execution, Approval mutation safety, Delivery fallback behavior
- HTTP APIs: OpenAI-compatible APIs, Tool invocation API, Admin API access, Hook ingress
- Hosted Web Surface: Control UI, WebChat hosting, Plugin web routes, Canvas and A2UI routes
- Hosted Web Surface: Control UI, WebChat hosting, Plugin web routes, Canvas routes
- Gateway RPC APIs and Events: Health APIs, Identity and presence APIs, Model APIs, Usage and memory APIs, Session APIs, Chat APIs, Channel APIs, Web login and wake APIs, Config and secrets APIs, Update and setup APIs, Agent and artifact APIs, Task and automation APIs, Tool and skill APIs, Request and event envelopes, Idempotent side effects, Method discovery, Event discovery, Accepted-then-final results, Event ordering, State refresh after gaps
- Device Auth and Pairing: Shared-secret login, Trusted proxy auth, Private ingress mode, Device challenge signing, Device tokens, Setup-code bootstrap, Auth mismatch recovery, Device auth migration, Client pairing, Node pairing
- Network Access and Discovery: Loopback and LAN access, Tailnet access, SSH tunnels, Endpoint discovery, Saved endpoints, TLS pinning

View file

@ -31,7 +31,7 @@ Variation from the default completeness process:
- Authoring and Packaging plugins: Root SDK entrypoint, Focused SDK imports, Entrypoint discovery, Migration shims, Plugin manifest, Package metadata, Runtime compatibility, Validation feedback
- Bundled plugins: Bundled plugin listing, Bundled source overlays, Packaged bundled plugins, Generated plugin inventory, Bundled channel IDs
- Canvas plugin: Hosted Canvas and A2UI surfaces, Agent canvas tool, Node Canvas commands, Control UI embeds, Canvas documents, A2UI transport and snapshots
- Canvas plugin: Hosted Canvas surfaces, Agent canvas tool, Node Canvas commands, Control UI embeds, Canvas documents
- Installing and running plugins: Plugin setup, Runtime activation, Enable and disable, Safe load failures, Dependency repair, Install update and uninstall
- Channel plugins: Inbound event handling, Outbound delivery, Ingress authorization, Destination resolution, Native approval prompts
- Provider and tool plugins: Provider plugins, Tool plugins, Model catalogs, Provider auth, Web search and fetch, Mixed plugins

View file

@ -53,7 +53,7 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- Do not run the aggregate `pnpm test:parallels:npm-update` wrapper in parallel with individual macOS/Windows/Linux smoke lanes; it touches the same guest families and snapshots.
- Do not start Parallels lanes while any unrelated host command may rebuild, clean, or restage `dist` (`pnpm build`, `pnpm ui:build`, `pnpm release:check`, `pnpm test:install:smoke`, npm pack/install smoke, or Docker lanes that run package/build prep). Run unrelated build/package gates first, let them finish, then start the VM matrix. Concurrent `dist` mutation can make host `npm pack` fail with missing files and wastes a full VM cycle.
- While running or optimizing the matrix, record wall-clock duration per lane and the slowest phase from `/tmp/openclaw-parallels-*` logs. Use that timing before changing smoke order, timeouts, or helper behavior.
- If a host build changes tracked generated files such as `src/canvas-host/a2ui/.bundle.hash`, stop before spending VM time. Commit the generated artifact separately or fix the generator drift, then rerun the smallest affected lane.
- If a host build changes tracked generated files, stop before spending VM time. Commit the generated artifact separately or fix the generator drift, then rerun the smallest affected lane.
- If `main` is moving under active multi-agent work, prefer a detached worktree pinned to one commit for long Parallels suites. The smoke scripts now verify the packed tgz commit instead of live `git rev-parse HEAD`, but a pinned worktree still avoids noisy rebuild/version drift during reruns.
- For `openclaw update --channel dev` lanes, remember the guest clones GitHub `main`, not your local worktree. If a local fix exists but the rerun still fails inside the cloned dev checkout, do not treat that as disproof of the fix until the branch has been pushed.
- For `prlctl exec`, pass the VM name before `--current-user` (`prlctl exec "$VM" --current-user ...`), not the other way around.

View file

@ -5,7 +5,7 @@ description: Write, refactor, review, or test Solid 2 UI in OpenClaw (Control UI
# Solid 2 in OpenClaw
The Control UI and plugin views (`extensions/x`, `extensions/workboard`) render with **Solid 2** (`solid-js`, `@solidjs/web`, `@solidjs/signals`, compiled by `@solidjs/vite-plugin`/`@solidjs/compiler`). Lit and Web Awesome are removed from these surfaces; new code is Solid only. The Canvas hosts and A2UI v0.8 catalog also use Solid; the v0.9 catalog still uses the upstream renderer from `@a2ui/web_core`.
The Control UI and plugin views (`extensions/x`, `extensions/workboard`) render with **Solid 2** (`solid-js`, `@solidjs/web`, `@solidjs/signals`, compiled by `@solidjs/vite-plugin`/`@solidjs/compiler`). Lit and Web Awesome are removed from these surfaces; new code is Solid only. The Canvas hosts also use Solid.
Read [ui/AGENTS.md](../../../ui/AGENTS.md) first; its state-ownership rules apply to Solid unchanged. Use the exact installed Solid pins from the owning `package.json`: the runtime and compiler are `2.0.0-rc.16`, and `@solidjs/vite-plugin` is `3.0.0-next.50`. `@solidjs/testing-library` remains `1.0.0-beta.3`. `solid-js` and `@solidjs/*` are exempt from the repository's seven-day release-age gate (Peter, 2026-10-11); third-party Solid ecosystem packages remain subject to it.

View file

@ -65,18 +65,10 @@ apps/ios/build
apps/
vendor/
# Needed for building the Canvas A2UI bundle during Docker image builds.
# Keep the rest of apps/ and vendor/ excluded to avoid a large build context.
# Keep the tool display catalog available without including the native app trees.
!apps/shared/
!apps/shared/OpenClawKit/
!apps/shared/OpenClawKit/Sources/
!apps/shared/OpenClawKit/Sources/OpenClawKit/
!apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/
!apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json
!apps/shared/OpenClawKit/Tools/
!apps/shared/OpenClawKit/Tools/CanvasA2UI/
!apps/shared/OpenClawKit/Tools/CanvasA2UI/**
!vendor/a2ui/
!vendor/a2ui/renderers/
!vendor/a2ui/renderers/lit/
!vendor/a2ui/renderers/lit/**

View file

@ -6,7 +6,7 @@ queries:
- uses: security-extended
query-filters:
# Android canvas intentionally runs trusted A2UI JavaScript; keep this profile focused on exploitable WebView edges.
# Android canvas intentionally runs trusted Canvas JavaScript; keep this profile focused on exploitable WebView edges.
- exclude:
id: java/android/websettings-javascript-enabled
# Gateway TLS already pins verified certificate SHA-256 fingerprints. OkHttp CertificatePinner pins SPKI hashes,

View file

@ -10,6 +10,7 @@ on:
- ".github/workflows/node-runtime-conformance.yml"
- "crates/**"
- "packages/gateway-protocol/src/index.ts"
- "packages/gateway-protocol/src/version.ts"
- "packages/gateway-protocol/src/schema/nodes.ts"
- "packages/gateway-protocol/src/validator-registry.ts"
- "src/gateway/node-command-policy.ts"

5
.gitignore vendored
View file

@ -70,11 +70,6 @@ apps/macos-mlx-tts/.build/**
apps/ios/*.xcfilelist
# Vendor build artifacts
vendor/a2ui/renderers/lit/dist/
src/canvas-host/a2ui/*.bundle.js
src/canvas-host/a2ui/*.map
extensions/canvas/src/host/a2ui/*.bundle.js
extensions/canvas/src/host/a2ui/*.map
.bundle.hash
# fastlane (iOS)

View file

@ -40,7 +40,6 @@
"patches/",
"pnpm-lock.yaml/",
"src/auto-reply/reply/export-html/",
"src/canvas-host/a2ui/a2ui.bundle.js",
"test/fixtures/agents/prompt-snapshots/codex-model-catalog/*.instructions.md",
"test/fixtures/agents/prompt-snapshots/happy-path/*.md",
"vendor/",

View file

@ -229,7 +229,6 @@
"dist-runtime/",
// Intentional negative-test corpora contain parser and lint violations by contract.
"test/fixtures/oxlint-boundary-guards/**",
"**/a2ui.bundle.js",
"extensions/diffs/assets/viewer-runtime.js",
"extensions/diffs-language-pack/assets/viewer-runtime.js",
"node_modules/",

View file

@ -156,14 +156,6 @@ RUN for dir in /app/${OPENCLAW_BUNDLED_PLUGIN_DIR} /app/.agent /app/.agents; do
fi; \
done
# A2UI bundle may fail under QEMU cross-compilation (e.g. building amd64
# on Apple Silicon). CI builds natively per-arch so this is a no-op there.
# Stub it so local cross-arch builds still succeed.
RUN pnpm_config_verify_deps_before_run=false pnpm canvas:a2ui:bundle || \
(echo "A2UI bundle: creating stub (non-fatal)" && \
mkdir -p extensions/canvas/src/host/a2ui && \
echo "/* A2UI bundle unavailable in this build */" > extensions/canvas/src/host/a2ui/a2ui.bundle.js && \
rm -rf vendor/a2ui apps/shared/OpenClawKit/Tools/CanvasA2UI)
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
ENV OPENCLAW_PREFER_PNPM=1
# Correction-release sources keep the base package version; official images

View file

@ -324,11 +324,11 @@ OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for *
- This operator-selected tradeoff is by design and not, by itself, a security vulnerability.
- Canvas host note: network-visible canvas is **intentional** for trusted node scenarios (LAN/tailnet).
- Expected setup: non-loopback bind + Gateway auth (token/password/trusted-proxy) + firewall/tailnet controls.
- Expected routes: `/__openclaw__/canvas/`, `/__openclaw__/a2ui/`.
- Expected routes: `/__openclaw__/canvas/`.
- This deployment model alone is not a security vulnerability.
- Do **not** expose it to the public internet (no direct bind to `0.0.0.0`, no public reverse proxy). It is not hardened for public exposure.
- If you need remote access, prefer an SSH tunnel or Tailscale serve/funnel (so the Gateway still binds to loopback), plus strong Gateway auth.
- The Gateway HTTP surface includes the canvas host (`/__openclaw__/canvas/`, `/__openclaw__/a2ui/`). Treat canvas content as sensitive/untrusted and avoid exposing it beyond loopback unless you understand the risk.
- The Gateway HTTP surface includes the canvas host (`/__openclaw__/canvas/`). Treat canvas content as sensitive/untrusted and avoid exposing it beyond loopback unless you understand the risk.
## Runtime Requirements

View file

@ -10,6 +10,7 @@ use crate::gateway_sleep::SleepPrepareOutcome;
use crate::quickchat::QUICKCHAT_LABEL;
#[cfg(test)]
use futures_util::{SinkExt, StreamExt};
use openclaw_gateway_client::protocol::{MIN_CLIENT_PROTOCOL_VERSION, PROTOCOL_VERSION};
#[cfg(test)]
use openclaw_gateway_client::AUTH_DEVICE_TOKEN_MISMATCH_DETAIL_CODE;
use openclaw_gateway_client::{
@ -56,9 +57,6 @@ const AUTH_PASSWORD_MISSING_DETAIL_CODE: &str = "AUTH_PASSWORD_MISSING";
const MAX_MESSAGE_BYTES: usize = 64 * 1024 * 1024;
const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
// Mirrors packages/gateway-protocol/src/version.ts. The Gateway rejects other ranges.
const MIN_PROTOCOL_VERSION: u32 = 4;
const MAX_PROTOCOL_VERSION: u32 = 4;
const INLINE_WIDGETS_CLIENT_CAPABILITY: &str = "inline-widgets";
#[derive(Clone, Copy, Eq, PartialEq)]
@ -1654,8 +1652,8 @@ fn connect_params(
client_caps.push(INLINE_WIDGETS_CLIENT_CAPABILITY);
}
let mut params = json!({
"minProtocol": MIN_PROTOCOL_VERSION,
"maxProtocol": MAX_PROTOCOL_VERSION,
"minProtocol": MIN_CLIENT_PROTOCOL_VERSION,
"maxProtocol": PROTOCOL_VERSION,
"client": {
"id": CLIENT_ID,
"version": env!("CARGO_PKG_VERSION"),
@ -1988,7 +1986,7 @@ fn validate_hello(payload: Value) -> Result<ValidatedHello, String> {
}
let hello: HelloOk = serde_json::from_value(payload)
.map_err(|error| format!("Invalid Gateway hello response: {error}"))?;
if hello.kind != "hello-ok" || hello.protocol != MAX_PROTOCOL_VERSION {
if hello.kind != "hello-ok" || hello.protocol != PROTOCOL_VERSION {
return Err("Gateway negotiated an unsupported protocol.".to_string());
}
for required in ["agents.list", "chat.send"] {
@ -2111,7 +2109,7 @@ pub(crate) mod tests {
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": MAX_PROTOCOL_VERSION,
"protocol": PROTOCOL_VERSION,
},
})
.to_string()
@ -2731,7 +2729,7 @@ pub(crate) mod tests {
let payload = match frame["method"].as_str().expect("request method") {
"connect" => json!({
"type": "hello-ok",
"protocol": MAX_PROTOCOL_VERSION,
"protocol": PROTOCOL_VERSION,
"features": { "methods": ["agents.list", "chat.send"] },
"auth": {},
"policy": { "tickIntervalMs": 30_000 },
@ -3848,8 +3846,8 @@ esac
true,
)
.expect("connect params");
assert_eq!(params["minProtocol"], MIN_PROTOCOL_VERSION);
assert_eq!(params["maxProtocol"], MAX_PROTOCOL_VERSION);
assert_eq!(params["minProtocol"], MIN_CLIENT_PROTOCOL_VERSION);
assert_eq!(params["maxProtocol"], PROTOCOL_VERSION);
assert_eq!(
params["caps"],
json!([
@ -3890,11 +3888,24 @@ esac
std::fs::remove_dir_all(directory).expect("remove connect fixture");
}
#[test]
fn operator_hello_rejects_the_previous_node_only_protocol() {
let hello = validate_hello(json!({
"type": "hello-ok",
"protocol": PROTOCOL_VERSION - 1,
"features": { "methods": ["agents.list", "chat.send"] },
"auth": {}
}));
assert!(
matches!(hello, Err(message) if message == "Gateway negotiated an unsupported protocol.")
);
}
#[test]
fn hello_tick_policy_sets_two_interval_watchdog() {
let hello = validate_hello(json!({
"type": "hello-ok",
"protocol": MAX_PROTOCOL_VERSION,
"protocol": PROTOCOL_VERSION,
"features": { "methods": ["agents.list", "chat.send"] },
"auth": { "deviceToken": "test-device-token" },
"policy": { "tickIntervalMs": 1_250 },

View file

@ -75,7 +75,6 @@ extensions/buzz/src/qa/adapter.runtime.ts 2
extensions/buzz/src/room-membership.ts 2
extensions/buzz/src/setup-verify.ts 2
extensions/buzz/src/types.ts 2
extensions/canvas/src/a2ui-jsonl.ts 1
extensions/canvas/src/cli.ts 2
extensions/canvas/src/config-migration.ts 2
extensions/canvas/src/tool.ts 1

View file

@ -3209,7 +3209,6 @@
"ui/src/e2e/assistant-panel-context.e2e.test.ts": 6,
"ui/src/e2e/avatar-initial-emoji.e2e.test.ts": 4,
"ui/src/e2e/background-work.real-gateway.e2e.test.ts": 39,
"ui/src/e2e/board-a2ui.e2e.test.ts": 9,
"ui/src/e2e/board-fixture.e2e.test.ts": 83,
"ui/src/e2e/board-mcp-app.e2e.test.ts": 13,
"ui/src/e2e/board-split-transcript.e2e.test.ts": 13,

View file

@ -18,4 +18,5 @@
# OPENCLAW_DISABLE_CRON_HOST_EXECUTION (#177855) controls Gateway-local automation host execution.
# OPENCLAW_DISABLE_GATEWAY_LOCAL_TERMINALS (#177855) controls Gateway-local terminals.
# OPENCLAW_DEVICE_NAME_PREFIX was removed with unread Matrix device-health projections (#178345).
461
# Retiring A2UI removes its four renderer build environment names.
457

View file

@ -188,7 +188,6 @@ const repositoryScriptEntries = [
"scripts/perf/branch-rotation-measure-preload.ts!",
// The documented placement shutdown reproduction is invoked as a standalone CLI.
"scripts/perf/worker-placement-reconcile-shutdown-measure.ts!",
"scripts/bundle-a2ui.mts!",
// The standalone build guide invokes this builder by path.
"scripts/build-bun-standalone.mjs!",
// Maintainers invoke the isolated macOS standalone Doctor proof by path.
@ -1115,7 +1114,6 @@ const config = {
"playwright-core",
"partial-json",
// The native Canvas bundle falls back without optional Markdown support.
"@a2ui/markdown-it",
"sqlite-vec",
"tree-sitter-bash",
...rootToolingAndWorkspaceDependencies,
@ -1276,14 +1274,7 @@ const config = {
// The opt-in browser benchmark is documented and invoked directly by path.
"scripts/bench-lightweight.ts!",
]),
[`${BUNDLED_PLUGIN_ROOT_DIR}/canvas`]: bundledPluginWorkspace([
// Package build/copy scripts are invoked from package.json.
"scripts/copy-a2ui.mjs!",
// Rolldown consumes this config and its browser bootstrap entry.
"src/host/a2ui-app/rolldown.config.mjs!",
"src/host/a2ui-app/bootstrap.jsx!",
"src/host/a2ui-app/bootstrap-v0.9.jsx!",
]),
[`${BUNDLED_PLUGIN_ROOT_DIR}/canvas`]: bundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/cloudflare-ai-gateway`]: bundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/chutes`]: bundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/clawrouter`]: bundledPluginWorkspace(),

View file

@ -192,7 +192,6 @@
["extensions/buzz/src/gateway.lifecycle.test.ts","./buzz-bus.js","3ed9e8f54f478b7157eb84d07e12f367776dc8c4b235459a4c4a0bd93d595b4e",1]
["extensions/buzz/src/gateway.lifecycle.test.ts","./inbound.js","a60d044a832a168f9db58cd293267a621516626870a1441df27f8a9181ec9f20",1]
["extensions/buzz/src/setup-verify.test.ts","openclaw/plugin-sdk/gateway-runtime","05babc864ca52d914f39004ea3b72dbf38e3b3f77a22a65099cecd630e5443ee",1]
["extensions/canvas/src/board-widget.test.ts","./host/a2ui.js","936b484cc341851976106de674f37f9e42c93d40f31f31dbc33d0ec7ac11d711",1]
["extensions/clawrouter/index.test.ts","openclaw/plugin-sdk/provider-auth-runtime","aa79590f83a377a0c8bbf101184dd75adf297d8908e60eeb5fe610f0e873da84",1]
["extensions/clickclack/src/gateway.thread-reply-window.test.ts","./access.js","4ea058cbc8181ce0445ada920e690c244a95a76b6ebec6e9da7697fb3d5d96dc",1]
["extensions/clickclack/src/outbound.test.ts","./accounts.js","5098b146cbdd697f7a4344c02031574631ad9cf928ebcdadace89f7b5cff90cc",1]

View file

@ -0,0 +1,28 @@
use std::{env, fs, path::PathBuf};
fn main() {
let source_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../packages/gateway-protocol/src/version.ts");
println!("cargo:rerun-if-changed={}", source_path.display());
let source = fs::read_to_string(source_path).expect("canonical Gateway protocol versions");
let mut generated =
String::from("// Generated from packages/gateway-protocol/src/version.ts.\n");
for name in [
"PROTOCOL_VERSION",
"MIN_CLIENT_PROTOCOL_VERSION",
"MIN_NODE_PROTOCOL_VERSION",
"MIN_PROBE_PROTOCOL_VERSION",
] {
let prefix = format!("export const {name} = ");
let value: u32 = source
.lines()
.find_map(|line| line.strip_prefix(&prefix))
.and_then(|value| value.strip_suffix(" as const;"))
.unwrap_or_else(|| panic!("missing canonical protocol constant {name}"))
.parse()
.unwrap_or_else(|_| panic!("invalid canonical protocol constant {name}"));
generated.push_str(&format!("pub const {name}: u32 = {value};\n"));
}
let output = PathBuf::from(env::var_os("OUT_DIR").expect("Cargo output directory"));
fs::write(output.join("protocol_versions.rs"), generated).expect("Rust protocol versions");
}

View file

@ -2,6 +2,11 @@
mod session;
/// Gateway compatibility levels generated from the canonical protocol owner.
pub mod protocol {
include!(concat!(env!("OUT_DIR"), "/protocol_versions.rs"));
}
pub use session::{
ClientError, ConnectAttempt, ConnectChallenge, DispatchContext, DispatchRejection, Event,
EventSubscription, GatewayClient, GatewayClientConfig, GatewaySession,

View file

@ -898,7 +898,7 @@ mod tests {
&mut socket,
json!({
"type":"res", "id":connect["id"], "ok":true,
"payload":{"type":"hello-ok","protocol":4}
"payload":{"type":"hello-ok","protocol":openclaw_gateway_client::protocol::PROTOCOL_VERSION}
}),
)
.await;
@ -922,7 +922,10 @@ mod tests {
);
let session = connect().await.unwrap();
assert_eq!(session.hello()["protocol"], 4);
assert_eq!(
session.hello()["protocol"],
openclaw_gateway_client::protocol::PROTOCOL_VERSION
);
assert!(issued_device_token.lock().unwrap().is_none());
drop(session);
server.await.unwrap();

View file

@ -1,4 +1,5 @@
use openclaw_gateway_client::{
protocol::{MIN_NODE_PROTOCOL_VERSION, PROTOCOL_VERSION},
ClientError as GatewayClientError, ConnectAttempt as GatewayConnectAttempt, GatewayClient,
GatewayClientConfig, GatewaySession, TlsTrust,
};
@ -16,8 +17,6 @@ use thiserror::Error;
use crate::identity::{DeviceSigningRequest, IdentityError, NodeIdentity};
const PROTOCOL_VERSION: u32 = 4;
const MINIMUM_NODE_PROTOCOL_VERSION: u32 = 3;
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const DEFAULT_CHALLENGE_TIMEOUT: Duration = Duration::from_secs(15);
const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
@ -28,17 +27,17 @@ const DEFAULT_MAX_EVENT_BUFFER_BYTES: usize = 64 * 1024 * 1024;
/// Node wire dialect selected for one Gateway session.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum NodeProtocolVersion {
/// Released legacy node envelope with direct invocation `params`.
V3,
/// Current node envelope with serialized invocation `paramsJSON`.
/// Previous node protocol with serialized invocation `paramsJSON`.
V4,
/// Current node protocol with serialized invocation `paramsJSON`.
V5,
}
impl NodeProtocolVersion {
const fn number(self) -> u32 {
match self {
Self::V3 => MINIMUM_NODE_PROTOCOL_VERSION,
Self::V4 => PROTOCOL_VERSION,
Self::V4 => MIN_NODE_PROTOCOL_VERSION,
Self::V5 => PROTOCOL_VERSION,
}
}
}
@ -278,7 +277,7 @@ impl NodeConnectOptions {
#[must_use]
pub fn new(version: impl Into<String>, platform: impl Into<String>) -> Self {
Self {
min_protocol: MINIMUM_NODE_PROTOCOL_VERSION,
min_protocol: MIN_NODE_PROTOCOL_VERSION,
max_protocol: PROTOCOL_VERSION,
client: ClientInfo {
id: "node-host",
@ -390,12 +389,11 @@ impl NodeConnectOptions {
public_key: [u8; 32],
challenge: &ConnectChallenge,
) -> Result<DeviceSigningRequest, IdentityError> {
let (platform, device_family) = self.identity_metadata(challenge.protocol);
DeviceSigningRequest::new(
public_key,
&challenge.nonce,
&platform,
device_family.as_deref(),
&self.client.platform,
self.client.device_family.as_deref(),
self.auth.as_ref().and_then(ConnectAuth::signature_token),
challenge.issued_at_ms,
)
@ -422,24 +420,9 @@ impl NodeConnectOptions {
fn for_protocol(mut self, protocol: NodeProtocolVersion) -> Self {
self.min_protocol = protocol.number();
self.max_protocol = protocol.number();
if protocol == NodeProtocolVersion::V3 {
self.client.platform = legacy_node_platform(&self.client.platform);
self.client.device_family = None;
}
self
}
fn identity_metadata(&self, protocol: NodeProtocolVersion) -> (String, Option<String>) {
if protocol == NodeProtocolVersion::V3 {
(legacy_node_platform(&self.client.platform), None)
} else {
(
self.client.platform.clone(),
self.client.device_family.clone(),
)
}
}
fn finalize_identity(mut self, nonce: &str, issued_at_ms: u64) -> Result<Self, IdentityError> {
if self.activated {
self.advertised_caps.clone_from(&self.declared_caps);
@ -461,14 +444,6 @@ impl NodeConnectOptions {
}
}
fn legacy_node_platform(platform: &str) -> String {
match platform {
"macos" => "darwin".to_string(),
"windows" => "win32".to_string(),
_ => platform.to_string(),
}
}
impl ConnectAuth {
fn signature_token(&self) -> Option<&str> {
self.token
@ -660,7 +635,10 @@ impl NodeClient {
let max = params["maxProtocol"]
.as_u64()
.ok_or("missing maximum protocol")?;
if min < 3 || max > 4 || min > max {
if min < u64::from(MIN_NODE_PROTOCOL_VERSION)
|| max > u64::from(PROTOCOL_VERSION)
|| min > max
{
return Err("unsupported signed node protocol range".to_owned());
}
*offered_protocols_ref = (min, max);
@ -689,16 +667,17 @@ impl NodeClient {
})
.await
.map_err(map_gateway_error)?;
let negotiated = gateway.hello()["protocol"].as_u64().unwrap_or(0);
if negotiated < offered_protocols.0 || negotiated > offered_protocols.1 {
// Hello reports the server version, which can exceed a compatible node's range.
let server_protocol = gateway.hello()["protocol"].as_u64().unwrap_or(0);
if server_protocol < offered_protocols.0 || server_protocol > u64::from(PROTOCOL_VERSION) {
gateway.close().await;
return Err(ClientError::InvalidFrame(
"hello protocol is outside the signed range".into(),
"hello protocol does not support the signed range".into(),
));
}
let protocol = match gateway.hello()["protocol"].as_u64() {
Some(3) => NodeProtocolVersion::V3,
Some(4) => NodeProtocolVersion::V4,
let protocol = match server_protocol.min(offered_protocols.1) {
version if version == u64::from(MIN_NODE_PROTOCOL_VERSION) => NodeProtocolVersion::V4,
version if version == u64::from(PROTOCOL_VERSION) => NodeProtocolVersion::V5,
_ => {
gateway.close().await;
return Err(ClientError::InvalidFrame(
@ -747,19 +726,19 @@ impl NodeClient {
let connection_surface = Arc::new(Mutex::new((
false,
BTreeSet::new(),
NodeProtocolVersion::V4,
NodeProtocolVersion::V5,
)));
let surface_for_connect = Arc::clone(&connection_surface);
let mut make_options = make_options;
let gateway = GatewayClient::connect_with_protocol_fallback(
gateway_config,
MINIMUM_NODE_PROTOCOL_VERSION,
MIN_NODE_PROTOCOL_VERSION,
move |challenge, attempt| {
let protocol = match attempt {
GatewayConnectAttempt::Current => NodeProtocolVersion::V4,
GatewayConnectAttempt::Current => NodeProtocolVersion::V5,
GatewayConnectAttempt::ProtocolFallback { expected_protocol } => {
debug_assert_eq!(expected_protocol, MINIMUM_NODE_PROTOCOL_VERSION);
NodeProtocolVersion::V3
debug_assert_eq!(expected_protocol, MIN_NODE_PROTOCOL_VERSION);
NodeProtocolVersion::V4
}
};
let challenge = ConnectChallenge {
@ -796,9 +775,11 @@ impl NodeClient {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
if gateway.hello()["protocol"].as_u64() != Some(u64::from(protocol.number())) {
if !gateway.hello()["protocol"].as_u64().is_some_and(|version| {
(u64::from(protocol.number())..=u64::from(PROTOCOL_VERSION)).contains(&version)
}) {
return Err(ClientError::InvalidFrame(
"Gateway hello protocol did not match the node connect envelope".into(),
"Gateway hello protocol does not support the node connect envelope".into(),
));
}
Ok(NodeSession {
@ -1330,24 +1311,26 @@ mod tests {
let public_key = ed25519_dalek::SigningKey::from_bytes(&[7; 32])
.verifying_key()
.to_bytes();
let request = options
.external_signing_request(
public_key,
&ConnectChallenge {
nonce: "nonce-1".into(),
issued_at_ms: 1_700_000_000_000,
protocol: NodeProtocolVersion::V4,
},
)
.expect("external signing request");
assert_eq!(
request.payload(),
format!(
"v3|{}|node-host|node|node||{}|test-token|nonce-1|windows|desktop",
request.device_id(),
request.signed_at()
)
);
for protocol in [NodeProtocolVersion::V4, NodeProtocolVersion::V5] {
let request = options
.external_signing_request(
public_key,
&ConnectChallenge {
nonce: "nonce-1".into(),
issued_at_ms: 1_700_000_000_000,
protocol,
},
)
.expect("external signing request");
assert_eq!(
request.payload(),
format!(
"v3|{}|node-host|node|node||{}|test-token|nonce-1|windows|desktop",
request.device_id(),
request.signed_at()
)
);
}
}
#[test]
fn invocation_accepts_gateway_null_params_and_session_key() {

View file

@ -1,5 +1,6 @@
use ed25519_dalek::{Signer, SigningKey};
use futures_util::{SinkExt, StreamExt};
use openclaw_gateway_client::protocol::PROTOCOL_VERSION;
use openclaw_node_host::{
ClientError, ClientErrorClass, CommandRuntime, ConnectAuth, IdentityError,
LifecycleDisconnectReason, LifecycleEvent, NodeClient, NodeClientConfig, NodeConnectOptions,
@ -119,7 +120,7 @@ async fn gateway_fixture() -> (std::net::SocketAddr, tokio::task::JoinHandle<()>
&mut socket,
json!({
"type":"res", "id":connect["id"], "ok":true,
"payload":{"type":"hello-ok","protocol":4,
"payload":{"type":"hello-ok","protocol":PROTOCOL_VERSION,
"server":{"version":"test-gateway"},
"auth":{"deviceToken":"issued-device-token"}}
}),
@ -151,7 +152,7 @@ fn expected_events() -> Vec<LifecycleEvent> {
LifecycleEvent::Connecting { attempt: 2 },
LifecycleEvent::Connected {
attempt: 2,
protocol: Some(4),
protocol: Some(u64::from(PROTOCOL_VERSION)),
server_version: Some("test-gateway".into()),
},
LifecycleEvent::Ready { attempt: 2 },

View file

@ -1,5 +1,6 @@
use ed25519_dalek::{Signer, SigningKey};
use futures_util::{SinkExt, StreamExt};
use openclaw_gateway_client::protocol::PROTOCOL_VERSION;
use openclaw_node_host::{
AuthenticatedSidecarChannel, CancellationToken, CommandRuntime, ConnectAuth, HandlerError,
NodeClient, NodeClientConfig, NodeConnectOptions, NodeIdentity, NodeProtocolVersion,
@ -108,7 +109,7 @@ async fn serve_public_runtime_authority(listener: TcpListener, handler_entered:
send_json(
&mut socket,
json!({"type":"res","id":connect["id"],"ok":true,
"payload":{"type":"hello-ok","protocol":4,
"payload":{"type":"hello-ok","protocol":PROTOCOL_VERSION,
"auth":{"deviceToken":"issued-device-token"}}}),
)
.await;
@ -179,7 +180,7 @@ async fn runtime_rejects_buffered_invocation_after_session_retirement_is_request
send_json(
&mut socket,
json!({"type":"res","id":connect["id"],"ok":true,
"payload":{"type":"hello-ok","protocol":4}}),
"payload":{"type":"hello-ok","protocol":PROTOCOL_VERSION}}),
)
.await;
send_json(
@ -264,7 +265,7 @@ async fn wire_cancellation_during_admission_prevents_handler_construction() {
send_json(
&mut socket,
json!({"type":"res","id":connect["id"],"ok":true,
"payload":{"type":"hello-ok","protocol":4}}),
"payload":{"type":"hello-ok","protocol":PROTOCOL_VERSION}}),
)
.await;
send_json(
@ -385,7 +386,7 @@ async fn sidecar_bridge_preserves_authority_through_the_public_runtime() {
send_json(
&mut socket,
json!({"type":"res","id":connect["id"],"ok":true,
"payload":{"type":"hello-ok","protocol":4}}),
"payload":{"type":"hello-ok","protocol":PROTOCOL_VERSION}}),
)
.await;
@ -568,99 +569,101 @@ fn sidecar_offer(role: SidecarPeerRole) -> SidecarProtocolOffer {
}
#[tokio::test]
async fn node_protocol_fallback_uses_fresh_legacy_connect_material_and_recovers_to_v4() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut current, current_connect) =
accept_node_connect(&listener, "nonce-v4", 1_700_000_000_123).await;
assert_current_connect(&current_connect, "nonce-v4");
send_json(
&mut current,
json!({
"type":"res", "id":current_connect["id"], "ok":false,
"error":{
"code":"INVALID_REQUEST",
"message":"protocol mismatch",
"details":{"expectedProtocol":3}
}
}),
)
.await;
async fn node_protocol_fallback_preserves_capabilities_and_recovers_to_v5() {
for server_protocol in [4, 5] {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut current, current_connect) =
accept_node_connect(&listener, "nonce-v5", 1_700_000_000_123).await;
assert_current_connect(&current_connect, "nonce-v5");
send_json(
&mut current,
json!({
"type":"res", "id":current_connect["id"], "ok":false,
"error":{
"code":"INVALID_REQUEST",
"message":"protocol mismatch",
"details":{"expectedProtocol":4}
}
}),
)
.await;
let (mut legacy, legacy_connect) =
accept_node_connect(&listener, "nonce-v3", 1_700_000_000_456).await;
assert_legacy_connect(&legacy_connect, "nonce-v3");
assert_ne!(
current_connect["params"]["device"]["signature"],
legacy_connect["params"]["device"]["signature"]
);
send_json(
&mut legacy,
json!({
"type":"res", "id":legacy_connect["id"], "ok":true,
"payload":{"type":"hello-ok","protocol":3}
}),
)
.await;
send_json(
&mut legacy,
json!({
"type":"event", "event":"node.invoke.request",
"payload":{
"id":"invoke-v3",
"nodeId":"node-1",
"command":"example.status",
"paramsJSON":"{\"verbose\":true}"
let (mut previous, previous_connect) =
accept_node_connect(&listener, "nonce-v4", 1_700_000_000_456).await;
assert_previous_connect(&previous_connect, "nonce-v4");
assert_ne!(
current_connect["params"]["device"]["signature"],
previous_connect["params"]["device"]["signature"]
);
send_json(
&mut previous,
json!({
"type":"res", "id":previous_connect["id"], "ok":true,
"payload":{"type":"hello-ok","protocol":server_protocol}
}),
)
.await;
send_json(
&mut previous,
json!({
"type":"event", "event":"node.invoke.request",
"payload":{
"id":"invoke-v4",
"nodeId":"node-1",
"command":"example.status",
"paramsJSON":"{\"verbose\":true}"
}
}),
)
.await;
while let Some(Ok(message)) = previous.next().await {
if matches!(message, Message::Close(_)) {
break;
}
}),
)
.await;
while let Some(Ok(message)) = legacy.next().await {
if matches!(message, Message::Close(_)) {
break;
}
}
let (mut upgraded, upgraded_connect) =
accept_node_connect(&listener, "nonce-v4-again", 1_700_000_000_789).await;
assert_current_connect(&upgraded_connect, "nonce-v4-again");
send_json(
&mut upgraded,
json!({
"type":"res", "id":upgraded_connect["id"], "ok":true,
"payload":{"type":"hello-ok","protocol":4}
}),
)
.await;
});
let (mut upgraded, upgraded_connect) =
accept_node_connect(&listener, "nonce-v5-again", 1_700_000_000_789).await;
assert_current_connect(&upgraded_connect, "nonce-v5-again");
send_json(
&mut upgraded,
json!({
"type":"res", "id":upgraded_connect["id"], "ok":true,
"payload":{"type":"hello-ok","protocol":PROTOCOL_VERSION}
}),
)
.await;
});
let connect = || {
NodeClient::connect(
NodeClientConfig::new(format!("ws://{address}")),
|_challenge| async move {
Ok::<_, std::io::Error>(
NodeConnectOptions::new("test", "macos")
.device_family("Mac")
.command("example.status")
.activate()
.identity(openclaw_node_host::NodeIdentity::from_secret_bytes([7; 32])),
)
},
)
};
let legacy = connect().await.unwrap();
assert_eq!(legacy.protocol(), NodeProtocolVersion::V3);
assert_eq!(
legacy.next_invocation().await.unwrap().params,
json!({"verbose":true})
);
legacy.close().await;
let connect = || {
NodeClient::connect(
NodeClientConfig::new(format!("ws://{address}")),
|_challenge| async move {
Ok::<_, std::io::Error>(
NodeConnectOptions::new("test", "macos")
.device_family("Mac")
.command("example.status")
.activate()
.identity(openclaw_node_host::NodeIdentity::from_secret_bytes([7; 32])),
)
},
)
};
let previous = connect().await.unwrap();
assert_eq!(previous.protocol(), NodeProtocolVersion::V4);
assert_eq!(
previous.next_invocation().await.unwrap().params,
json!({"verbose":true})
);
previous.close().await;
let current = connect().await.unwrap();
assert_eq!(current.protocol(), NodeProtocolVersion::V4);
current.close().await;
server.await.unwrap();
let current = connect().await.unwrap();
assert_eq!(current.protocol(), NodeProtocolVersion::V5);
current.close().await;
server.await.unwrap();
}
}
#[tokio::test]
@ -726,7 +729,7 @@ async fn duplex_input_overflow_forces_a_terminal_failure() {
send_json(
&mut socket,
json!({"type":"res","id":connect["id"],"ok":true,
"payload":{"type":"hello-ok","protocol":4}}),
"payload":{"type":"hello-ok","protocol":PROTOCOL_VERSION}}),
)
.await;
send_json(
@ -782,7 +785,7 @@ async fn direct_dispatch_rejects_duplex_without_running_an_event_loop() {
send_json(
&mut socket,
json!({"type":"res","id":connect["id"],"ok":true,
"payload":{"type":"hello-ok","protocol":4}}),
"payload":{"type":"hello-ok","protocol":PROTOCOL_VERSION}}),
)
.await;
send_json(
@ -860,7 +863,7 @@ async fn runtime_enforces_the_manifest_of_each_connection() {
send_json(
&mut socket,
json!({"type":"res","id":connect["id"],"ok":true,
"payload":{"type":"hello-ok","protocol":4}}),
"payload":{"type":"hello-ok","protocol":PROTOCOL_VERSION}}),
)
.await;
@ -966,7 +969,7 @@ async fn serve_duplex_runtime(listener: TcpListener, fixture: Value) {
send_json(
&mut socket,
json!({"type":"res", "id":connect["id"], "ok":true,
"payload":{"type":"hello-ok","protocol":4}}),
"payload":{"type":"hello-ok","protocol":PROTOCOL_VERSION}}),
)
.await;
send_json(
@ -1060,18 +1063,20 @@ async fn accept_node_connect(
}
fn assert_current_connect(connect: &Value, nonce: &str) {
assert_eq!(connect["params"]["minProtocol"], 5);
assert_eq!(connect["params"]["maxProtocol"], 5);
assert_eq!(connect["params"]["client"]["platform"], "macos");
assert_eq!(connect["params"]["client"]["deviceFamily"], "Mac");
assert_eq!(connect["params"]["commands"], json!(["example.status"]));
assert_eq!(connect["params"]["device"]["nonce"], nonce);
}
fn assert_previous_connect(connect: &Value, nonce: &str) {
assert_eq!(connect["params"]["minProtocol"], 4);
assert_eq!(connect["params"]["maxProtocol"], 4);
assert_eq!(connect["params"]["client"]["platform"], "macos");
assert_eq!(connect["params"]["client"]["deviceFamily"], "Mac");
assert_eq!(connect["params"]["device"]["nonce"], nonce);
}
fn assert_legacy_connect(connect: &Value, nonce: &str) {
assert_eq!(connect["params"]["minProtocol"], 3);
assert_eq!(connect["params"]["maxProtocol"], 3);
assert_eq!(connect["params"]["client"]["platform"], "darwin");
assert!(connect["params"]["client"].get("deviceFamily").is_none());
assert_eq!(connect["params"]["commands"], json!(["example.status"]));
assert!(connect["params"]["client"].get("modelIdentifier").is_none());
assert_eq!(connect["params"]["device"]["nonce"], nonce);
}
@ -1098,35 +1103,53 @@ fn lifecycle_fixture() -> Value {
#[tokio::test]
async fn native_signed_connect_preserves_product_fields_and_rejects_invalid_node_manifests() {
let signed = json!({"minProtocol":4,"maxProtocol":4,"role":"node","scopes":[],
let signed = json!({"minProtocol":5,"maxProtocol":5,"role":"node","scopes":[],
"client":{"id":"openclaw-macos","mode":"node","platform":"macOS","version":"test"},
"commands":["system.notify","computer.act"],"computerUse":{"test":"native-authority"},
"device":{"id":"native-device","signature":"unchanged-signature","nonce":"native-nonce"},
"auth":{"token":"fixture-token"}});
for invalid in [
None,
Some("previous"),
Some("previous-server"),
Some("range"),
Some("omitted"),
Some("empty"),
Some("role"),
Some("mode"),
Some("protocol"),
Some("retired-protocol"),
Some("future-protocol"),
Some("duplicate"),
Some("command"),
] {
let mut params = signed.clone();
let rejected = !matches!(invalid, None | Some("omitted" | "empty"));
let rejected = !matches!(
invalid,
None | Some("previous" | "previous-server" | "range" | "omitted" | "empty")
);
match invalid {
Some("previous" | "previous-server") => {
params["minProtocol"] = json!(4);
params["maxProtocol"] = json!(4);
}
Some("range") => params["minProtocol"] = json!(4),
Some("omitted") => {
params.as_object_mut().unwrap().remove("commands");
}
Some("empty") => params["commands"] = json!([]),
Some("role") => params["role"] = json!("operator"),
Some("mode") => params["client"]["mode"] = json!("operator"),
Some("protocol") => params["maxProtocol"] = json!(5),
Some("retired-protocol") => params["minProtocol"] = json!(3),
Some("future-protocol") => params["maxProtocol"] = json!(6),
Some("duplicate") => params["commands"] = json!(["system.notify", "system.notify"]),
Some("command") => params["commands"] = json!(["system.notify "]),
_ => {}
}
let server_protocol = if invalid == Some("previous-server") {
4
} else {
5
};
let expected = params.clone();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
@ -1151,7 +1174,7 @@ async fn native_signed_connect_preserves_product_fields_and_rejects_invalid_node
send_json(
&mut socket,
json!({"type":"res","id":connect["id"],"ok":true,
"payload":{"type":"hello-ok","protocol":4}}),
"payload":{"type":"hello-ok","protocol":server_protocol}}),
)
.await;
}
@ -1168,7 +1191,18 @@ async fn native_signed_connect_preserves_product_fields_and_rejects_invalid_node
assert!(connected.is_err());
} else {
let session = connected.unwrap();
let expected_commands = if invalid.is_none() {
assert_eq!(
session.protocol(),
if matches!(invalid, Some("previous" | "previous-server")) {
NodeProtocolVersion::V4
} else {
NodeProtocolVersion::V5
}
);
let expected_commands = if matches!(
invalid,
None | Some("previous" | "previous-server" | "range")
) {
vec!["computer.act", "system.notify"]
} else {
vec![]

View file

@ -1,4 +1,4 @@
52164ede5939374af5a1ba02a01fe7e5dec54ad3eade2c3a50f7eda877024ca6 config-baseline.json
e892d8782881f73deae810b9733f8b3bf8172763adbcb13f5a5d05f4badf315a config-baseline.core.json
9ae1181ff0b10e758983144e3a8d5aee2c526a7df9a6c14999ed957b5fb1f2d1 config-baseline.json
907f4d5a86aec81fe178d85b8098b35cf05b706416a4081cdc22216ddb8bfd00 config-baseline.core.json
79622d10e662abd5712b294f1a87d513e8a39dd4ac683305b873fe90a3ea3f73 config-baseline.channel.json
b92420826dad8c1a18d830b963ac003043c8bf77a87835a2540684c40dbd91b1 config-baseline.plugin.json
93a41ded8c0c3334167cc42738b29621509521296463e9ae9d4bebd806f9a35b config-baseline.plugin.json

View file

@ -384,7 +384,7 @@ later Doctor runs do not overwrite edits or recreate a deleted migrated job.
Use `automations show`, `runs`, `enable`, and `disable` to manage the jobs.
Legacy `system heartbeat` controls apply only to converted/default jobs. See [Heartbeat migration](/gateway/heartbeat).
The `cron.*` Gateway methods and protocol v4 remain compatible. The deprecated
The `cron.*` Gateway methods remain compatible. The deprecated
`--wake now|next-heartbeat` field remains accepted, but scheduled jobs execute
their payload through ordinary session execution when the job is due. It does
not add another wake or deferral step. Use the job's schedule to control timing.

View file

@ -17,7 +17,6 @@ title: "Gateway architecture"
- One Gateway per host. It is the only place that opens a WhatsApp session.
- The **hosted widget surface** is served by the Gateway HTTP server under:
- `/__openclaw__/canvas/` (hosted widget documents)
- `/__openclaw__/a2ui/` (A2UI renderer assets)
It uses the same port as the Gateway (default `18789`).

View file

@ -88,8 +88,8 @@ Connect (first message):
"id": "c1",
"method": "connect",
"params": {
"minProtocol": 3,
"maxProtocol": 4,
"minProtocol": 5,
"maxProtocol": 5,
"client": {
"id": "openclaw-macos",
"displayName": "macos",
@ -111,7 +111,7 @@ Hello-ok response:
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 4,
"protocol": 5,
"server": { "version": "dev", "connId": "ws-1" },
"features": { "methods": ["health"], "events": ["tick"] },
"snapshot": {
@ -158,8 +158,8 @@ ws.on("open", () => {
id: "c1",
method: "connect",
params: {
minProtocol: 4,
maxProtocol: 4,
minProtocol: 5,
maxProtocol: 5,
client: {
id: "cli",
displayName: "example",
@ -264,7 +264,7 @@ The published JSON Schema's `oneOf` frame order is a separate contract: `req`, `
## Versioning and compatibility
- `PROTOCOL_VERSION` lives in `packages/gateway-protocol/src/version.ts` (current value: `4`).
- `PROTOCOL_VERSION` lives in `packages/gateway-protocol/src/version.ts` (current value: `5`).
- Clients send `minProtocol` and `maxProtocol`; the server rejects ranges that do not include its current protocol.
- The Swift models keep unknown frame types to avoid breaking older clients.

View file

@ -336,10 +336,10 @@ transition racing the list request is neither lost nor resurrected.
## Track protocol versions
The current wire version is `4`. General operator and WebChat clients must
negotiate the exact current version with `minProtocol: 4` and `maxProtocol: 4`.
The current wire version is `5`. General operator and WebChat clients must
negotiate the exact current version with `minProtocol: 5` and `maxProtocol: 5`.
Only authenticated node clients and lightweight checks have the N-1 acceptance
window, currently protocol `3` through `4`.
window, currently protocol `4` through `5`.
Protocol changes are additive first. `protocol.schema.json` includes `since`
release-vintage metadata and required scope metadata for core methods, but a wire

View file

@ -385,8 +385,7 @@ See [Plugins](/tools/plugin).
```
- `host.enabled` is the single Canvas host switch and defaults to enabled. It
gates hosted widget documents under `/__openclaw__/canvas/` and A2UI renderer
assets under `/__openclaw__/a2ui/`.
gates hosted widget documents under `/__openclaw__/canvas/`.
- Local-only: keep `gateway.bind: "loopback"` (default).
- Non-loopback binds: these routes require Gateway auth (token/password/trusted-proxy), same as other Gateway HTTP surfaces.
- Node WebViews typically don't send auth headers; after a macOS node is paired and connected, the Gateway advertises a node-scoped `pluginSurfaceUrls.canvas` capability URL.

View file

@ -34,6 +34,29 @@ backed by those local providers and repairs references to unavailable providers.
This repair leaves `models.json` unchanged, preserving its comments. Unreadable
or malformed catalogs still produce a warning and leave affected references intact.
## Retired A2UI widgets
A2UI widgets and the `registerBoardWidgetContentKind` Plugin SDK API have been
removed. Use [`show_widget`](/tools/show-widget) with `kind: "html"` for
interactive widget content. Calls that still request `kind: "a2ui"` fail
validation with the next step: `A2UI widgets were removed; use kind "html"`.
Doctor backs up the affected agent database before deleting stored A2UI widgets
identified by `pluginKind: "canvas:a2ui"` or `registeredContentKind: "a2ui"`.
It preserves other widgets and records a warning naming the deleted count,
explaining that A2UI widgets were retired and can be recreated as HTML widgets.
This repair does not convert their source into HTML.
Other widgets using the retired registered-renderer API become inert HTML source
previews after the same backup. Doctor preserves their original descriptor and
manifest in the preview, keeps their layout and valid identity, and removes
previous capabilities. Existing HTML, native plugin, and MCP widgets are unchanged.
During `openclaw update`, the candidate's Doctor step owns this backed-up repair,
including when the update was started by an older installed update driver.
Stored A2UI rows do not prevent the update. Runtime readers accept only the
current widget formats; they do not retain a compatibility renderer.
## Group allowlist fallback
For channels that support legacy DM sender fallback, Doctor copies `allowFrom`

View file

@ -108,7 +108,7 @@ Base port = `gateway.port` (or `OPENCLAW_GATEWAY_PORT` / `--port`).
- Sandbox host listener port = base + 1 when the isolated widget/MCP App host is active (unless `mcp.apps.sandboxPort` overrides it).
- Browser control service port = base + 2 (loopback only).
- Hosted widget documents and A2UI renderer assets are served on the Gateway HTTP server itself (same port as `gateway.port`).
- Hosted widget documents are served on the Gateway HTTP server itself (same port as `gateway.port`).
- Browser profile CDP ports auto-allocate from `browser control port + 9` through `+ 108`.
Override any of these in config or env and you must keep them unique per instance.

View file

@ -79,7 +79,7 @@ Session-wide exec policy belongs to the permission mode. When the mode is unset,
Before resuming sessions after upgrading a store with legacy session exec policy, run `openclaw doctor --fix`; the runtime no longer reads that policy. Missing policy values inherit the layered global and per-agent exec policy. When historical sandbox availability is unknown, migration uses the stricter sandbox base for automatic host selection. Restrictive policies migrate without broadening exec access to `read-only` or `guarded`. Existing permission modes remain unchanged. Legacy full-access policy is removed with a notice so configuration applies, never converted into a `full` permission grant.
The retired `execSecurity` and `execAsk` fields remain in the protocol v4 schemas for `sessions.patch` and `sessions.patchMany`. Requests containing either field, including `null`, are rejected with `INVALID_REQUEST` and guidance to set `permissionMode` (`read-only`, `guarded`, `workspace`, or `full`) or use `/exec` for one run. Neither field is stored as runtime session policy.
The retired `execSecurity` and `execAsk` fields remain in the wire schemas for `sessions.patch` and `sessions.patchMany`. Requests containing either field, including `null`, are rejected with `INVALID_REQUEST` and guidance to set `permissionMode` (`read-only`, `guarded`, `workspace`, or `full`) or use `/exec` for one run. Neither field is stored as runtime session policy.
An explicit `full` mode is the admin-authorized exception to host approval-file floors: its OpenClaw exec policy remains `full` with approvals off unless a turn-scoped override tightens it. Approval-file floors continue to tighten config-driven exec policy, unset modes, and every non-full session mode. Tightening a full session's security restores those floors; tightening only `ask` does not. Sandbox restrictions and tool allow/deny policy remain independent, and a harness may clamp an unsupported mode to a compatible safer policy tuple. Codex also continues to honor externally enforced `requirements.toml` constraints.

View file

@ -37,8 +37,8 @@ Client replies with `connect`:
"id": "…",
"method": "connect",
"params": {
"minProtocol": 4,
"maxProtocol": 4,
"minProtocol": 5,
"maxProtocol": 5,
"client": {
"id": "cli",
"version": "1.2.3",
@ -87,7 +87,7 @@ checks.
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 4,
"protocol": 5,
"server": { "version": "…", "connId": "…" },
"features": { "methods": ["…"], "events": ["…"] },
"snapshot": { "…": "…" },
@ -315,8 +315,8 @@ Client capabilities describe the connected client, not authorization. Agent tool
"id": "…",
"method": "connect",
"params": {
"minProtocol": 4,
"maxProtocol": 4,
"minProtocol": 5,
"maxProtocol": 5,
"client": {
"id": "ios-node",
"version": "1.2.3",

View file

@ -19,31 +19,35 @@ Which protocol versions a client may negotiate, and the constants a reference cl
`packages/gateway-protocol/src/version.ts`.
- Clients send `minProtocol` + `maxProtocol`. Operator and UI clients must
include the current protocol in that range; current clients and servers run
protocol v4.
protocol v5.
- Authenticated clients with both `role: "node"` and `client.mode: "node"`
may use the N-1 node protocol (v3). Lightweight restart checks use
may use the N-1 node protocol (v4). Lightweight restart checks use
the same N-1 window. Device auth, pairing, scopes, command policy, and exec
approvals are unchanged by this compatibility window. Plugin-owned node
capabilities and commands are withheld until the node upgrades to the current
protocol because their hosted surfaces are not part of the N-1 contract.
approvals are unchanged by this compatibility window. Supported v4 nodes retain
their approved plugin capabilities, commands, and hosted surface URLs.
- Schemas and models are generated from TypeBox definitions:
- `pnpm protocol:gen`
- `pnpm protocol:gen:swift`
- `pnpm protocol:check`
Protocol v5 removes the registered board-widget content variant and its
`registeredContentKind` and `kindLabel` fields. General clients limited to v4
must upgrade. Authenticated v4 nodes and lightweight v4 probes remain accepted
under the compatibility window above.
### Client constants
The reference client implementation lives in `packages/gateway-client/src/`
(OpenClaw wraps it via the thin `src/gateway/client.ts` facade). These
defaults are stable across protocol v4 and are the expected baseline for
defaults are stable across protocol v5 and are the expected baseline for
third-party clients.
| Constant | Default | Source |
| ----------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `PROTOCOL_VERSION` | `4` | `packages/gateway-protocol/src/version.ts` |
| `MIN_CLIENT_PROTOCOL_VERSION` | `4` | `packages/gateway-protocol/src/version.ts` |
| `MIN_NODE_PROTOCOL_VERSION` | `3` | `packages/gateway-protocol/src/version.ts` |
| `MIN_PROBE_PROTOCOL_VERSION` | `3` | `packages/gateway-protocol/src/version.ts` |
| `PROTOCOL_VERSION` | `5` | `packages/gateway-protocol/src/version.ts` |
| `MIN_CLIENT_PROTOCOL_VERSION` | `5` | `packages/gateway-protocol/src/version.ts` |
| `MIN_NODE_PROTOCOL_VERSION` | `4` | `packages/gateway-protocol/src/version.ts` |
| `MIN_PROBE_PROTOCOL_VERSION` | `4` | `packages/gateway-protocol/src/version.ts` |
| Request timeout (per RPC) | `30_000` ms | `packages/gateway-client/src/client.ts` (`requestTimeoutMs`) |
| Preauth / connect-challenge timeout | `30_000` ms | `packages/gateway-client/src/timeouts.ts` (`OPENCLAW_HANDSHAKE_TIMEOUT_MS` env can raise the paired server/client budget) |
| Initial reconnect backoff | `1_000` ms | `packages/gateway-client/src/client.ts` (`GATEWAY_RECONNECT_POLICY`) |

View file

@ -11,7 +11,7 @@ title: "Network exposure"
### Bind, port, firewall
The Gateway multiplexes WebSocket + HTTP on one port (default `18789`; config/flags/env: `gateway.port`, `--port`, `OPENCLAW_GATEWAY_PORT`). That HTTP surface includes the Control UI (SPA assets, default base path `/`), hosted widget documents (`/__openclaw__/canvas`), and A2UI renderer assets (`/__openclaw__/a2ui`). Widget documents contain agent-authored HTML/JS; treat them as untrusted content when loaded in a normal browser, do not expose them to untrusted networks or users, and do not share their origin with privileged web surfaces.
The Gateway multiplexes WebSocket + HTTP on one port (default `18789`; config/flags/env: `gateway.port`, `--port`, `OPENCLAW_GATEWAY_PORT`). That HTTP surface includes the Control UI (SPA assets, default base path `/`), and hosted widget documents (`/__openclaw__/canvas`). Widget documents contain agent-authored HTML/JS; treat them as untrusted content when loaded in a normal browser, do not expose them to untrusted networks or users, and do not share their origin with privileged web surfaces.
`gateway.bind` controls where the Gateway listens:

View file

@ -198,7 +198,7 @@ boundaries. The sidecar contains the resources read from disk:
- Plugin manifests, package metadata, and assets such as browser scripts, styles,
WebAssembly, SQL templates, browser extensions, and plugin-declared static files.
- Control UI static files.
- Canvas A2UI bundles, session-card fonts, HTML export templates, and bundled hook metadata.
- Session-card fonts, HTML export templates, and bundled hook metadata.
- Native addons, shared libraries, dependency resources, and their package and
license metadata.
- Runtime identity and startup metadata, install-owner metadata, and license notices.

View file

@ -19,7 +19,7 @@ Most operations flow through the Gateway (`openclaw gateway`), a single long-run
shared-secret token/password auth, or a correctly configured non-loopback
`trusted-proxy` deployment.
- **One Gateway per host** is recommended. For isolation, run multiple gateways with isolated profiles and ports ([Multiple Gateways](/gateway/multiple-gateways)).
- **Hosted widget documents and A2UI renderer assets** are served on the same port as the Gateway (`/__openclaw__/canvas/`, `/__openclaw__/a2ui/`), protected by Gateway auth when bound beyond loopback.
- **Hosted widget documents** are served on the same port as the Gateway (`/__openclaw__/canvas/`), protected by Gateway auth when bound beyond loopback.
- **Remote access** is typically an SSH tunnel or Tailscale VPN ([Remote Access](/gateway/remote)).
Key references:

View file

@ -26,8 +26,6 @@ Notes:
- The agent-facing path is [`show_widget`](/tools/show-widget) with
`presentation.target: "node_panel"`; use the CLI helpers for direct operator
control.
- A2UI renders on [session dashboards](/web/dashboards), not through node
Canvas commands.
## Photos + videos (node camera)

View file

@ -85,16 +85,15 @@ omitted. See
## Version skew and upgrade order
The Gateway WebSocket accepts authenticated node clients across an N-1 protocol window.
The current v4 Gateway therefore accepts v3 nodes when the connection declares
The current v5 Gateway therefore accepts v4 nodes when the connection declares
both `role: "node"` and `client.mode: "node"`. Operator and UI sessions must
still use the current protocol.
For staged fleet upgrades, upgrade the Gateway first, then upgrade each node.
An N-1 node remains visible and manageable while it is upgraded; the Gateway
logs `legacy node protocol accepted` with an upgrade recommendation. Pairing,
An N-1 node remains visible and manageable while it is upgraded, including its
approved plugin capabilities, commands, and hosted surface URLs. Pairing,
device authentication, command allowlists, and exec approvals still apply.
Plugin-owned capabilities and commands stay hidden until the node upgrades to
the current protocol. Nodes older than N-1 require an out-of-band upgrade before
Nodes older than N-1 require an out-of-band upgrade before
reconnecting.
The direct watchOS HTTPS transport requires the current protocol version; update

View file

@ -503,7 +503,7 @@ The bundled Linux Canvas bridge and its desktop Canvas window have been removed.
For inline widgets in the Control UI, use [`show_widget`](/tools/show-widget).
The separate [macOS widget panel](/platforms/mac/canvas) requires a connected
Mac and is render-only. These widget surfaces do not restore the former Linux
Canvas bridge or its A2UI push commands.
Canvas bridge.
## Install

View file

@ -10,7 +10,7 @@ doc-schema-version: 1
The macOS app includes a native panel for presenting hosted widget documents.
The Canvas plugin owns this presentation path. It is not a standalone visual
workspace or an A2UI push target.
workspace.
The recommended agent path is [`show_widget`](/tools/show-widget) with
`presentation.target: "node_panel"`. OpenClaw stores the widget as a hosted
@ -79,16 +79,6 @@ openclaw-canvas://<session>/<path>
Files addressed by that scheme must remain inside the session's Canvas root in
Application Support. Directory traversal is blocked.
## A2UI belongs on session dashboards
A2UI widgets render on [session dashboards](/web/dashboards), where they share
the same pinning, layout, approval, and interaction model as other dashboard
widgets. Their renderer bundles continue to load from the Gateway's
`/__openclaw__/a2ui/` asset route.
The macOS panel does not accept A2UI push/reset commands and does not
automatically navigate to an A2UI page.
## Migrating documents from a custom root
Run `openclaw doctor --fix` to move documents from the retired

View file

@ -254,8 +254,8 @@ resource-specific policy remain required where the direct listener requires them
The surface refuses
general APIs, webhooks, `/v1`, standalone MCP, worker and node transfers, and
undeclared plugin routes. Service workers and local browser bootstrap recovery
are blocked. The sandbox surface serves only its dedicated shell and declared
public renderer resources, never Gateway data.
are blocked. The sandbox surface serves only its dedicated shell, never Gateway
data.
The HTTP transport is not arbitrary forwarding or a private network proxy. MCP
integrations use the separate grant-bound RPC operation, not an HTTP route

View file

@ -35,7 +35,6 @@ plugins.
| `api.session.workflow.sendSessionAttachment(...)` | Bundled-only host-mediated file attachment delivery to the active direct-outbound session route |
| `api.session.workflow.scheduleSessionTurn(...)` / `unscheduleSessionTurnsByTag(...)` | Bundled-only Cron-backed scheduled session turns plus tag-based cleanup |
| `api.session.controls.registerSessionAction(...)` | Typed session actions clients can dispatch through the Gateway |
| `api.registerBoardWidgetContentKind(...)` | Sandboxed board widget source validation, renderer resources, and document composition |
Runtime lifecycle registrations can also supply `dispose()` for resources captured
by that registration. Explicitly owned, uncached plugin inspections invoke it on
@ -146,30 +145,6 @@ Standalone programmatic CLI calls and caller-owned Commander programs retain
their existing lifecycle. `cli-metadata` remains inert and accepts CLI descriptors
only; keep its `machineOutput` resolver pure and synchronous.
`registerBoardWidgetContentKind(...)` is for plugins that own a declarative
widget source format. The registration supplies a globally unique lowercase
`kind`, a short label, one capability-scoped plugin surface plus its renderer
resource paths, a synchronous `validateSource(source)` callback, and a
synchronous `composeDocument(...)` callback. Core adds the document shell,
sandbox, theme, and ticket-bound action bridge. Registrations exist only while
their plugin is active; invalid, reserved, or duplicate kinds fail plugin load.
Use `dashboard.dataBindings` and `dashboard.actionVerbs` for host capabilities,
not for renderer registration.
For inline rendering, `resources.readPublicResource(path)` can optionally return
`{ body: Uint8Array, contentType: string }` for the registered resource paths.
These bytes are public: the isolated sandbox listener serves them with no
Gateway credentials. Return only static renderer assets, never user data or
secrets. Unregistered paths and registrations without this callback stay private.
Opting in reserves every declared path in one global sandbox namespace: no other
content kind may declare the same path, even without a public reader. Registration
rejects these collisions regardless of order; only private registrations may
share paths. Public paths must already be normalized URL pathnames, without dot
segments, backslashes, query strings, or fragments. The sandbox host endpoint
`/mcp-app-sandbox` is reserved. These additional path restrictions apply only to
registrations with `readPublicResource`; private paths retain their capability
URL encoding.
A `surface: "tab"` descriptor adds a sidebar tab to the Control UI. Active
plugins' tab descriptors are advertised to dashboard clients in the gateway
hello (`controlUiTabs`), so the tab appears only while the plugin is enabled.

View file

@ -120,17 +120,6 @@ containing checkout as the ownership boundary. Other `pnpm tsgo` lanes continue
to use the native CLI; its wrapper does not create or reuse a shared external
install.
Canvas A2UI asset builds use the shared build-artifact cache, including
`BUILD_ALL_CACHE_ROOT` when consecutive update candidates use different checkouts.
The cache verifies and restores both dialect bundles together. Canvas source,
its package manifest, the lockfile, workspace dependency settings, patches, build
scripts, Node version, and build environment invalidate reuse; unrelated root
package scripts do not. `OPENCLAW_BUILD_CACHE=0` forces compilation. Explicit
`OPENCLAW_A2UI_BUNDLE_OUT` destinations bypass the shared cache. Sparse checkouts
retain their existing complete-prebuilt-bundle behavior. Cold A2UI builds default
the native Rayon and Rolldown worker pools to four threads each, preserving explicit
`RAYON_NUM_THREADS` and `ROLLDOWN_WORKER_THREADS` overrides.
Packaged SDK declarations belong to one staged owner shared by full, package, and
`ciArtifacts` builds. It serializes the two standard tsdown SDK groups on a miss
and caches their complete staged generation. Each successful compiler supplies its

View file

@ -74,7 +74,7 @@ The public plugin SDK publishes about 150 entrypoints, held under shrink-only su
## Open standards
OpenClaw adopts protocols used across AI tools. It is an [MCP client](/tools/mcp) (Streamable HTTP, SSE, and stdio transports, with OAuth) and an [MCP server](/cli/mcp), and plugins can [ship their own MCP servers and apps](/plugins/manifest/surfaces#mcp-server-reference). Other agents reach it through the Linux Foundation [A2A 1.0 protocol](/channels/a2a) — Agent Card discovery, authenticated JSON-RPC tasks, and outbound peer messaging — and editors connect over the [Agent Client Protocol](/cli/acp), which OpenClaw also uses to [host external harnesses](/tools/acp-agents). Agents render live [A2UI widgets](/web/dashboards) on session dashboards.
OpenClaw adopts protocols used across AI tools. It is an [MCP client](/tools/mcp) (Streamable HTTP, SSE, and stdio transports, with OAuth) and an [MCP server](/cli/mcp), and plugins can [ship their own MCP servers and apps](/plugins/manifest/surfaces#mcp-server-reference). Other agents reach it through the Linux Foundation [A2A 1.0 protocol](/channels/a2a) — Agent Card discovery, authenticated JSON-RPC tasks, and outbound peer messaging — and editors connect over the [Agent Client Protocol](/cli/acp), which OpenClaw also uses to [host external harnesses](/tools/acp-agents). Agents render interactive [HTML widgets](/tools/show-widget) on session dashboards.
Skills follow the [AgentSkills spec](/tools/skills), plugin installs auto-detect [Agent Plugins, Codex, Claude, and Cursor bundle layouts](/plugins/bundles), and the Gateway can serve an [OpenAI-compatible API](/gateway/openai-http-api) (`/v1/chat/completions` with a documented function-tool subset, [`/v1/responses`](/gateway/openresponses-http-api), `/v1/models`, `/v1/embeddings`; disabled by default, `/v1/responses` separately enabled) so OpenAI clients can target the Gateway directly. Observability exports over [OpenTelemetry](/gateway/opentelemetry) and [Prometheus](/gateway/prometheus); gateways advertise via [Bonjour and DNS-SD](/gateway/bonjour); channels include native [Matrix](/channels/matrix), [IRC](/channels/irc), and [Nostr](/channels/nostr) protocol implementations; and releases ship with [npm provenance and verifiable artifact attestations](/reference/RELEASING).

View file

@ -250,7 +250,7 @@ Example:
A standalone `/exec security=deny` acknowledges the run-only setting but starts no agent run and does not affect the next message. Use [session permission modes](/gateway/permission-modes) to keep a policy across messages.
The `execSecurity` and `execAsk` fields in `sessions.patch` and `sessions.patchMany` were retired in 2026.8.1. They remain in the protocol v4 wire schema, but requests containing either field (including `null`) are rejected with `INVALID_REQUEST` and replacement guidance. Set `permissionMode` (`read-only`, `guarded`, `workspace`, or `full`) for session-wide policy, or use `/exec` with a message for a single run.
The `execSecurity` and `execAsk` fields in `sessions.patch` and `sessions.patchMany` were retired in 2026.8.1. They remain in the wire schema, but requests containing either field (including `null`) are rejected with `INVALID_REQUEST` and replacement guidance. Set `permissionMode` (`read-only`, `guarded`, `workspace`, or `full`) for session-wide policy, or use `/exec` with a message for a single run.
When a session has a permission mode, per-turn `/exec` overrides can only tighten its security and approval policy. For example, `/exec security=deny` blocks exec for that turn even in a full-access session. An override requesting looser security or fewer approvals leaves the session mode's limits unchanged. Full-access sessions bypass host approval-file floors only while effective security remains `full`. Tightening `ask` alone still applies the requested approval level without restoring those floors.

View file

@ -139,8 +139,7 @@ Common plugin-provided tools include:
output
- [Tool Search](/tools/tool-search) for discovering and calling large tool
catalogs without putting every schema in the prompt
- [Canvas](/plugins/reference/canvas) for the macOS widget-panel presenter and
A2UI dashboard content
- [Canvas](/plugins/reference/canvas) for the macOS widget-panel presenter
## Configure access and approvals

View file

@ -150,14 +150,14 @@ rewrite chat history.
## Use the tool
The core tool requires `title` and one content input: `widget_code` for HTML or registered source, or `report` for a native dashboard report.
The core tool requires `title` and one content input: `widget_code` for HTML or SVG, or `report` for a native dashboard report.
<ParamField path="title" type="string" required>
Short widget title shown by the destination surface. HTML documents also use it as the document title. Discord accepts up to 80 characters.
</ParamField>
<ParamField path="widget_code" type="string">
Required for HTML, SVG, or registered source. Omit when providing `report`. For HTML, core parses every inline JavaScript `<script>` (classic and module), skipping scripts with `src` or a non-JavaScript `type`. The call is rejected with the line and column of the first syntax error, so a widget with a broken script is never hosted. For inline-widget clients, input beginning with `<svg` after trimming is rendered in SVG mode. HTML and SVG accept up to 10 MiB of UTF-8 data, including OpenClaw's wrapper in the final document. Registered source remains limited to 262,144 characters and 256 KiB when pinned. The Discord presenter accepts HTML source up to 48 KiB. A Discord-only route does not advertise or accept registered non-HTML content kinds.
Required for HTML or SVG. Omit when providing `report`. For HTML, core parses every inline JavaScript `<script>` (classic and module), skipping scripts with `src` or a non-JavaScript `type`. The call is rejected with the line and column of the first syntax error, so a widget with a broken script is never hosted. For inline-widget clients, input beginning with `<svg` after trimming is rendered in SVG mode. HTML and SVG accept up to 10 MiB of UTF-8 data, including OpenClaw's wrapper in the final document. The Discord presenter accepts HTML source up to 48 KiB.
</ParamField>
<ParamField path="report" type="object">
@ -168,8 +168,8 @@ Discord also accepts optional `button_label` text for the Activity launch button
The core `show_widget` tool also accepts these optional dashboard placement fields, including when Discord is the presentation destination:
- `kind`: `html` by default, plus active registered source kinds when available. Applies to `widget_code`. Omit with `report`.
- `pin`: also place an HTML or registered widget on the session dashboard. Required for reports and on the pinned-only scheduled surface.
- `kind`: `html` (the default). Applies to `widget_code`. Omit with `report`.
- `pin`: also place an HTML widget on the session dashboard. Required for reports and on the pinned-only scheduled surface.
- `name`: stable widget name. Defaults to a slug of `title`.
- `tab`: destination tab slug.
- `size`: one of `sm`, `md`, `lg`, `xl`, or `full`.
@ -216,7 +216,7 @@ Use a report for a pinned summary made of text, metrics, tables, simple charts,
}
```
The result names the pinned board widget. It creates no inline preview, native device panel, or Discord Activity. Do not combine `report` with `widget_code`, `kind`, `capabilities`, or device presentation. The report field does not change the registered source-kind namespace.
The result names the pinned board widget. It creates no inline preview, native device panel, or Discord Activity. Do not combine `report` with `widget_code`, `kind`, `capabilities`, or device presentation.
Each block has a required `type`:
@ -309,7 +309,7 @@ Accepted prompts appear in the transcript as regular user messages and start a n
## Dashboard capabilities
Pinned HTML and registered-source widgets expose one ticket-bound host API. An explicit [session permission mode](/gateway/permission-modes) determines how declared capabilities are approved: **Full access** grants them immediately. **Workspace** uses an AI reviewer and rejects anything it does not allow. **Guarded** shows an **Allow** / **Reject** card. **Read only** rejects them. Without an explicit session mode, the equivalent configured exec approval policy applies.
Pinned HTML widgets expose one ticket-bound host API. An explicit [session permission mode](/gateway/permission-modes) determines how declared capabilities are approved: **Full access** grants them immediately. **Workspace** uses an AI reviewer and rejects anything it does not allow. **Guarded** shows an **Allow** / **Reject** card. **Read only** rejects them. Without an explicit session mode, the equivalent configured exec approval policy applies.
- `openclaw.host.controlUiBaseUrl` exposes the Control UI origin plus its configured base path after the dashboard host initializes. It is `null` before initialization and outside the dashboard, so read it in the link's click handler rather than when the widget script first runs.
- `openclaw.prompt.send(text)` requires transient user activation and posts a visible composer message. Declaring and receiving the `prompt` tool grant skips the extra per-click confirmation. Validation, focus checks, and rate limits still apply.
@ -371,7 +371,7 @@ preview credential or a human's publication-only **My GitHub** connection.
A configured but unavailable identity blocks the request with reconnect guidance.
It does not retry anonymously or fall through to another account.
Before saving an HTML or registered widget declaring this binding, the Gateway
Before saving an HTML widget declaring this binding, the Gateway
verifies the selected identity. An unavailable identity or retired caller fails
without replacing an existing widget, creating a pending grant, or broadcasting
a change. Reconnect the agent's GitHub identity in Settings and retry. Pinning
@ -411,7 +411,7 @@ hits. Removing one widget does not fail another authorized widget's shared read.
## Security and storage
Widget documents use restrictive Content Security Policies. Inline style and script and the listed public CDN scripts, stylesheets, and fonts are allowed; other external resource loads remain blocked. Registered content kinds can load their explicitly public static renderer assets from the isolated sandbox origin. Inline transcript widgets cannot make API connections. A pinned dashboard widget can fetch only exact HTTPS origins that the agent declared and the session policy granted.
Widget documents use restrictive Content Security Policies. Inline style and script and the listed public CDN scripts, stylesheets, and fonts are allowed; other external resource loads remain blocked. Inline transcript widgets cannot make API connections. A pinned dashboard widget can fetch only exact HTTPS origins that the agent declared and the session policy granted.
The Control UI's widget content iframe always omits `allow-same-origin`, even when the global embed mode is `trusted`, so widget scripts cannot read the parent application origin. With scripts enabled, the outer proxy runs on a dedicated origin and relays messages across the frame boundary. In `strict` mode, the Control UI still reads the document through its authenticated Gateway connection, but renders it without scripts or scripted interactions. Native clients use isolated, nonpersistent web views and block navigation away from the hosted widget. The core document host also serves sandbox-marked widgets with the resource policy and `sandbox allow-scripts` in the response header, so direct rendering still runs the widget in an opaque origin instead of an application origin. Only render widget code you are willing to execute in that isolated frame.

View file

@ -139,13 +139,13 @@ sandbox proxy described below.
DB (`board_widgets`), served by a core gateway route
(`/__openclaw__/board/<agentId>/<sessionKey>/<name>/`) that reads the DB.
Pinning a transcript widget copies the bytes. Caps: 10 MiB of UTF-8 HTML per
document including the wrapper, 256 KiB per registered widget's source,
8 KiB per native widget's JSON props, and 48 widgets per board.
document including the wrapper, 8 KiB per native widget's JSON props, and
48 widgets per board.
- **Update in place:** re-emitting a widget with the same `name` and content
owner replaces its content, bumps `revision`, and broadcasts `board.changed`.
Live views update that cell. Document widgets reload that iframe only.
Changing the content owner requires removing the widget before creating its replacement.
- **Byte freezing:** HTML and registered-source grants bind to the SHA-256
- **Byte freezing:** HTML grants bind to the SHA-256
digest of the approved content. Preserving a grant requires the same content
scope, a matching approved digest, and a declaration that does not widen.
Changed bytes require a new decision under the session's approval policy,
@ -162,39 +162,12 @@ content kind:
- `html` — agent-authored via `show_widget`, bytes in board storage.
- `mcp-app` — a third-party MCP app view (`ui://` resource from a configured
server) hosted inside the widget cell.
- Registered plugin kinds — plugin-validated source rendered through the same
sandboxed document frame. The Canvas plugin registers `a2ui`. Core discovers
the active registry and never hardcodes plugin kind names.
MCP apps do not define the widget model. Widgets gained the ability to host
them. Identity, placement, pinning, grants, and the author-facing API stay
OpenClaw's — so `show_widget` code stays as short as it is today and never
needs to know the MCP Apps spec exists.
Registered kinds use a small runtime Plugin SDK API. A registration owns the
agent-facing kind name, source validation, capability-scoped renderer
resources, and document-body composition. The Gateway validates source again
at `board.widget.put`, stores it in the existing generic `plugin` descriptor
envelope, and composes the framed document only after a ticketed board read.
This keeps stored source out of board snapshots and avoids a database CHECK or
schema-version change. Disabled plugins are absent from the registry, so new
puts fail with an enable-and-retry error and existing cells render as disabled.
The A2UI implementation composes a small document that references its renderer
bundle. Ticketed board documents use the capability-scoped Gateway asset route.
Inline documents load the same public static renderer from the sandbox origin.
Core adds the same CSP, theme bridge, size reporter, and private-port host bridge
used by HTML widgets. v0.8 and v0.9 use separate renderer bundles because their
Lit custom elements share tag names but their processors and action contracts
differ.
A registered kind can expose public static renderer bytes through
`resources.readPublicResource(path)`. The isolated listener serves only exact
registered `resources.paths`, only for `GET` or `HEAD`, and rechecks the active
plugin registry after reading. It does not proxy Gateway routes, credentials,
or data. Canvas opts in its two A2UI bundles. This grants no widget network or
host-tool capability.
Shared hosting infrastructure:
- **One sandbox host.** `html` widgets render through the same hardened
@ -229,15 +202,15 @@ Shared hosting infrastructure:
This is independent of `connect-src` and host-tool grants. Third-party code
runs with the widget's content and granted capabilities; asset URLs must not
contain private data. See [Libraries and fonts](/tools/show-widget#libraries-and-fonts).
- **Grants.** HTML and registered widgets declaring nothing render immediately
- **Grants.** HTML widgets declaring nothing render immediately
(sandboxed, `default-src 'none'`, prompt sends individually confirmed).
Declared capabilities and interactive MCP Apps follow an explicit
[session permission mode](/gateway/permission-modes). **Full access** grants.
**Workspace** uses an AI reviewer and rejects anything it does not allow.
**Guarded** shows **Allow**/**Reject**. **Read only** rejects. Without an
explicit session mode, the equivalent configured exec approval policy applies.
Grants are per widget name and content scope. HTML and registered-source
updates preserve a grant only while the approved digest matches and the
Grants are per widget name and content scope. HTML updates preserve a grant
only while the approved digest matches and the
declaration does not widen. Wrapper-authored board widgets forward user-clicked
`http`/`https` new-tab links to the Control UI host. This ordinary navigation
needs no grant and never grants iframe popup permissions.
@ -403,8 +376,8 @@ agent, is shared with the widget/session audience.
Author guidance is conditional on a usable connected agent identity, not a
tool-construction-time check. `board.widget.put` verifies and revalidates that
identity before saving HTML (including materialized Canvas documents) or
registered widgets declaring this host capability. The same preparation owner
identity before saving HTML (including materialized Canvas documents) declaring
this host capability. The same preparation owner
serves pinning and reads, including source-config preview-credential scrubbing
and OAuth refresh. Caller cancellation and session mutation authorization are
rechecked before persistence. Failure leaves existing content and grants intact.
@ -592,10 +565,10 @@ client, one unambiguous matching current-channel presenter, or a persistent-sess
automation whose server-authored tool policy explicitly allows pinned-only authoring:
- `show_widget { title, widget_code?, report?, kind?, name?, pin?, size?, tab?, after?,
presentation?, capabilities? }` — create/update by name. `kind` defaults to `html` and its enum
includes active registered kinds. HTML and registered source use `widget_code`.
presentation?, capabilities? }` — create/update by name. `kind` accepts `html`,
which is the default. HTML uses `widget_code`.
Native reports use `report` without `kind` or `widget_code`, require `pin: true`,
and render only on the board. For HTML and registered kinds, `pin` also places the widget on the board.
and render only on the board. For HTML, `pin` also places the widget on the board.
Without `name`/`pin` it behaves exactly like today (inline, ephemeral).
Headless scheduled authoring requires `pin: true`, cannot select a presentation
target, and is unavailable to detached cron-run sessions.
@ -628,8 +601,8 @@ agent is told about user tier-1 events via session notices, e.g.
binding gating, rate limits), byte-frozen approval.
- **Core owns widget hosting.** The canvas doc store, document wrapper, HTTP
serving, and the `show_widget` tool live in core (`src/canvas/`). The Canvas
plugin owns the macOS node-panel presenter and the A2UI
dashboard content kind. The `pluginSurfaceUrls["canvas"]` advertisement and
plugin owns the macOS node-panel presenter. The `pluginSurfaceUrls["canvas"]`
advertisement and
`/__openclaw__/canvas` paths are shipped native-client contracts and stay
stable. Discord Activities register a contextual presenter behind core's
shared `show_widget` tool.
@ -641,5 +614,5 @@ agent is told about user tier-1 events via session notices, e.g.
grants remain separate from membership.
- Native macOS/iOS board rendering is through the embedded Control UI. The
inline-widget path is unchanged.
- Enabled plugins extend content kinds, data bindings, and action verbs through
- Enabled plugins extend data bindings and action verbs through
the existing registries.

View file

@ -186,12 +186,11 @@ never needs the agent.
plugin widgets, moves, resizes, and removes widgets, manages tabs, switches
the visible tab, and requests a split or expanded dashboard with
`set_presentation` and `presentation: "split"` or `"expanded"`. The `show_widget` tool
creates or refreshes native reports, custom HTML, and registered-source widgets.
An update uses `pin: true`, the same `name`, and new `widget_code` for HTML or
registered source, or a new `report` object for a native report.
Board snapshots identify each widget's `contentOwner` and, when applicable,
`registeredContentKind`; remove a widget before replacing its content owner
or registered source kind.
creates or refreshes native reports and custom HTML widgets.
An update uses `pin: true`, the same `name`, and new `widget_code` for HTML,
or a new `report` object for a native report.
Board snapshots identify each widget's `contentOwner`; remove a widget before
replacing its content owner.
Ask "show the finance tab and expand the dashboard" and watch it happen.
To publish the initial view instead, use `dashboard` with
@ -364,7 +363,7 @@ Revoking session access denies new dashboard writes. A write already in progress
when access is revoked may still finish saving its widget or layout. Reading
private content and using a widget's tools still require current access.
Custom HTML and registered-source widgets that only render need no approval —
Custom HTML widgets that only render need no approval —
they appear instantly, exactly like inline chat widgets, and their network access
is fully disabled.
@ -387,13 +386,13 @@ explicit session mode, the equivalent configured exec approval policy applies.
Enabled plugins can add their own named read-only feeds and actions to these capability lists; disabling the plugin removes those integrations.
Grants are bound to the exact widget bytes approved by your session policy.
Changed HTML or registered-source bytes require a new decision even when the
Changed HTML bytes require a new decision even when the
permissions stay the same or shrink. A grant is preserved only when the
approved bytes still match and the requested permissions do not widen.
The authoring result distinguishes pending, rejected, and granted access;
saving a widget does not imply its capabilities were approved.
Automatic review can reuse a low-risk approval when the same agent recreates
an HTML or registered-source widget with the same name, source, and declarations
an HTML widget with the same name, source, and declarations
in another session. This bounded in-memory reuse resets when the runtime
configuration or exec-approval policy changes. Each session's current permission
mode and each widget's grant authority still apply. MCP apps and incognito
@ -410,28 +409,16 @@ board with fresh sessions. By default they render without server tools or
same-server resource access. Granting the widget its declared server tools
enables both bridges while that revision-bound grant remains active.
## A2UI widgets
<a id="a2ui-widgets" />
When the Canvas plugin is enabled, agents can render A2UI JSONL as a dashboard
widget. A2UI widgets use the same stable name, tab, size, pinning, sandbox, and
update-in-place behavior as HTML widgets. The renderer is loaded from the
Gateway's `/__openclaw__/a2ui/` asset route, so the renderer bundle is not
copied into each widget. The Canvas plugin and its hosted routes must be
enabled; both are enabled by default.
## Retired widget formats
The v0.9 basic catalog supports bound form controls, lists, tabs, media, and
dialogs. Text bodies render basic Markdown; raw HTML, links, and images in
Markdown are not rendered. Use the catalog's Image component for images.
If a component fails to render, it shows an error and a **Retry** button while
neighboring components remain interactive. Corrected A2UI updates restore the
affected component automatically.
A2UI actions use the normal widget bridge. By default, clicks become quiet
session notices that the agent sees on its next turn. If the widget declares
and receives the `prompt` grant, its actions can instead send a visible prompt
into the thread. Disabling the Canvas plugin removes the A2UI kind and leaves
stored widgets visibly unavailable until the plugin is enabled again.
A2UI widgets and the plugin API for registering widget content kinds have been
removed. Create interactive content as [HTML widgets](/tools/show-widget).
During an update, candidate Doctor backs up the affected agent database before
deleting stored A2UI widgets. It records a warning with the number deleted and
explains that they can be recreated as HTML widgets. Other widgets are preserved.
See [migration repairs](/gateway/doctor/config-migrations#retired-a2ui-widgets).
## Retired Workspaces

View file

@ -1,5 +1,3 @@
import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net";
import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { assert, beforeEach, describe, expect, it, vi } from "vitest";
@ -8,20 +6,12 @@ import canvasPlugin from "./index.js";
const mocks = vi.hoisted(() => {
const toolExecute = vi.fn(async () => ({ content: [{ type: "text", text: "ok" }] }));
return {
handleHttpRequest: vi.fn(async () => true),
loadRenderer: vi.fn(),
registerNodesCanvasCommands: vi.fn(),
toolExecute,
createCanvasTool: vi.fn(() => ({ execute: toolExecute })),
};
});
// mock-isolation: the real renderer must stay unloaded until the HTTP route is used.
vi.mock("./src/host/a2ui.js", () => {
mocks.loadRenderer();
return { handleA2uiHttpRequest: mocks.handleHttpRequest };
});
vi.mock("./src/cli.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./src/cli.js")>()),
registerNodesCanvasCommands: mocks.registerNodesCanvasCommands,
@ -43,7 +33,6 @@ function registerCanvas(config: OpenClawPluginApi["config"] = {}) {
registerTool: vi.fn<OpenClawPluginApi["registerTool"]>(),
registerNodeCliFeature: vi.fn<OpenClawPluginApi["registerNodeCliFeature"]>(),
registerNodeInvokePolicy: vi.fn<OpenClawPluginApi["registerNodeInvokePolicy"]>(),
registerBoardWidgetContentKind: vi.fn<OpenClawPluginApi["registerBoardWidgetContentKind"]>(),
};
canvasPlugin.register?.(
createTestPluginApi({ id: "canvas", name: "Canvas", config, ...registrations }),
@ -62,12 +51,8 @@ describe("Canvas plugin entry", () => {
commands: ["canvas.present", "canvas.hide", "canvas.navigate"],
defaultPlatforms: ["macos"],
});
expect(api.registerHttpRoute).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ path: "/__openclaw__/a2ui", match: "prefix" }),
);
expect(api.registerBoardWidgetContentKind).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ kind: "a2ui", label: "A2UI" }),
);
expect(api.registerHttpRoute).not.toHaveBeenCalled();
expect(api.registerService).not.toHaveBeenCalled();
expect(api.registerWidgetPresenter).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
target: "node_panel",
@ -78,29 +63,14 @@ describe("Canvas plugin entry", () => {
);
});
it("uses host.enabled as the single presenter and hosted-resource gate", () => {
it("uses host.enabled as the presenter gate", () => {
const api = registerCanvas({
plugins: { entries: { canvas: { config: { host: { enabled: false } } } } },
});
expect(api.registerBoardWidgetContentKind).not.toHaveBeenCalled();
expect(api.registerHttpRoute).not.toHaveBeenCalled();
expect(api.registerWidgetPresenter).not.toHaveBeenCalled();
});
it("defers A2UI asset implementation until the route is used", async () => {
const api = registerCanvas();
expect(api.registerService).not.toHaveBeenCalled();
expect(mocks.loadRenderer).not.toHaveBeenCalled();
const request = new IncomingMessage(new Socket());
request.url = "/__openclaw__/a2ui/a2ui.bundle.js";
const [route] = api.registerHttpRoute.mock.calls;
assert(route);
await route[0].handler(request, new ServerResponse(request));
expect(mocks.loadRenderer).toHaveBeenCalledTimes(1);
expect(mocks.handleHttpRequest).toHaveBeenCalledTimes(1);
});
it("defers Canvas CLI and tool implementations until use", async () => {
const api = registerCanvas();
expect(api.registerTool).toHaveBeenCalledTimes(1);

View file

@ -2,9 +2,7 @@ import { randomUUID } from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { definePluginEntry, type AnyAgentTool } from "openclaw/plugin-sdk/plugin-entry";
import { canvasA2UIBoardWidgetKind } from "./src/board-widget.js";
import { canvasConfigSchema, isCanvasHostEnabled } from "./src/config.js";
import { A2UI_PATH } from "./src/host/a2ui-shared.js";
import { CANVAS_PRESENT_COMMAND, isEligibleCanvasNode } from "./src/node-eligibility.js";
import { canvasToolDefinition } from "./src/tool-schema.js";
@ -29,15 +27,6 @@ export default definePluginEntry({
configSchema: canvasConfigSchema,
register(api) {
if (isCanvasHostEnabled(api.config)) {
api.registerBoardWidgetContentKind(canvasA2UIBoardWidgetKind);
const loadRenderer = createLazyRuntimeModule(() => import("./src/host/a2ui.js"));
api.registerHttpRoute({
path: A2UI_PATH,
auth: "plugin",
match: "prefix",
nodeCapability: { surface: "canvas" },
handler: async (req, res) => await (await loadRenderer()).handleA2uiHttpRequest(req, res),
});
const nodesRuntime = api.runtime.nodes;
type CanvasRuntimeNode = Awaited<ReturnType<typeof nodesRuntime.list>>["nodes"][number];
async function selectCanvasNode(): Promise<CanvasRuntimeNode | null> {

View file

@ -24,7 +24,7 @@
"uiHints": {
"host": {
"label": "Widget Presenter",
"help": "Controls hosted widget document and renderer routes.",
"help": "Controls hosted widget documents and node-panel presentation.",
"advanced": true
},
"host.enabled": {

View file

@ -5,28 +5,14 @@
"description": "OpenClaw Canvas plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"@solidjs/compiler": "2.0.0-rc.16"
"@openclaw/plugin-sdk": "workspace:*"
},
"dependencies": {
"@a2ui/web_core": "0.13.0",
"@solidjs/web": "2.0.0-rc.16",
"dompurify": "3.4.17",
"marked": "18.1.1",
"solid-js": "2.0.0-rc.16",
"typebox": "1.4.1"
},
"openclaw": {
"extensions": [
"./index.ts"
],
"assetScripts": {
"build": "node --import ../../scripts/tsx.mjs ../../scripts/bundle-a2ui.mts",
"buildOutputs": [
"src/host/a2ui/a2ui.bundle.js",
"src/host/a2ui/a2ui-v0.9.bundle.js"
],
"copy": "node scripts/copy-a2ui.mjs"
}
]
}
}

View file

@ -6,5 +6,4 @@ export {
type CanvasHostConfig,
type CanvasPluginConfig,
} from "./src/config.js";
export { A2UI_PATH, CANVAS_HOST_PATH, handleA2uiHttpRequest } from "./src/host/a2ui.js";
export { registerNodesCanvasCommands, type CanvasNodesRpcOpts } from "./src/cli.js";

View file

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

View file

@ -1,71 +0,0 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const rootDir = path.resolve(pluginDir, "../..");
function getA2uiPaths(env = process.env) {
const srcDir = env.OPENCLAW_A2UI_SRC_DIR ?? path.join(pluginDir, "src", "host", "a2ui");
const outDir = env.OPENCLAW_A2UI_OUT_DIR ?? path.join(rootDir, "dist", "canvas-host", "a2ui");
return { srcDir, outDir };
}
function shouldSkipMissingA2uiAssets(env = process.env) {
return env.OPENCLAW_A2UI_SKIP_MISSING === "1" || Boolean(env.OPENCLAW_SPARSE_PROFILE);
}
function isRelativeWithin(relPath) {
return (
relPath === "" ||
(relPath !== ".." && !relPath.startsWith(`..${path.sep}`) && !path.isAbsolute(relPath))
);
}
function pathsOverlap(leftDir, rightDir) {
const left = path.resolve(leftDir);
const right = path.resolve(rightDir);
return (
isRelativeWithin(path.relative(left, right)) || isRelativeWithin(path.relative(right, left))
);
}
/** Copies A2UI assets, optionally tolerating missing bundles in sparse builds. */
export async function copyA2uiAssets({ srcDir, outDir }) {
if (pathsOverlap(srcDir, outDir)) {
throw new Error("A2UI source and output directories must not overlap.");
}
const skipMissing = shouldSkipMissingA2uiAssets(process.env);
try {
await fs.stat(path.join(srcDir, "a2ui.bundle.js"));
await fs.stat(path.join(srcDir, "a2ui-v0.9.bundle.js"));
} catch (err) {
const message = 'Missing A2UI bundle assets. Run "pnpm canvas:a2ui:bundle" and retry.';
if (skipMissing) {
console.warn(
`${message} Skipping copy because OPENCLAW_A2UI_SKIP_MISSING=1 or OPENCLAW_SPARSE_PROFILE is set.`,
);
return;
}
throw new Error(message, { cause: err });
}
await fs.mkdir(path.dirname(outDir), { recursive: true });
await fs.rm(outDir, { recursive: true, force: true });
await fs.cp(srcDir, outDir, { recursive: true });
}
async function main() {
const { srcDir, outDir } = getA2uiPaths();
await copyA2uiAssets({ srcDir, outDir });
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
main().catch(
/** @param {unknown} err */ (err) => {
console.error(String(err));
process.exit(1);
},
);
}

View file

@ -1,81 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { copyA2uiAssets } from "./copy-a2ui.mjs";
describe("canvas a2ui copy", () => {
beforeEach(() => {
vi.stubEnv("OPENCLAW_A2UI_SKIP_MISSING", undefined);
vi.stubEnv("OPENCLAW_SPARSE_PROFILE", undefined);
});
afterEach(() => vi.unstubAllEnvs());
function withA2uiFixture(run: (paths: { srcDir: string; outDir: string }) => Promise<void>) {
return withTempWorkspace(
{ rootDir: resolvePreferredOpenClawTmpDir(), prefix: "openclaw-a2ui-" },
({ dir }) => run({ srcDir: path.join(dir, "src"), outDir: path.join(dir, "out") }),
);
}
it("throws a helpful error when assets are missing", async () => {
await withA2uiFixture(async (paths) => {
await expect(copyA2uiAssets(paths)).rejects.toThrow('Run "pnpm canvas:a2ui:bundle"');
});
});
it("skips missing assets when OPENCLAW_A2UI_SKIP_MISSING=1", async () => {
await withA2uiFixture(async (paths) => {
vi.stubEnv("OPENCLAW_A2UI_SKIP_MISSING", "1");
await expect(copyA2uiAssets(paths)).resolves.toBeUndefined();
});
});
it("skips missing assets when OPENCLAW_SPARSE_PROFILE is set", async () => {
await withA2uiFixture(async (paths) => {
vi.stubEnv("OPENCLAW_SPARSE_PROFILE", "core");
await expect(copyA2uiAssets(paths)).resolves.toBeUndefined();
});
});
it("copies nested bundled assets and removes stale output", async () => {
await withA2uiFixture(async ({ srcDir, outDir }) => {
const nestedAssetDir = path.join(srcDir, "assets", "demo");
await fs.mkdir(nestedAssetDir, { recursive: true });
await fs.mkdir(outDir, { recursive: true });
await fs.writeFile(path.join(srcDir, "a2ui.bundle.js"), "console.log(1);", "utf8");
await fs.writeFile(path.join(srcDir, "a2ui-v0.9.bundle.js"), "console.log(2);", "utf8");
await fs.writeFile(path.join(nestedAssetDir, "sample.txt"), "nested-asset", "utf8");
await fs.writeFile(path.join(outDir, "stale.txt"), "stale-output", "utf8");
await copyA2uiAssets({ srcDir, outDir });
await expect(
Promise.all(
["a2ui.bundle.js", "a2ui-v0.9.bundle.js", "assets/demo/sample.txt"].map((file) =>
fs.readFile(path.join(outDir, file), "utf8"),
),
),
).resolves.toEqual(["console.log(1);", "console.log(2);", "nested-asset"]);
await expect(fs.stat(path.join(outDir, "stale.txt"))).rejects.toMatchObject({
code: "ENOENT",
});
});
});
it("rejects overlapping source and output directories before cleaning output", async () => {
await withA2uiFixture(async ({ srcDir }) => {
await fs.mkdir(srcDir, { recursive: true });
await fs.writeFile(path.join(srcDir, "a2ui.bundle.js"), "console.log(1);", "utf8");
await fs.writeFile(path.join(srcDir, "a2ui-v0.9.bundle.js"), "console.log(2);", "utf8");
await expect(copyA2uiAssets({ srcDir, outDir: srcDir })).rejects.toThrow("must not overlap");
await expect(fs.readFile(path.join(srcDir, "a2ui.bundle.js"), "utf8")).resolves.toBe(
"console.log(1);",
);
await expect(copyA2uiAssets({ srcDir, outDir: path.join(srcDir, "dist") })).rejects.toThrow(
"must not overlap",
);
});
});
});

View file

@ -14,6 +14,6 @@ The Canvas tool is limited to direct panel control:
- `hide`: hide the panel.
- `navigate`: navigate the visible panel to a hosted document path or local app scheme.
Do not invent local file paths or arbitrary external URLs. Hosted documents use the capability-scoped `/__openclaw__/canvas/documents/...` route returned by `show_widget`. A2UI content renders on session boards and loads renderer assets from `/__openclaw__/a2ui`.
Do not invent local file paths or arbitrary external URLs. Use HTML widgets with `show_widget`; hosted documents use the capability-scoped `/__openclaw__/canvas/documents/...` route it returns.
If `plugins.entries.canvas.config.host.enabled` is `false`, hosted widget documents, A2UI renderer assets, and node-panel presentation are disabled together.
If `plugins.entries.canvas.config.host.enabled` is `false`, hosted widget documents and node-panel presentation are disabled together.

View file

@ -1,63 +0,0 @@
import { describe, expect, it } from "vitest";
import { validateSupportedA2UIJsonl } from "./a2ui-jsonl.js";
const BASIC_CATALOG = "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json";
describe("Canvas A2UI JSONL validation", () => {
it("accepts the v0.9 create, component, data, and delete message set", () => {
const messages = [
{
version: "v0.9",
createSurface: { surfaceId: "main", catalogId: BASIC_CATALOG },
},
{
version: "v0.9",
updateComponents: {
surfaceId: "main",
components: [{ id: "root", component: "Text", text: "hello" }],
},
},
{
version: "v0.9",
updateDataModel: { surfaceId: "main", path: "/status", value: "ready" },
},
{ version: "v0.9", deleteSurface: { surfaceId: "main" } },
];
const jsonl = messages.map((message) => JSON.stringify(message)).join("\n");
expect(validateSupportedA2UIJsonl(jsonl)).toMatchObject({
version: "v0.9",
messages,
});
});
it.each([
["empty input", " \n ", "no JSONL messages"],
["invalid JSON", "{", "line 1"],
[
"versioned v0.8 operation",
JSON.stringify({ version: "v0.9", surfaceUpdate: { surfaceId: "main", components: [] } }),
"v0.9 action key",
],
[
"unversioned v0.9 operation",
JSON.stringify({ createSurface: { surfaceId: "main", catalogId: BASIC_CATALOG } }),
"v0.8 action key",
],
[
"malformed createSurface",
JSON.stringify({ version: "v0.9", createSurface: { surfaceId: "main" } }),
"Invalid input",
],
[
"mixed versions",
[
JSON.stringify({ deleteSurface: { surfaceId: "legacy" } }),
JSON.stringify({ version: "v0.9", deleteSurface: { surfaceId: "modern" } }),
].join("\n"),
"mixed A2UI",
],
])("rejects $0", (_name, jsonl, expected) => {
expect(() => validateSupportedA2UIJsonl(jsonl)).toThrow(expected);
});
});

View file

@ -1,98 +0,0 @@
import { A2uiMessageSchema as A2uiV09MessageSchema } from "@a2ui/web_core/v0_9";
const A2UI_V08_ACTION_KEYS = [
"beginRendering",
"surfaceUpdate",
"dataModelUpdate",
"deleteSurface",
] as const;
const A2UI_V09_ACTION_KEYS = [
"createSurface",
"updateComponents",
"updateDataModel",
"deleteSurface",
] as const;
export function validateSupportedA2UIJsonl(jsonl: string) {
const lines = jsonl.split(/\r?\n/);
const errors: string[] = [];
let sawV08 = false;
let sawV09 = false;
const messages: unknown[] = [];
lines.forEach((line, idx) => {
const trimmed = line.trim();
if (!trimmed) {
return;
}
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 explicitVersion = record.version;
// Bundled v0.8 is strict and unversioned; v0.9 identifies every message.
if (explicitVersion === "v0.8") {
errors.push(`line ${idx + 1}: A2UI v0.8 messages must not include a version field`);
return;
}
if (explicitVersion !== undefined && explicitVersion !== "v0.9") {
errors.push(`line ${idx + 1}: unsupported A2UI version: ${JSON.stringify(explicitVersion)}`);
return;
}
const actionKeys = (
explicitVersion === "v0.9" ? A2UI_V09_ACTION_KEYS : A2UI_V08_ACTION_KEYS
).filter((key) => key in record);
if (actionKeys.length !== 1) {
errors.push(
`line ${idx + 1}: expected exactly one ${explicitVersion === "v0.9" ? "v0.9" : "v0.8"} action key`,
);
return;
}
const allowedTopLevelKeys = new Set(
explicitVersion === "v0.9" ? ["version", actionKeys[0]] : [actionKeys[0]],
);
if (Object.keys(record).some((key) => !allowedTopLevelKeys.has(key))) {
errors.push(`line ${idx + 1}: unexpected top-level A2UI field`);
return;
}
const payload = record[actionKeys[0]!];
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
errors.push(`line ${idx + 1}: action payload must be an object`);
return;
}
if (explicitVersion === "v0.9") {
const result = A2uiV09MessageSchema.safeParse(record);
if (!result.success) {
errors.push(
`line ${idx + 1}: ${result.error.issues[0]?.message ?? "invalid v0.9 message"}`,
);
return;
}
sawV09 = true;
} else {
sawV08 = true;
}
messages.push(record);
});
if (messages.length === 0 && errors.length === 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: "v0.8" | "v0.9" = sawV09 ? "v0.9" : "v0.8";
return { version, messages };
}

View file

@ -1,81 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { canvasA2UIBoardWidgetKind } from "./board-widget.js";
const readPublicResource = vi.hoisted(() =>
vi.fn(async () => ({ body: new Uint8Array([42]), contentType: "application/javascript" })),
);
vi.mock("./host/a2ui.js", () => ({ readPublicA2uiResource: readPublicResource }));
const V08_SOURCE = [
JSON.stringify({
surfaceUpdate: {
surfaceId: "main",
components: [{ id: "root", component: { Text: { text: { literalString: "hello" } } } }],
},
}),
JSON.stringify({ beginRendering: { surfaceId: "main", root: "root" } }),
].join("\n");
const V09_SOURCE = JSON.stringify({
version: "v0.9",
deleteSurface: { surfaceId: "main" },
});
describe("Canvas A2UI board documents", () => {
it("exposes only its two registered renderer bundles as public static resources", async () => {
readPublicResource.mockClear();
for (const resourcePath of canvasA2UIBoardWidgetKind.resources.paths) {
await expect(
canvasA2UIBoardWidgetKind.resources.readPublicResource?.(resourcePath),
).resolves.toMatchObject({ contentType: "application/javascript" });
expect(readPublicResource).toHaveBeenLastCalledWith(resourcePath);
}
for (const resourcePath of [
"/__openclaw__/a2ui/private.json",
"/__openclaw__/a2ui/../config.json",
"/__openclaw__/canvas/documents/private/index.html",
]) {
await expect(
canvasA2UIBoardWidgetKind.resources.readPublicResource?.(resourcePath),
).resolves.toBeUndefined();
}
expect(readPublicResource).toHaveBeenCalledTimes(2);
});
it.each([
["v0.8", V08_SOURCE, "/__openclaw__/a2ui/a2ui.bundle.js"],
["v0.9", V09_SOURCE, "/__openclaw__/a2ui/a2ui-v0.9.bundle.js"],
])("composes %s with the capability-scoped renderer resource", (_name, source, path) => {
const resourceUrl = `/__openclaw__/cap/token${path}`;
const document = canvasA2UIBoardWidgetKind.composeDocument?.({
source,
title: "A2UI",
resourceUrls: { [path]: resourceUrl },
promptGranted: false,
});
expect(document).toContain("<openclaw-a2ui-host></openclaw-a2ui-host>");
expect(document).toContain(resourceUrl);
expect(document).toContain('"actionTier":"state"');
});
it("rejects a document when its renderer resource was not provisioned", () => {
expect(() =>
canvasA2UIBoardWidgetKind.composeDocument?.({
source: V09_SOURCE,
title: "A2UI",
resourceUrls: {},
promptGranted: true,
}),
).toThrow("A2UI renderer resource unavailable");
});
it("escapes a provisioned renderer URL inside its quoted script attribute", () => {
const document = canvasA2UIBoardWidgetKind.composeDocument?.({
source: V09_SOURCE,
title: "A2UI",
resourceUrls: { "/__openclaw__/a2ui/a2ui-v0.9.bundle.js": `/cap/a&b<test>"'` },
promptGranted: false,
});
expect(document).toContain('<script src="/cap/a&amp;b&lt;test&gt;&quot;&#39;"></script>');
});
});

View file

@ -1,53 +0,0 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { escapeHtml } from "openclaw/plugin-sdk/text-utility-runtime";
import { validateSupportedA2UIJsonl } from "./a2ui-jsonl.js";
const A2UI_V08_BUNDLE_PATH = "/__openclaw__/a2ui/a2ui.bundle.js";
const A2UI_V09_BUNDLE_PATH = "/__openclaw__/a2ui/a2ui-v0.9.bundle.js";
function bootScript(messages: unknown[], promptGranted: boolean): string {
const boot = JSON.stringify({ messages, actionTier: promptGranted ? "prompt" : "state" })
.replaceAll("<", "\\u003c")
.replaceAll("\u2028", "\\u2028")
.replaceAll("\u2029", "\\u2029");
return `<script>globalThis.openclawA2UIBoot=${boot};</script>`;
}
function resourceScript(path: string, url: string): string {
if (url !== path) {
return `<script src="${escapeHtml(url)}"></script>`;
}
const serializedPath = JSON.stringify(path).replaceAll("<", "\\u003c");
return `<script>(()=>{const match=location.pathname.match(/^\\/__openclaw__\\/cap\\/[^/]+/u);const script=document.createElement("script");script.src=(match?.[0]??"")+${serializedPath};document.head.appendChild(script);})();</script>`;
}
/** Canvas-owned A2UI source validation and document composition for boards. */
export const canvasA2UIBoardWidgetKind: Parameters<
OpenClawPluginApi["registerBoardWidgetContentKind"]
>[0] = {
kind: "a2ui",
label: "A2UI",
resources: {
surface: "canvas",
paths: [A2UI_V08_BUNDLE_PATH, A2UI_V09_BUNDLE_PATH],
readPublicResource: async (resourcePath) => {
if (resourcePath !== A2UI_V08_BUNDLE_PATH && resourcePath !== A2UI_V09_BUNDLE_PATH) {
return undefined;
}
const { readPublicA2uiResource } = await import("./host/a2ui.js");
return await readPublicA2uiResource(resourcePath);
},
},
validateSource(source) {
validateSupportedA2UIJsonl(source);
},
composeDocument({ source, resourceUrls, promptGranted }) {
const parsed = validateSupportedA2UIJsonl(source);
const bundlePath = parsed.version === "v0.9" ? A2UI_V09_BUNDLE_PATH : A2UI_V08_BUNDLE_PATH;
const bundleUrl = resourceUrls[bundlePath];
if (!bundleUrl) {
throw new Error(`A2UI renderer resource unavailable: ${bundlePath}`);
}
return `${bootScript(parsed.messages, promptGranted)}<style>html,body{height:100%;overflow:hidden;background:transparent}openclaw-a2ui-host{display:block;height:100%}</style><openclaw-a2ui-host></openclaw-a2ui-host>${resourceScript(bundlePath, bundleUrl)}`;
},
};

View file

@ -1,125 +0,0 @@
import { MessageProcessor } from "@a2ui/web_core/processing";
import { getValue } from "@a2ui/web_core/reactivity";
import { NodeResolver } from "@a2ui/web_core/resolution";
import { For, Show, onCleanup, untrack } from "solid-js";
import { observeSource } from "./catalog-primitives.jsx";
import { basicCatalog, CatalogNode, CatalogStyles, observeValue } from "./catalog-v0.9.jsx";
import { defineHost } from "./host.jsx";
const actionText = (action) => {
const context =
action?.context && Object.keys(action.context).length ? action.context : undefined;
return context
? `A2UI action ${action.name}: ${JSON.stringify(context)}`
: `A2UI action ${action?.name ?? "selected"}`;
};
class A2UIModel {
#processor = this.#createProcessor();
error = "";
notify = () => {};
#createProcessor() {
const processor = new MessageProcessor([basicCatalog], async (action) => {
try {
const api = globalThis.openclaw;
if (!api?.state?.emit) {
throw new Error("missing board action bridge");
}
if (globalThis.openclawA2UIBoot?.actionTier === "prompt" && api.prompt?.send) {
await api.prompt.send(actionText(action));
} else {
await api.state.emit({ eventType: "a2ui.action", action });
}
} catch (error) {
if (processor !== this.#processor) {
return;
}
this.error = String(error?.message ?? error);
this.notify();
}
});
return processor;
}
connect() {
return () => {};
}
get surfaces() {
return Array.from(this.#processor.model.surfacesMap.entries());
}
getSurfaces() {
return this.surfaces.map(([id]) => id);
}
applyMessages(messages) {
if (!Array.isArray(messages)) {
throw new Error("A2UI: expected messages array");
}
this.#processor.processMessages(messages);
this.notify();
return { ok: true, surfaces: this.getSurfaces() };
}
reset() {
this.#processor.model.dispose();
this.#processor = this.#createProcessor();
this.error = "";
this.notify();
return { ok: true };
}
}
function Surface(props) {
// The parent keys each surface by the processor-owned model identity.
const surface = untrack(() => props.surface);
const resolver = new NodeResolver(surface, surface.defaultCatalog);
const root = observeValue(() => getValue(resolver.rootNode));
onCleanup(() => {
resolver.dispose();
});
return (
<a2ui-surface>
<Show when={root()} fallback={<div>Loading surface...</div>}>
<CatalogNode node={root()} />
</Show>
</a2ui-surface>
);
}
function A2UIApp(props) {
const model = untrack(() => props.model);
const { read } = observeSource(
() => model,
(notify) => {
model.notify = notify;
return () => {
model.notify = () => {};
};
},
);
return (
<>
<CatalogStyles />
<style>{`
openclaw-a2ui-host { display: block; min-height: 100%; color: var(--text); background: transparent; }
openclaw-a2ui-host #surfaces { display: grid; gap: 12px; min-height: 100%; }
openclaw-a2ui-host .error { color: var(--danger); padding: 12px; }
`}</style>
<Show when={read().error}>
{(error) => (
<div class="error" role="alert">
{error()}
</div>
)}
</Show>
<section id="surfaces">
<For each={read().surfaces} keyed={(entry) => entry[1]}>
{(entry) => <Surface surface={entry()[1]} />}
</For>
</section>
</>
);
}
defineHost(A2UIModel, A2UIApp);

View file

@ -1,270 +0,0 @@
import { A2uiMessageProcessor } from "@a2ui/web_core/v0_8";
import { For, Show, untrack } from "solid-js";
import { observeSource } from "./catalog-primitives.jsx";
import { CatalogNode, catalogStyles } from "./catalog-v0.8.jsx";
import { hostStyles } from "./host-styles.js";
import { defineHost } from "./host.jsx";
const createSecureActionId = () => {
const crypto = globalThis.crypto;
if (typeof crypto?.randomUUID === "function") {
return crypto.randomUUID();
}
if (typeof crypto?.getRandomValues === "function") {
const bytes = crypto.getRandomValues(new Uint8Array(16));
return `a2ui_${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
}
return null;
};
class A2UIModel {
#processor = new A2uiMessageProcessor();
surfaces = [];
pendingAction = null;
toast = null;
notify = () => {};
#toastTimer;
#actionListener = (event) => this.#handleA2UIAction(event);
#statusListener = (event) => this.#handleActionStatus(event);
connect(host) {
host.addEventListener("a2uiaction", this.#actionListener);
globalThis.addEventListener("openclaw:a2ui-action-status", this.#statusListener);
return () => {
host.removeEventListener("a2uiaction", this.#actionListener);
globalThis.removeEventListener("openclaw:a2ui-action-status", this.#statusListener);
clearTimeout(this.#toastTimer);
this.toast = null;
};
}
get processor() {
return this.#processor;
}
getSurfaces() {
return Array.from(this.#processor.getSurfaces().keys());
}
#setToast(text, kind = "ok", timeoutMs = 1400) {
const toast = { text, kind, expiresAt: Date.now() + timeoutMs };
this.toast = toast;
this.notify();
clearTimeout(this.#toastTimer);
this.#toastTimer = setTimeout(() => {
if (this.toast === toast) {
this.toast = null;
this.notify();
}
}, 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.notify();
}
#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 = surfaces.has(payload.surfaceId) ? payload.surfaceId : null;
let sourceNode = surfaceId ? payload.sourceComponent : null;
for (const [sid, surface] of sourceNode ? [] : 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 = createSecureActionId();
if (!actionId) {
this.#setToast("Secure action identifiers unavailable", "error", 4500);
return;
}
this.pendingAction = { id: actionId, name, phase: "sending", startedAt: Date.now() };
this.notify();
const userAction = {
id: actionId,
name,
surfaceId: surfaceId ?? "main",
sourceComponentId,
timestamp: new Date().toISOString(),
...(Object.keys(context).length ? { context } : {}),
};
globalThis["__openclawLastA2UIAction"] = userAction;
const boardApi = globalThis.openclaw;
if (boardApi?.state?.emit) {
const request =
globalThis.openclawA2UIBoot?.actionTier === "prompt" && boardApi.prompt?.send
? boardApi.prompt.send(
Object.keys(context).length
? `A2UI action ${name}: ${JSON.stringify(context)}`
: `A2UI action ${name}`,
)
: boardApi.state.emit({ eventType: "a2ui.action", action: userAction });
void Promise.resolve(request).then(
() => this.#handleActionStatus({ detail: { id: actionId, ok: true } }),
(/** @type {unknown} */ error) =>
this.#handleActionStatus({
detail: { id: actionId, ok: false, error: String(error?.message ?? error) },
}),
);
return;
}
this.pendingAction = {
id: actionId,
name,
phase: "error",
startedAt: Date.now(),
error: "missing board action bridge",
};
this.#setToast("Failed: missing board action 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.notify();
return { ok: true, surfaces: this.surfaces.map(([id]) => id) };
}
reset() {
this.#processor.clearSurfaces();
this.#syncSurfaces();
this.pendingAction = null;
this.notify();
return { ok: true };
}
#syncSurfaces() {
this.surfaces = Array.from(this.#processor.getSurfaces().entries());
}
}
function A2UIApp(props) {
const model = untrack(() => props.model);
const { read, revision } = observeSource(
() => model,
(notify) => {
model.notify = notify;
return () => {
model.notify = () => {};
};
},
);
const statusText = () => {
const action = read().pendingAction;
return action ? `${action.phase === "sent" ? "Working" : "Sending"}: ${action.name}` : "";
};
return (
<>
<style>{hostStyles + catalogStyles}</style>
<Show
when={read().surfaces.length > 0}
fallback={
<div class="empty">
<div class="empty-title">Canvas (A2UI)</div>
</div>
}
>
<Show when={read().pendingAction && read().pendingAction.phase !== "error"}>
<div class="status">
<div class="spinner" />
<div>{statusText()}</div>
</div>
</Show>
<Show when={read().toast}>
{(toast) => (
<div class={["toast", { error: toast().kind === "error" }]}>{toast().text}</div>
)}
</Show>
<section id="surfaces">
<For each={read().surfaces} keyed={(entry) => entry[0]}>
{(entry) => (
<a2ui-surface>
<CatalogNode
node={entry()[1].componentTree}
processor={model.processor}
surfaceId={entry()[0]}
revision={revision()}
onDataChange={() => model.notify()}
/>
</a2ui-surface>
)}
</For>
</section>
</Show>
</>
);
}
defineHost(A2UIModel, A2UIApp);

View file

@ -1,92 +0,0 @@
import { Errored, createEffect, createMemo, createSignal, onSettled } from "solid-js";
/** Read through to the synchronous owner; notifications invalidate without copying its state. */
export function observeSource(read, subscribe) {
const [revision, setRevision] = createSignal(0);
onSettled(() => subscribe(() => setRevision((value) => value + 1)));
return {
revision,
read: () => {
revision();
return read();
},
};
}
export function CatalogBoundary(props) {
return (
<Errored
fallback={(_error, reset) => (
<div class="error" role="alert">
Unable to render component.{" "}
<button
type="button"
onClick={(event) => {
event.stopPropagation();
reset();
}}
>
Retry
</button>
</div>
)}
>
{props.children}
</Errored>
);
}
export const catalogAlignment = (value) =>
({
start: "flex-start",
end: "flex-end",
spaceAround: "space-around",
spaceBetween: "space-between",
spaceEvenly: "space-evenly",
})[value] ?? value;
/** Commit-on-change fields keep their native draft when unrelated props change. */
export function catalogInputValue(read) {
let field;
const value = createMemo(read);
const write = (next) => {
if (field && field.value !== next) {
field.value = next;
}
};
createEffect(value, write);
return (element) => {
field = element;
write(value());
};
}
/** Both dialects keep their catalog markup; the native dialog owns focus and dismissal. */
export function CatalogDialog(props) {
let dialog;
onSettled(() => () => {
if (dialog.open) {
dialog.close();
}
});
return (
<>
<div class={props.triggerClass} onClick={() => dialog.showModal()}>
{props.trigger}
</div>
<dialog
class={props.class}
ref={(element) => {
dialog = element;
}}
onClick={(event) => {
if (event.target === dialog) {
dialog.close();
}
}}
>
{props.children(() => dialog.close())}
</dialog>
</>
);
}

View file

@ -1,496 +0,0 @@
import { isSafeRegex } from "@a2ui/web_core/v0_8";
import { Dynamic } from "@solidjs/web";
import { For, Match, Show, Switch, createMemo, createSignal, createUniqueId } from "solid-js";
import { CatalogBoundary, CatalogDialog, catalogAlignment } from "./catalog-primitives.jsx";
const tags = Object.fromEntries(
"Text Image Icon Video AudioPlayer Row Column List Card Tabs Divider Modal Button CheckBox TextField DateTimeInput MultipleChoice Slider"
.split(" ")
.map((name) => [name, `a2ui-${name.toLowerCase()}`]),
);
// The processor owns path normalization and template data contexts; the view only reads bindings.
function readValue(props, value) {
void props.revision;
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return value;
}
for (const key of [
"literalString",
"literalNumber",
"literalBoolean",
"literalArray",
"literal",
]) {
if (Object.hasOwn(value, key)) {
return value[key];
}
}
return typeof value.path === "string"
? props.processor.getData(props.node, value.path, props.surfaceId)
: undefined;
}
function writeValue(props, binding, value) {
if (typeof binding?.path !== "string") {
return;
}
props.processor.setData(props.node, binding.path, value, props.surfaceId);
props.onDataChange();
}
function InputField(props) {
const inputId = createUniqueId();
const value = createMemo(() => props.value ?? "");
const pattern = createMemo(() =>
props.pattern && isSafeRegex(props.pattern) ? props.pattern : undefined,
);
return (
<section class="field">
<Show when={props.label}>
<label for={inputId}>{props.label}</label>
</Show>
<input
id={inputId}
name="data"
autocomplete="off"
type={props.type}
prop:value={value()}
pattern={pattern()}
placeholder={props.placeholder}
onInput={(event) =>
props.onInput(event.currentTarget.value, event.currentTarget.checkValidity(), event)
}
/>
</section>
);
}
function Choice(props) {
const [filter, setFilter] = createSignal("");
const choices = () => props.node.properties;
const selected = () => {
const value = readValue(props, choices().selections);
return Array.isArray(value) ? value : [];
};
const options = createMemo(() =>
(choices().options ?? []).filter((option) =>
String(readValue(props, option.label) ?? "")
.toLowerCase()
.includes(filter().toLowerCase()),
),
);
const toggle = (value) => {
const current = selected();
const next = current.includes(value)
? current.filter((item) => item !== value)
: [...current, value];
if (
choices().maxAllowedSelections !== undefined &&
next.length > choices().maxAllowedSelections
) {
return;
}
writeValue(props, choices().selections, next);
};
return (
<div class="container">
<Dynamic component={choices().variant === "chips" ? "div" : "details"}>
<Show when={choices().variant !== "chips"}>
<summary class="dropdown-header">
<span class="header-text">
{selected().length ? `${selected().length} Selected` : "Select items"}
</span>
<span class="chevron">⌄</span>
</summary>
</Show>
<div class={["dropdown-wrapper", { open: choices().variant === "chips" }]}>
<Show when={choices().filterable}>
<div class="filter-container">
<input
class="filter-input"
placeholder="Filter options..."
aria-label="Filter options"
prop:value={filter()}
onInput={(event) => setFilter(event.currentTarget.value)}
/>
</div>
</Show>
<div
class={choices().variant === "chips" ? "chips-container" : "options-scroll-container"}
>
<For each={options()} keyed={(option) => option.value}>
{(option) => (
<button
type="button"
class={[
choices().variant === "chips" ? "chip" : "option-item",
{ selected: selected().includes(option().value) },
]}
aria-pressed={selected().includes(option().value) ? "true" : "false"}
onClick={() => toggle(option().value)}
>
<span class="checkbox-icon" aria-hidden="true">
{selected().includes(option().value) ? "✓" : ""}
</span>
<span>{readValue(props, option().label)}</span>
</button>
)}
</For>
<Show when={options().length === 0}>
<div>No options found</div>
</Show>
</div>
</div>
</Dynamic>
</div>
);
}
function Tabs(props) {
const groupId = createUniqueId();
const [selected, setSelected] = createSignal(0);
const items = () => props.node.properties.tabItems ?? [];
const active = () => Math.min(selected(), Math.max(0, items().length - 1));
return (
<section>
<div class="controls" role="tablist">
<For each={items()} keyed={(item) => `${item.child.id}:${item.child.dataContextPath}`}>
{(item, index) => (
<button
type="button"
role="tab"
aria-selected={active() === index() ? "true" : "false"}
id={`${groupId}-tab-${index()}`}
aria-controls={`${groupId}-panel-${index()}`}
tabindex={active() === index() ? 0 : -1}
onKeyDown={(event) => {
const count = items().length;
const next = {
ArrowRight: (index() + 1) % count,
ArrowLeft: (index() + count - 1) % count,
Home: 0,
End: count - 1,
}[event.key];
if (next === undefined) {
return;
}
event.preventDefault();
setSelected(next);
event.currentTarget.parentElement.children[next].focus();
}}
onClick={() => setSelected(index())}
>
{readValue(props, item().title)}
</button>
)}
</For>
</div>
<For each={items()} keyed={(item) => `${item.child.id}:${item.child.dataContextPath}`}>
{(item, index) => (
<div
role="tabpanel"
id={`${groupId}-panel-${index()}`}
aria-labelledby={`${groupId}-tab-${index()}`}
hidden={active() !== index()}
>
<CatalogNode {...props} node={item().child} />
</div>
)}
</For>
</section>
);
}
function Modal(props) {
return (
<CatalogDialog
trigger={<CatalogNode {...props} node={props.node.properties.entryPointChild} />}
>
{(close) => (
<section class="modal-content">
<div id="controls">
<button type="button" aria-label="Close dialog" onClick={close}>
×
</button>
</div>
<CatalogNode {...props} node={props.node.properties.contentChild} />
</section>
)}
</CatalogDialog>
);
}
function localDate(value, type) {
if (!value) {
return "";
}
// Native input values already describe local wall time, not UTC instants.
const nativeFormat = {
date: /^\d{4}-\d{2}-\d{2}$/,
time: /^\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/,
"datetime-local": /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/,
};
if (nativeFormat[type].test(value)) {
return value;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return "";
}
const pad = (part) => String(part).padStart(2, "0");
const day = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`;
return type === "date" ? day : type === "time" ? time : `${day}T${time}`;
}
export function CatalogNode(props) {
return (
<For
each={props.node ? [props.node] : []}
keyed={(node) => `${node.id}:${node.dataContextPath}`}
>
{(node) => (
<CatalogBoundary>
<CatalogElement {...props} node={node()} />
</CatalogBoundary>
)}
</For>
);
}
function CatalogElement(props) {
const fieldId = createUniqueId();
const type = () => props.node.type;
const data = () => props.node.properties;
const value = (key) => readValue(props, data()[key]);
const text = () => {
const content = String(value("text") ?? "");
const hint = data().usageHint ?? "";
// Canvas never installed the optional markdown renderer; retain its visible fallback.
return /^h[1-5]$/.test(hint)
? `${"#".repeat(Number(hint[1]))} ${content}`
: hint === "caption"
? `*${content}*`
: content;
};
const dateType = () =>
data().enableDate !== false && data().enableTime === false
? "date"
: data().enableDate === false && data().enableTime !== false
? "time"
: "datetime-local";
const children = () => data().children ?? (data().child ? [data().child] : []);
return (
<Dynamic
component={tags[type()] ?? "a2ui-unknown"}
id={props.node.id}
slot={props.node.slotName}
usage-hint={data().usageHint}
direction={type() === "List" ? (data().direction ?? "vertical") : undefined}
style={{ "--weight": props.node.weight ?? "initial" }}
>
<Switch>
<Match when={type() === "Text"}>
<div class={["text", data().usageHint ?? "body"]}>
<span class="no-markdown-renderer">{text()}</span>
</div>
</Match>
<Match when={type() === "Image"}>
<img
src={value("url")}
alt={value("altText") ?? ""}
style={{ "object-fit": data().fit ?? "fill" }}
/>
</Match>
<Match when={type() === "Icon"}>
<section>
<span class="g-icon">
{String(value("name") ?? "")
.replace(/([A-Z])/g, "_$1")
.toLowerCase()}
</span>
</section>
</Match>
<Match when={type() === "Video"}>
<video controls src={value("url")} />
</Match>
<Match when={type() === "AudioPlayer"}>
<audio controls src={value("url")} aria-label={value("description")} />
</Match>
<Match when={["Row", "Column", "List", "Card"].includes(type())}>
<section
style={{
display: type() === "Card" ? "block" : "flex",
"flex-direction":
type() === "Row" || (type() === "List" && data().direction === "horizontal")
? "row"
: "column",
"align-items": catalogAlignment(
data().alignment ?? (type() === "Row" ? "center" : "stretch"),
),
"justify-content": catalogAlignment(data().distribution ?? "start"),
}}
>
<For each={children()} keyed={(child) => `${child.id}:${child.dataContextPath}`}>
{(child) => <CatalogNode {...props} node={child()} />}
</For>
</section>
</Match>
<Match when={type() === "Button"}>
<button
type="button"
onClick={(event) => {
if (!data().action) {
return;
}
const payload = {
eventType: "a2ui.action",
surfaceId: props.surfaceId,
sourceComponentId: props.node.id,
sourceComponent: props.node,
dataContextPath: props.node.dataContextPath,
action: data().action,
};
event.currentTarget.parentElement.dispatchEvent(
Object.assign(
new CustomEvent("a2uiaction", {
bubbles: true,
cancelable: true,
composed: true,
detail: payload,
}),
{ payload },
),
);
}}
>
<CatalogNode {...props} node={data().child} />
</button>
</Match>
<Match when={type() === "CheckBox"}>
<section class="checkbox-field">
<input
id={fieldId}
type="checkbox"
prop:checked={Boolean(value("value"))}
onInput={(event) => writeValue(props, data().value, event.currentTarget.checked)}
/>
<label for={fieldId}>{value("label")}</label>
</section>
</Match>
<Match when={type() === "TextField"}>
<InputField
label={value("label")}
value={value("text")}
pattern={data().validationRegexp}
placeholder="Please enter a value"
type={
{ obscured: "password", number: "number", date: "date" }[data().textFieldType] ??
"text"
}
onInput={(next, valid, event) => {
event.currentTarget.dispatchEvent(
new CustomEvent("a2ui-validation-input", {
bubbles: true,
composed: true,
detail: {
eventType: "a2ui-validation-input",
componentId: props.node.id,
value: next,
valid,
},
}),
);
writeValue(props, data().text, next);
}}
/>
</Match>
<Match when={type() === "DateTimeInput"}>
<InputField
label={dateType() === "date" ? "Date" : dateType() === "time" ? "Time" : "Date & Time"}
type={dateType()}
value={localDate(value("value"), dateType())}
onInput={(next) => writeValue(props, data().value, next)}
/>
</Match>
<Match when={type() === "Slider"}>
<section class="field">
<Show when={value("label")}>
<label for={fieldId}>{value("label")}</label>
</Show>
<input
id={fieldId}
type="range"
min={data().minValue ?? 0}
max={data().maxValue ?? 100}
prop:value={value("value") ?? 0}
onInput={(event) =>
writeValue(props, data().value, event.currentTarget.valueAsNumber)
}
/>
<output for={fieldId}>{value("value") ?? 0}</output>
</section>
</Match>
<Match when={type() === "Divider"}>
<hr
style={{
width: data().axis === "vertical" ? `${data().thickness ?? 1}px` : "100%",
height: data().axis === "vertical" ? "100%" : `${data().thickness ?? 1}px`,
"background-color": data().color,
}}
/>
</Match>
<Match when={type() === "MultipleChoice"}>
<Choice {...props} />
</Match>
<Match when={type() === "Tabs"}>
<Tabs {...props} />
</Match>
<Match when={type() === "Modal"}>
<Modal {...props} />
</Match>
</Switch>
</Dynamic>
);
}
export const catalogStyles = `
openclaw-a2ui-host a2ui-surface { display:flex; flex-direction:column; gap:16px; min-width:0; }
openclaw-a2ui-host :is(a2ui-text,a2ui-image,a2ui-icon,a2ui-video,a2ui-audioplayer,a2ui-row,a2ui-column,a2ui-list,a2ui-card,a2ui-tabs,a2ui-divider,a2ui-modal,a2ui-button,a2ui-checkbox,a2ui-textfield,a2ui-datetimeinput,a2ui-multiplechoice,a2ui-slider) { display:block; min-width:0; box-sizing:border-box; flex:var(--weight); }
openclaw-a2ui-host :is(a2ui-row,a2ui-column,a2ui-list) > section { gap:10px; min-width:0; }
openclaw-a2ui-host a2ui-list[direction="horizontal"] > section { max-width:100%; overflow-x:auto; overflow-y:hidden; scrollbar-width:none; }
openclaw-a2ui-host a2ui-list[direction="horizontal"] > section > * { flex:1 0 fit-content; max-width:min(80%,400px); }
openclaw-a2ui-host a2ui-card > section { background:linear-gradient(180deg,rgba(255,255,255,.06),rgba(255,255,255,.03)); border:1px solid rgba(255,255,255,.09); border-radius:14px; padding:14px; box-shadow:0 10px 30px rgba(0,0,0,.35); }
openclaw-a2ui-host a2ui-button > button { background:linear-gradient(135deg,#22c55e 0%,#06b6d4 100%); border:0; border-radius:12px; padding:10px 14px; color:#071016; font-weight:650; cursor:pointer; box-shadow:0 10px 25px rgba(6,182,212,.18); }
openclaw-a2ui-host button { font:inherit; }
openclaw-a2ui-host a2ui-text .text { font-size:13px; line-height:1.4; white-space:pre-wrap; overflow-wrap:anywhere; }
openclaw-a2ui-host a2ui-text .h1 { font-size:20px; font-weight:750; margin:0 0 6px; }
openclaw-a2ui-host a2ui-text .h2 { font-size:16px; font-weight:700; margin:0 0 6px; }
openclaw-a2ui-host a2ui-text .caption { opacity:.8; }
openclaw-a2ui-host a2ui-image img { display:block; width:100%; max-width:100%; border-radius:12px; }
openclaw-a2ui-host :is(video,audio) { max-width:100%; }
openclaw-a2ui-host .field { display:grid; gap:6px; }
openclaw-a2ui-host .field input { box-sizing:border-box; width:100%; font:inherit; }
openclaw-a2ui-host .field input:invalid { outline-color:var(--danger,#f87171); }
openclaw-a2ui-host .checkbox-field { display:flex; gap:6px; align-items:center; }
openclaw-a2ui-host a2ui-divider hr { border:0; background:#ccc; opacity:.25; }
openclaw-a2ui-host a2ui-tabs .controls { display:flex; gap:6px; margin-bottom:10px; }
openclaw-a2ui-host a2ui-tabs [role=tab][aria-selected=true] { font-weight:700; }
openclaw-a2ui-host a2ui-modal dialog { padding:24px; border:0; background:transparent; color:inherit; max-width:100vw; max-height:100vh; }
openclaw-a2ui-host a2ui-modal dialog::backdrop { background:rgba(5,8,16,.65); backdrop-filter:blur(6px); }
openclaw-a2ui-host a2ui-modal .modal-content { box-sizing:border-box; background:rgba(12,16,24,.92); border:1px solid rgba(255,255,255,.12); border-radius:16px; padding:16px; box-shadow:0 30px 80px rgba(0,0,0,.6); width:min(520px,calc(100vw - 48px)); }
openclaw-a2ui-host a2ui-modal #controls { display:flex; justify-content:flex-end; margin-bottom:4px; }
openclaw-a2ui-host a2ui-modal #controls button { background:none; border:0; color:inherit; font-size:24px; cursor:pointer; }
openclaw-a2ui-host a2ui-multiplechoice .container { display:flex; flex-direction:column; gap:8px; }
openclaw-a2ui-host a2ui-multiplechoice .dropdown-header { display:flex; align-items:center; justify-content:space-between; padding:12px 16px; border:1px solid currentColor; border-radius:8px; cursor:pointer; }
openclaw-a2ui-host a2ui-multiplechoice :is(.dropdown-wrapper,.options-scroll-container) { display:flex; flex-direction:column; gap:6px; }
openclaw-a2ui-host a2ui-multiplechoice .dropdown-wrapper { margin-top:4px; }
openclaw-a2ui-host a2ui-multiplechoice .options-scroll-container { max-height:300px; overflow:auto; }
openclaw-a2ui-host a2ui-multiplechoice .chips-container { display:flex; flex-wrap:wrap; gap:6px; }
openclaw-a2ui-host a2ui-multiplechoice :is(.chip,.option-item) { display:flex; gap:6px; align-items:center; padding:8px 12px; border:1px solid currentColor; background:transparent; color:inherit; text-align:start; cursor:pointer; }
openclaw-a2ui-host a2ui-multiplechoice .chip { border-radius:999px; }
openclaw-a2ui-host a2ui-multiplechoice .selected { background:rgba(6,182,212,.2); }
openclaw-a2ui-host a2ui-multiplechoice .checkbox-icon { min-width:1em; }
openclaw-a2ui-host .g-icon { font-family:"Material Symbols Outlined"; font-size:24px; font-weight:normal; line-height:1; font-feature-settings:"liga"; }
@media (max-width:860px) { openclaw-a2ui-host a2ui-row > section { flex-wrap:wrap; } openclaw-a2ui-host a2ui-row > section > * { flex:1 1 100%; min-width:100%; } }
`;

View file

@ -1,111 +0,0 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Styles adapted from @a2ui/web_core 0.13.0 basic catalog.
// Keep the upstream tags, classes, and customization variables without loading its Lit elements.
export const catalogStyles = `
a2ui-basic-text { display: contents; }
a2ui-basic-text .a2ui-text p, a2ui-basic-text .a2ui-text h1, a2ui-basic-text .a2ui-text h2, a2ui-basic-text .a2ui-text h3, a2ui-basic-text .a2ui-text h4, a2ui-basic-text .a2ui-text h5, a2ui-basic-text .a2ui-text h6, a2ui-basic-text .a2ui-text ol, a2ui-basic-text .a2ui-text ul, a2ui-basic-text .a2ui-text li, a2ui-basic-text .a2ui-text blockquote, a2ui-basic-text .a2ui-text pre { margin: var(--_a2ui-text-margin, 0); }
a2ui-basic-text .a2ui-text { color: var(--_a2ui-text-color, var(--a2ui-text-color-text, var(--a2ui-color-on-background))); }
a2ui-basic-text .a2ui-text h1, a2ui-basic-text .a2ui-text h2, a2ui-basic-text .a2ui-text h3, a2ui-basic-text .a2ui-text h4, a2ui-basic-text .a2ui-text h5, a2ui-basic-text .a2ui-text h6 { font-family: var(--a2ui-font-family-title, inherit); line-height: var(--a2ui-line-height-headings, 1.2); }
a2ui-basic-text .a2ui-text h1 { font-size: var(--a2ui-font-size-2xl); }
a2ui-basic-text .a2ui-text h2 { font-size: var(--a2ui-font-size-xl); }
a2ui-basic-text .a2ui-text h3 { font-size: var(--a2ui-font-size-l); }
a2ui-basic-text .a2ui-text p, a2ui-basic-text .a2ui-text h4 { font-size: var(--a2ui-font-size-m); }
a2ui-basic-text .a2ui-text h5 { font-size: var(--a2ui-font-size-s); }
a2ui-basic-text .a2ui-text p { line-height: var(--a2ui-line-height-body, 1.5); }
a2ui-basic-text .a2ui-text.caption, a2ui-basic-text .a2ui-caption { font-size: var(--a2ui-font-size-xs); color: var(--a2ui-text-caption-color, light-dark(#666, #aaa)); }
a2ui-basic-text .a2ui-text a { color: var(--a2ui-text-a-color, inherit); font-weight: var(--a2ui-text-a-font-weight, inherit); }
a2ui-basic-button .a2ui-button { padding: var( --a2ui-button-padding, var(--a2ui-spacing-m, 0.5rem) var(--a2ui-spacing-l, 1rem) ); border-radius: var(--a2ui-button-border-radius, var(--a2ui-spacing-s, 0.25rem)); border: var( --a2ui-button-border, var(--a2ui-border-width, 1px) solid var(--a2ui-color-border, #ccc) ); cursor: pointer; margin: var(--a2ui-button-margin, var(--a2ui-spacing-m, 0.5rem)); background: var(--a2ui-button-background, var(--a2ui-color-surface, #fff)); box-shadow: var(--a2ui-button-box-shadow, none); font-weight: var(--a2ui-button-font-weight, normal); --_a2ui-text-margin: 0; --_a2ui-text-color: var(--a2ui-color-on-secondary, #333); color: var(--_a2ui-text-color); }
a2ui-basic-button .a2ui-button:hover { background-color: var(--a2ui-color-secondary-hover, #ddd); }
a2ui-basic-button .a2ui-button.primary { background: var(--a2ui-color-primary, #17e); --_a2ui-text-color: var(--a2ui-color-on-primary, #fff); color: var(--_a2ui-text-color); border: none; }
a2ui-basic-button .a2ui-button.primary:hover { background-color: var(--a2ui-color-primary-hover, #fbd); }
a2ui-basic-button .a2ui-button.borderless { background: none; border: none; padding: 0; color: var(--a2ui-color-primary, #17e); }
a2ui-basic-button .a2ui-button:disabled { cursor: not-allowed; opacity: 0.5; }
a2ui-basic-textfield, a2ui-basic-textfield { display: flex; flex-direction: column; width: var(--a2ui-textfield-width, 100%); gap: var(--a2ui-spacing-xs, 0.25rem); }
a2ui-basic-textfield .a2ui-textfield { box-sizing: border-box; width: 100%; background-color: var(--a2ui-color-input, #fff); color: var(--a2ui-color-on-input, #333); border: var(--a2ui-textfield-border, var(--a2ui-border)); border-radius: var(--a2ui-textfield-border-radius, var(--a2ui-spacing-m)); padding: var(--a2ui-textfield-padding, var(--a2ui-spacing-m)); font-family: inherit; }
a2ui-basic-textfield .a2ui-textfield:focus { outline: none; border-color: var(--a2ui-textfield-color-border-focus, var(--a2ui-color-primary, #17e)); }
a2ui-basic-textfield .a2ui-textfield.invalid { border-color: var(--a2ui-textfield-color-error, red); }
a2ui-basic-textfield label { font-size: var( --a2ui-textfield-label-font-size, var(--a2ui-label-font-size, var(--a2ui-font-size-s)) ); font-weight: var(--a2ui-textfield-label-font-weight, var(--a2ui-label-font-weight, bold)); }
a2ui-basic-textfield .error { color: var(--a2ui-textfield-color-error, red); font-size: var(--a2ui-font-size-xs, 0.75rem); }
a2ui-basic-row, a2ui-basic-row { display: flex; flex-direction: row; gap: var(--a2ui-row-gap, var(--a2ui-spacing-m)); }
a2ui-basic-column, a2ui-basic-column { display: flex; flex-direction: column; width: var(--a2ui-column-width, 100%); gap: var(--a2ui-column-gap, var(--a2ui-spacing-m)); }
a2ui-list .a2ui-list { display: flex; padding-inline-start: var(--a2ui-list-padding, var(--a2ui-spacing-l, 24px)); margin: 0; }
a2ui-list .a2ui-list.vertical { flex-direction: column; gap: var(--a2ui-list-gap, var(--a2ui-spacing-s, 8px)); }
a2ui-list .a2ui-list.horizontal { flex-direction: row; gap: var(--a2ui-list-gap, var(--a2ui-spacing-m, 16px)); list-style-position: inside; }
a2ui-list .a2ui-list-item-none { display: block; }
a2ui-list .horizontal .a2ui-list-item-none { display: inline-block; }
a2ui-image .a2ui-image { display: block; max-width: 100%; height: auto; border-radius: var(--a2ui-image-border-radius, 0); }
a2ui-image .a2ui-image.icon { width: var(--a2ui-image-icon-size, 24px); height: var(--a2ui-image-icon-size, 24px); }
a2ui-image .a2ui-image.avatar { width: var(--a2ui-image-avatar-size, 40px); height: var(--a2ui-image-avatar-size, 40px); border-radius: 50%; }
a2ui-image .a2ui-image.smallFeature { max-width: var(--a2ui-image-small-feature-size, 100px); }
a2ui-image .a2ui-image.largeFeature { max-height: var(--a2ui-image-large-feature-size, 400px); }
a2ui-image .a2ui-image.header { height: var(--a2ui-image-header-size, 200px); }
a2ui-icon .a2ui-icon { display: inline-block; width: var(--a2ui-icon-size, 24px); height: var(--a2ui-icon-size, 24px); font-size: var(--a2ui-icon-size, 24px); font-style: normal; font-weight: normal; font-family: var(--a2ui-icon-font-family, 'Material Icons', 'Material Symbols Outlined'); color: var( --a2ui-icon-color, var(--a2ui-text-color-text, var(--a2ui-color-on-background, #333)) ); font-variation-settings: var(--a2ui-icon-font-variation-settings, 'FILL' 1); line-height: 1; text-transform: none; letter-spacing: normal; word-wrap: normal; white-space: nowrap; direction: ltr; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; -moz-osx-font-smoothing: grayscale; font-feature-settings: 'liga'; vertical-align: middle; }
a2ui-icon .a2ui-icon.svg { fill: currentColor; }
a2ui-video .a2ui-video-container { width: 100%; max-width: 100%; }
a2ui-video .a2ui-video { width: 100%; height: auto; display: block; border-radius: var(--a2ui-video-border-radius, 0); }
a2ui-audioplayer .a2ui-audio-player { display: flex; flex-direction: column; gap: var(--a2ui-spacing-xs, 0.25rem); background: var(--a2ui-audioplayer-background, transparent); border-radius: var(--a2ui-audioplayer-border-radius, 0); padding: var(--a2ui-audioplayer-padding, 0); width: 100%; }
a2ui-audioplayer .a2ui-audio-description { font-size: var(--a2ui-font-size-s, 0.875rem); color: var( --a2ui-audioplayer-description-color, var(--a2ui-text-caption-color, light-dark(#666, #aaa)) ); }
a2ui-audioplayer .a2ui-audio { width: 100%; }
a2ui-card .a2ui-card { padding: var(--a2ui-card-padding, var(--a2ui-spacing-m, 16px)); border-radius: var(--a2ui-card-border-radius, var(--a2ui-border-radius, 8px)); box-shadow: var(--a2ui-card-box-shadow, 0 2px 4px rgba(0, 0, 0, 0.1)); background: var(--a2ui-card-background, var(--a2ui-color-surface, #fff)); color: var(--a2ui-color-on-surface, #333); border: var( --a2ui-card-border, var(--a2ui-border-width, 1px) solid var(--a2ui-color-border, #ccc) ); margin: var(--a2ui-card-margin, var(--a2ui-spacing-m, 16px)); }
a2ui-divider .a2ui-divider { border: 0; border-top: var( --a2ui-divider-border, var(--a2ui-border-width, 1px) solid var(--a2ui-color-border, #ccc) ); margin: var(--a2ui-divider-spacing, var(--a2ui-spacing-m, 16px)) 0; width: 100%; }
a2ui-divider .a2ui-divider.vertical { width: var(--a2ui-border-width, 1px); height: 100%; margin: 0 var(--a2ui-divider-spacing, var(--a2ui-spacing-m, 16px)); border-top: 0; border-left: var( --a2ui-divider-border, var(--a2ui-border-width, 1px) solid var(--a2ui-color-border, #ccc) ); }
a2ui-checkbox, a2ui-checkbox { display: block; }
a2ui-checkbox .container { display: flex; flex-direction: column; margin: var(--a2ui-checkbox-margin, var(--a2ui-spacing-m)); }
a2ui-checkbox label.a2ui-checkbox { display: inline-flex; align-items: center; gap: var(--a2ui-checkbox-gap, var(--a2ui-spacing-s, 0.5rem)); font-size: var( --a2ui-checkbox-label-font-size, var(--a2ui-label-font-size, var(--a2ui-font-size-s)) ); font-weight: var(--a2ui-checkbox-label-font-weight, var(--a2ui-label-font-weight, bold)); cursor: pointer; }
a2ui-checkbox label.invalid { color: var(--a2ui-checkbox-color-error, red); }
a2ui-checkbox input { width: var(--a2ui-checkbox-size, 1rem); height: var(--a2ui-checkbox-size, 1rem); background: var(--a2ui-checkbox-background, inherit); border: var(--a2ui-checkbox-border, var(--a2ui-border)); border-radius: var(--a2ui-checkbox-border-radius, 4px); }
a2ui-checkbox input.invalid { outline: 1px solid var(--a2ui-checkbox-color-error, red); }
a2ui-checkbox .error, a2ui-checkbox .a2ui-error-message { color: var(--a2ui-checkbox-color-error, red); font-size: var(--a2ui-font-size-xs, 0.75rem); margin-top: 4px; }
a2ui-slider .a2ui-slider-container { width: 100%; display: flex; flex-direction: column; gap: var(--a2ui-spacing-xs, 4px); margin: var(--a2ui-slider-margin, var(--a2ui-spacing-m, 16px)); }
a2ui-slider .a2ui-slider-header { display: flex; justify-content: space-between; font-size: var( --a2ui-slider-label-font-size, var(--a2ui-label-font-size, var(--a2ui-font-size-s, 14px)) ); font-weight: var(--a2ui-slider-label-font-weight, bold); color: var(--a2ui-text-color-text, var(--a2ui-color-on-background, #333)); }
a2ui-slider .a2ui-slider { width: 100%; cursor: pointer; accent-color: var(--a2ui-slider-thumb-color, var(--a2ui-color-primary, #007bff)); background: var(--a2ui-slider-track-color, var(--a2ui-color-secondary, #e9ecef)); }
a2ui-slider .error, a2ui-slider .a2ui-error-message { color: var(--a2ui-slider-color-error, red); font-size: var(--a2ui-font-size-xs, 0.75rem); }
a2ui-datetimeinput .a2ui-date-time-container { display: flex; flex-direction: column; gap: var(--a2ui-spacing-xs, 4px); width: var(--a2ui-datetimeinput-width, 100%); }
a2ui-datetimeinput input { box-sizing: border-box; width: 100%; }
a2ui-datetimeinput .a2ui-date-time-label { font-size: var( --a2ui-datetimeinput-label-font-size, var(--a2ui-label-font-size, var(--a2ui-font-size-s, 14px)) ); font-weight: var(--a2ui-datetimeinput-label-font-weight, bold); color: var(--a2ui-text-color-text, var(--a2ui-color-on-background, #333)); }
a2ui-datetimeinput .a2ui-date-time-inputs { display: flex; gap: var(--a2ui-spacing-s, 8px); width: 100%; }
a2ui-datetimeinput .a2ui-date-time-input { padding: var(--a2ui-datetimeinput-padding, 8px); border-radius: var(--a2ui-datetimeinput-border-radius, 4px); border: var(--a2ui-datetimeinput-border, 1px solid var(--a2ui-color-border, #ccc)); background-color: var(--a2ui-datetimeinput-background, var(--a2ui-color-input, #fff)); color: var(--a2ui-datetimeinput-color, var(--a2ui-color-on-input, #333)); font-family: inherit; flex: 1; }
a2ui-datetimeinput .a2ui-date-time-input::-webkit-datetime-edit, a2ui-datetimeinput .a2ui-date-time-input::-webkit-datetime-edit-fields-wrapper { color: var(--a2ui-datetimeinput-color, var(--a2ui-color-on-input, #333)); }
a2ui-datetimeinput .error, a2ui-datetimeinput .a2ui-error-message { color: var(--a2ui-datetimeinput-color-error, red); font-size: var(--a2ui-font-size-xs, 0.75rem); }
a2ui-choicepicker, a2ui-choicepicker .a2ui-choice-picker { display: flex; flex-direction: column; width: 100%; gap: var(--a2ui-choicepicker-gap, var(--a2ui-spacing-xs, 0.25rem)); padding: var(--a2ui-choicepicker-padding, 0); }
a2ui-choicepicker .options, a2ui-choicepicker .a2ui-options-group { display: flex; flex-direction: column; gap: var(--a2ui-choicepicker-gap, var(--a2ui-spacing-xs, 0.25rem)); }
a2ui-choicepicker label, a2ui-choicepicker .a2ui-option-label { display: flex; align-items: center; gap: var(--a2ui-choicepicker-gap, var(--a2ui-spacing-xs, 0.25rem)); cursor: pointer; color: var(--a2ui-text-color-text, var(--a2ui-choicepicker-label-color, inherit)); font-size: var(--a2ui-choicepicker-label-font-size, inherit); }
a2ui-choicepicker, a2ui-choicepicker > label { font-size: var( --a2ui-choicepicker-label-font-size, var(--a2ui-label-font-size, var(--a2ui-font-size-s)) ); font-weight: var(--a2ui-choicepicker-label-font-weight, var(--a2ui-label-font-weight, bold)); }
a2ui-choicepicker .filter-input { background-color: var(--a2ui-color-input, #fff); color: var(--a2ui-color-on-input, #333); border: var( --a2ui-textfield-border, var(--a2ui-border, 1px solid var(--a2ui-color-border, #ccc)) ); border-radius: var(--a2ui-textfield-border-radius, var(--a2ui-spacing-m, 4px)); padding: var( --a2ui-choicepicker-filter-padding, var(--a2ui-spacing-xs, 4px) var(--a2ui-spacing-s, 8px) ); margin-bottom: var(--a2ui-choicepicker-filter-margin-bottom, var(--a2ui-spacing-s, 0.25rem)); font-family: inherit; }
a2ui-choicepicker .filter-input:focus { outline: none; border-color: var(--a2ui-textfield-color-border-focus, var(--a2ui-color-primary, #17e)); }
a2ui-choicepicker .a2ui-option-input { width: var(--a2ui-choicepicker-checkbox-size, 1rem); height: var(--a2ui-choicepicker-checkbox-size, 1rem); }
a2ui-choicepicker .chips, a2ui-choicepicker .a2ui-chips-group { display: flex; flex-direction: row; flex-wrap: wrap; gap: var(--a2ui-choicepicker-gap, var(--a2ui-spacing-xs, 0.25rem)); }
a2ui-choicepicker .chip, a2ui-choicepicker .a2ui-chip { padding: var( --a2ui-choicepicker-chip-padding, var(--a2ui-spacing-s, 4px) var(--a2ui-spacing-m, 8px) ); border-radius: var(--a2ui-choicepicker-chip-border-radius, 999px); border: var(--a2ui-choicepicker-chip-border, 1px solid var(--a2ui-color-border, #ccc)); background-color: var(--a2ui-choicepicker-chip-background, var(--a2ui-color-surface, #fff)); color: var(--a2ui-color-on-surface, inherit); cursor: pointer; font-size: var(--a2ui-choicepicker-chip-font-size, var(--a2ui-font-size-xs, 0.75rem)); font-family: inherit; font-weight: var(--a2ui-choicepicker-chip-font-weight, normal); transition: all 0.2s; }
a2ui-choicepicker .chip.selected, a2ui-choicepicker .chip.active, a2ui-choicepicker .a2ui-chip.selected, a2ui-choicepicker .a2ui-chip.active { background-color: var( --a2ui-choicepicker-chip-background-selected, var(--a2ui-color-primary, #007bff) ); color: var(--a2ui-color-on-primary, #fff); border-color: var( --a2ui-choicepicker-chip-background-selected, var(--a2ui-color-primary, #007bff) ); }
a2ui-choicepicker .error, a2ui-choicepicker .a2ui-error-message { color: var(--a2ui-choicepicker-color-error, red); font-size: var(--a2ui-font-size-xs, 0.75rem); }
a2ui-tabs .a2ui-tabs { display: flex; flex-direction: column; width: 100%; }
a2ui-tabs .a2ui-tab-bar { display: flex; border-bottom: var(--a2ui-tabs-border, 2px solid var(--a2ui-color-border, #eee)); gap: var(--a2ui-spacing-m, 16px); }
a2ui-tabs .a2ui-tab-button { padding: var(--a2ui-spacing-s, 8px) var(--a2ui-spacing-m, 16px); border: none; background: var(--a2ui-tabs-header-background, transparent); cursor: pointer; font-weight: 500; color: var(--a2ui-tabs-header-color, var(--a2ui-text-caption-color, #666)); border-bottom: 2px solid transparent; margin-bottom: -2px; }
a2ui-tabs .a2ui-tab-button.active { background: var(--a2ui-tabs-header-background-active, transparent); color: var(--a2ui-tabs-header-color-active, var(--a2ui-color-primary, #007bff)); border-bottom: 2px solid var(--a2ui-color-primary, #007bff); }
a2ui-tabs .a2ui-tab-content { padding: var(--a2ui-tabs-content-padding, var(--a2ui-spacing-m, 16px) 0); }
a2ui-modal, a2ui-modal { display: inline-block; }
a2ui-modal .a2ui-modal-wrapper { display: inline-block; }
a2ui-modal .a2ui-modal-trigger { cursor: pointer; }
a2ui-modal .a2ui-modal-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: var(--a2ui-modal-backdrop-bg, rgba(0, 0, 0, 0.5)); display: flex; justify-content: center; align-items: center; z-index: 1000; }
a2ui-modal .a2ui-modal-content { background: var(--a2ui-modal-background, var(--a2ui-color-surface, #fff)); padding: var(--a2ui-modal-padding, var(--a2ui-spacing-xl, 32px)); border-radius: var(--a2ui-modal-border-radius, var(--a2ui-border-radius, 8px)); position: relative; min-width: 300px; max-width: 80%; max-height: 80%; overflow-y: auto; box-shadow: var(--a2ui-modal-box-shadow, 0 10px 25px rgba(0, 0, 0, 0.2)); }
a2ui-modal .a2ui-modal-close { position: absolute; top: 10px; right: 15px; border: none; background: none; font-size: 24px; cursor: pointer; color: var(--a2ui-text-caption-color, #999); }
a2ui-modal .a2ui-modal-close:hover { color: var(--a2ui-text-color, #333); }
a2ui-modal dialog.a2ui-modal-overlay { border:0; padding:0; color:inherit; background:transparent; width:100vw; height:100vh; max-width:none; max-height:none; }
a2ui-modal dialog.a2ui-modal-overlay:not([open]) { display:none; }
a2ui-modal dialog.a2ui-modal-overlay::backdrop { background:var(--a2ui-modal-backdrop-bg,rgba(0,0,0,.5)); }
a2ui-modal dialog.a2ui-modal-overlay[open] { display:flex; background:transparent; }
`;

View file

@ -1,571 +0,0 @@
import { Catalog } from "@a2ui/web_core/catalog";
import { effect, getValue } from "@a2ui/web_core/reactivity";
import { isWritable, ResolvedBinding } from "@a2ui/web_core/resolution";
import {
BASIC_COMPONENTS,
BASIC_FUNCTIONS,
BasicCatalogThemeSchema,
computeColorVariant,
injectBasicCatalogStyles,
} from "@a2ui/web_core/v0_9/basic_catalog";
import { Dynamic } from "@solidjs/web";
import DOMPurify from "dompurify";
import { Marked } from "marked";
import {
For,
Match,
Show,
Switch,
createMemo,
createSignal,
createUniqueId,
onSettled,
} from "solid-js";
import {
CatalogBoundary,
CatalogDialog,
catalogAlignment,
catalogInputValue,
observeSource,
} from "./catalog-primitives.jsx";
import { catalogStyles } from "./catalog-v0.9-styles.js";
// Only the headless schemas/functions reach the bundle. No universal element is registered.
export const basicCatalog = new Catalog(
"https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json",
"0.9",
BASIC_COMPONENTS,
BASIC_FUNCTIONS,
BasicCatalogThemeSchema,
);
const tags = {
Text: "a2ui-basic-text",
Button: "a2ui-basic-button",
TextField: "a2ui-basic-textfield",
Row: "a2ui-basic-row",
Column: "a2ui-basic-column",
List: "a2ui-list",
Image: "a2ui-image",
Icon: "a2ui-icon",
Video: "a2ui-video",
AudioPlayer: "a2ui-audioplayer",
Card: "a2ui-card",
Divider: "a2ui-divider",
CheckBox: "a2ui-checkbox",
Slider: "a2ui-slider",
DateTimeInput: "a2ui-datetimeinput",
ChoicePicker: "a2ui-choicepicker",
Tabs: "a2ui-tabs",
Modal: "a2ui-modal",
};
const markdown = new Marked({
renderer: {
html: ({ text }) =>
text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;"),
},
});
const unwrap = (value) => (value instanceof ResolvedBinding ? value.value : value);
// Bridge the processor's synchronous signal owner without replacing its implementation.
export function observeValue(read) {
return observeSource(read, (notify) =>
effect(() => {
read();
notify();
}),
).read;
}
export function CatalogStyles() {
onSettled(() => injectBasicCatalogStyles());
return <style>{catalogStyles}</style>;
}
function ValidationErrors(props) {
return (
<Show when={props.data.isValid === false}>
<For each={props.data.validationErrors ?? []}>
{(message) => <div class="error a2ui-error-message">{message}</div>}
</For>
</Show>
);
}
function Text(props) {
const variant = () => props.value("variant") || "body";
const text = () => String(props.value("text") ?? "");
const isHeading = () => /^h[1-5]$/.test(variant());
const html = createMemo(() =>
DOMPurify.sanitize(markdown.parse(text(), { async: false }), {
ALLOWED_TAGS: [
"p",
"br",
"em",
"strong",
"s",
"code",
"pre",
"blockquote",
"ul",
"ol",
"li",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
],
ALLOWED_ATTR: [],
}),
);
return (
<span class={["a2ui-text", variant()]} style={{ flex: props.value("weight") }}>
<Show when={isHeading() || variant() === "caption"} fallback={<span innerHTML={html()} />}>
<Dynamic component={isHeading() ? variant() : "em"}>{text()}</Dynamic>
</Show>
</span>
);
}
function ChoicePicker(props) {
const [filter, setFilter] = createSignal("");
const group = createUniqueId();
const selected = () => {
const value = props.value("value");
return Array.isArray(value) ? value : typeof value === "string" ? [value] : [];
};
const multi = () => props.data.variant === "multipleSelection";
const chips = () => props.data.displayStyle === "chips";
const options = createMemo(() =>
(props.data.options ?? []).filter(
(option) =>
!props.data.filterable ||
String(unwrap(option.label)).toLowerCase().includes(filter().toLowerCase()),
),
);
const toggle = (value) =>
props.write(
"value",
multi()
? selected().includes(value)
? selected().filter((item) => item !== value)
: [...selected(), value]
: [value],
);
return (
<>
<Show when={props.value("label")}>
<label>{props.value("label")}</label>
</Show>
<Show when={props.data.filterable}>
<input
type="text"
class="filter-input"
placeholder="Filter options..."
aria-label="Filter options"
value={filter()}
onInput={(event) => setFilter(event.currentTarget.value)}
/>
</Show>
<div
class={[
"a2ui-choice-picker options",
{ "a2ui-options-group": !chips(), "chips a2ui-chips-group": chips() },
]}
>
<For each={options()} keyed={(option) => option.value}>
{(option) => (
<Show
when={chips()}
fallback={
<label class="a2ui-option-label">
<input
type={multi() ? "checkbox" : "radio"}
name={group}
value={option().value}
checked={selected().includes(option().value)}
onChange={() => toggle(option().value)}
class="a2ui-option-input"
/>
<span class="a2ui-option-text">{unwrap(option().label)}</span>
</label>
}
>
<button
type="button"
class={[
"a2ui-chip chip",
{
active: selected().includes(option().value),
selected: selected().includes(option().value),
},
]}
aria-pressed={selected().includes(option().value) ? "true" : "false"}
onClick={() => toggle(option().value)}
>
{unwrap(option().label)}
</button>
</Show>
)}
</For>
</div>
<ValidationErrors data={props.data} />
</>
);
}
function Tabs(props) {
const [selected, setSelected] = createSignal(0);
const tabs = () => props.data.tabs ?? [];
const active = () => (selected() < tabs().length ? selected() : 0);
const group = createUniqueId();
return (
<div class="a2ui-tabs">
<div class="a2ui-tab-bar" role="tablist">
<For each={tabs()} keyed={(tab) => tab.child.id}>
{(tab, index) => (
<button
type="button"
class={["a2ui-tab-button", { active: active() === index() }]}
role="tab"
aria-selected={active() === index() ? "true" : "false"}
aria-controls={`${group}-panel`}
tabindex={active() === index() ? 0 : -1}
onClick={() => setSelected(index())}
onKeyDown={(event) => {
const next = {
ArrowRight: (index() + 1) % tabs().length,
ArrowLeft: (index() + tabs().length - 1) % tabs().length,
Home: 0,
End: tabs().length - 1,
}[event.key];
if (next === undefined) {
return;
}
event.preventDefault();
setSelected(next);
event.currentTarget.parentElement.children[next].focus();
}}
>
{unwrap(tab().title)}
</button>
)}
</For>
</div>
<div id={`${group}-panel`} class="a2ui-tab-content" role="tabpanel">
<CatalogNode node={tabs()[active()]?.child} />
</div>
</div>
);
}
function Modal(props) {
return (
<div class="a2ui-modal-wrapper">
<CatalogDialog
triggerClass="a2ui-modal-trigger"
trigger={<CatalogNode node={props.data.trigger} />}
class="a2ui-modal-overlay"
>
{(close) => (
<div class="a2ui-modal-content">
<button type="button" class="a2ui-modal-close" aria-label="Close" onClick={close}>
×
</button>
<CatalogNode node={props.data.content} />
</div>
)}
</CatalogDialog>
</div>
);
}
function DateTimeInput(props) {
const raw = () => String(props.value("value") ?? "");
const date = () => (raw().includes("-") ? raw().split("T")[0].slice(0, 10) : "");
const time = () =>
(raw().includes("T") ? raw().split("T")[1] : raw().includes(":") ? raw() : "").slice(0, 5);
const dateValue = catalogInputValue(date);
const timeValue = catalogInputValue(time);
const dateEnabled = () => props.data.enableDate ?? true;
const timeEnabled = () => props.data.enableTime ?? false;
const dateBound = (key) => String(props.value(key) ?? "").split("T")[0] || undefined;
const timeBound = (key) => {
const bound = String(props.value(key) ?? "");
const parts = bound.split("T");
// Combined bounds restrict time only on the boundary day, not every day in the range.
if (dateEnabled() && parts.length > 1 && date() !== parts[0]) {
return undefined;
}
return (parts.length > 1 ? parts[1] : bound).slice(0, 5) || undefined;
};
const changeDate = (event) =>
props.write(
"value",
timeEnabled()
? `${event.currentTarget.value}T${raw().includes("T") ? raw().split("T")[1] : raw().includes(":") ? raw() : "00:00:00"}`
: event.currentTarget.value,
);
const changeTime = (event) => {
const today = new Date();
const currentDate =
date() ||
`${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
props.write(
"value",
dateEnabled() ? `${currentDate}T${event.currentTarget.value}:00` : event.currentTarget.value,
);
};
return (
<div class="a2ui-date-time-container">
<Show when={props.value("label")}>
<label class="a2ui-date-time-label">{props.value("label")}</label>
</Show>
<div class="a2ui-date-time-inputs">
<Show when={dateEnabled()}>
<input
type="date"
class="a2ui-date-time-input"
aria-label="Date"
min={dateBound("min")}
max={dateBound("max")}
ref={dateValue}
onChange={changeDate}
/>
</Show>
<Show when={timeEnabled()}>
<input
type="time"
class="a2ui-date-time-input"
aria-label="Time"
min={timeBound("min")}
max={timeBound("max")}
ref={timeValue}
onChange={changeTime}
/>
</Show>
</div>
<ValidationErrors data={props.data} />
</div>
);
}
export function CatalogNode(props) {
// A new resolver node means a new component lifetime, including its local interaction state.
return (
<Show when={props.node} keyed>
{(node) => (
<Show
when={node.impl}
fallback={
<div role="alert">
{node.isPlaceholder ? "Loading component..." : `Unknown component type: ${node.type}`}
</div>
}
>
<CatalogBoundary>
<CatalogElement node={node} />
</CatalogBoundary>
</Show>
)}
</Show>
);
}
function CatalogElement(props) {
const data = observeValue(() => getValue(props.node.props));
const value = (key) => unwrap(data()[key]);
const write = (key, next) => {
const binding = data()[key];
if (isWritable(binding)) {
binding.set(next);
}
};
const type = props.node.type;
const inputId = createUniqueId();
const variant = () => data().variant ?? "default";
const invalid = () => data().isValid === false;
const primary = () => props.node.context.theme?.primaryColor;
return (
<Dynamic
component={tags[type]}
id={props.node.instanceId}
aria-label={data().accessibility?.label}
aria-description={data().accessibility?.description}
aria-live={data().accessibility?.live}
aria-hidden={
data().accessibility?.hidden === undefined ? undefined : String(data().accessibility.hidden)
}
style={{
flex: data().weight,
"justify-content": catalogAlignment(data().justify),
"align-items": catalogAlignment(data().align),
"--a2ui-color-primary": primary(),
"--a2ui-color-primary-light": primary()
? computeColorVariant("light", { colorVar: "--a2ui-color-primary" })
: undefined,
"--a2ui-color-primary-dark": primary()
? computeColorVariant("dark", { colorVar: "--a2ui-color-primary" })
: undefined,
"--a2ui-color-primary-hover": primary()
? computeColorVariant("hover", {
darkVar: "--a2ui-color-primary-dark",
lightVar: "--a2ui-color-primary-light",
})
: undefined,
}}
>
<Switch>
<Match when={type === "Text"}>
<Text value={value} />
</Match>
<Match when={type === "Icon"}>
<Show
when={value("name")?.svgPath}
fallback={
<i class="material-icons a2ui-icon">
{{
play: "play_arrow",
rewind: "fast_rewind",
favoriteOff: "favorite_border",
starOff: "star_border",
}[value("name")] ??
String(value("name") ?? "").replace(
/[A-Z]/g,
(letter) => `_${letter.toLowerCase()}`,
)}
</i>
}
>
<svg class="a2ui-icon svg" viewBox="0 0 24 24">
<path d={value("name").svgPath} />
</svg>
</Show>
</Match>
<Match when={type === "Image"}>
<img
class={["a2ui-image", variant()]}
src={value("url")}
alt={value("description") ?? ""}
style={{ "object-fit": data().fit ?? "cover" }}
/>
</Match>
<Match when={type === "Video"}>
<div class="a2ui-video-container">
<video class="a2ui-video" src={value("url") || undefined} controls />
</div>
</Match>
<Match when={type === "AudioPlayer"}>
<div class="a2ui-audio-player">
<Show when={value("description")}>
<div class="a2ui-audio-description">{value("description")}</div>
</Show>
<audio class="a2ui-audio" src={value("url") || undefined} controls />
</div>
</Match>
<Match when={type === "Row" || type === "Column"}>
<For each={data().children ?? []}>{(child) => <CatalogNode node={child} />}</For>
</Match>
<Match when={type === "List"}>
<div class={["a2ui-list", data().direction ?? "vertical"]}>
<For each={data().children ?? []}>
{(child) => (
<div class="a2ui-list-item-none">
<CatalogNode node={child} />
</div>
)}
</For>
</div>
</Match>
<Match when={type === "Card"}>
<div class="a2ui-card">
<CatalogNode node={data().child} />
</div>
</Match>
<Match when={type === "Button"}>
<button
type="button"
class={["a2ui-button", variant(), `a2ui-button-${variant()}`]}
disabled={invalid()}
onClick={() => {
if (!invalid()) {
data().action?.();
}
}}
>
<CatalogNode node={data().child} />
</button>
</Match>
<Match when={type === "CheckBox"}>
<div class="container">
<label class={["a2ui-checkbox", { invalid: invalid() }]}>
<input
type="checkbox"
class={{ invalid: invalid() }}
checked={value("value") || false}
onChange={(event) => write("value", event.currentTarget.checked)}
/>
<span class="a2ui-check-box-text">{value("label")}</span>
</label>
<ValidationErrors data={data()} />
</div>
</Match>
<Match when={type === "TextField"}>
<Show when={value("label")}>
<label for={inputId}>{value("label")}</label>
</Show>
<Dynamic
component={variant() === "longText" ? "textarea" : "input"}
id={inputId}
type={
variant() === "obscured" ? "password" : variant() === "number" ? "number" : "text"
}
class={["a2ui-textfield", { invalid: invalid() }]}
value={value("value") ?? ""}
onInput={(event) => write("value", event.currentTarget.value)}
/>
<ValidationErrors data={data()} />
</Match>
<Match when={type === "Slider"}>
<div class="a2ui-slider-container">
<div class="a2ui-slider-header">
<label class="a2ui-slider-label" for={inputId}>
{value("label")}
</label>
<span class="a2ui-slider-value">{value("value")}</span>
</div>
<input
id={inputId}
type="range"
class="a2ui-slider"
min={data().min ?? 0}
max={data().max ?? 100}
step="any"
value={value("value") ?? 0}
onInput={(event) => write("value", event.currentTarget.valueAsNumber)}
/>
<ValidationErrors data={data()} />
</div>
</Match>
<Match when={type === "Divider"}>
<hr class={["a2ui-divider", data().axis ?? "horizontal"]} />
</Match>
<Match when={type === "ChoicePicker"}>
<ChoicePicker data={data()} value={value} write={write} />
</Match>
<Match when={type === "DateTimeInput"}>
<DateTimeInput data={data()} value={value} write={write} />
</Match>
<Match when={type === "Tabs"}>
<Tabs data={data()} />
</Match>
<Match when={type === "Modal"}>
<Modal data={data()} />
</Match>
</Switch>
</Dynamic>
);
}

View file

@ -1,97 +0,0 @@
const isAndroid = /Android/i.test(globalThis.navigator?.userAgent ?? "");
const statusBlur = isAndroid ? "10px" : "14px";
const statusShadow = isAndroid
? "0 2px 10px rgba(0, 0, 0, 0.18)"
: "0 10px 24px rgba(0, 0, 0, 0.25)";
export const hostStyles = `
openclaw-a2ui-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);
}
openclaw-a2ui-host #surfaces {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
height: 100%;
overflow: auto;
padding-bottom: var(--openclaw-a2ui-scroll-pad-bottom, 0px);
}
openclaw-a2ui-host .status,
openclaw-a2ui-host .toast {
position: absolute;
left: 50%;
transform: translateX(-50%);
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(${statusBlur});
-webkit-backdrop-filter: blur(${statusBlur});
box-shadow: ${statusShadow};
z-index: 5;
}
openclaw-a2ui-host .status {
top: var(--openclaw-a2ui-status-top, 12px);
}
openclaw-a2ui-host .toast {
bottom: var(--openclaw-a2ui-toast-bottom, 12px);
}
openclaw-a2ui-host .toast.error {
border-color: rgba(255, 109, 109, 0.35);
color: rgba(255, 223, 223, 0.98);
}
openclaw-a2ui-host .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;
}
openclaw-a2ui-host .empty-title {
font-weight: 700;
margin-bottom: 6px;
}
openclaw-a2ui-host .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);
}
}
`;

View file

@ -1,40 +0,0 @@
import { render } from "@solidjs/web";
// The document contract creates this tag before loading the renderer script.
// This native adapter owns only connection/disposal; Solid owns its children.
export function defineHost(Model, App) {
class A2UIHost extends HTMLElement {
#model = new Model();
#dispose;
#disconnect;
#api;
connectedCallback() {
this.#disconnect = this.#model.connect(this);
this.#api = {
applyMessages: (messages) => this.#model.applyMessages(messages),
reset: () => this.#model.reset(),
getSurfaces: () => this.#model.getSurfaces(),
};
globalThis.openclawA2UI = this.#api;
const messages = globalThis.openclawA2UIBoot?.messages;
if (Array.isArray(messages)) {
this.#model.applyMessages(messages);
}
this.#dispose = render(() => <App model={this.#model} />, this);
}
disconnectedCallback() {
this.#dispose?.();
this.#dispose = undefined;
this.#disconnect?.();
this.#disconnect = undefined;
if (globalThis.openclawA2UI === this.#api) {
delete globalThis.openclawA2UI;
}
}
}
if (!customElements.get("openclaw-a2ui-host")) {
customElements.define("openclaw-a2ui-host", A2UIHost);
}
}

View file

@ -1,46 +0,0 @@
/** Bundle each Canvas A2UI dialect with its own renderer and catalog. */
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const { transform } = require("@solidjs/compiler");
const outputFile = process.env.OPENCLAW_A2UI_BUNDLE_OUT
? path.resolve(process.env.OPENCLAW_A2UI_BUNDLE_OUT)
: path.resolve(here, "..", "a2ui", "a2ui.bundle.js");
const outputV09File = process.env.OPENCLAW_A2UI_BUNDLE_OUT
? `${outputFile}.v0.9.js`
: path.resolve(here, "..", "a2ui", "a2ui-v0.9.bundle.js");
const createConfig = (input, file) => ({
input: path.resolve(here, input),
platform: "browser",
experimental: { attachDebugInfo: "none" },
// Keep only the used Solid and headless catalog APIs from both dialects' barrels.
treeshake: true,
plugins: [
{
name: "canvas-solid",
transform(source, id) {
if (!id.endsWith(".jsx")) {
return null;
}
return {
code: transform(source, {
filename: id,
generate: "dom",
dev: false,
sourceMap: false,
}).code,
};
},
},
],
output: { file, format: "esm", codeSplitting: false, sourcemap: false },
});
export default [
createConfig("bootstrap.jsx", outputFile),
createConfig("bootstrap-v0.9.jsx", outputV09File),
];

View file

@ -1,82 +0,0 @@
import { readFileHandleBounded } from "openclaw/plugin-sdk/file-access-runtime";
import { detectMime } from "openclaw/plugin-sdk/media-mime";
import { lowercasePreservingWhitespace } from "openclaw/plugin-sdk/string-coerce-runtime";
import { A2UI_PATH, isA2uiPath } from "./a2ui-shared.js";
import { resolveFileWithinRoot } from "./file-resolver.js";
type A2uiRootResolver = () => Promise<string | null>;
export type A2uiHttpRequest = { method?: string; url?: string };
export type A2uiHttpResponse = {
statusCode: number;
setHeader(name: string, value: number | string | readonly string[]): void;
end(chunk?: Buffer | string): void;
};
export async function handleA2uiHttpRequestWithRootResolver(
req: A2uiHttpRequest,
res: A2uiHttpResponse,
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);
await using 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;
}
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 (mime === "text/html") {
const buf = (await readFileHandleBounded(result.handle, Infinity)).toString("utf8");
res.setHeader("Content-Type", "text/html; charset=utf-8");
if (req.method === "HEAD") {
res.setHeader("Content-Length", String(Buffer.byteLength(buf)));
res.end();
return true;
}
res.end(buf);
return true;
}
res.setHeader("Content-Type", mime);
if (req.method === "HEAD") {
res.setHeader("Content-Length", String(result.stat.size));
res.end();
return true;
}
res.end(await readFileHandleBounded(result.handle, Infinity));
return true;
}

View file

@ -1,7 +0,0 @@
export const A2UI_PATH = "/__openclaw__/a2ui";
export const CANVAS_HOST_PATH = "/__openclaw__/canvas";
export function isA2uiPath(pathname: string): boolean {
return pathname === A2UI_PATH || pathname.startsWith(`${A2UI_PATH}/`);
}

View file

@ -1,99 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { handleA2uiHttpRequestWithRootResolver } from "./a2ui-route.js";
import { A2UI_PATH } from "./a2ui-shared.js";
let fixtureRoot = "";
let fixtureRootReal = "";
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-canvas-a2ui-route-"),
);
const assetRoot = path.join(fixtureRoot, "canvas-host", "a2ui");
await fs.mkdir(assetRoot, { recursive: true });
await Promise.all([
fs.writeFile(path.join(assetRoot, "a2ui.bundle.js"), "window.v08 = true;", "utf8"),
fs.writeFile(path.join(assetRoot, "a2ui-v0.9.bundle.js"), "window.v09 = true;", "utf8"),
]);
fixtureRootReal = await fs.realpath(assetRoot);
});
afterAll(async () => {
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
type CapturedResponse = {
handled: boolean;
status: number;
headers: Record<string, number | string | string[]>;
body: Buffer;
};
async function capture(
url: string,
method = "GET",
resolveRootReal: () => Promise<string | null> = async () => fixtureRootReal,
): Promise<CapturedResponse> {
const response: CapturedResponse = {
handled: false,
status: 200,
headers: {},
body: Buffer.alloc(0),
};
const res = {
statusCode: 200,
setHeader(name: string, value: number | string | readonly string[]) {
response.headers[name.toLowerCase()] = typeof value === "object" ? [...value] : value;
},
end(chunk?: string | Buffer) {
response.status = this.statusCode;
response.body = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk ?? "");
},
};
response.handled = await handleA2uiHttpRequestWithRootResolver(
{ method, url },
res,
resolveRootReal,
);
response.status = res.statusCode;
return response;
}
describe("Canvas A2UI renderer asset route", () => {
it.each(["a2ui.bundle.js", "a2ui-v0.9.bundle.js"])(
"serves %s for GET and HEAD",
async (fileName) => {
const get = await capture(`${A2UI_PATH}/${fileName}`);
const head = await capture(`${A2UI_PATH}/${fileName}`, "HEAD");
expect(get.status).toBe(200);
expect(get.body.byteLength).toBeGreaterThan(0);
expect(head.status).toBe(200);
expect(head.body).toHaveLength(0);
expect(head.headers["content-length"]).toBe(String(get.body.byteLength));
},
);
it("rejects missing, malformed, and traversal paths", async () => {
for (const url of [
`${A2UI_PATH}/missing.js`,
`${A2UI_PATH}/%E0%A4%A`,
`${A2UI_PATH}/%2e%2e%2fpackage.json`,
]) {
const response = await capture(url);
expect(response.status).toBe(404);
expect(response.body.toString("utf8")).toBe("not found");
}
expect((await capture(A2UI_PATH, "POST")).status).toBe(405);
});
it("returns unavailable when renderer assets cannot be resolved", async () => {
const response = await capture(`${A2UI_PATH}/a2ui.bundle.js`, "GET", async () => null);
expect(response.status).toBe(503);
expect(response.body.toString("utf8")).toBe("A2UI assets not found");
});
});

View file

@ -1,82 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { readFileHandleBounded } from "openclaw/plugin-sdk/file-access-runtime";
import {
handleA2uiHttpRequestWithRootResolver,
type A2uiHttpRequest,
type A2uiHttpResponse,
} from "./a2ui-route.js";
import { A2UI_PATH } from "./a2ui-shared.js";
import { resolveFileWithinRoot } from "./file-resolver.js";
export { A2UI_PATH, CANVAS_HOST_PATH } from "./a2ui-shared.js";
let cachedA2uiRootReal: Promise<string | null> | undefined;
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 bundlePath = path.join(dir, "a2ui.bundle.js");
const v09BundlePath = path.join(dir, "a2ui-v0.9.bundle.js");
await fs.stat(bundlePath);
await fs.stat(v09BundlePath);
return dir;
} catch {
// try next
}
}
return null;
}
function resolveA2uiRootReal(): Promise<string | null> {
return (cachedA2uiRootReal ??= resolveA2uiRoot().then((root) =>
root ? fs.realpath(root) : null,
));
}
export async function handleA2uiHttpRequest(
req: A2uiHttpRequest,
res: A2uiHttpResponse,
): Promise<boolean> {
return await handleA2uiHttpRequestWithRootResolver(req, res, resolveA2uiRootReal);
}
/** Read an explicitly registered public renderer bundle without request or Gateway authority. */
export async function readPublicA2uiResource(
resourcePath: string,
): Promise<{ body: Uint8Array; contentType: string } | undefined> {
const root = await resolveA2uiRootReal();
await using opened = root
? await resolveFileWithinRoot(root, resourcePath.slice(A2UI_PATH.length))
: null;
if (!opened) {
return undefined;
}
return {
body: await readFileHandleBounded(opened.handle, Infinity),
contentType: "application/javascript; charset=utf-8",
};
}

View file

@ -1,64 +0,0 @@
// 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 { 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("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.runIf(process.platform !== "win32")("rejects symlink entries", async () => {
await withCanvasTemp("openclaw-canvas-resolver-", async (root) => {
await withCanvasTemp("openclaw-canvas-resolver-outside-", async (outside) => {
const target = path.join(outside, "outside.html");
const link = path.join(root, "link.html");
await fs.writeFile(target, "outside");
await fs.symlink(target, link);
await expect(resolveFileWithinRoot(root, "/link.html")).resolves.toBeNull();
});
});
});
});

View file

@ -1,51 +0,0 @@
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"]>>;
/** Opens a Canvas-hosted file only when the request stays inside the root. */
export async function resolveFileWithinRoot(
rootReal: string,
urlPath: string,
): Promise<CanvasOpenResult | null> {
let decoded: string;
try {
decoded = decodeURIComponent(urlPath || "/");
} catch {
return null;
}
let depth = 0;
for (const segment of decoded.split("/")) {
if (segment === "" || segment === ".") {
continue;
}
if (segment === "..") {
if (depth === 0) {
return null;
}
depth--;
continue;
}
depth++;
}
const normalizedPath = path.posix.normalize(decoded);
const normalized = normalizedPath.startsWith("/") ? normalizedPath : `/${normalizedPath}`;
const rel = normalized.replace(/^\/+/, "");
const root = await fsRoot(rootReal);
try {
if (normalized.endsWith("/")) {
return await root.open(path.posix.join(rel, "index.html"));
}
const st = await root.stat(rel);
if (st.isSymbolicLink) {
return null;
}
return await root.open(st.isDirectory ? path.posix.join(rel, "index.html") : rel);
} catch (err) {
if (err instanceof FsSafeError) {
return null;
}
throw err;
}
}

View file

@ -1765,7 +1765,6 @@
"build:plugin-sdk:dts": "node --import ./scripts/tsx.mjs scripts/write-plugin-sdk-entry-dts.ts",
"build:plugin-sdk:strict-smoke": "node --import ./scripts/tsx.mjs scripts/build-all.mts pluginSdkStrictSmoke",
"build:strict-smoke": "node --import ./scripts/tsx.mjs scripts/build-all.mts strictSmoke",
"canvas:a2ui:bundle": "node --import ./scripts/tsx.mjs scripts/bundle-a2ui.mts",
"channels:catalog:check": "node scripts/write-official-channel-catalog.mjs --check",
"channels:catalog:gen": "node scripts/write-official-channel-catalog.mjs --write",
"changed:lanes": "node scripts/changed-lanes.mjs",
@ -2413,7 +2412,6 @@
"zod-to-json-schema": "3.25.2"
},
"devDependencies": {
"@a2ui/web_core": "0.13.0",
"@babel/types": "8.0.6",
"@copilotkit/aimock": "1.45.1",
"@mdx-js/mdx": "3.1.1",

View file

@ -5,17 +5,17 @@ connection state machine used by OpenClaw's own Node and browser clients:
challenge-based authentication, typed protocol frames, request correlation,
timeouts, reconnect backoff, device-token handling, and event delivery.
The current wire protocol is version 4. General clients must advertise exactly v4 with
`minProtocol: 4` and `maxProtocol: 4`. See the
The current wire protocol is version 5. General clients must advertise exactly v5 with
`minProtocol: 5` and `maxProtocol: 5`. See the
[Gateway protocol specification](https://docs.openclaw.ai/gateway/protocol) for
the complete handshake, authentication, role, scope, and method contracts.
Exact node identities (`role: "node"` plus `mode: "node"`) and diagnostic clients
can use v3. The built-in node host starts with an exact v4 envelope, then retries
an exact v3 envelope after a v3 Gateway rejects v4. If that legacy connection attempt reaches
an upgraded v4 Gateway, the client reconnects with the full v4 envelope before
reporting readiness. Other exact node identities default to `[3, 4]`. Explicit
bounds override these defaults; `[3, 4]` on the built-in node host selects the
same bounded negotiation.
can use v4. The built-in node host starts with an exact v5 envelope, then retries
an exact v4 envelope after a v4 Gateway rejects v5. If that previous-version connection attempt reaches
an upgraded v5 Gateway, the client reconnects with the full v5 envelope before
reporting readiness. Both versions preserve client metadata and node capabilities.
Other exact node identities default to `[4, 5]`. Explicit bounds override these
defaults; `[4, 5]` on the built-in node host selects the same bounded negotiation.
## Versioning
@ -101,8 +101,8 @@ const connected = Promise.withResolvers<void>();
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
token: process.env.OPENCLAW_GATEWAY_TOKEN,
minProtocol: PROTOCOL_VERSION, // v4
maxProtocol: PROTOCOL_VERSION, // v4
minProtocol: PROTOCOL_VERSION, // v5
maxProtocol: PROTOCOL_VERSION, // v5
onHelloOk: () => connected.resolve(),
onConnectError: (error) => connected.reject(error),
onEvent: (event) => {

View file

@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { PROTOCOL_VERSION } from "@openclaw/gateway-protocol/version";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createDeferred } from "../../../test/helpers/promise.js";
import type {
@ -164,7 +165,7 @@ function connect(
const storedToken = { token: "synthetic-stored-token", scopes: ["operator.read"] };
const hello = {
type: "hello-ok",
protocol: 4,
protocol: PROTOCOL_VERSION,
auth: { deviceToken: "synthetic-issued-token", role: "operator", scopes: ["operator.read"] },
};

View file

@ -8,7 +8,11 @@ import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
import { createDeferred } from "../../../test/helpers/promise.js";
import { TEST_TLS_CERT_PEM, TEST_TLS_KEY_PEM } from "../../../test/helpers/tls-fixture.js";
import { validatePreviousConnectParams } from "../../gateway-protocol/src/connect-compatibility.test-support.js";
import { GATEWAY_SERVER_CAPS, validateConnectParams } from "../../gateway-protocol/src/index.js";
import {
GATEWAY_SERVER_CAPS,
PROTOCOL_VERSION,
validateConnectParams,
} from "../../gateway-protocol/src/index.js";
import {
GatewayClient,
GatewayClientRequestTimeoutError,
@ -256,7 +260,7 @@ it.each([
id: frame.id,
ok: valid,
...(valid
? { payload: { type: "hello-ok", protocol: 4 } }
? { payload: { type: "hello-ok", protocol: PROTOCOL_VERSION } }
: { error: { code: "INVALID_REQUEST", message: "invalid connect params" } }),
});
});
@ -487,7 +491,7 @@ describe("GatewayClient", () => {
if (frame.method === "connect") {
sendResponse(socket, frame.id, {
type: "hello-ok",
protocol: 4,
protocol: PROTOCOL_VERSION,
policy: { tickIntervalMs: 20 },
});
return;

View file

@ -219,17 +219,6 @@ const FORCE_STOP_TERMINATE_GRACE_MS = 250;
const STOP_AND_WAIT_TIMEOUT_MS = 1_000;
const MAX_SUPPRESSED_TRANSIENT_PRE_HELLO_CLEAN_CLOSES = 1;
function resolveLegacyNodePlatform(platform: string): string | undefined {
switch (platform) {
case "macos":
return "darwin";
case "windows":
return "win32";
default:
return undefined;
}
}
type PendingStop = {
ws: WebSocket;
promise: Promise<void>;
@ -243,7 +232,7 @@ export class GatewayClient {
private opts: GatewayClientOptions;
private deps: Required<GatewayClientHostDeps>;
private stopped = false;
private useLegacyNodeProtocolEnvelope = false;
private usePreviousNodeProtocol = false;
private nodeProtocolTransitionPending = false;
private pendingDeviceTokenRetry = false;
private deviceTokenRetryBudgetUsed = false;
@ -673,15 +662,15 @@ export class GatewayClient {
role === "node" &&
clientMode === GATEWAY_CLIENT_MODES.NODE &&
clientId === GATEWAY_CLIENT_NAMES.NODE_HOST;
const negotiatesNodeProtocol = this.shouldNegotiateLegacyNodeProtocol();
const useLegacyNodeProtocolEnvelope =
const negotiatesNodeProtocol = this.shouldNegotiatePreviousNodeProtocol();
const usePreviousNodeProtocol =
isBuiltInNodeHost &&
(this.useLegacyNodeProtocolEnvelope ||
(this.usePreviousNodeProtocol ||
(this.opts.maxProtocol === MIN_NODE_PROTOCOL_VERSION &&
(this.opts.minProtocol ?? MIN_NODE_PROTOCOL_VERSION) <= MIN_NODE_PROTOCOL_VERSION));
// Match server admission: only probes and exact node role+mode identities
// may advertise specialized floors; every other client stays current-only.
const minProtocol = useLegacyNodeProtocolEnvelope
const minProtocol = usePreviousNodeProtocol
? MIN_NODE_PROTOCOL_VERSION
: negotiatesNodeProtocol
? PROTOCOL_VERSION
@ -691,18 +680,13 @@ export class GatewayClient {
: role === "node" && clientMode === GATEWAY_CLIENT_MODES.NODE
? MIN_NODE_PROTOCOL_VERSION
: MIN_CLIENT_PROTOCOL_VERSION));
const maxProtocol = useLegacyNodeProtocolEnvelope
const maxProtocol = usePreviousNodeProtocol
? MIN_NODE_PROTOCOL_VERSION
: negotiatesNodeProtocol
? PROTOCOL_VERSION
: (this.opts.maxProtocol ?? PROTOCOL_VERSION);
const configuredPlatform = this.opts.platform ?? process.platform;
// A released v3 Gateway rejects v4 before authentication, so the retry can
// reproduce the shipped node-host envelope without mutating v4 pairings.
const platform = useLegacyNodeProtocolEnvelope
? (resolveLegacyNodePlatform(configuredPlatform) ?? configuredPlatform)
: configuredPlatform;
const deviceFamily = useLegacyNodeProtocolEnvelope ? undefined : this.opts.deviceFamily;
const platform = this.opts.platform ?? process.platform;
const deviceFamily = this.opts.deviceFamily;
return {
params: {
@ -715,18 +699,18 @@ export class GatewayClient {
buildId: this.opts.clientBuildId,
platform,
deviceFamily,
modelIdentifier: useLegacyNodeProtocolEnvelope ? undefined : this.opts.modelIdentifier,
modelIdentifier: this.opts.modelIdentifier,
mode: clientMode,
instanceId: this.opts.instanceId,
},
...resolveModelCatalogConnect({
caps: Array.isArray(this.opts.caps) ? this.opts.caps : [],
modelCatalog: useLegacyNodeProtocolEnvelope ? undefined : this.opts.modelCatalog,
modelCatalog: this.opts.modelCatalog,
serverCapabilities: params.serverCapabilities,
}),
commands: Array.isArray(this.opts.commands) ? this.opts.commands : undefined,
computerUse: useLegacyNodeProtocolEnvelope ? undefined : this.opts.computerUse,
workerRuns: useLegacyNodeProtocolEnvelope ? undefined : this.opts.workerRuns,
computerUse: this.opts.computerUse,
workerRuns: this.opts.workerRuns,
permissions:
this.opts.permissions && typeof this.opts.permissions === "object"
? this.opts.permissions
@ -755,7 +739,7 @@ export class GatewayClient {
};
}
private shouldNegotiateLegacyNodeProtocol(): boolean {
private shouldNegotiatePreviousNodeProtocol(): boolean {
if (
this.opts.role !== "node" ||
this.opts.mode !== GATEWAY_CLIENT_MODES.NODE ||
@ -771,7 +755,7 @@ export class GatewayClient {
private shouldRetryWithAlternateNodeProtocol(error: GatewayProtocolRequestError): boolean {
if (
!this.shouldNegotiateLegacyNodeProtocol() ||
!this.shouldNegotiatePreviousNodeProtocol() ||
!(error instanceof GatewayClientRequestError)
) {
return false;
@ -781,7 +765,7 @@ export class GatewayClient {
?.expectedProtocol;
return (
expectedProtocol ===
(this.useLegacyNodeProtocolEnvelope ? PROTOCOL_VERSION : MIN_NODE_PROTOCOL_VERSION) &&
(this.usePreviousNodeProtocol ? PROTOCOL_VERSION : MIN_NODE_PROTOCOL_VERSION) &&
(detailCode === ConnectErrorDetailCodes.PROTOCOL_MISMATCH ||
normalizeGatewayErrorText(error.message).includes("protocol mismatch"))
);
@ -894,11 +878,11 @@ export class GatewayClient {
private completeConnectHello(helloOk: HelloOk): void {
const reconnectWithCurrentNodeProtocol =
this.useLegacyNodeProtocolEnvelope &&
this.shouldNegotiateLegacyNodeProtocol() &&
this.usePreviousNodeProtocol &&
this.shouldNegotiatePreviousNodeProtocol() &&
helloOk.protocol > MIN_NODE_PROTOCOL_VERSION;
if (reconnectWithCurrentNodeProtocol) {
this.useLegacyNodeProtocolEnvelope = false;
this.usePreviousNodeProtocol = false;
}
this.nodeProtocolTransitionPending = false;
this.pendingDeviceTokenRetry = false;
@ -907,8 +891,8 @@ export class GatewayClient {
this.tickIntervalMs =
typeof helloOk.policy?.tickIntervalMs === "number" ? helloOk.policy.tickIntervalMs : 30_000;
if (reconnectWithCurrentNodeProtocol) {
// A v4 Gateway accepted the exact-v3 probe as a legacy session. Reconnect
// before reporting readiness so node capabilities are not silently filtered.
// Reconnect to the current protocol before reporting readiness so node
// capabilities do not retain the previous protocol's restrictions.
this.protocol.resetReconnectBackoff(250);
this.protocol.closeSocket(1012, "gateway protocol upgraded");
return;
@ -942,15 +926,15 @@ export class GatewayClient {
) {
if (this.shouldRetryWithAlternateNodeProtocol(error)) {
const resetBackoff = !this.nodeProtocolTransitionPending;
this.useLegacyNodeProtocolEnvelope = !this.useLegacyNodeProtocolEnvelope;
this.usePreviousNodeProtocol = !this.usePreviousNodeProtocol;
this.nodeProtocolTransitionPending = true;
if (resetBackoff) {
this.protocol.resetReconnectBackoff(250);
}
this.logDebug(
this.useLegacyNodeProtocolEnvelope
? "gateway rejected protocol v4; retrying node host with protocol v3"
: "gateway rejected protocol v3; retrying node host with protocol v4",
this.usePreviousNodeProtocol
? `gateway rejected protocol v${PROTOCOL_VERSION}; retrying node host with protocol v${MIN_NODE_PROTOCOL_VERSION}`
: `gateway rejected protocol v${MIN_NODE_PROTOCOL_VERSION}; retrying node host with protocol v${PROTOCOL_VERSION}`,
);
return { closeCode: 1008, closeReason: "connect retry" };
}

View file

@ -8,28 +8,41 @@ authoring dates (2026), not package publication dates.
## Unreleased
No changes outside the dated history below.
- **Breaking:** retire A2UI widgets and remove the generic registered board-content schemas, `registeredContentKind`, and renderer labels. Stored A2UI widgets are backed up and deleted by Doctor; recreate them as HTML widgets.
- Advance the wire protocol to v5 for the closed board-schema removal. General clients must use v5; authenticated nodes and lightweight probes retain the N-1 window at v4. Current native clients and Control UI derive their negotiation levels from the protocol owner.
## Deferred to the next wire version
## Deferred to a later wire version
Changes that are agreed but intentionally NOT shipped, because they alter the
negotiated shape of the current wire version. A version bump is an explicit
breaking event for third-party clients (see "Notes for external versioning"), so
each entry stays here until an owner approves the bump and lands it together with
client follow-through. Add an entry whenever you defer a shape change; remove it
in the commit that bumps the version.
each entry stays here until its owner approves the specific removal and lands it
with client follow-through. The v5 bump below covers only A2UI retirement. Add an
entry whenever you defer a shape change; remove it when that change lands.
- **Remove `execSecurity` / `execAsk` from `SessionsPatchMutation`.** Retired in
favour of `permissionMode` (PR #132740, landed `a151c1e0d35`). The properties
remain declared in the closed v4 mutation schema so existing v4 callers keep a
remain declared in the closed mutation schema so existing callers keep a
valid request shape; the applier rejects either field before any mutation with
guidance naming `permissionMode` and `/exec`. Deleting the properties changes
the closed-object shape, so it waits for the bump. Code marker:
the closed-object shape, so this separate retirement remains deferred beyond v5. Code marker:
`packages/gateway-protocol/src/schema/sessions-patch.ts`.
## Protocol v4 (current)
## Protocol v5 (current)
Introduced 2026-05-07 (commit `330ba1f`). The stable, current wire version.
Introduced 2026-10-24 to retire registered board-widget content. `BoardWidgetContent`
and `BoardWidgetPutContent` no longer accept `kind: "registered"`; board snapshots
no longer expose the registered content owner, `registeredContentKind`, or
`kindLabel`. The generic `registerBoardWidgetContentKind` plugin API is removed.
Use HTML widgets or the existing native plugin widget surface.
Compatibility window (`version.ts`):
- `MIN_CLIENT_PROTOCOL_VERSION = 5` — general clients must speak v5.
- `MIN_NODE_PROTOCOL_VERSION = 4` and `MIN_PROBE_PROTOCOL_VERSION = 4` — nodes and probes retain the N-1 window during rolling upgrades.
## Protocol v4
Introduced 2026-05-07 (commit `330ba1f`). The preceding stable wire version.
Wire contract:
- Frame envelopes: `req` / `res` / `event`, discriminated on `type`.
@ -57,7 +70,7 @@ Compatibility window (`version.ts`):
rolling upgrades. Added 2026-07-06 (#101109).
- A transient v5 (2026-05-16, `07f05e9`) renamed `inboundTurnKind` -> `inboundEventKind`;
it was reverted the next day (2026-05-17, `ad155fb`, "restore v4 message action
protocol"). v5 never shipped as a stable ceiling; v4 remains current.
protocol"). That v5 experiment never shipped as a stable ceiling; v4 remained current until the A2UI retirement above.
## Protocol v3
@ -236,7 +249,7 @@ Enhancement-only month (no new schema modules):
## Notes for external versioning
- Removals and semantic breaks have shipped in v4 builds without a wire-version bump, including the July question/catalog reshaping and August RPC/field removals above. The negotiated version remains `4`, with minimum client/node/probe versions `4` / `3` / `3`; it does not identify a fixed schema vintage. Monthly history records authoring, not a guarantee that every intermediate shape reached a stable package release.
- Removals and semantic breaks have shipped in v4 builds without a wire-version bump, including the July question/catalog reshaping and August RPC/field removals above. During v4, the negotiated version remained `4`, with minimum client/node/probe versions `4` / `3` / `3`; it did not identify a fixed schema vintage. Monthly history records authoring, not a guarantee that every intermediate shape reached a stable package release.
- Discover current methods, events, and capabilities through `hello-ok.features`; consult the matching release's schemas for payload contracts. The Deferred section records shape changes still waiting for an owner-approved version bump, not changes already made under v4.
- Core method `since` values are release-train metadata (`<=2026.7` predates tracking), not exact authoring dates. Use the method table for retained methods, generated Kotlin catalog history for method/event changes, and schema/generated Swift diffs for field changes; the sparse `x-openclaw-since` annotations are not a complete schema history.
- `schema/types.ts` was removed 2026-07-11 (#103679); it re-exported compile-time type

View file

@ -3,7 +3,7 @@
Typed schemas, inferred TypeScript types, and runtime validators for the OpenClaw
Gateway WebSocket protocol.
The current wire protocol is version 4. General clients must use v4; authenticated
The current wire protocol is version 5. General clients must use v5; authenticated
node clients and lightweight connectivity checks may use the N-1 window during rolling upgrades.
See the [Gateway protocol specification](https://docs.openclaw.ai/gateway/protocol)
for transport, authentication, roles, scopes, and complete frame examples.

View file

@ -66,18 +66,8 @@ it("BoardSnapshotSchema: accepts optional HTML widget view metadata", () => {
});
accepts(BoardSnapshotSchema, withWidget());
accepts(BoardSnapshotSchema, withWidget({ contentOwner: "html" }));
accepts(
BoardSnapshotSchema,
withWidget({
contentKind: "plugin",
pluginKind: "diagrams:diagram",
contentOwner: "registered",
registeredContentKind: "diagram",
}),
);
for (const invalid of [
{ contentOwner: "unknown" },
{ contentOwner: "registered", registeredContentKind: "Invalid Kind" },
{ frameUrl: 42 },
{ presentation: "floating" },
{ heightMode: "elastic" },

View file

@ -73,9 +73,8 @@ export const BoardWidgetSchema = closedObject({
title: Type.Optional(Type.String({ minLength: 1, maxLength: 80 })),
contentKind: Type.Union([Type.Literal("html"), Type.Literal("mcp-app"), Type.Literal("plugin")]),
contentOwner: Type.Optional(
Type.Enum(["html", "mcp-app", "plugin", "registered"] as const, { type: "string" }),
Type.Enum(["html", "mcp-app", "plugin"] as const, { type: "string" }),
),
registeredContentKind: Type.Optional(Type.String({ pattern: "^[a-z][a-z0-9-]{0,31}$" })),
pluginKind: Type.Optional(BoardWidgetPluginKindSchema),
props: Type.Optional(BoardWidgetPluginPropsSchema),
presentation: Type.Optional(BoardWidgetPresentationSchema),
@ -100,7 +99,6 @@ export const BoardWidgetSchema = closedObject({
sandboxUrl: Type.Optional(Type.String()),
sandboxPort: Type.Optional(Type.Integer({ minimum: 1, maximum: 65535 })),
sandboxOrigin: Type.Optional(Type.String()),
kindLabel: Type.Optional(Type.String({ minLength: 1, maxLength: 80 })),
});
export type BoardWidget = Static<typeof BoardWidgetSchema>;
@ -201,26 +199,16 @@ export const BoardWidgetPluginContentSchema = closedObject({
pluginKind: BoardWidgetPluginKindSchema,
props: Type.Optional(BoardWidgetPluginPropsSchema),
});
export const BoardWidgetRegisteredContentSchema = closedObject({
kind: Type.Literal("registered"),
contentKind: Type.String({ pattern: "^[a-z][a-z0-9-]{0,31}$" }),
source: Type.String({ maxLength: 262_144 }),
});
export const BoardWidgetContentSchema = Type.Union([
BoardWidgetHtmlContentSchema,
BoardWidgetMcpAppContentSchema,
BoardWidgetPluginContentSchema,
BoardWidgetRegisteredContentSchema,
]);
export type BoardWidgetContent = Static<typeof BoardWidgetContentSchema>;
export type BoardWidgetMaterializedContent =
| Static<typeof BoardWidgetHtmlContentSchema>
| (Static<typeof BoardWidgetMcpAppContentSchema> & { interactive: boolean })
| Static<typeof BoardWidgetPluginContentSchema>;
export type BoardWidgetRegisteredMaterializedContent = Static<
typeof BoardWidgetRegisteredContentSchema
> & { pluginKind: string };
export const BoardCanvasDocumentSourceSchema = closedObject({
kind: Type.Literal("canvas-doc"),
docId: NonEmptyString,
@ -231,7 +219,6 @@ export const BoardWidgetPutContentSchema = Type.Union([
BoardWidgetHtmlContentSchema,
BoardWidgetMcpAppPutContentSchema,
BoardWidgetPluginContentSchema,
BoardWidgetRegisteredContentSchema,
BoardCanvasDocumentSourceSchema,
]);
export type BoardWidgetPutContent = Static<typeof BoardWidgetPutContentSchema>;
@ -257,7 +244,7 @@ export const BoardWidgetPutParamsSchema = closedObject({
export type BoardWidgetPutParams = Static<typeof BoardWidgetPutParamsSchema>;
/** Materialized input accepted by the board store after gateway source resolution. */
export type BoardWidgetMaterializedPutParams = Omit<BoardWidgetPutParams, "content"> & {
content: BoardWidgetMaterializedContent | BoardWidgetRegisteredMaterializedContent;
content: BoardWidgetMaterializedContent;
};
export const BoardWidgetPutResultSchema = closedObject({
...BoardSnapshotFields,

View file

@ -1,8 +1,8 @@
/** Current gateway protocol version emitted by modern clients and servers. */
export const PROTOCOL_VERSION = 4 as const;
export const PROTOCOL_VERSION = 5 as const;
/** Lowest general client protocol version accepted by the gateway. */
export const MIN_CLIENT_PROTOCOL_VERSION = 4 as const;
export const MIN_CLIENT_PROTOCOL_VERSION = 5 as const;
/** Lowest authenticated node protocol version accepted by the gateway. */
export const MIN_NODE_PROTOCOL_VERSION = 3 as const;
export const MIN_NODE_PROTOCOL_VERSION = 4 as const;
/** Lowest lightweight probe protocol version accepted by the gateway. */
export const MIN_PROBE_PROTOCOL_VERSION = 3 as const;
export const MIN_PROBE_PROTOCOL_VERSION = 4 as const;

View file

@ -99,7 +99,7 @@ describe("mime detection", () => {
);
it.each([
["a2ui.bundle.js", "text/javascript"],
["widget.bundle.js", "text/javascript"],
["config.yml", "application/yaml"],
["config.yaml", "application/yaml"],
["report.html", "text/html"],

View file

@ -1,5 +1,6 @@
import { once } from "node:events";
import { rawDataToString } from "@openclaw/gateway-client/websocket-data";
import { PROTOCOL_VERSION } from "@openclaw/gateway-protocol/version";
import { asRecord } from "@openclaw/normalization-core/record-coerce";
import { WebSocket, WebSocketServer } from "ws";
@ -33,7 +34,7 @@ export async function createSdkWebSocketServer(port = 0) {
if (request.method === "connect") {
reply(socket, request.id, {
type: "hello-ok",
protocol: 4,
protocol: PROTOCOL_VERSION,
server: { version: "sdk-live", connId: "sdk-live-connection" },
features: { methods: ["connect"], events: ["chat", "agent"] },
snapshot: {

112
pnpm-lock.yaml generated
View file

@ -450,9 +450,6 @@ importers:
specifier: 3.25.2
version: 3.25.2(zod@4.6.7)
devDependencies:
'@a2ui/web_core':
specifier: 0.13.0
version: 0.13.0
'@babel/types':
specifier: 8.0.6
version: 8.0.6
@ -903,21 +900,6 @@ importers:
extensions/canvas:
dependencies:
'@a2ui/web_core':
specifier: 0.13.0
version: 0.13.0
'@solidjs/web':
specifier: 2.0.0-rc.16
version: 2.0.0-rc.16(patch_hash=0612db277d44b0b93cb93e5047ac67609474e5fb13d387936de4e6d57ab6f484)(solid-js@2.0.0-rc.16)
dompurify:
specifier: 3.4.17
version: 3.4.17
marked:
specifier: 18.1.1
version: 18.1.1
solid-js:
specifier: 2.0.0-rc.16
version: 2.0.0-rc.16
typebox:
specifier: 1.4.1
version: 1.4.1
@ -925,9 +907,6 @@ importers:
'@openclaw/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
'@solidjs/compiler':
specifier: 2.0.0-rc.16
version: 2.0.0-rc.16
extensions/cerebras:
devDependencies:
@ -3212,9 +3191,6 @@ importers:
packages:
'@a2ui/web_core@0.13.0':
resolution: {integrity: sha512-4Clpksne0RY9KAheQoPoTwVDJ+6XI0Es2yqVME/PN0aWQ1321Wih69SEr8Nkc+7uKMyV5FlK32o88uoyM7ZBCw==}
'@agentclientprotocol/claude-agent-acp@0.93.0':
resolution: {integrity: sha512-Uec78bLVFgzXrJMxzg8b5r3WkgIEeo+yv2MrQBTAEXFK5E4Jzzd1XPbN3Khsd9EFIewWkbVOZ5PeStdPykaCHQ==}
engines: {node: '>=22'}
@ -4670,15 +4646,6 @@ packages:
resolution: {integrity: sha512-AHCtmdgcVDElRLlrR8jxSracF4beWholl3Ywnl6TrPgEiSYH81fSovgWVO/LbyN0tlGidCzJMAsOLXNddF5FGg==}
engines: {node: '>=22'}
'@lit-labs/ssr-dom-shim@1.6.0':
resolution: {integrity: sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==}
'@lit/context@1.1.6':
resolution: {integrity: sha512-M26qDE6UkQbZA2mQ3RjJ3Gzd8TxP+/0obMgE5HfkfLhEEyYE3Bui4A5XHiGPjy0MUGAyxB3QgVuw2ciS0kHn6A==}
'@lit/reactive-element@2.1.2':
resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==}
'@lydell/node-pty-darwin-arm64@1.2.0-beta.15':
resolution: {integrity: sha512-6TSBbzdcLiNTHl1mTuzflqXrkmcC36USVGvERoDgvHk2ItEDaMaFZuAJ1CqPmwYj0DyhCS16TVS8OGK9xZnjyQ==}
cpu: [arm64]
@ -5788,9 +5755,6 @@ packages:
'@postalsys/vmc@1.1.5':
resolution: {integrity: sha512-WwleGWC51o/VWQFTId6xMiO9aqkIVCM7ODy6UWfzFoQ6UzXewsNrm3ANa4HbBcyYaTK6YPvp4Gt0U+1cXLTbzw==}
'@preact/signals-core@1.14.4':
resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==}
'@quansync/fs@1.1.0':
resolution: {integrity: sha512-qAPG/t3HqML1TlN7sY/pTbEjzFVAKsMjNNMGheyDosro+kT4iw2KCUoHcVdmliwWjorm4elZbgNQyU2eD97eDg==}
@ -8970,15 +8934,6 @@ packages:
linkify-it@6.1.0:
resolution: {integrity: sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==}
lit-element@4.2.2:
resolution: {integrity: sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==}
lit-html@3.3.3:
resolution: {integrity: sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==}
lit@3.3.3:
resolution: {integrity: sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
@ -10078,11 +10033,6 @@ packages:
recma-stringify@1.0.0:
resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==}
redos-detector@6.1.4:
resolution: {integrity: sha512-lPlka1rEH6kK42gtgokvvxMmpAvyc28DcRjQbGxeP5RJsJWgAduBOdGedVCDPdSyCr4ay/JDxq3nGYsJZyt8tA==}
engines: {node: '>=16.0.0'}
hasBin: true
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
@ -10095,10 +10045,6 @@ packages:
regex@6.1.0:
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
regjsparser@0.13.0:
resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==}
hasBin: true
rehype-recma@1.0.0:
resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==}
@ -10810,9 +10756,6 @@ packages:
resolution: {integrity: sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==}
hasBin: true
validate-color@2.2.4:
resolution: {integrity: sha512-Znolz+b6CwW6eBXYld7MFM3O7funcdyRfjKC/X9hqYV/0VcC5LB/L45mff7m3dIn9wdGdNOAQ/fybNuD5P/HDw==}
validate-npm-package-name@5.0.0:
resolution: {integrity: sha512-YuKoXDAhBYxY7SfOKxHBDoSyENFeW5VvIIQp2TGQuit8gpK6MnWaQelBKxso72DoxTZfZdcP3W90LqpSkgPzLQ==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@ -11147,16 +11090,6 @@ packages:
snapshots:
'@a2ui/web_core@0.13.0':
dependencies:
'@lit/context': 1.1.6
'@preact/signals-core': 1.14.4
lit: 3.3.3
redos-detector: 6.1.4
validate-color: 2.2.4
zod: 3.25.76
zod-to-json-schema: 3.25.2(zod@3.25.76)
'@agentclientprotocol/claude-agent-acp@0.93.0(@anthropic-ai/sdk@0.135.0(zod@4.6.7))(@modelcontextprotocol/sdk@1.32.1(supports-color@10.2.2)(zod@4.6.7))':
dependencies:
'@agentclientprotocol/sdk': 1.9.0(zod@4.6.7)
@ -12921,16 +12854,6 @@ snapshots:
dependencies:
'@types/node': 24.19.2
'@lit-labs/ssr-dom-shim@1.6.0': {}
'@lit/context@1.1.6':
dependencies:
'@lit/reactive-element': 2.1.2
'@lit/reactive-element@2.1.2':
dependencies:
'@lit-labs/ssr-dom-shim': 1.6.0
'@lydell/node-pty-darwin-arm64@1.2.0-beta.15':
optional: true
@ -13956,8 +13879,6 @@ snapshots:
'@peculiar/asn1-x509': 2.8.0
'@peculiar/asn1-x509-logotype': 2.8.0
'@preact/signals-core@1.14.4': {}
'@quansync/fs@1.1.0':
dependencies:
quansync: 1.0.0
@ -14865,7 +14786,8 @@ snapshots:
'@types/tough-cookie@4.0.5': {}
'@types/trusted-types@2.0.7': {}
'@types/trusted-types@2.0.7':
optional: true
'@types/unist@2.0.11': {}
@ -17290,22 +17212,6 @@ snapshots:
dependencies:
uc.micro: 3.0.0
lit-element@4.2.2:
dependencies:
'@lit-labs/ssr-dom-shim': 1.6.0
'@lit/reactive-element': 2.1.2
lit-html: 3.3.3
lit-html@3.3.3:
dependencies:
'@types/trusted-types': 2.0.7
lit@3.3.3:
dependencies:
'@lit/reactive-element': 2.1.2
lit-element: 4.2.2
lit-html: 3.3.3
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
@ -18575,10 +18481,6 @@ snapshots:
unified: 11.0.5
vfile: 6.0.3
redos-detector@6.1.4:
dependencies:
regjsparser: 0.13.0
reflect-metadata@0.2.2: {}
regex-recursion@6.0.2:
@ -18591,10 +18493,6 @@ snapshots:
dependencies:
regex-utilities: 2.3.0
regjsparser@0.13.0:
dependencies:
jsesc: 3.1.0
rehype-recma@1.0.0(supports-color@10.2.2):
dependencies:
'@types/estree': 1.0.9
@ -19366,8 +19264,6 @@ snapshots:
uuid@14.0.3: {}
validate-color@2.2.4: {}
validate-npm-package-name@5.0.0:
dependencies:
builtins: 5.1.0
@ -19639,10 +19535,6 @@ snapshots:
- bufferutil
- utf-8-validate
zod-to-json-schema@3.25.2(zod@3.25.76):
dependencies:
zod: 3.25.76
zod-to-json-schema@3.25.2(zod@4.6.7):
dependencies:
zod: 4.6.7

View file

@ -8,14 +8,14 @@ scenario:
- gateway.admin-api-access
- gateway.control-ui
- gateway.plugin-web-routes
- gateway.canvas-and-a2ui-routes
objective: Prove that one authenticated Gateway listener serves its Control UI, public admin route, and Canvas-owned A2UI renderer assets.
- gateway.canvas-routes
objective: Prove that one authenticated Gateway listener serves its Control UI, public admin route, and persisted HTML Canvas documents.
successCriteria:
- The Gateway serves real Control UI HTML with its security headers.
- The opt-in admin HTTP RPC route rejects unauthenticated requests and dispatches health through the live Gateway context.
- Public admin and Canvas plugin entrypoints register routes that win before the Control UI fallback.
- The public admin plugin and core Canvas document routes win before the Control UI fallback.
- A real operator WebSocket receives a capability-scoped Canvas surface URL.
- Both capability-scoped A2UI renderer bundles work, while wrong and expired capabilities are rejected.
- Capability-scoped HTML Canvas documents work, while wrong and expired capabilities are rejected.
docsRefs:
- docs/gateway/index.md
- docs/plugins/admin-http-rpc.md
@ -24,10 +24,11 @@ scenario:
codeRefs:
- extensions/admin-http-rpc/index.ts
- extensions/canvas/index.ts
- src/canvas/serve.runtime.ts
- src/gateway/server-runtime-state.ts
- src/gateway/server/ws-connection/connect-session.ts
- test/e2e/qa-lab/runtime/gateway-hosted-web.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/gateway-hosted-web.e2e.test.ts
summary: Start a real token-authenticated Gateway and exercise Control UI, admin RPC, and capability-scoped A2UI renderer assets.
summary: Start a real token-authenticated Gateway and exercise Control UI, admin RPC, and capability-scoped HTML Canvas documents.

View file

@ -1,155 +0,0 @@
#!/usr/bin/env node
import { spawnSync, type SpawnSyncOptions } from "node:child_process";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
finalizeBuildStepCache,
resolveBuildStepCacheState,
restoreBuildStepCacheOutputs,
type BuildCacheStep,
} from "./lib/build-artifact-cache.mts";
import { resolvePnpmRunner } from "./pnpm-runner.mts";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const pluginDir = path.join(rootDir, "extensions", "canvas");
const require = createRequire(path.join(pluginDir, "package.json"));
const outputFile =
process.env.OPENCLAW_A2UI_BUNDLE_OUT ??
path.join(pluginDir, "src", "host", "a2ui", "a2ui.bundle.js");
const outputV09File = process.env.OPENCLAW_A2UI_BUNDLE_OUT
? `${process.env.OPENCLAW_A2UI_BUNDLE_OUT}.v0.9.js`
: path.join(pluginDir, "src", "host", "a2ui", "a2ui-v0.9.bundle.js");
const a2uiAppDir = path.join(pluginDir, "src", "host", "a2ui-app");
function fail(message: string) {
console.error(message);
console.error("A2UI bundling failed. Re-run with: pnpm canvas:a2ui:bundle");
console.error("If this persists, verify pnpm deps and try again.");
process.exit(1);
}
async function pathExists(targetPath: string) {
try {
await fs.stat(targetPath);
return true;
} catch {
return false;
}
}
function runStep(command: string, args: string[], options: SpawnSyncOptions = {}) {
const result = spawnSync(command, args, {
cwd: rootDir,
// Two small dialects do not benefit from native pools sized to a many-core host.
env: {
...process.env,
RAYON_NUM_THREADS: process.env.RAYON_NUM_THREADS?.trim() || "4",
ROLLDOWN_WORKER_THREADS: process.env.ROLLDOWN_WORKER_THREADS?.trim() || "4",
},
stdio: "inherit",
...options,
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
function runPnpm(pnpmArgs: string[]) {
const runner = resolvePnpmRunner({
pnpmArgs,
nodeExecPath: process.execPath,
npmExecPath: process.env.npm_execpath,
comSpec: process.env.ComSpec,
platform: process.platform,
});
runStep(runner.command, runner.args, {
shell: runner.shell,
windowsVerbatimArguments: runner.windowsVerbatimArguments,
});
}
async function main() {
const hasAppDir = await pathExists(a2uiAppDir);
const hasOutputFile = await pathExists(outputFile);
const hasV09OutputFile = await pathExists(outputV09File);
let hasA2uiPackage = true;
try {
require.resolve("@solidjs/compiler");
require.resolve("@solidjs/web");
require.resolve("solid-js");
require.resolve("signal-utils/map");
require.resolve("@a2ui/web_core/v0_9");
} catch {
hasA2uiPackage = false;
}
if (!hasA2uiPackage || !hasAppDir) {
if (hasOutputFile && hasV09OutputFile) {
console.log("A2UI package missing; keeping prebuilt bundle.");
return;
}
if (process.env.OPENCLAW_SPARSE_PROFILE || process.env.OPENCLAW_A2UI_SKIP_MISSING === "1") {
console.error(
"A2UI package missing; skipping bundle because OPENCLAW_A2UI_SKIP_MISSING=1 or OPENCLAW_SPARSE_PROFILE is set.",
);
return;
}
fail(
`A2UI package missing and no complete prebuilt bundle found at: ${outputFile}, ${outputV09File}`,
);
}
const outputs = [outputFile, outputV09File].map((file) => path.relative(rootDir, file));
const step: BuildCacheStep = {
label: "canvas-a2ui",
cache: {
inputs: [
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
"patches",
"extensions/canvas/package.json",
"extensions/canvas/src/host/a2ui-app",
"scripts/bundle-a2ui.mts",
"scripts/pnpm-runner.mts",
"scripts/windows-cmd-helpers.mjs",
"scripts/lib/build-artifact-cache.mts",
],
env: ["NODE_ENV", "SOURCE_DATE_EPOCH"],
outputs,
requiredOutputs: outputs,
},
};
// Explicit output locations belong to callers, not the shared candidate cache.
const cache =
process.env.OPENCLAW_BUILD_CACHE !== "0" && !process.env.OPENCLAW_A2UI_BUNDLE_OUT
? resolveBuildStepCacheState(step, { rootDir })
: undefined;
if (cache?.fresh && (!cache.restorable || restoreBuildStepCacheOutputs(cache, { rootDir }))) {
console.log("A2UI bundle up to date; shared artifacts reused.");
return;
}
const localRolldownCli = [
path.join(rootDir, "node_modules", "rolldown", "bin", "cli.mjs"),
path.join(rootDir, "node_modules", ".pnpm", "node_modules", "rolldown", "bin", "cli.mjs"),
].find((candidate) => existsSync(candidate));
const configPath = path.join(a2uiAppDir, "rolldown.config.mjs");
if (localRolldownCli) {
runStep(process.execPath, [localRolldownCli, "-c", configPath]);
} else {
runPnpm(["-s", "exec", "rolldown", "-c", configPath]);
}
if (cache) {
finalizeBuildStepCache(step, cache, { rootDir });
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main().catch((error: unknown) => {
fail(error instanceof Error ? error.message : String(error));
});
}

View file

@ -321,7 +321,6 @@ function isGeneratedAssetSourceFile(filePath: string) {
const normalized = filePath.replaceAll(path.sep, "/");
return (
/(?:^|\/)extensions\/[^/]+\/(?:assets|dist)\/.+\.[cm]?js$/u.test(normalized) ||
/(?:^|\/)extensions\/canvas\/src\/host\/a2ui\/[^/]+\.bundle\.js$/u.test(normalized) ||
/(?:^|\/)packages\/[^/]+\/dist\/.+\.[cm]?js$/u.test(normalized)
);
}

View file

@ -163,7 +163,9 @@ export const MOBILE_PAIRING_NODE_PERMISSIONS = Object.freeze({
const MOBILE_PAIRING_OPERATOR_CAPS = Object.freeze(["inline-widgets"]);
export const MOBILE_PAIRING_APPROVAL_SCOPES = Object.freeze(["operator.pairing", "operator.admin"]);
const GATEWAY_PROTOCOL_VERSION = 4;
// Span the published driver and candidate; each selects its supported version.
const GATEWAY_PROTOCOL_VERSION = 5;
const GATEWAY_MIN_OPERATOR_PROTOCOL_VERSION = 4;
const GATEWAY_MIN_NODE_PROTOCOL_VERSION = 3;
const PAIRING_AUDIT_SCOPES = ["operator.pairing"];
const EXPECTED_UPGRADE_COMMAND_ADDITIONS = ["watch.notify", "watch.status"];
@ -351,7 +353,9 @@ export function buildConnectRequest(params: {
id: params.id ?? `connect-${randomUUID()}`,
method: "connect",
params: {
minProtocol: isNode ? GATEWAY_MIN_NODE_PROTOCOL_VERSION : GATEWAY_PROTOCOL_VERSION,
minProtocol: isNode
? GATEWAY_MIN_NODE_PROTOCOL_VERSION
: GATEWAY_MIN_OPERATOR_PROTOCOL_VERSION,
maxProtocol: GATEWAY_PROTOCOL_VERSION,
client: { ...params.client, mode: params.mode },
caps: isNode ? [...MOBILE_PAIRING_NODE_CAPS] : [...MOBILE_PAIRING_OPERATOR_CAPS],

View file

@ -11,6 +11,10 @@ import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
import {
MIN_CLIENT_PROTOCOL_VERSION,
PROTOCOL_VERSION,
} from "../../packages/gateway-protocol/src/version.ts";
import { finalizeManagedChild, loadManagedChildSpawner } from "../lib/managed-child-process.mts";
const checkout = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
@ -345,8 +349,8 @@ async function startGateway() {
client = createGatewayWsClient({ url: "ws://127.0.0.1:" + gatewayPort });
await client.waitOpen();
const hello = await rpc("connect", {
minProtocol: 4,
maxProtocol: 4,
minProtocol: MIN_CLIENT_PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client: {
id: "gateway-client",
displayName: "isolated-mcp-events-proof",

View file

@ -34,7 +34,7 @@ export function standaloneResourceCategory(name, declaredPluginAssets = new Set(
if (name.startsWith("dist/control-ui/")) {
return "controlUi";
}
if (name.startsWith("dist/assets/") || name.startsWith("dist/canvas-host/a2ui/")) {
if (name.startsWith("dist/assets/")) {
return "runtimeAssets";
}
if (/^dist\/bundled\/[^/]+\/HOOK\.md$/.test(name)) {

View file

@ -103,9 +103,6 @@ const SELF_CONTAINED_WORKER_DEPLOY_DIST_PATHS = new Set([
packageActivationRuntimeEntrypoint.distWorkerPath,
]);
const OPTIONAL_OR_EXTERNALIZED_RUNTIME_IMPORTS = new Set([
// Optional A2UI markdown renderer. The Canvas host bundle catches the missing
// package and falls back when the optional renderer is unavailable.
"@a2ui/markdown-it",
"@discordjs/opus",
"@lancedb/lancedb",
// Feishu/Lark remains a bundled plugin package. Root dist can retain orphaned

View file

@ -3371,7 +3371,6 @@ const EXACT_TOOLING_TARGETS = new Map<string, string[]>([
["test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts"],
],
["scripts/e2e/qr-import-docker.sh", [dockerBuild]],
["scripts/bundle-a2ui.mts", ["bundled-plugin-assets"]],
]);
const SEMANTIC_TOOLING_TARGET_PATTERNS: Array<[RegExp, string[]]> = [

Some files were not shown because too many files have changed in this diff Show more