mirror of
https://github.com/openclaw/openclaw.git
synced 2026-11-02 21:49:09 +00:00
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:
parent
e90948d26e
commit
6f88c26dcf
173 changed files with 1357 additions and 6443 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
5
.gitignore
vendored
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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/",
|
||||
|
|
|
|||
|
|
@ -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/",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
28
crates/openclaw-gateway-client/build.rs
Normal file
28
crates/openclaw-gateway-client/build.rs
Normal 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");
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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(¤t_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(¤t_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![]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`) |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export declare function copyA2uiAssets(params: { srcDir: string; outDir: string }): Promise<void>;
|
||||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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&b<test>"'"></script>');
|
||||
});
|
||||
});
|
||||
|
|
@ -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)}`;
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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%; } }
|
||||
`;
|
||||
|
|
@ -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; }
|
||||
`;
|
||||
|
|
@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">"),
|
||||
},
|
||||
});
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
];
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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}/`);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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"] },
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
112
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue