diff --git a/.github/labeler.yml b/.github/labeler.yml index 036a335d05e7..b6f997de1adb 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -228,6 +228,7 @@ - any-glob-to-any-file: - "apps/linux/**" - "docs/platforms/linux.md" + - "extensions/linux-canvas/**" "app: web-ui": - changed-files: - any-glob-to-any-file: diff --git a/apps/linux/README.md b/apps/linux/README.md index 197b65459ee5..f160afbf32ed 100644 --- a/apps/linux/README.md +++ b/apps/linux/README.md @@ -26,6 +26,12 @@ cargo build The app uses `OPENCLAW_DESKTOP_CLI` when set. Otherwise it checks `~/.openclaw/bin/openclaw`, then `openclaw` on `PATH`. +## Canvas bridge + +The running app gives the headless `openclaw node run` host a single Canvas WebView. The bundled `linux-canvas` plugin advertises `canvas.*` only while the app socket exists. The app listens at `$XDG_RUNTIME_DIR/openclaw-canvas.sock` (or `/tmp/openclaw-canvas-$UID.sock`) with mode `0600`; a headless Linux node without the app does not advertise Canvas. + +The plugin-generated A2UI renderer in `extensions/canvas/src/host/a2ui/` remains the source of truth. The app embeds its committed, synced OpenClawKit mirror from `apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasA2UI/`. Run `node scripts/sync-native-a2ui.mjs --check` from the repository root after changing those assets. + ## Installer resource `tauri.conf.json` bundles the repository's canonical `scripts/install-cli.sh` directly as `install-cli.sh`. The app never keeps a forked copy. Stable, beta, and dev installs select `latest`, `beta`, and a managed Git `main` checkout respectively, always under `~/.openclaw`. diff --git a/apps/linux/src-tauri/Cargo.lock b/apps/linux/src-tauri/Cargo.lock index 600bf6fd7f4c..bc117dd3008e 100644 --- a/apps/linux/src-tauri/Cargo.lock +++ b/apps/linux/src-tauri/Cargo.lock @@ -1451,6 +1451,8 @@ dependencies = [ "moxcms", "num-traits", "png 0.18.1", + "zune-core", + "zune-jpeg", ] [[package]] @@ -2043,10 +2045,15 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" name = "openclaw-desktop-linux" version = "0.1.0" dependencies = [ + "base64 0.22.1", + "cairo-rs", + "image", + "libc", "serde", "serde_json", "tauri", "tauri-build", + "webkit2gtk", ] [[package]] @@ -4417,3 +4424,18 @@ name = "zmij" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd2f034a4bebf216c9e4b7083603e024cf930873fd67830cfb083c9fa33129d9" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/apps/linux/src-tauri/Cargo.toml b/apps/linux/src-tauri/Cargo.toml index 66e7c2efa1ad..7310413d9527 100644 --- a/apps/linux/src-tauri/Cargo.toml +++ b/apps/linux/src-tauri/Cargo.toml @@ -13,6 +13,11 @@ path = "src/main.rs" tauri-build = "2.6.3" [dependencies] +base64 = "0.22.1" +cairo-rs = { version = "0.18.5", features = ["png"] } +image = { version = "0.25.10", default-features = false, features = ["jpeg", "png"] } +libc = "0.2.186" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" tauri = { version = "2.11.5", features = ["image-png", "tray-icon"] } +webkit2gtk = "2.0.2" diff --git a/apps/linux/src-tauri/build.rs b/apps/linux/src-tauri/build.rs index 261851f6b60e..5af2bd82fda0 100644 --- a/apps/linux/src-tauri/build.rs +++ b/apps/linux/src-tauri/build.rs @@ -1,3 +1,13 @@ fn main() { - tauri_build::build(); + const COMMANDS: &[&str] = &[ + "bootstrap", + "canvas_a2ui_action", + "gateway_action", + "install_cli", + ]; + tauri_build::try_build( + tauri_build::Attributes::new() + .app_manifest(tauri_build::AppManifest::new().commands(COMMANDS)), + ) + .expect("Tauri build configuration should be valid"); } diff --git a/apps/linux/src-tauri/permissions/autogenerated/bootstrap.toml b/apps/linux/src-tauri/permissions/autogenerated/bootstrap.toml new file mode 100644 index 000000000000..d1d2cb42c983 --- /dev/null +++ b/apps/linux/src-tauri/permissions/autogenerated/bootstrap.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-bootstrap" +description = "Enables the bootstrap command without any pre-configured scope." +commands.allow = ["bootstrap"] + +[[permission]] +identifier = "deny-bootstrap" +description = "Denies the bootstrap command without any pre-configured scope." +commands.deny = ["bootstrap"] diff --git a/apps/linux/src-tauri/permissions/autogenerated/canvas_a2ui_action.toml b/apps/linux/src-tauri/permissions/autogenerated/canvas_a2ui_action.toml new file mode 100644 index 000000000000..904890953a53 --- /dev/null +++ b/apps/linux/src-tauri/permissions/autogenerated/canvas_a2ui_action.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-canvas-a2ui-action" +description = "Enables the canvas_a2ui_action command without any pre-configured scope." +commands.allow = ["canvas_a2ui_action"] + +[[permission]] +identifier = "deny-canvas-a2ui-action" +description = "Denies the canvas_a2ui_action command without any pre-configured scope." +commands.deny = ["canvas_a2ui_action"] diff --git a/apps/linux/src-tauri/permissions/autogenerated/gateway_action.toml b/apps/linux/src-tauri/permissions/autogenerated/gateway_action.toml new file mode 100644 index 000000000000..2e617e4ad317 --- /dev/null +++ b/apps/linux/src-tauri/permissions/autogenerated/gateway_action.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-gateway-action" +description = "Enables the gateway_action command without any pre-configured scope." +commands.allow = ["gateway_action"] + +[[permission]] +identifier = "deny-gateway-action" +description = "Denies the gateway_action command without any pre-configured scope." +commands.deny = ["gateway_action"] diff --git a/apps/linux/src-tauri/permissions/autogenerated/install_cli.toml b/apps/linux/src-tauri/permissions/autogenerated/install_cli.toml new file mode 100644 index 000000000000..ac92966582dc --- /dev/null +++ b/apps/linux/src-tauri/permissions/autogenerated/install_cli.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-install-cli" +description = "Enables the install_cli command without any pre-configured scope." +commands.allow = ["install_cli"] + +[[permission]] +identifier = "deny-install-cli" +description = "Denies the install_cli command without any pre-configured scope." +commands.deny = ["install_cli"] diff --git a/apps/linux/src-tauri/src/canvas.rs b/apps/linux/src-tauri/src/canvas.rs new file mode 100644 index 000000000000..4524a52089f7 --- /dev/null +++ b/apps/linux/src-tauri/src/canvas.rs @@ -0,0 +1,1026 @@ +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use image::codecs::jpeg::JpegEncoder; +use image::imageops::FilterType; +use image::ImageFormat; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::fs; +use std::io::{BufRead, BufReader, Cursor, Write}; +use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt}; +use std::os::unix::io::AsRawFd; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; +use tauri::{ + AppHandle, LogicalPosition, LogicalSize, Manager, Url, WebviewUrl, WebviewWindow, + WebviewWindowBuilder, +}; +use webkit2gtk::{SnapshotOptions, SnapshotRegion, WebViewExt}; + +const CANVAS_LABEL: &str = "canvas"; +const CANVAS_SCHEME: &str = "openclaw-canvas"; +const BUNDLED_CANVAS_HREF: &str = "openclaw-canvas://localhost/index.html"; +const MAX_FRAME_BYTES: usize = 32 * 1024 * 1024; +const WEBVIEW_TIMEOUT: Duration = Duration::from_secs(8); +const A2UI_READY_TIMEOUT: Duration = Duration::from_secs(6); +const A2UI_READY_INTERVAL: Duration = Duration::from_millis(100); +const A2UI_READY_EVAL_TIMEOUT: Duration = Duration::from_millis(100); + +const A2UI_INDEX: &[u8] = include_bytes!( + "../../../../apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasA2UI/index.html" +); +const A2UI_BUNDLE: &[u8] = include_bytes!( + "../../../../apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasA2UI/a2ui.bundle.js" +); + +const ACTION_BRIDGE_SCRIPT: &str = r#" +(() => { + const dispatchFailure = (message, error) => { + try { + const parsed = JSON.parse(String(message)); + const id = parsed?.userAction?.id; + if (typeof id === "string") { + window.dispatchEvent(new CustomEvent("openclaw:a2ui-action-status", { + detail: { id, ok: false, error: String(error) } + })); + } + } catch {} + }; + Object.defineProperty(window, "openclawCanvasA2UIAction", { + configurable: false, + value: { + postMessage(message) { + if (window.location.protocol !== "openclaw-canvas:") return; + const invoke = window.__TAURI__?.core?.invoke; + if (typeof invoke !== "function") { + dispatchFailure(message, "desktop action bridge unavailable"); + return; + } + void invoke("canvas_a2ui_action", { message: String(message) }) + .catch((error) => dispatchFailure(message, error)); + } + } + }); +})(); +"#; + +#[derive(Clone)] +pub struct CanvasBridge { + inner: Arc, +} + +struct CanvasBridgeInner { + clients: Mutex>>>, + command_tx: mpsc::Sender, + active_client_id: AtomicU64, + next_client_id: AtomicU64, + socket_path: PathBuf, + socket_inode: u64, + stopping: AtomicBool, +} + +struct CanvasRequestJob { + client_id: u64, + request: IpcRequest, + writer: Arc>, +} + +#[derive(Debug)] +struct CanvasError { + code: &'static str, + message: String, +} + +impl CanvasError { + fn invalid(message: impl Into) -> Self { + Self { + code: "INVALID_REQUEST", + message: message.into(), + } + } + + fn unavailable(message: impl Into) -> Self { + Self { + code: "CANVAS_UNAVAILABLE", + message: message.into(), + } + } +} + +#[derive(Deserialize)] +struct IpcRequest { + id: String, + command: String, + #[serde(rename = "paramsJSON")] + params_json: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Placement { + x: Option, + y: Option, + width: Option, + height: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PresentParams { + url: Option, + placement: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct NavigateParams { + url: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct EvalParams { + #[serde(rename = "javaScript")] + java_script: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SnapshotParams { + format: String, + #[serde(rename = "maxWidth")] + max_width: Option, + quality: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PushParams { + messages: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PushJsonlParams { + jsonl: String, +} + +impl CanvasBridge { + pub fn start(app: AppHandle) -> Result { + let socket_path = socket_path(); + prepare_socket_path(&socket_path)?; + let listener = UnixListener::bind(&socket_path) + .map_err(|error| format!("Could not bind Canvas socket: {error}"))?; + if let Err(error) = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600)) { + let _ = fs::remove_file(&socket_path); + return Err(format!("Could not secure Canvas socket: {error}")); + } + let socket_inode = match fs::symlink_metadata(&socket_path) { + Ok(metadata) => metadata.ino(), + Err(error) => { + let _ = fs::remove_file(&socket_path); + return Err(format!("Could not inspect Canvas socket: {error}")); + } + }; + if let Err(error) = listener.set_nonblocking(true) { + let _ = remove_socket_if_owned(&socket_path, socket_inode); + return Err(format!("Could not configure Canvas socket: {error}")); + } + + let (command_tx, command_rx) = mpsc::channel(); + let bridge = Self { + inner: Arc::new(CanvasBridgeInner { + clients: Mutex::new(HashMap::new()), + command_tx, + active_client_id: AtomicU64::new(0), + next_client_id: AtomicU64::new(1), + socket_path, + socket_inode, + stopping: AtomicBool::new(false), + }), + }; + let command_bridge = bridge.clone(); + let command_app = app.clone(); + thread::spawn(move || command_bridge.run_commands(command_app, command_rx)); + let server_bridge = bridge.clone(); + thread::spawn(move || { + while !server_bridge.inner.stopping.load(Ordering::Acquire) { + match listener.accept() { + Ok((stream, _address)) => server_bridge.accept(app.clone(), stream), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(100)); + } + Err(error) => { + eprintln!("Canvas IPC accept failed: {error}"); + thread::sleep(Duration::from_millis(100)); + } + } + } + }); + Ok(bridge) + } + + pub fn shutdown(&self) { + if self.inner.stopping.swap(true, Ordering::AcqRel) { + return; + } + if let Ok(mut clients) = self.inner.clients.lock() { + for client in clients.values() { + if let Ok(client) = client.lock() { + let _ = client.shutdown(std::net::Shutdown::Both); + } + } + clients.clear(); + } + let _ = remove_socket_if_owned(&self.inner.socket_path, self.inner.socket_inode); + } + + fn accept(&self, app: AppHandle, stream: UnixStream) { + // Socket mode closes normal access; peer credentials also close the + // short bind-to-chmod window on the /tmp fallback. + if peer_uid(&stream).ok() != Some(unsafe { libc::geteuid() }) { + let _ = stream.shutdown(std::net::Shutdown::Both); + return; + } + let client_id = self.inner.next_client_id.fetch_add(1, Ordering::Relaxed); + let writer = match stream.try_clone() { + Ok(writer) => Arc::new(Mutex::new(writer)), + Err(error) => { + eprintln!("Canvas IPC client clone failed: {error}"); + return; + } + }; + let mut clients = self + .inner + .clients + .lock() + .expect("Canvas client mutex poisoned"); + // The CLI host is the single Gateway node connection. Replace a stale + // socket here so one click cannot fan out into duplicate agent turns. + for previous in clients.values() { + if let Ok(previous) = previous.lock() { + let _ = previous.shutdown(std::net::Shutdown::Both); + } + } + clients.clear(); + clients.insert(client_id, writer.clone()); + self.inner + .active_client_id + .store(client_id, Ordering::Release); + drop(clients); + let bridge = self.clone(); + thread::spawn(move || { + let mut reader = BufReader::new(stream); + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) => break, + Ok(_) if line.len() > MAX_FRAME_BYTES => break, + Ok(_) => {} + Err(_) => break, + } + let Ok(frame) = serde_json::from_str::(line.trim_end()) else { + continue; + }; + if bridge.inner.active_client_id.load(Ordering::Acquire) != client_id { + break; + } + if frame.get("event").and_then(Value::as_str) == Some("a2ui-action-result") { + dispatch_action_status(&app, &frame); + continue; + } + let Ok(request) = serde_json::from_value::(frame) else { + continue; + }; + if bridge + .inner + .command_tx + .send(CanvasRequestJob { + client_id, + request, + writer: writer.clone(), + }) + .is_err() + { + break; + } + } + bridge + .inner + .clients + .lock() + .expect("Canvas client mutex poisoned") + .remove(&client_id); + let _ = bridge.inner.active_client_id.compare_exchange( + client_id, + 0, + Ordering::AcqRel, + Ordering::Acquire, + ); + }); + } + + fn run_commands(&self, app: AppHandle, receiver: mpsc::Receiver) { + for job in receiver { + let response = if self.inner.active_client_id.load(Ordering::Acquire) != job.client_id { + json!({ + "id": job.request.id, + "error": { + "code": "CANVAS_UNAVAILABLE", + "message": "Canvas node connection was replaced" + } + }) + } else { + match handle_request(&app, &job.request) { + Ok(payload_json) => { + json!({"id": job.request.id, "ok": true, "payloadJSON": payload_json}) + } + Err(error) => json!({ + "id": job.request.id, + "error": {"code": error.code, "message": error.message} + }), + } + }; + // Response completion is part of the FIFO command. The node host + // updates the owning agent session only after receiving it. + let _ = write_frame(&job.writer, &response); + } + } + + fn send_action(&self, action: Value) -> Result<(), String> { + let id = action + .get("id") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "A2UI action is missing an id.".to_string())?; + let frame = json!({"event": "a2ui-action", "id": id, "action": action}); + let mut failed = Vec::new(); + let mut delivered = false; + let clients = self + .inner + .clients + .lock() + .map_err(|_| "Canvas client registry is unavailable.".to_string())?; + if clients.is_empty() { + return Err("OpenClaw node host is not connected.".to_string()); + } + for (client_id, writer) in clients.iter() { + if write_frame(writer, &frame).is_err() { + failed.push(*client_id); + } else { + delivered = true; + } + } + drop(clients); + if !failed.is_empty() { + let mut clients = self + .inner + .clients + .lock() + .map_err(|_| "Canvas client registry is unavailable.".to_string())?; + for client_id in failed { + clients.remove(&client_id); + } + } + if delivered { + Ok(()) + } else { + Err("OpenClaw node host disconnected before the action was sent.".to_string()) + } + } +} + +pub fn register_protocol(builder: tauri::Builder) -> tauri::Builder { + builder.register_uri_scheme_protocol(CANVAS_SCHEME, |_context, request| { + let (body, content_type, status) = match request.uri().path() { + "/" | "/index.html" => (A2UI_INDEX, "text/html; charset=utf-8", 200), + "/a2ui.bundle.js" => (A2UI_BUNDLE, "text/javascript; charset=utf-8", 200), + _ => (&b"not found"[..], "text/plain; charset=utf-8", 404), + }; + tauri::http::Response::builder() + .status(status) + .header("Content-Type", content_type) + .header("Cache-Control", "no-store") + .body(body.to_vec()) + .expect("Canvas protocol response must be valid") + }) +} + +#[tauri::command] +pub fn canvas_a2ui_action( + window: WebviewWindow, + bridge: tauri::State<'_, CanvasBridge>, + message: String, +) -> Result<(), String> { + if window.label() != CANVAS_LABEL { + return Err("A2UI actions are accepted only from the Canvas window.".to_string()); + } + let url = window + .url() + .map_err(|error| format!("Could not read Canvas URL: {error}"))?; + if !is_bundled_canvas_url(&url) { + return Err("A2UI actions are accepted only from the bundled Canvas renderer.".to_string()); + } + let payload: Value = serde_json::from_str(&message) + .map_err(|error| format!("A2UI action is invalid JSON: {error}"))?; + let action = payload.get("userAction").cloned().unwrap_or(payload); + if action + .get("name") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .is_none() + { + return Err("A2UI action is missing a name.".to_string()); + } + bridge.send_action(action) +} + +fn handle_request(app: &AppHandle, request: &IpcRequest) -> Result { + match request.command.as_str() { + "canvas.present" => { + let params: PresentParams = decode_params(&request.params_json)?; + let window = ensure_canvas_window(app)?; + if let Some(url) = params.url.as_deref() { + window.navigate(parse_canvas_url(url)?).map_err(|error| { + CanvasError::unavailable(format!("navigation failed: {error}")) + })?; + } else { + ensure_a2ui_host(&window)?; + } + if let Some(placement) = params.placement { + apply_placement(&window, placement); + } + window + .show() + .map_err(|error| CanvasError::unavailable(format!("show failed: {error}")))?; + Ok(json!({"ok": true}).to_string()) + } + "canvas.hide" => { + decode_empty_params(&request.params_json)?; + if let Some(window) = app.get_webview_window(CANVAS_LABEL) { + window + .hide() + .map_err(|error| CanvasError::unavailable(format!("hide failed: {error}")))?; + } + Ok(json!({"ok": true}).to_string()) + } + "canvas.navigate" => { + let params: NavigateParams = decode_params(&request.params_json)?; + ensure_canvas_window(app)? + .navigate(parse_canvas_url(¶ms.url)?) + .map_err(|error| CanvasError::unavailable(format!("navigation failed: {error}")))?; + Ok(json!({"ok": true}).to_string()) + } + "canvas.eval" => { + let params: EvalParams = decode_params(&request.params_json)?; + let window = ensure_canvas_window(app)?; + // Native WebKit evaluation is not governed by the loaded page's + // `unsafe-eval` CSP and matches the macOS/iOS Canvas contract. + let result = eval_json(&window, ¶ms.java_script)?; + Ok(json!({"result": evaluation_result_string(result)}).to_string()) + } + "canvas.snapshot" => { + let params: SnapshotParams = decode_params(&request.params_json)?; + let window = ensure_canvas_window(app)?; + snapshot(&window, params) + } + "canvas.a2ui.push" => { + let params: PushParams = decode_params(&request.params_json)?; + apply_a2ui_messages(app, params.messages, true) + } + "canvas.a2ui.pushJSONL" => { + let params: PushJsonlParams = decode_params(&request.params_json)?; + let messages = params + .jsonl + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + serde_json::from_str::(line).map_err(|error| { + CanvasError::invalid(format!("invalid A2UI JSONL: {error}")) + }) + }) + .collect::, _>>()?; + apply_a2ui_messages(app, messages, true) + } + "canvas.a2ui.reset" => { + decode_empty_params(&request.params_json)?; + let window = ensure_canvas_window(app)?; + ensure_a2ui_host(&window)?; + let result = eval_json(&window, &a2ui_reset_script())?; + if result.get("ok").and_then(Value::as_bool) != Some(true) { + return Err(CanvasError::invalid( + result + .get("error") + .and_then(Value::as_str) + .unwrap_or("A2UI reset failed"), + )); + } + Ok(result.to_string()) + } + _ => Err(CanvasError::invalid("unknown Canvas command")), + } +} + +fn apply_a2ui_messages( + app: &AppHandle, + messages: Vec, + show: bool, +) -> Result { + let window = ensure_canvas_window(app)?; + ensure_a2ui_host(&window)?; + let messages_json = serde_json::to_string(&messages) + .map_err(|error| CanvasError::invalid(error.to_string()))?; + let result = eval_json( + &window, + &guarded_a2ui_script(&format!( + "return globalThis.openclawA2UI.applyMessages({messages_json});" + )), + )?; + if result.get("ok").and_then(Value::as_bool) != Some(true) { + return Err(CanvasError::invalid( + result + .get("error") + .and_then(Value::as_str) + .unwrap_or("A2UI update failed"), + )); + } + if show { + window + .show() + .map_err(|error| CanvasError::unavailable(format!("show failed: {error}")))?; + } + Ok(result.to_string()) +} + +fn ensure_canvas_window(app: &AppHandle) -> Result { + if let Some(window) = app.get_webview_window(CANVAS_LABEL) { + return Ok(window); + } + let url = bundled_canvas_url()?; + let data_directory = app + .path() + .app_cache_dir() + .map_err(|error| CanvasError::unavailable(format!("cache path unavailable: {error}")))? + .join("canvas-webview"); + WebviewWindowBuilder::new(app, CANVAS_LABEL, WebviewUrl::CustomProtocol(url)) + .title("OpenClaw Canvas") + .inner_size(900.0, 700.0) + .visible(false) + // Canvas is agent-scriptable: it must not share storage with the + // privileged dashboard window, and must not persist browser state + // across restarts. A dedicated data_directory gives Tauri a distinct + // WebContext key so it attaches the openclaw-canvas:// protocol closure; + // incognito then makes Wry swap in a fresh *ephemeral* context carrying + // those protocols. Incognito alone reused the default context and lost + // the handler (page never loaded); the directory alone persisted cookies + // and origin storage. Both together keep the handler and stay ephemeral. + .data_directory(data_directory) + .incognito(true) + .initialization_script(ACTION_BRIDGE_SCRIPT) + .on_navigation(|url| matches!(url.scheme(), "http" | "https") || is_bundled_canvas_url(url)) + .build() + .map_err(|error| CanvasError::unavailable(format!("window creation failed: {error}"))) +} + +fn ensure_a2ui_host(window: &WebviewWindow) -> Result<(), CanvasError> { + // `navigate` is asynchronous. Stop any earlier remote load before deciding + // whether the bundled renderer is already current. + stop_pending_navigation(window)?; + let current = window + .url() + .map_err(|error| CanvasError::unavailable(format!("could not read URL: {error}")))?; + let renderer_ready = is_bundled_canvas_url(¤t) + && eval_json_with_timeout(window, &a2ui_ready_script(), A2UI_READY_EVAL_TIMEOUT) + .is_ok_and(|value| value == Value::Bool(true)); + if !renderer_ready { + window + .navigate(bundled_canvas_url()?) + .map_err(|error| CanvasError::unavailable(format!("A2UI load failed: {error}")))?; + } + let deadline = Instant::now() + A2UI_READY_TIMEOUT; + loop { + // The loaded page participates in this probe. Check its committed URL + // in the same evaluation so remote content cannot spoof renderer readiness. + let ready = eval_json_with_timeout(window, &a2ui_ready_script(), A2UI_READY_EVAL_TIMEOUT); + if ready + .as_ref() + .is_ok_and(|value| value == &Value::Bool(true)) + { + return Ok(()); + } + let now = Instant::now(); + if now >= deadline { + break; + } + thread::sleep(A2UI_READY_INTERVAL.min(deadline - now)); + } + Err(CanvasError::unavailable( + "A2UI renderer did not become ready", + )) +} + +fn stop_pending_navigation(window: &WebviewWindow) -> Result<(), CanvasError> { + let (sender, receiver) = mpsc::sync_channel(1); + window + .with_webview(move |platform| { + platform.inner().stop_loading(); + let _ = sender.send(()); + }) + .map_err(|error| CanvasError::unavailable(format!("navigation stop failed: {error}")))?; + receiver + .recv_timeout(WEBVIEW_TIMEOUT) + .map_err(|_| CanvasError::unavailable("navigation stop timed out")) +} + +fn eval_json(window: &WebviewWindow, script: &str) -> Result { + eval_json_with_timeout(window, script, WEBVIEW_TIMEOUT) +} + +fn eval_json_with_timeout( + window: &WebviewWindow, + script: &str, + timeout: Duration, +) -> Result { + let (sender, receiver) = mpsc::sync_channel(1); + window + .eval_with_callback(script, move |result| { + let _ = sender.send(result); + }) + .map_err(|error| CanvasError::unavailable(format!("JavaScript failed: {error}")))?; + let result = receiver + .recv_timeout(timeout) + .map_err(|_| CanvasError::unavailable("JavaScript timed out"))?; + serde_json::from_str(&result) + .map_err(|error| CanvasError::invalid(format!("JavaScript returned invalid JSON: {error}"))) +} + +fn evaluation_result_string(result: Value) -> String { + match result { + Value::Null => String::new(), + Value::String(value) => value, + value => value.to_string(), + } +} + +fn snapshot(window: &WebviewWindow, params: SnapshotParams) -> Result { + if !matches!(params.format.as_str(), "png" | "jpeg") { + return Err(CanvasError::invalid("snapshot format must be png or jpeg")); + } + if params.max_width == Some(0) { + return Err(CanvasError::invalid("maxWidth must be greater than zero")); + } + if params + .quality + .is_some_and(|value| !(0.0..=1.0).contains(&value)) + { + return Err(CanvasError::invalid("quality must be between 0 and 1")); + } + let (sender, receiver) = mpsc::sync_channel(1); + window + .with_webview(move |platform| { + platform.inner().snapshot( + SnapshotRegion::Visible, + SnapshotOptions::NONE, + None::<&webkit2gtk::gio::Cancellable>, + move |result| { + let encoded = result + .map_err(|error| error.to_string()) + .and_then(|surface| encode_surface(surface, ¶ms)); + let _ = sender.send(encoded); + }, + ); + }) + .map_err(|error| CanvasError::unavailable(format!("snapshot failed: {error}")))?; + receiver + .recv_timeout(WEBVIEW_TIMEOUT) + .map_err(|_| CanvasError::unavailable("snapshot timed out"))? + .map_err(CanvasError::unavailable) +} + +fn encode_surface(surface: cairo::Surface, params: &SnapshotParams) -> Result { + let mut png = Vec::new(); + surface + .write_to_png(&mut png) + .map_err(|error| format!("snapshot encoding failed: {error}"))?; + let mut image = image::load_from_memory_with_format(&png, ImageFormat::Png) + .map_err(|error| format!("snapshot decoding failed: {error}"))?; + if let Some(max_width) = params.max_width.filter(|width| image.width() > *width) { + let height = ((image.height() as f64 * max_width as f64 / image.width() as f64).round() + as u32) + .max(1); + image = image.resize(max_width, height, FilterType::Lanczos3); + } + let bytes = if params.format == "jpeg" { + let mut bytes = Vec::new(); + let quality = (params.quality.unwrap_or(0.8) * 100.0).round() as u8; + JpegEncoder::new_with_quality(&mut bytes, quality.clamp(1, 100)) + .encode_image(&image) + .map_err(|error| format!("JPEG encoding failed: {error}"))?; + bytes + } else { + let mut cursor = Cursor::new(Vec::new()); + image + .write_to(&mut cursor, ImageFormat::Png) + .map_err(|error| format!("PNG encoding failed: {error}"))?; + cursor.into_inner() + }; + Ok(json!({"format": params.format, "base64": BASE64.encode(bytes)}).to_string()) +} + +fn apply_placement(window: &WebviewWindow, placement: Placement) { + let scale_factor = window.scale_factor().unwrap_or(1.0).max(f64::EPSILON); + let width = placement.width.filter(|value| *value > 0.0); + let height = placement.height.filter(|value| *value > 0.0); + if width.is_some() || height.is_some() { + let current = if width.is_none() || height.is_none() { + window.inner_size().ok().map(|size| { + ( + size.width as f64 / scale_factor, + size.height as f64 / scale_factor, + ) + }) + } else { + None + }; + if let (Some(width), Some(height)) = ( + width.or_else(|| current.map(|size| size.0)), + height.or_else(|| current.map(|size| size.1)), + ) { + let _ = window.set_size(LogicalSize::new(width, height)); + } + } + + if placement.x.is_some() || placement.y.is_some() { + let current = if placement.x.is_none() || placement.y.is_none() { + window.outer_position().ok().map(|position| { + ( + position.x as f64 / scale_factor, + position.y as f64 / scale_factor, + ) + }) + } else { + None + }; + if let (Some(x), Some(y)) = ( + placement.x.or_else(|| current.map(|position| position.0)), + placement.y.or_else(|| current.map(|position| position.1)), + ) { + let _ = window.set_position(LogicalPosition::new(x, y)); + } + } +} + +fn parse_canvas_url(value: &str) -> Result { + let url = Url::parse(value).map_err(|_| CanvasError::invalid("Canvas URL is invalid"))?; + if matches!(url.scheme(), "http" | "https") || is_bundled_canvas_url(&url) { + return Ok(url); + } + Err(CanvasError::invalid( + "Canvas navigation allows only http(s) or the bundled A2UI renderer", + )) +} + +fn is_bundled_canvas_url(url: &Url) -> bool { + url.scheme() == CANVAS_SCHEME + && url.host_str() == Some("localhost") + && matches!(url.path(), "/" | "/index.html") +} + +fn bundled_canvas_url() -> Result { + Url::parse(BUNDLED_CANVAS_HREF) + .map_err(|_| CanvasError::unavailable("bundled A2UI URL is invalid")) +} + +fn a2ui_ready_script() -> String { + format!( + "Boolean(globalThis.location.href === {href:?} && globalThis.openclawA2UI?.applyMessages && globalThis.openclawA2UI?.reset)", + href = BUNDLED_CANVAS_HREF + ) +} + +fn guarded_a2ui_script(body: &str) -> String { + format!( + "(() => {{ try {{ if (globalThis.location.href !== {href:?}) return {{ok:false,error:'A2UI renderer origin changed'}}; {body} }} catch (error) {{ return {{ok:false,error:String(error)}}; }} }})()", + href = BUNDLED_CANVAS_HREF + ) +} + +fn a2ui_reset_script() -> String { + guarded_a2ui_script("globalThis.openclawA2UI.reset(); return {ok:true};") +} + +fn decode_params Deserialize<'de>>(params_json: &str) -> Result { + serde_json::from_str(params_json) + .map_err(|error| CanvasError::invalid(format!("invalid command parameters: {error}"))) +} + +fn decode_empty_params(params_json: &str) -> Result<(), CanvasError> { + let value: Value = decode_params(params_json)?; + if value.as_object().is_some_and(|object| object.is_empty()) { + Ok(()) + } else { + Err(CanvasError::invalid("command parameters must be empty")) + } +} + +fn write_frame(writer: &Arc>, frame: &Value) -> std::io::Result<()> { + let mut writer = writer + .lock() + .map_err(|_| std::io::Error::other("Canvas writer mutex poisoned"))?; + serde_json::to_writer(&mut *writer, frame)?; + writer.write_all(b"\n")?; + writer.flush() +} + +fn dispatch_action_status(app: &AppHandle, frame: &Value) { + let Some(window) = app.get_webview_window(CANVAS_LABEL) else { + return; + }; + if window.url().ok().as_ref().map(Url::scheme) != Some(CANVAS_SCHEME) { + return; + } + let detail = json!({ + "id": frame.get("id").and_then(Value::as_str).unwrap_or(""), + "ok": frame.get("ok").and_then(Value::as_bool).unwrap_or(false), + "error": frame.get("error").and_then(Value::as_str).unwrap_or("") + }); + let _ = window.eval(format!( + "window.dispatchEvent(new CustomEvent('openclaw:a2ui-action-status', {{detail:{detail}}}));" + )); +} + +fn socket_path() -> PathBuf { + match std::env::var_os("XDG_RUNTIME_DIR").filter(|value| !value.is_empty()) { + Some(runtime_dir) => PathBuf::from(runtime_dir).join("openclaw-canvas.sock"), + // Both independently started processes need the specified rendezvous + // path. Foreign-owned entries fail closed; desktop startup stays usable. + None => PathBuf::from(format!("/tmp/openclaw-canvas-{}.sock", unsafe { + libc::geteuid() + })), + } +} + +fn prepare_socket_path(path: &Path) -> Result<(), String> { + let Ok(metadata) = fs::symlink_metadata(path) else { + return Ok(()); + }; + let uid = unsafe { libc::geteuid() }; + if !metadata.file_type().is_socket() || metadata.uid() != uid { + return Err("Canvas socket path exists but is not a stale user-owned socket.".to_string()); + } + let socket_table = fs::read_to_string("/proc/net/unix") + .map_err(|error| format!("Could not inspect the existing Canvas socket: {error}"))?; + if socket_table_contains(&socket_table, path) { + return Err("Another OpenClaw desktop app already owns the Canvas socket.".to_string()); + } + fs::remove_file(path).map_err(|error| format!("Could not remove stale Canvas socket: {error}")) +} + +fn socket_table_contains(socket_table: &str, path: &Path) -> bool { + let Some(path) = path.to_str() else { + return false; + }; + socket_table.lines().any(|line| { + line.strip_suffix(path) + .is_some_and(|prefix| prefix.ends_with(' ')) + }) +} + +fn remove_socket_if_owned(path: &Path, inode: u64) -> std::io::Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_socket() && metadata.ino() == inode => { + fs::remove_file(path) + } + Ok(_) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +fn peer_uid(stream: &UnixStream) -> std::io::Result { + let mut peer = libc::ucred { + pid: 0, + uid: 0, + gid: 0, + }; + let mut length = std::mem::size_of::() as libc::socklen_t; + let result = unsafe { + libc::getsockopt( + stream.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERCRED, + (&mut peer as *mut libc::ucred).cast(), + &mut length, + ) + }; + if result == 0 { + Ok(peer.uid) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn navigation_allows_only_http_and_bundled_renderer() { + assert!(parse_canvas_url("https://example.com/canvas").is_ok()); + assert!(parse_canvas_url("openclaw-canvas://localhost/index.html").is_ok()); + assert!(parse_canvas_url("file:///tmp/secret").is_err()); + assert!(parse_canvas_url("openclaw-canvas://other/index.html").is_err()); + } + + #[test] + fn a2ui_scripts_require_the_exact_bundled_document() { + let expected = format!("globalThis.location.href === {BUNDLED_CANVAS_HREF:?}"); + assert!(a2ui_ready_script().contains(&expected)); + + let guarded = guarded_a2ui_script("return true;"); + assert!(guarded.contains(&format!( + "globalThis.location.href !== {BUNDLED_CANVAS_HREF:?}" + ))); + assert!(guarded.find("location.href").unwrap() < guarded.find("return true").unwrap()); + + let reset = a2ui_reset_script(); + assert!(reset.contains("globalThis.openclawA2UI.reset(); return {ok:true};")); + } + + #[test] + fn empty_params_are_closed() { + assert!(decode_empty_params("{}").is_ok()); + assert!(decode_empty_params("{\"extra\":true}").is_err()); + } + + #[test] + fn ipc_request_uses_camel_case_payload_field() { + let request: IpcRequest = + serde_json::from_str(r#"{"id":"1","command":"canvas.hide","paramsJSON":"{}"}"#) + .expect("request should decode"); + assert_eq!(request.command, "canvas.hide"); + assert_eq!(request.params_json, "{}"); + } + + #[test] + fn present_accepts_partial_placement() { + let params: PresentParams = + serde_json::from_str(r#"{"placement":{"width":640.0,"height":480.0}}"#) + .expect("partial placement should decode"); + let placement = params.placement.expect("placement should be present"); + assert_eq!(placement.x, None); + assert_eq!(placement.y, None); + assert_eq!(placement.width, Some(640.0)); + assert_eq!(placement.height, Some(480.0)); + } + + #[test] + fn evaluation_results_match_the_canvas_string_contract() { + assert_eq!(evaluation_result_string(Value::Null), ""); + assert_eq!(evaluation_result_string(json!(true)), "true"); + assert_eq!(evaluation_result_string(json!(42)), "42"); + assert_eq!(evaluation_result_string(json!("hello")), "hello"); + assert_eq!( + evaluation_result_string(json!({"ok": true})), + r#"{"ok":true}"# + ); + } + + #[test] + fn socket_table_matches_only_the_exact_rendezvous_path() { + let table = concat!( + "Num RefCount Protocol Flags Type St Inode Path\n", + "000: 00000002 00000000 00010000 0001 01 1 /tmp/openclaw-canvas-501.sock.old\n", + "001: 00000002 00000000 00010000 0001 01 2 /tmp/openclaw-canvas-501.sock\n", + ); + assert!(socket_table_contains( + table, + Path::new("/tmp/openclaw-canvas-501.sock") + )); + assert!(!socket_table_contains( + table, + Path::new("/tmp/openclaw-canvas-502.sock") + )); + } + + #[test] + fn shutdown_removes_only_the_socket_inode_it_bound() { + let path = + std::env::temp_dir().join(format!("openclaw-canvas-test-{}.sock", std::process::id())); + let _ = fs::remove_file(&path); + let listener = UnixListener::bind(&path).expect("test socket should bind"); + let inode = fs::symlink_metadata(&path) + .expect("test socket should exist") + .ino(); + + remove_socket_if_owned(&path, inode + 1).expect("foreign inode check should succeed"); + assert!(path.exists()); + remove_socket_if_owned(&path, inode).expect("owned socket should be removed"); + assert!(!path.exists()); + drop(listener); + } +} diff --git a/apps/linux/src-tauri/src/main.rs b/apps/linux/src-tauri/src/main.rs index 10ca8a5c6914..87cfa44dfa0e 100644 --- a/apps/linux/src-tauri/src/main.rs +++ b/apps/linux/src-tauri/src/main.rs @@ -1,3 +1,4 @@ +mod canvas; mod cli; mod gateway; mod installer; @@ -279,18 +280,25 @@ async fn gateway_action( } fn main() { - tauri::Builder::default() + let app = canvas::register_protocol(tauri::Builder::default()) .setup(|app| { let window = app .get_webview_window("main") .expect("tauri.conf.json must define the main window"); let state = DesktopState::new(window.url()?); app.manage(state.clone()); + match canvas::CanvasBridge::start(app.handle().clone()) { + Ok(bridge) => { + app.manage(bridge); + } + Err(error) => eprintln!("Canvas bridge unavailable: {error}"), + } state.set_tray(tray::build(app, state.clone())?); Ok(()) }) .invoke_handler(tauri::generate_handler![ bootstrap, + canvas::canvas_a2ui_action, install_cli, gateway_action ]) @@ -303,6 +311,13 @@ fn main() { } } }) - .run(tauri::generate_context!()) + .build(tauri::generate_context!()) .expect("OpenClaw desktop app failed"); + app.run(|app, event| { + if matches!(event, tauri::RunEvent::Exit) { + if let Some(bridge) = app.try_state::() { + bridge.shutdown(); + } + } + }); } diff --git a/apps/linux/src-tauri/tauri.conf.json b/apps/linux/src-tauri/tauri.conf.json index 247e6c870b74..78893f5260e1 100644 --- a/apps/linux/src-tauri/tauri.conf.json +++ b/apps/linux/src-tauri/tauri.conf.json @@ -29,7 +29,20 @@ "description": "Local setup screens can invoke app commands and receive installer progress.", "local": true, "windows": ["main"], - "permissions": ["core:event:allow-listen", "core:event:allow-unlisten"] + "permissions": [ + "allow-bootstrap", + "allow-gateway-action", + "allow-install-cli", + "core:event:allow-listen", + "core:event:allow-unlisten" + ] + }, + { + "identifier": "canvas-renderer", + "description": "The bundled Canvas renderer can relay A2UI actions.", + "local": true, + "windows": ["canvas"], + "permissions": ["allow-canvas-a2ui-action"] } ] } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 186b6ec2dde1..b724e9e2832b 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -2123,6 +2123,7 @@ public struct NodeInvokeParams: Codable, Sendable { public let params: AnyCodable? public let timeoutms: Int? public let idempotencykey: String + public let sessionkey: String? public let turnsourcechannel: String? public let turnsourceto: String? public let turnsourceaccountid: String? @@ -2134,6 +2135,7 @@ public struct NodeInvokeParams: Codable, Sendable { params: AnyCodable? = nil, timeoutms: Int? = nil, idempotencykey: String, + sessionkey: String? = nil, turnsourcechannel: String? = nil, turnsourceto: String? = nil, turnsourceaccountid: String? = nil, @@ -2144,6 +2146,7 @@ public struct NodeInvokeParams: Codable, Sendable { self.params = params self.timeoutms = timeoutms self.idempotencykey = idempotencykey + self.sessionkey = sessionkey self.turnsourcechannel = turnsourcechannel self.turnsourceto = turnsourceto self.turnsourceaccountid = turnsourceaccountid @@ -2156,6 +2159,7 @@ public struct NodeInvokeParams: Codable, Sendable { case params case timeoutms = "timeoutMs" case idempotencykey = "idempotencyKey" + case sessionkey = "sessionKey" case turnsourcechannel = "turnSourceChannel" case turnsourceto = "turnSourceTo" case turnsourceaccountid = "turnSourceAccountId" diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json index ce339cf3aeb8..7d203bbb3e38 100644 --- a/docs/.i18n/glossary.zh-CN.json +++ b/docs/.i18n/glossary.zh-CN.json @@ -1526,5 +1526,9 @@ { "source": "Security audit", "target": "安全审计" + }, + { + "source": "Linux Canvas plugin", + "target": "Linux Canvas plugin" } ] diff --git a/docs/docs_map.md b/docs/docs_map.md index 3ae8e66af9d2..0af5d9a05832 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -5172,6 +5172,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Route: /platforms/linux - Headings: - H2: Desktop companion + - H3: Canvas - H2: CLI and SSH alternative - H2: Node capabilities - H2: Install @@ -6519,6 +6520,14 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Surface - H2: Related docs +## plugins/reference/linux-canvas.md + +- Route: /plugins/reference/linux-canvas +- Headings: + - H1: Linux Canvas plugin + - H2: Distribution + - H2: Surface + ## plugins/reference/linux-node.md - Route: /plugins/reference/linux-node diff --git a/docs/nodes/index.md b/docs/nodes/index.md index 858a992750d4..a59b32a95f45 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -450,7 +450,7 @@ Default allowlists by platform (before plugin defaults and `allowCommands`/`deny These rows describe the Gateway policy ceiling, not the commands implemented by every node app. A command is usable only when the connected node also declares it. In particular, the current macOS app does not declare the device and personal-data families listed in the macOS policy row. -`canvas.*` commands (`canvas.present`, `canvas.hide`, `canvas.navigate`, `canvas.eval`, `canvas.snapshot`, `canvas.a2ui.*`) are a plugin default on iOS, Android, macOS, Windows, and unknown platforms (not Linux); all of them are foreground-restricted on iOS. +`canvas.*` commands (`canvas.present`, `canvas.hide`, `canvas.navigate`, `canvas.eval`, `canvas.snapshot`, `canvas.a2ui.*`) are a plugin default on iOS, Android, macOS, Windows, Linux, and unknown platforms. Linux nodes declare them only when the desktop app's local Canvas socket is present. All Canvas commands are foreground-restricted on iOS. `talk.ptt.start`, `talk.ptt.stop`, `talk.ptt.cancel`, and `talk.ptt.once` are allowed by default for any node that advertises the `talk` capability or declares `talk.*` commands, independent of platform label. @@ -541,7 +541,7 @@ openclaw nodes canvas eval --node --js "document.title" Notes: -- `canvas present` accepts URLs or local file paths (`--target`), plus optional `--x/--y/--width/--height` for positioning. +- `canvas present` accepts URLs or local file paths (`--target`) on nodes that support local paths, plus optional `--x/--y/--width/--height` for positioning. Linux Canvas accepts HTTP(S) URLs or its bundled A2UI renderer. - `canvas eval` accepts inline JS (`--js`) or a positional arg. ### A2UI (Canvas) @@ -554,10 +554,11 @@ openclaw nodes canvas a2ui reset --node Notes: -- Mobile nodes use a bundled app-owned A2UI page for action-capable rendering. +- Mobile and Linux desktop nodes use a bundled app-owned A2UI page for action-capable rendering. - Only A2UI v0.8 JSONL is supported (v0.9/createSurface is rejected). - iOS and Android render remote Gateway Canvas pages, but A2UI button actions are dispatched only from the bundled app-owned A2UI page. Gateway-hosted HTTP/HTTPS A2UI pages are render-only on those mobile clients. - macOS can dispatch actions from the exact capability-scoped Gateway A2UI page selected by the app. Other HTTP/HTTPS pages remain render-only. +- Linux dispatches actions only from the bundled A2UI page. Other HTTP/HTTPS pages remain render-only, and a headless Linux node without the desktop app does not advertise Canvas. ## Photos + videos (node camera) diff --git a/docs/platforms/linux.md b/docs/platforms/linux.md index c8ffaf1beef4..c107fafda550 100644 --- a/docs/platforms/linux.md +++ b/docs/platforms/linux.md @@ -20,6 +20,7 @@ The OpenClaw Linux companion is a Tauri desktop app for a local Gateway. It: - attaches to a healthy Gateway before attempting service changes - delegates install, start, stop, and restart operations to the CLI-managed systemd user service - opens the Gateway-served Control UI with its resolved authentication URL +- renders agent-driven Canvas and bundled A2UI content for a colocated CLI node host - remains available from the system tray when its window is closed Stable releases built from `main` ship `.deb` and AppImage bundles as assets on the @@ -43,6 +44,14 @@ The `Linux App` CI workflow uploads the same bundles as the manual runs. See `apps/linux/README.md` in the repository for Linux build dependencies and development commands. +### Canvas + +Linux Canvas uses two cooperating processes. `openclaw node run` remains the single Gateway node connection; the bundled `linux-canvas` plugin forwards `canvas.*` calls to the running desktop app over a user-only Unix socket. The app owns one on-demand WebView window, including the bundled A2UI renderer and action bridge back to the agent. + +The plugin is enabled by default. It advertises Canvas only when the desktop socket exists at `$XDG_RUNTIME_DIR/openclaw-canvas.sock`, or `/tmp/openclaw-canvas-$UID.sock` when `XDG_RUNTIME_DIR` is unavailable. Disable it with `plugins.entries.linux-canvas.enabled: false`. On a headless Linux server without the desktop app, Canvas is not advertised. + +Linux v1 uses one Canvas window. HTTP and HTTPS pages are renderable, but A2UI actions are accepted only from the bundled renderer. + ## CLI and SSH alternative The CLI remains the simplest option for a headless server, a VPS, or a remote Gateway: diff --git a/docs/plugins/plugin-inventory.md b/docs/plugins/plugin-inventory.md index 1b426015f390..af5b026c1e97 100644 --- a/docs/plugins/plugin-inventory.md +++ b/docs/plugins/plugin-inventory.md @@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description. ## Core npm package -67 plugins +68 plugins - **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint. @@ -99,6 +99,8 @@ Each entry lists the package, distribution route, and description. - **[imessage](/plugins/reference/imessage)** (`@openclaw/imessage`) - included in OpenClaw. Adds the iMessage channel surface for sending and receiving OpenClaw messages. +- **[linux-canvas](/plugins/reference/linux-canvas)** (`@openclaw/linux-canvas`) - included in OpenClaw. Canvas rendering bridge for the OpenClaw Linux desktop app. + - **[linux-node](/plugins/reference/linux-node)** (`@openclaw/linux-node`) - included in OpenClaw. Desktop notifications, camera capture, and location for Linux node hosts. - **[litellm](/plugins/reference/litellm)** (`@openclaw/litellm-provider`) - included in OpenClaw. Adds LiteLLM model provider support to OpenClaw. diff --git a/docs/plugins/reference.md b/docs/plugins/reference.md index ea55f5991516..4d4ccfadaf35 100644 --- a/docs/plugins/reference.md +++ b/docs/plugins/reference.md @@ -15,5 +15,5 @@ This page is generated from `extensions/*/package.json` and pnpm plugins:inventory:gen ``` -Use [Plugin inventory](/plugins/plugin-inventory) to browse all 140 +Use [Plugin inventory](/plugins/plugin-inventory) to browse all 141 generated plugin reference pages by distribution, package, and description. diff --git a/docs/plugins/reference/linux-canvas.md b/docs/plugins/reference/linux-canvas.md new file mode 100644 index 000000000000..04c91bc249cd --- /dev/null +++ b/docs/plugins/reference/linux-canvas.md @@ -0,0 +1,19 @@ +--- +summary: "Canvas rendering bridge for the OpenClaw Linux desktop app." +read_when: + - You are installing, configuring, or auditing the linux-canvas plugin +title: "Linux Canvas plugin" +--- + +# Linux Canvas plugin + +Canvas rendering bridge for the OpenClaw Linux desktop app. + +## Distribution + +- Package: `@openclaw/linux-canvas` +- Install route: included in OpenClaw + +## Surface + +plugin diff --git a/extensions/canvas/index.test.ts b/extensions/canvas/index.test.ts index 09e5cb31898d..e5b1f43b94df 100644 --- a/extensions/canvas/index.test.ts +++ b/extensions/canvas/index.test.ts @@ -126,6 +126,19 @@ describe("Canvas plugin entry", () => { vi.clearAllMocks(); }); + it("allowlists Canvas on every native node platform, including Linux", () => { + const { nodeInvokePolicies } = registerCanvas(); + + expect(nodeInvokePolicies[0]?.defaultPlatforms).toEqual([ + "ios", + "android", + "macos", + "windows", + "linux", + "unknown", + ]); + }); + it("defers Canvas host implementation until a registered route is used", async () => { const { routes, services } = registerCanvas(); @@ -176,6 +189,7 @@ describe("Canvas plugin entry", () => { const tool = (toolFactory as Exclude)({ config: {}, workspaceDir: "/tmp/workspace", + sessionKey: "agent:main:canvas", sessionId: "session-1", agentId: "agent-1", }); @@ -192,6 +206,7 @@ describe("Canvas plugin entry", () => { expect(mocks.createCanvasTool).toHaveBeenCalledWith({ config: {}, workspaceDir: "/tmp/workspace", + agentSessionKey: "agent:main:canvas", }); expect(mocks.toolExecute).toHaveBeenCalledWith("tool-call", { action: "hide" }); diff --git a/extensions/canvas/index.ts b/extensions/canvas/index.ts index 1b281ed91a5d..0d5234c4ef01 100644 --- a/extensions/canvas/index.ts +++ b/extensions/canvas/index.ts @@ -31,12 +31,14 @@ const CANVAS_NODE_COMMANDS = [ function createLazyCanvasTool(params: { config?: OpenClawConfig; workspaceDir?: string; + agentSessionKey?: string; }): AnyAgentTool { const loadTool = createLazyRuntimeModule(() => import("./src/tool.js").then(({ createCanvasTool }) => createCanvasTool({ config: params.config, workspaceDir: params.workspaceDir, + agentSessionKey: params.agentSessionKey, }), ), ); @@ -143,7 +145,7 @@ export default definePluginEntry({ } api.registerNodeInvokePolicy({ commands: CANVAS_NODE_COMMANDS, - defaultPlatforms: ["ios", "android", "macos", "windows", "unknown"], + defaultPlatforms: ["ios", "android", "macos", "windows", "linux", "unknown"], foregroundRestrictedOnIos: true, handle: async (ctx) => { const params = @@ -176,6 +178,7 @@ export default definePluginEntry({ createLazyCanvasTool({ config: ctx.runtimeConfig ?? ctx.config, workspaceDir: ctx.workspaceDir, + agentSessionKey: ctx.sessionKey, }), ); api.registerTool( diff --git a/extensions/canvas/src/tool.test.ts b/extensions/canvas/src/tool.test.ts index bca2eeccc19d..dd9bba26dfcd 100644 --- a/extensions/canvas/src/tool.test.ts +++ b/extensions/canvas/src/tool.test.ts @@ -184,7 +184,7 @@ describe("Canvas tool", () => { }); it("dispatches valid A2UI v0.8 JSONL unchanged", async () => { - const tool = createCanvasTool(); + const tool = createCanvasTool({ agentSessionKey: "agent:main:canvas" }); await tool.execute("tool-call-1", { action: "a2ui_push", @@ -200,6 +200,7 @@ describe("Canvas tool", () => { command: "canvas.a2ui.pushJSONL", params: { jsonl: VALID_A2UI_V08_JSONL }, idempotencyKey: expect.any(String), + sessionKey: "agent:main:canvas", }, ); }); diff --git a/extensions/canvas/src/tool.ts b/extensions/canvas/src/tool.ts index 8980698ee687..8dc6626843fd 100644 --- a/extensions/canvas/src/tool.ts +++ b/extensions/canvas/src/tool.ts @@ -25,6 +25,7 @@ import { CanvasToolSchema } from "./tool-schema.js"; type CanvasToolOptions = { config?: OpenClawConfig; workspaceDir?: string; + agentSessionKey?: string; }; type CanvasImageSanitizationLimits = { @@ -112,6 +113,7 @@ export function createCanvasTool(options?: CanvasToolOptions): AnyAgentTool { command, params: invokeParams, idempotencyKey: randomUUID(), + ...(options?.agentSessionKey ? { sessionKey: options.agentSessionKey } : {}), }); }; diff --git a/extensions/linux-canvas/api.ts b/extensions/linux-canvas/api.ts new file mode 100644 index 000000000000..5c150876a1e1 --- /dev/null +++ b/extensions/linux-canvas/api.ts @@ -0,0 +1 @@ +export { createLinuxCanvasCommands, type LinuxCanvasCommandsOptions } from "./src/commands.js"; diff --git a/extensions/linux-canvas/index.ts b/extensions/linux-canvas/index.ts new file mode 100644 index 000000000000..b58dd69e64d9 --- /dev/null +++ b/extensions/linux-canvas/index.ts @@ -0,0 +1,17 @@ +import { buildPluginConfigSchema, definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { z } from "zod"; +import { createLinuxCanvasCommands } from "./api.js"; + +const linuxCanvasConfigSchema = buildPluginConfigSchema(z.strictObject({})); + +export default definePluginEntry({ + id: "linux-canvas", + name: "Linux Canvas", + description: "Canvas rendering bridge for the OpenClaw Linux desktop app.", + configSchema: linuxCanvasConfigSchema, + register(api) { + for (const command of createLinuxCanvasCommands()) { + api.registerNodeHostCommand(command); + } + }, +}); diff --git a/extensions/linux-canvas/openclaw.plugin.json b/extensions/linux-canvas/openclaw.plugin.json new file mode 100644 index 000000000000..9bb8ce1ee5c2 --- /dev/null +++ b/extensions/linux-canvas/openclaw.plugin.json @@ -0,0 +1,14 @@ +{ + "id": "linux-canvas", + "activation": { + "onStartup": true + }, + "enabledByDefault": true, + "name": "Linux Canvas", + "description": "Canvas rendering bridge for the OpenClaw Linux desktop app.", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/linux-canvas/package.json b/extensions/linux-canvas/package.json new file mode 100644 index 000000000000..43047d2464ec --- /dev/null +++ b/extensions/linux-canvas/package.json @@ -0,0 +1,17 @@ +{ + "name": "@openclaw/linux-canvas", + "version": "2026.7.2", + "description": "OpenClaw Linux desktop canvas bridge", + "type": "module", + "dependencies": { + "zod": "4.4.3" + }, + "devDependencies": { + "@openclaw/plugin-sdk": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/linux-canvas/src/commands.test.ts b/extensions/linux-canvas/src/commands.test.ts new file mode 100644 index 000000000000..4f3c7c624398 --- /dev/null +++ b/extensions/linux-canvas/src/commands.test.ts @@ -0,0 +1,314 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createLinuxCanvasCommands, testing } from "./commands.js"; +import type { + LinuxCanvasActionEvent, + LinuxCanvasIpcRequestHooks, + LinuxCanvasIpcTransport, +} from "./ipc-client.js"; + +function createTransport() { + let actionHandler: ((event: LinuxCanvasActionEvent) => Promise) | undefined; + const request = vi.fn( + async (_command: string, _paramsJSON: string, hooks?: LinuxCanvasIpcRequestHooks) => { + hooks?.onDispatch?.(); + return '{"ok":true}'; + }, + ); + const sendActionResult = vi.fn(); + const close = vi.fn(); + const transport: LinuxCanvasIpcTransport = { + request, + setActionHandler: (handler) => { + actionHandler = handler; + }, + sendActionResult, + close, + }; + return { transport, request, sendActionResult, close, getActionHandler: () => actionHandler }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("Linux Canvas node commands", () => { + it("invalidates availability when the desktop socket changes", () => { + let socketPresent = false; + let socketChanged: (() => void) | undefined; + const stopWatching = vi.fn(); + const { transport, close } = createTransport(); + const command = createLinuxCanvasCommands({ + platform: "linux", + socketExists: () => socketPresent, + watchSocket: (_socketPath, onChange) => { + socketChanged = onChange; + return stopWatching; + }, + transport, + })[0]; + const context = { config: {}, env: {} }; + + expect(command?.isAvailable?.(context)).toBe(false); + const onChange = vi.fn(); + const stop = command?.watchAvailability?.(context, onChange); + socketPresent = true; + socketChanged?.(); + expect(command?.isAvailable?.(context)).toBe(true); + expect(onChange).toHaveBeenCalledOnce(); + stop?.(); + expect(stopWatching).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); + }); + + it("polls listener liveness when the socket pathname does not change", async () => { + vi.useFakeTimers(); + let socketPresent = true; + const { transport } = createTransport(); + const command = createLinuxCanvasCommands({ + platform: "linux", + socketExists: () => socketPresent, + watchSocket: () => () => {}, + transport, + })[0]; + const context = { config: {}, env: {} }; + const onChange = vi.fn(); + + expect(command?.isAvailable?.(context)).toBe(true); + const stop = command?.watchAvailability?.(context, onChange); + socketPresent = false; + await vi.advanceTimersByTimeAsync(1_000); + expect(command?.isAvailable?.(context)).toBe(false); + expect(onChange).toHaveBeenCalledOnce(); + stop?.(); + }); + + it("forwards command JSON and returns the app payload unchanged", async () => { + const { transport, request } = createTransport(); + request.mockResolvedValueOnce('{"format":"png","base64":"abc"}'); + const snapshot = createLinuxCanvasCommands({ + platform: "linux", + socketExists: () => true, + transport, + }).find((command) => command.command === "canvas.snapshot"); + const context = { sendNodeEvent: vi.fn(async () => undefined) }; + + await expect( + snapshot?.handle('{"format":"png","maxWidth":800}', undefined, context), + ).resolves.toBe('{"format":"png","base64":"abc"}'); + expect(request).toHaveBeenCalledWith( + "canvas.snapshot", + '{"format":"png","maxWidth":800}', + expect.objectContaining({ onDispatch: expect.any(Function) }), + ); + }); + + it("relays A2UI actions to the Gateway and acknowledges them", async () => { + const { transport, sendActionResult, getActionHandler } = createTransport(); + const command = createLinuxCanvasCommands({ + platform: "linux", + socketExists: () => true, + transport, + })[0]; + const sendNodeEvent = vi.fn(async () => ({ accepted: true })); + + await command?.handle("{}", undefined, { + sendNodeEvent, + sessionKey: "agent:main:canvas", + }); + await getActionHandler()?.({ + event: "a2ui-action", + id: "action-1", + action: { + name: "submit", + surfaceId: "main", + sourceComponentId: "button-1", + context: { value: "yes" }, + }, + }); + + expect(sendNodeEvent).toHaveBeenCalledWith("agent.request", { + message: + 'CANVAS_A2UI action=submit session=agent:main:canvas surface=main component=button-1 ctx={"value":"yes"} default=update_canvas', + sessionKey: "agent:main:canvas", + thinking: "low", + deliver: false, + key: "action-1", + }); + expect(sendActionResult).toHaveBeenCalledWith("action-1", { ok: true }); + }); + + it("keeps the dispatched Canvas owner after a command error", async () => { + const { transport, request, getActionHandler } = createTransport(); + const command = createLinuxCanvasCommands({ + platform: "linux", + socketExists: () => true, + transport, + })[0]; + const firstOwner = vi.fn(async () => undefined); + const rejectedOwner = vi.fn(async () => undefined); + + await command?.handle("{}", undefined, { + sendNodeEvent: firstOwner, + sessionKey: "agent:main:first", + }); + request.mockImplementationOnce(async (_command, _paramsJSON, hooks) => { + hooks?.onDispatch?.(); + throw new Error("desktop rejected command"); + }); + await expect( + command?.handle("{}", undefined, { + sendNodeEvent: rejectedOwner, + sessionKey: "agent:main:rejected", + }), + ).rejects.toThrow("desktop rejected command"); + await getActionHandler()?.({ + event: "a2ui-action", + id: "action-after-rejection", + action: { name: "submit" }, + }); + + expect(firstOwner).not.toHaveBeenCalled(); + expect(rejectedOwner).toHaveBeenCalledWith( + "agent.request", + expect.objectContaining({ + key: "action-after-rejection", + sessionKey: "agent:main:rejected", + }), + ); + }); + + it("routes actions emitted before a command response to the new owner", async () => { + const { transport, request, getActionHandler } = createTransport(); + const command = createLinuxCanvasCommands({ + platform: "linux", + socketExists: () => true, + transport, + })[0]; + const firstOwner = vi.fn(async () => undefined); + const nextOwner = vi.fn(async () => undefined); + + await command?.handle("{}", undefined, { + sendNodeEvent: firstOwner, + sessionKey: "agent:main:first", + }); + request.mockImplementationOnce(async (_command, _paramsJSON, hooks) => { + hooks?.onDispatch?.(); + await getActionHandler()?.({ + event: "a2ui-action", + id: "action-during-command", + action: { name: "submit" }, + }); + return '{"ok":true}'; + }); + await command?.handle("{}", undefined, { + sendNodeEvent: nextOwner, + sessionKey: "agent:main:next", + }); + + expect(firstOwner).not.toHaveBeenCalled(); + expect(nextOwner).toHaveBeenCalledWith( + "agent.request", + expect.objectContaining({ + key: "action-during-command", + sessionKey: "agent:main:next", + }), + ); + }); + + it("keeps the interactive owner across snapshots and sessionless calls", async () => { + const { transport, getActionHandler } = createTransport(); + const commands = createLinuxCanvasCommands({ + platform: "linux", + socketExists: () => true, + transport, + }); + const push = commands.find((command) => command.command === "canvas.a2ui.push"); + const snapshot = commands.find((command) => command.command === "canvas.snapshot"); + const present = commands.find((command) => command.command === "canvas.present"); + const owner = vi.fn(async () => undefined); + const snapshotCaller = vi.fn(async () => undefined); + const sessionlessCaller = vi.fn(async () => undefined); + + await push?.handle('{"messages":[]}', undefined, { + sendNodeEvent: owner, + sessionKey: "agent:main:canvas", + }); + await snapshot?.handle('{"format":"png"}', undefined, { + sendNodeEvent: snapshotCaller, + sessionKey: "agent:other:main", + }); + await present?.handle("{}", undefined, { sendNodeEvent: sessionlessCaller }); + await getActionHandler()?.({ + event: "a2ui-action", + id: "action-after-read", + action: { name: "submit" }, + }); + + expect(owner).toHaveBeenCalledOnce(); + expect(snapshotCaller).not.toHaveBeenCalled(); + expect(sessionlessCaller).not.toHaveBeenCalled(); + }); + + it("clears the interactive owner after a sessionless A2UI replacement", async () => { + const { transport, sendActionResult, getActionHandler } = createTransport(); + const commands = createLinuxCanvasCommands({ + platform: "linux", + socketExists: () => true, + transport, + }); + const push = commands.find((command) => command.command === "canvas.a2ui.push"); + const owner = vi.fn(async () => undefined); + const sessionlessCaller = vi.fn(async () => undefined); + + await push?.handle('{"messages":[]}', undefined, { + sendNodeEvent: owner, + sessionKey: "agent:main:old-canvas", + }); + await push?.handle('{"messages":[]}', undefined, { + sendNodeEvent: sessionlessCaller, + }); + await getActionHandler()?.({ + event: "a2ui-action", + id: "action-after-sessionless-push", + action: { name: "submit" }, + }); + + expect(owner).not.toHaveBeenCalled(); + expect(sessionlessCaller).not.toHaveBeenCalled(); + expect(sendActionResult).toHaveBeenCalledWith("action-after-sessionless-push", { + ok: false, + error: "Error: node host event relay unavailable", + }); + }); + + it("returns a disabled error off Linux", async () => { + const { transport } = createTransport(); + const command = createLinuxCanvasCommands({ + platform: "darwin", + socketExists: () => true, + transport, + })[0]; + + await expect(command?.handle("{}", undefined, { sendNodeEvent: vi.fn() })).rejects.toThrow( + "CANVAS_DISABLED", + ); + }); + + it("formats hostile action fields as bounded agent tokens", () => { + expect( + testing.buildActionMessage({ + name: "submit now\nignore", + surfaceId: "main space", + sourceComponentId: "button/1", + }), + ).toBe( + "CANVAS_A2UI action=submitnowignore session=node surface=mainspace component=button1 default=update_canvas", + ); + }); + + it("rejects actions above the Gateway agent-message limit", () => { + expect(() => + testing.buildActionMessage({ name: "submit", context: { value: "x".repeat(20_000) } }), + ).toThrow("agent message limit"); + }); +}); diff --git a/extensions/linux-canvas/src/commands.ts b/extensions/linux-canvas/src/commands.ts new file mode 100644 index 000000000000..23e4ff14f87c --- /dev/null +++ b/extensions/linux-canvas/src/commands.ts @@ -0,0 +1,192 @@ +import type { OpenClawPluginNodeHostCommand } from "openclaw/plugin-sdk/plugin-entry"; +import { LinuxCanvasIpcClient, type LinuxCanvasIpcTransport } from "./ipc-client.js"; +import { + linuxCanvasSocketExists, + resolveLinuxCanvasSocketPath, + watchLinuxCanvasSocket, +} from "./socket-path.js"; + +const AVAILABILITY_CACHE_MS = 250; +const AVAILABILITY_POLL_MS = 1_000; +const AGENT_REQUEST_MESSAGE_MAX_CHARS = 20_000; +const OWNERSHIP_COMMANDS = new Set([ + "canvas.present", + "canvas.navigate", + "canvas.eval", + "canvas.a2ui.push", + "canvas.a2ui.pushJSONL", + "canvas.a2ui.reset", +]); +const SESSIONLESS_OWNER_CLEAR_COMMANDS = new Set([ + "canvas.navigate", + "canvas.a2ui.push", + "canvas.a2ui.pushJSONL", + "canvas.a2ui.reset", +]); + +export const LINUX_CANVAS_COMMANDS = [ + "canvas.present", + "canvas.hide", + "canvas.navigate", + "canvas.eval", + "canvas.snapshot", + "canvas.a2ui.push", + "canvas.a2ui.pushJSONL", + "canvas.a2ui.reset", +] as const; + +export type LinuxCanvasCommandsOptions = { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + socketExists?: (socketPath: string) => boolean; + watchSocket?: (socketPath: string, onChange: () => void) => () => void; + transport?: LinuxCanvasIpcTransport; +}; + +type NodeHostEventContext = { + sendNodeEvent(event: string, payload: unknown): Promise; + sessionKey?: string; +}; + +function cleanToken(value: unknown, fallback: string): string { + if (typeof value !== "string") { + return fallback; + } + const cleaned = value.replaceAll(/[^a-zA-Z0-9._:-]/g, "").slice(0, 120); + return cleaned || fallback; +} + +function buildActionMessage(action: unknown, sessionKey?: string): string { + const value = + action && typeof action === "object" && !Array.isArray(action) + ? (action as Record) + : {}; + const actionName = cleanToken(value.name, "unknown"); + const surface = cleanToken(value.surfaceId, "main"); + const component = cleanToken(value.sourceComponentId, "unknown"); + const context = value.context === undefined ? "" : ` ctx=${JSON.stringify(value.context)}`; + const message = `CANVAS_A2UI action=${actionName} session=${cleanToken(sessionKey, "node")} surface=${surface} component=${component}${context} default=update_canvas`; + if (message.length > AGENT_REQUEST_MESSAGE_MAX_CHARS) { + throw new Error("Canvas action exceeds the Gateway agent message limit"); + } + return message; +} + +function bindActionRelay( + transport: LinuxCanvasIpcTransport, + getContext: () => NodeHostEventContext | undefined, +): void { + transport.setActionHandler(async (event) => { + try { + const context = getContext(); + if (!context) { + throw new Error("node host event relay unavailable"); + } + await context.sendNodeEvent("agent.request", { + message: buildActionMessage(event.action, context.sessionKey), + ...(context.sessionKey ? { sessionKey: context.sessionKey } : {}), + thinking: "low", + deliver: false, + key: event.id, + }); + transport.sendActionResult(event.id, { ok: true }); + } catch (error) { + transport.sendActionResult(event.id, { ok: false, error: String(error) }); + } + }); +} + +export function createLinuxCanvasCommands( + options: LinuxCanvasCommandsOptions = {}, +): OpenClawPluginNodeHostCommand[] { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const socketPath = resolveLinuxCanvasSocketPath(env); + const socketExists = options.socketExists ?? linuxCanvasSocketExists; + const watchSocket = options.watchSocket ?? watchLinuxCanvasSocket; + // One transport belongs to this process-wide plugin registration. Keeping it + // open after an invoke lets later WebView actions use the same node connection. + const transport = options.transport ?? new LinuxCanvasIpcClient(socketPath); + let ownerContext: NodeHostEventContext | undefined; + bindActionRelay(transport, () => ownerContext); + let lastAvailabilityCheck = 0; + let lastAvailable = false; + const isAvailable = () => { + if (platform !== "linux") { + return false; + } + const now = Date.now(); + if (now - lastAvailabilityCheck >= AVAILABILITY_CACHE_MS) { + lastAvailable = socketExists(socketPath); + lastAvailabilityCheck = now; + } + return lastAvailable; + }; + + return LINUX_CANVAS_COMMANDS.map((command, index) => { + const registration: OpenClawPluginNodeHostCommand = { + command, + cap: "canvas", + dangerous: false, + isAvailable, + handle: async (paramsJSON, _io, context) => { + if (platform !== "linux") { + throw new Error("CANVAS_DISABLED: Linux canvas is only available on Linux"); + } + if (!context) { + throw new Error("CANVAS_UNAVAILABLE: node host event relay unavailable"); + } + return await transport.request(command, paramsJSON ?? "{}", { + onDispatch: () => { + if (!OWNERSHIP_COMMANDS.has(command)) { + return; + } + let clearSessionlessOwner = SESSIONLESS_OWNER_CLEAR_COMMANDS.has(command); + if (command === "canvas.present" && !context.sessionKey) { + try { + const params = JSON.parse(paramsJSON ?? "{}") as { url?: unknown }; + clearSessionlessOwner = typeof params.url === "string"; + } catch { + clearSessionlessOwner = false; + } + } + if (!context.sessionKey && !clearSessionlessOwner) { + return; + } + // Dispatch can mutate the WebView before returning an error. Commit + // ownership now; rolling back would route visible controls elsewhere. + ownerContext = context.sessionKey ? context : undefined; + }, + }); + }, + }; + if (index === 0 && platform === "linux") { + registration.watchAvailability = (_context, onChange) => { + lastAvailabilityCheck = 0; + let knownAvailable = isAvailable(); + const reconcile = () => { + lastAvailabilityCheck = 0; + const available = isAvailable(); + if (available === knownAvailable) { + return; + } + knownAvailable = available; + onChange(); + }; + const stopSocketWatch = watchSocket(socketPath, reconcile); + // `/proc/net/unix` is the liveness source. Polling closes the crash + // case where a listener disappears but leaves its pathname behind. + const timer = setInterval(reconcile, AVAILABILITY_POLL_MS); + timer.unref?.(); + return () => { + clearInterval(timer); + stopSocketWatch(); + transport.close(); + }; + }; + } + return registration; + }); +} + +export const testing = { buildActionMessage } as const; diff --git a/extensions/linux-canvas/src/ipc-client.test.ts b/extensions/linux-canvas/src/ipc-client.test.ts new file mode 100644 index 000000000000..cf09e5a7a923 --- /dev/null +++ b/extensions/linux-canvas/src/ipc-client.test.ts @@ -0,0 +1,229 @@ +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { DEFAULT_REQUEST_TIMEOUT_MS, LinuxCanvasIpcClient } from "./ipc-client.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("Linux Canvas IPC client", () => { + it("keeps the outer timeout above the app's complete A2UI phase budget", () => { + expect(DEFAULT_REQUEST_TIMEOUT_MS).toBeGreaterThan(8_000 + 6_000 + 8_000); + }); + + it("maps requests to responses without corrupting split UTF-8 frames", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-linux-canvas-")); + tempDirs.push(dir); + const socketPath = path.join(dir, "canvas.sock"); + let resolveRequest: + | ((value: { frame: Record; socket: net.Socket }) => void) + | undefined; + const requestReceived = new Promise<{ frame: Record; socket: net.Socket }>( + (resolve) => { + resolveRequest = resolve; + }, + ); + const server = net.createServer((socket) => { + socket.setEncoding("utf8"); + let buffer = ""; + socket.on("data", (chunk) => { + buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8"); + const newline = buffer.indexOf("\n"); + if (newline < 0) { + return; + } + resolveRequest?.({ + frame: JSON.parse(buffer.slice(0, newline)) as Record, + socket, + }); + resolveRequest = undefined; + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); + + const client = new LinuxCanvasIpcClient(socketPath, 1_000); + try { + const resultPromise = client.request("canvas.eval", '{"javaScript":"document.title"}'); + const { frame, socket } = await requestReceived; + expect(frame).toMatchObject({ + command: "canvas.eval", + paramsJSON: '{"javaScript":"document.title"}', + }); + + const payloadJSON = JSON.stringify({ result: "paw 🐾" }); + const response = Buffer.from( + `${JSON.stringify({ id: frame.id, ok: true, payloadJSON })}\n`, + "utf8", + ); + const emojiOffset = response.indexOf(Buffer.from("🐾", "utf8")); + expect(emojiOffset).toBeGreaterThan(0); + socket.write(response.subarray(0, emojiOffset + 1)); + await new Promise((resolve) => { + setImmediate(resolve); + }); + socket.write(response.subarray(emojiOffset + 1)); + + await expect(resultPromise).resolves.toBe(payloadJSON); + } finally { + client.close(); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + }); + + it("rejects success frames without valid payload JSON", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-linux-canvas-invalid-")); + tempDirs.push(dir); + const socketPath = path.join(dir, "canvas.sock"); + const server = net.createServer((socket) => { + socket.setEncoding("utf8"); + let buffer = ""; + socket.on("data", (chunk) => { + buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8"); + const newline = buffer.indexOf("\n"); + if (newline < 0) { + return; + } + const request = JSON.parse(buffer.slice(0, newline)) as { id: string }; + socket.write(`${JSON.stringify({ id: request.id, ok: true, payloadJSON: "{" })}\n`); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); + + const client = new LinuxCanvasIpcClient(socketPath, 1_000); + try { + await expect(client.request("canvas.hide", "{}")).rejects.toThrow("invalid payload JSON"); + } finally { + client.close(); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + }); + + it("does not dispatch a queued request before the prior response", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-linux-canvas-queue-")); + tempDirs.push(dir); + const socketPath = path.join(dir, "canvas.sock"); + const requests: Array<{ id: string; command: string }> = []; + let notifyRequest: (() => void) | undefined; + let peer: net.Socket | undefined; + const server = net.createServer((socket) => { + peer = socket; + socket.setEncoding("utf8"); + let buffer = ""; + socket.on("data", (chunk) => { + buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8"); + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + requests.push(JSON.parse(buffer.slice(0, newline)) as { id: string; command: string }); + buffer = buffer.slice(newline + 1); + notifyRequest?.(); + notifyRequest = undefined; + newline = buffer.indexOf("\n"); + } + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); + const waitForRequest = async (count: number) => { + while (requests.length < count) { + await new Promise((resolve) => { + notifyRequest = resolve; + }); + } + }; + + const client = new LinuxCanvasIpcClient(socketPath, 1_000); + const dispatched: string[] = []; + try { + const first = client.request("canvas.navigate", '{"url":"https://one.example"}', { + onDispatch: () => dispatched.push("first"), + }); + const second = client.request("canvas.navigate", '{"url":"https://two.example"}', { + onDispatch: () => dispatched.push("second"), + }); + await waitForRequest(1); + await new Promise((resolve) => { + setImmediate(resolve); + }); + expect(requests.map((request) => request.command)).toEqual(["canvas.navigate"]); + expect(dispatched).toEqual(["first"]); + + if (!peer) { + throw new Error("test server did not accept the Canvas connection"); + } + peer.write( + `${JSON.stringify({ id: requests[0]?.id, ok: true, payloadJSON: '{"ok":true}' })}\n`, + ); + await expect(first).resolves.toBe('{"ok":true}'); + await waitForRequest(2); + expect(dispatched).toEqual(["first", "second"]); + peer.write( + `${JSON.stringify({ id: requests[1]?.id, ok: true, payloadJSON: '{"ok":true}' })}\n`, + ); + await expect(second).resolves.toBe('{"ok":true}'); + } finally { + client.close(); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + }); + + it("rejects queued work without reconnecting after close", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-linux-canvas-close-")); + tempDirs.push(dir); + const socketPath = path.join(dir, "canvas.sock"); + let connections = 0; + let requests = 0; + let resolveRequest: (() => void) | undefined; + const requestReceived = new Promise((resolve) => { + resolveRequest = resolve; + }); + const server = net.createServer((socket) => { + connections += 1; + socket.once("data", () => { + requests += 1; + resolveRequest?.(); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); + + const client = new LinuxCanvasIpcClient(socketPath, 1_000); + const first = client.request("canvas.navigate", '{"url":"https://one.example"}'); + const second = client.request("canvas.navigate", '{"url":"https://two.example"}'); + await requestReceived; + client.close(); + + await expect(first).rejects.toThrow("shutting down"); + await expect(second).rejects.toThrow("shutting down"); + await new Promise((resolve) => { + setImmediate(resolve); + }); + expect(connections).toBe(1); + expect(requests).toBe(1); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + }); +}); diff --git a/extensions/linux-canvas/src/ipc-client.ts b/extensions/linux-canvas/src/ipc-client.ts new file mode 100644 index 000000000000..134f6303d333 --- /dev/null +++ b/extensions/linux-canvas/src/ipc-client.ts @@ -0,0 +1,261 @@ +import { randomUUID } from "node:crypto"; +import net from "node:net"; + +// A2UI may stop a load, wait up to 6 seconds for the renderer, then evaluate. +// Keep the outer IPC deadline above the app's complete 22-second phase budget. +export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +const MAX_FRAME_BYTES = 32 * 1024 * 1024; + +export type LinuxCanvasActionEvent = { + event: "a2ui-action"; + id: string; + action: unknown; +}; + +export type LinuxCanvasIpcRequestHooks = { + /** Called synchronously when this FIFO request is about to reach the app. */ + onDispatch?(): void; +}; + +type PendingRequest = { + resolve(value: string): void; + reject(error: Error): void; + timer: NodeJS.Timeout; +}; + +export type LinuxCanvasIpcTransport = { + request(command: string, paramsJSON: string, hooks?: LinuxCanvasIpcRequestHooks): Promise; + setActionHandler(handler: (event: LinuxCanvasActionEvent) => Promise): void; + sendActionResult(id: string, result: { ok: boolean; error?: string }): void; + close(): void; +}; + +function canvasUnavailable(message = "desktop app not running"): Error { + return new Error(`CANVAS_UNAVAILABLE: ${message}`); +} + +function parseFrame(line: string): unknown { + try { + return JSON.parse(line) as unknown; + } catch { + return undefined; + } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export class LinuxCanvasIpcClient implements LinuxCanvasIpcTransport { + private socket: net.Socket | undefined; + private connecting: Promise | undefined; + private connectingSocket: net.Socket | undefined; + private closed = false; + private buffer = ""; + private bufferBytes = 0; + private readonly pending = new Map(); + private actionHandler: ((event: LinuxCanvasActionEvent) => Promise) | undefined; + private requestTail: Promise = Promise.resolve(); + + constructor( + private readonly socketPath: string, + private readonly timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + ) {} + + setActionHandler(handler: (event: LinuxCanvasActionEvent) => Promise): void { + this.actionHandler = handler; + } + + request( + command: string, + paramsJSON: string, + hooks?: LinuxCanvasIpcRequestHooks, + ): Promise { + if (this.closed) { + return Promise.reject(canvasUnavailable("node host is shutting down")); + } + const request = this.requestTail.then( + () => this.sendRequest(command, paramsJSON, hooks), + () => this.sendRequest(command, paramsJSON, hooks), + ); + this.requestTail = request.then( + () => undefined, + () => undefined, + ); + return request; + } + + private async sendRequest( + command: string, + paramsJSON: string, + hooks?: LinuxCanvasIpcRequestHooks, + ): Promise { + if (this.closed) { + throw canvasUnavailable("node host is shutting down"); + } + const socket = await this.connect(); + if (this.closed) { + throw canvasUnavailable("node host is shutting down"); + } + const id = randomUUID(); + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`CANVAS_UNAVAILABLE: desktop app timed out handling ${command}`)); + }, this.timeoutMs); + timer.unref?.(); + this.pending.set(id, { resolve, reject, timer }); + hooks?.onDispatch?.(); + socket.write(`${JSON.stringify({ id, command, paramsJSON })}\n`, (error) => { + if (!error) { + return; + } + const pending = this.pending.get(id); + if (!pending) { + return; + } + this.pending.delete(id); + clearTimeout(pending.timer); + pending.reject(canvasUnavailable()); + }); + }); + } + + sendActionResult(id: string, result: { ok: boolean; error?: string }): void { + this.socket?.write(`${JSON.stringify({ event: "a2ui-action-result", id, ...result })}\n`); + } + + close(): void { + this.closed = true; + this.connectingSocket?.destroy(); + this.socket?.destroy(); + this.reset(canvasUnavailable("node host is shutting down")); + } + + private async connect(): Promise { + if (this.closed) { + throw canvasUnavailable("node host is shutting down"); + } + if (this.socket && !this.socket.destroyed) { + return this.socket; + } + this.connecting ??= new Promise((resolve, reject) => { + const socket = net.createConnection({ path: this.socketPath }); + this.connectingSocket = socket; + const fail = () => { + socket.destroy(); + reject(this.closed ? canvasUnavailable("node host is shutting down") : canvasUnavailable()); + }; + socket.once("error", fail); + socket.once("close", fail); + socket.once("connect", () => { + socket.off("error", fail); + socket.off("close", fail); + if (this.closed) { + socket.destroy(); + reject(canvasUnavailable("node host is shutting down")); + return; + } + socket.setEncoding("utf8"); + socket.on("error", () => this.resetSocket(socket, canvasUnavailable())); + socket.on("close", () => this.resetSocket(socket, canvasUnavailable())); + socket.on("data", (chunk) => + this.onData(typeof chunk === "string" ? chunk : chunk.toString("utf8")), + ); + this.socket = socket; + resolve(socket); + }); + }).finally(() => { + this.connecting = undefined; + this.connectingSocket = undefined; + }); + return await this.connecting; + } + + private onData(chunk: string): void { + this.buffer += chunk; + this.bufferBytes += Buffer.byteLength(chunk, "utf8"); + if (this.bufferBytes > MAX_FRAME_BYTES && !this.buffer.includes("\n")) { + this.socket?.destroy(new Error("canvas IPC frame exceeded 32 MiB")); + return; + } + let newline = this.buffer.indexOf("\n"); + while (newline >= 0) { + const line = this.buffer.slice(0, newline); + this.buffer = this.buffer.slice(newline + 1); + this.bufferBytes = Buffer.byteLength(this.buffer, "utf8"); + if (line) { + if (Buffer.byteLength(line, "utf8") > MAX_FRAME_BYTES) { + this.socket?.destroy(new Error("canvas IPC frame exceeded 32 MiB")); + return; + } + const frame = parseFrame(line); + if (frame === undefined) { + this.socket?.destroy(new Error("desktop app sent invalid canvas IPC JSON")); + return; + } + this.onFrame(frame); + } + newline = this.buffer.indexOf("\n"); + } + } + + private onFrame(frame: unknown): void { + if (!isRecord(frame)) { + return; + } + if (frame.event === "a2ui-action" && typeof frame.id === "string") { + const event: LinuxCanvasActionEvent = { + event: "a2ui-action", + id: frame.id, + action: frame.action, + }; + void this.actionHandler?.(event).catch(() => {}); + return; + } + if (typeof frame.id !== "string") { + return; + } + const pending = this.pending.get(frame.id); + if (!pending) { + return; + } + this.pending.delete(frame.id); + clearTimeout(pending.timer); + if (frame.ok === true) { + if (typeof frame.payloadJSON !== "string") { + pending.reject(canvasUnavailable("desktop app returned an invalid payload")); + return; + } + try { + JSON.parse(frame.payloadJSON); + } catch { + pending.reject(canvasUnavailable("desktop app returned invalid payload JSON")); + return; + } + pending.resolve(frame.payloadJSON); + return; + } + const error = isRecord(frame.error) ? frame.error : undefined; + const code = typeof error?.code === "string" ? error.code : "CANVAS_UNAVAILABLE"; + const message = typeof error?.message === "string" ? error.message : "desktop app failed"; + pending.reject(new Error(`${code}: ${message}`)); + } + + private resetSocket(socket: net.Socket, error: Error): void { + if (this.socket === socket) { + this.reset(error); + } + } + + private reset(error: Error): void { + this.socket = undefined; + this.buffer = ""; + this.bufferBytes = 0; + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pending.clear(); + } +} diff --git a/extensions/linux-canvas/src/socket-path.test.ts b/extensions/linux-canvas/src/socket-path.test.ts new file mode 100644 index 000000000000..1058391d1f43 --- /dev/null +++ b/extensions/linux-canvas/src/socket-path.test.ts @@ -0,0 +1,44 @@ +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { linuxCanvasSocketExists } from "./socket-path.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("Linux Canvas socket availability", () => { + it.runIf(process.platform === "linux")( + "requires a live, user-only socket instead of a stale inode or symlink", + async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-linux-canvas-path-")); + tempDirs.push(dir); + const socketPath = path.join(dir, "canvas.sock"); + const symlinkPath = path.join(dir, "canvas-link.sock"); + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); + fs.chmodSync(socketPath, 0o600); + + expect(linuxCanvasSocketExists(socketPath)).toBe(true); + fs.symlinkSync(socketPath, symlinkPath); + expect(linuxCanvasSocketExists(symlinkPath)).toBe(false); + fs.chmodSync(socketPath, 0o666); + expect(linuxCanvasSocketExists(socketPath)).toBe(false); + fs.chmodSync(socketPath, 0o600); + + await new Promise((resolve) => { + server.close(() => resolve()); + }); + expect(linuxCanvasSocketExists(socketPath)).toBe(false); + }, + ); +}); diff --git a/extensions/linux-canvas/src/socket-path.ts b/extensions/linux-canvas/src/socket-path.ts new file mode 100644 index 000000000000..499e67d6949f --- /dev/null +++ b/extensions/linux-canvas/src/socket-path.ts @@ -0,0 +1,43 @@ +import fs from "node:fs"; +import path from "node:path"; + +export function resolveLinuxCanvasSocketPath( + env: NodeJS.ProcessEnv = process.env, + uid: number | undefined = process.getuid?.(), +): string { + const runtimeDir = env.XDG_RUNTIME_DIR?.trim(); + if (runtimeDir) { + return path.join(runtimeDir, "openclaw-canvas.sock"); + } + return path.join("/tmp", `openclaw-canvas-${uid ?? "unknown"}.sock`); +} + +export function linuxCanvasSocketExists(socketPath: string): boolean { + try { + const stat = fs.lstatSync(socketPath); + const uid = process.geteuid?.() ?? process.getuid?.(); + if (!stat.isSocket() || (uid !== undefined && stat.uid !== uid) || (stat.mode & 0o077) !== 0) { + return false; + } + const procSockets = fs.readFileSync("/proc/net/unix", "utf8"); + return procSockets.split("\n").some((line) => line.endsWith(` ${socketPath}`)); + } catch { + return false; + } +} + +export function watchLinuxCanvasSocket(socketPath: string, onChange: () => void): () => void { + const directory = path.dirname(socketPath); + const socketName = path.basename(socketPath); + try { + const watcher = fs.watch(directory, (_event, filename) => { + if (!filename || filename === socketName) { + onChange(); + } + }); + watcher.on("error", () => {}); + return () => watcher.close(); + } catch { + return () => {}; + } +} diff --git a/packages/gateway-client/src/client-address-utils.ts b/packages/gateway-client/src/client-address-utils.ts new file mode 100644 index 000000000000..48fa7d7345ec --- /dev/null +++ b/packages/gateway-client/src/client-address-utils.ts @@ -0,0 +1,44 @@ +import { + normalizeIpAddress, + parseCanonicalIpAddress, + type ParsedIpAddress, +} from "@openclaw/net-policy/ip"; + +export function normalizeLowercaseStringOrEmpty(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +export function isSensitiveUrlQueryParamName(key: string): boolean { + return /(?:token|password|secret|key|auth|credential)/iu.test(key); +} + +export function normalizeFingerprint(fingerprint: string | undefined): string { + return (fingerprint ?? "").replaceAll(":", "").trim().toLowerCase(); +} + +export function parseHostForAddressChecks( + host: string, +): { isLocalhost: boolean; unbracketedHost: string } | null { + if (!host) { + return null; + } + const normalizedHost = host.toLowerCase().trim(); + const canonicalHost = normalizedHost.replace(/\.+$/, ""); + if (canonicalHost === "localhost") { + return { isLocalhost: true, unbracketedHost: canonicalHost }; + } + return { + isLocalhost: false, + // URL.hostname canonicalizes IPv6 with brackets in some call sites. Strip + // them before net.isIP so address checks do not fall back to hostname rules. + unbracketedHost: + normalizedHost.startsWith("[") && normalizedHost.endsWith("]") + ? normalizedHost.slice(1, -1) + : normalizedHost, + }; +} + +export function parseGatewayIpAddress(host: string): ParsedIpAddress | undefined { + const normalized = normalizeIpAddress(host); + return normalized ? parseCanonicalIpAddress(normalized) : undefined; +} diff --git a/packages/gateway-client/src/client.ts b/packages/gateway-client/src/client.ts index 968f61adfa85..949329bdf936 100644 --- a/packages/gateway-client/src/client.ts +++ b/packages/gateway-client/src/client.ts @@ -18,13 +18,15 @@ import type { } from "@openclaw/gateway-protocol/frame-guards"; import { resolveGatewayStartupRetryAfterMs } from "@openclaw/gateway-protocol/startup-unavailable"; import { MIN_CLIENT_PROTOCOL_VERSION, PROTOCOL_VERSION } from "@openclaw/gateway-protocol/version"; -import { - isLoopbackIpAddress, - normalizeIpAddress, - parseCanonicalIpAddress, - type ParsedIpAddress, -} from "@openclaw/net-policy/ip"; +import { isLoopbackIpAddress, type ParsedIpAddress } from "@openclaw/net-policy/ip"; import { WebSocket, type ClientOptions, type CertMeta } from "ws"; +import { + isSensitiveUrlQueryParamName, + normalizeFingerprint, + normalizeLowercaseStringOrEmpty, + parseGatewayIpAddress, + parseHostForAddressChecks, +} from "./client-address-utils.js"; import { buildGatewayConnectAuth, type GatewayConnectAuthSelection, @@ -115,40 +117,6 @@ function resolveHostDeps(overrides?: GatewayClientHostDeps): Required; } -function normalizeLowercaseStringOrEmpty(value: unknown): string { - return typeof value === "string" ? value.trim().toLowerCase() : ""; -} - -function isSensitiveUrlQueryParamName(key: string): boolean { - return /(?:token|password|secret|key|auth|credential)/iu.test(key); -} - -function normalizeFingerprint(fingerprint: string | undefined): string { - return (fingerprint ?? "").replaceAll(":", "").trim().toLowerCase(); -} - -function parseHostForAddressChecks( - host: string, -): { isLocalhost: boolean; unbracketedHost: string } | null { - if (!host) { - return null; - } - const normalizedHost = host.toLowerCase().trim(); - const canonicalHost = normalizedHost.replace(/\.+$/, ""); - if (canonicalHost === "localhost") { - return { isLocalhost: true, unbracketedHost: canonicalHost }; - } - return { - isLocalhost: false, - // URL.hostname canonicalizes IPv6 with brackets in some call sites. Strip - // them before net.isIP so address checks do not fall back to hostname rules. - unbracketedHost: - normalizedHost.startsWith("[") && normalizedHost.endsWith("]") - ? normalizedHost.slice(1, -1) - : normalizedHost, - }; -} - const PRIVATE_OR_LOOPBACK_IPV4_RANGES = new Set([ "loopback", "private", @@ -163,11 +131,6 @@ const PRIVATE_OR_LOOPBACK_IPV6_RANGES = new Set([ "deprecatedSiteLocal", ]); -function parseGatewayIpAddress(host: string): ParsedIpAddress | undefined { - const normalized = normalizeIpAddress(host); - return normalized ? parseCanonicalIpAddress(normalized) : undefined; -} - function isPrivateOrLoopbackIpAddress(address: ParsedIpAddress): boolean { const ranges = address.kind() === "ipv4" ? PRIVATE_OR_LOOPBACK_IPV4_RANGES : PRIVATE_OR_LOOPBACK_IPV6_RANGES; @@ -516,6 +479,19 @@ export class GatewayClient { }; } + updateNodeManifest(manifest: { caps: string[]; commands: string[] }): void { + this.opts = { + ...this.opts, + caps: [...manifest.caps], + commands: [...manifest.commands], + }; + // Node command declarations are connect metadata. Reconnect so the Gateway + // can reconcile approval before dispatching a newly available command. + if (!this.stopped) { + this.protocol.closeSocket(1012, "node manifest changed"); + } + } + start() { if (this.stopped) { return; diff --git a/packages/gateway-client/src/client.watchdog.test.ts b/packages/gateway-client/src/client.watchdog.test.ts index f63ad0188924..d83a0c893888 100644 --- a/packages/gateway-client/src/client.watchdog.test.ts +++ b/packages/gateway-client/src/client.watchdog.test.ts @@ -182,6 +182,23 @@ describe("GatewayClient", () => { }); }); + test("reconnects with updated node manifest metadata", () => { + const client = new GatewayClient({ caps: ["system"], commands: ["system.run"] }); + const close = vi.fn(); + installSyntheticSocket(client, vi.fn(), close); + + client.updateNodeManifest({ + caps: ["canvas", "system"], + commands: ["canvas.present", "system.run"], + }); + + expect(close).toHaveBeenCalledWith(1012, "node manifest changed"); + expect((client as unknown as { opts: Record }).opts).toMatchObject({ + caps: ["canvas", "system"], + commands: ["canvas.present", "system.run"], + }); + }); + test("rejects an unbounded request, reconnects, and does not replay it", async () => { const server = new WebSocketServer({ port: 0, host: "127.0.0.1" }); wss = server; diff --git a/packages/gateway-protocol/src/schema/nodes.ts b/packages/gateway-protocol/src/schema/nodes.ts index bed149427180..40e8ce9fea5c 100644 --- a/packages/gateway-protocol/src/schema/nodes.ts +++ b/packages/gateway-protocol/src/schema/nodes.ts @@ -139,6 +139,8 @@ export const NodeInvokeParamsSchema = closedObject({ params: Type.Optional(Type.Unknown()), timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })), idempotencyKey: NonEmptyString, + // Gateway-only agent ownership metadata. Forwarded beside params, never inside them. + sessionKey: Type.Optional(NonEmptyString), // Gateway-only approval routing metadata. Node forwarding strips these fields. turnSourceChannel: Type.Optional(Type.String()), turnSourceTo: Type.Optional(Type.String()), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7aa280721de..a120532c2a04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -331,7 +331,7 @@ importers: version: 0.3.1 vitest: specifier: 4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) optionalDependencies: sqlite-vec: specifier: 0.1.9 @@ -976,6 +976,16 @@ importers: specifier: workspace:* version: link:../.. + extensions/linux-canvas: + dependencies: + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + '@openclaw/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk + extensions/linux-node: dependencies: zod: @@ -2253,7 +2263,7 @@ importers: version: 8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) vitest: specifier: 4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages: @@ -5077,6 +5087,7 @@ packages: audio-decode@2.2.3: resolution: {integrity: sha512-Z0lHvMayR/Pad9+O9ddzaBJE0DrhZkQlStrC1RwcAHF3AhQAsdwKHeLGK8fYKyp2DDU6xHxzGb4CLMui12yVrg==} + deprecated: Renamed to @audio/decode — same API; this name remains a thin alias. npm i @audio/decode audio-type@2.4.1: resolution: {integrity: sha512-dK9Z/P83C/rBfTrXXgPD3jZ+aXxx2o/P4rq8+H1JqxbXklitEeJw4CrcwMC5CkON3CX3yy2gaWnIEVYejYh0zQ==} @@ -11073,7 +11084,7 @@ snapshots: '@vitest/browser-playwright@4.1.9(playwright@1.61.1)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.9)': dependencies: - '@vitest/browser': 4.1.9(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.9) + '@vitest/browser': 4.1.9(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.9) '@vitest/mocker': 4.1.9(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) playwright: 1.61.1 tinyrainbow: 3.1.0 @@ -11147,7 +11158,7 @@ snapshots: tinyrainbow: 3.1.0 vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) optionalDependencies: - '@vitest/browser': 4.1.9(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.9) + '@vitest/browser': 4.1.9(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.9) '@vitest/expect@4.1.9': dependencies: diff --git a/scripts/deadcode-exports.baseline.mjs b/scripts/deadcode-exports.baseline.mjs index e6fb92243266..82bae33cf19d 100644 --- a/scripts/deadcode-exports.baseline.mjs +++ b/scripts/deadcode-exports.baseline.mjs @@ -28,6 +28,11 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [ "extensions/googlechat/src/monitor.ts: testing", "extensions/googlechat/src/targets.ts: resolveGoogleChatSpaceChatType", "extensions/imessage/src/monitor-reply-cache.ts: resetIMessageShortIdState", + "extensions/linux-canvas/src/commands.ts: LINUX_CANVAS_COMMANDS", + "extensions/linux-canvas/src/commands.ts: testing", + "extensions/linux-canvas/src/ipc-client.ts: DEFAULT_REQUEST_TIMEOUT_MS", + "extensions/linux-canvas/src/ipc-client.ts: LinuxCanvasActionEvent", + "extensions/linux-canvas/src/ipc-client.ts: LinuxCanvasIpcRequestHooks", "extensions/matrix/src/approval-reactions.ts: clearMatrixApprovalReactionTargetsForTest", "extensions/matrix/src/matrix/client/config.ts: setMatrixAuthClientDepsForTest", "extensions/matrix/src/matrix/monitor/handler.ts: MatrixRetryableInboundError", @@ -333,6 +338,7 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [ "src/mcp/openclaw-tools-serve-config.ts: resolveOpenClawToolsMcpToolSelection", "src/music-generation/capabilities.ts: resolveMusicGenerationMode", "src/node-host/invoke.ts: testing", + "src/node-host/runtime.ts: NodeHostManifest", "src/plugin-state/plugin-state-store.sqlite.ts: probePluginStateStore", "src/plugin-state/plugin-state-store.sqlite.ts: seedPluginStateDatabaseEntriesForTests", "src/plugin-state/plugin-state-store.sqlite.ts: setMaxPluginStateEntriesPerPluginForTests", diff --git a/scripts/run-vitest.mjs b/scripts/run-vitest.mjs index 949c6320b93c..be657c362763 100644 --- a/scripts/run-vitest.mjs +++ b/scripts/run-vitest.mjs @@ -556,6 +556,24 @@ function collectExplicitProjectRouterTargetArgs(argv, cwd = process.cwd(), fsImp ); } +function isExplicitDirectoryTargetArg(arg, cwd = process.cwd(), fsImpl = fs) { + if (!isPathLikeExplicitFileArg(arg) || GLOB_PATTERN_CHARS_RE.test(arg)) { + return false; + } + const targetPath = path.isAbsolute(arg) ? arg : path.resolve(cwd, arg); + try { + return fsImpl.statSync(targetPath).isDirectory(); + } catch { + return false; + } +} + +function collectExplicitDirectoryTargetArgs(argv, cwd = process.cwd(), fsImpl = fs) { + return collectExplicitFileTargetArgs(argv, (arg) => + isExplicitDirectoryTargetArg(arg, cwd, fsImpl), + ); +} + function collectExplicitTestFileArgs(argv) { return collectExplicitFileTargetArgs(argv, isExplicitTestFileArg); } @@ -737,6 +755,18 @@ export function resolveImplicitVitestArgs(argv, cwd = process.cwd()) { if (hasExplicitVitestConfigArg(argv)) { return argv; } + const separatorIndex = argv.indexOf("--"); + const optionArgs = separatorIndex < 0 ? argv : argv.slice(0, separatorIndex); + const hasExplicitIsolation = optionArgs.some( + (arg) => arg === "--isolate" || arg === "--no-isolate" || arg.startsWith("--isolate="), + ); + if (!hasExplicitIsolation && collectExplicitDirectoryTargetArgs(argv, cwd).length > 1) { + // Mixed directory selectors can activate overlapping Vitest projects. + // Isolate their module caches so one project's mocks cannot poison another. + const resolved = [...argv]; + resolved.splice(separatorIndex < 0 ? resolved.length : separatorIndex, 0, "--isolate"); + return resolved; + } const testTargets = argv .filter((arg) => !arg.startsWith("-") && arg.endsWith(".test.ts")) .map((arg) => toRepoRelativeArg(arg, cwd)); diff --git a/scripts/sync-native-a2ui.d.mts b/scripts/sync-native-a2ui.d.mts index 7547e4a0e5c9..06b1ddffdc21 100644 --- a/scripts/sync-native-a2ui.d.mts +++ b/scripts/sync-native-a2ui.d.mts @@ -2,7 +2,13 @@ export function getNativeA2uiResourcePaths(repoRoot?: string): { sourceDir: string; nativeDir: string; + linuxConsumerFile: string; }; +export function checkLinuxCanvasA2uiReferences({ + linuxConsumerFile, +}: { + linuxConsumerFile: string; +}): Promise; export function syncNativeA2uiResources({ sourceDir, nativeDir, diff --git a/scripts/sync-native-a2ui.mjs b/scripts/sync-native-a2ui.mjs index 35dd9dc0ff4e..ed127dcfd6a4 100644 --- a/scripts/sync-native-a2ui.mjs +++ b/scripts/sync-native-a2ui.mjs @@ -23,9 +23,24 @@ export function getNativeA2uiResourcePaths(repoRoot = rootDir) { "Resources", "CanvasA2UI", ), + linuxConsumerFile: path.join(repoRoot, "apps", "linux", "src-tauri", "src", "canvas.rs"), }; } +export async function checkLinuxCanvasA2uiReferences({ linuxConsumerFile }) { + const source = await fs.readFile(linuxConsumerFile, "utf8"); + const expectedReferences = [ + "../../../../apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasA2UI/index.html", + "../../../../apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasA2UI/a2ui.bundle.js", + ]; + const missing = expectedReferences.filter((reference) => !source.includes(reference)); + if (missing.length > 0) { + throw new Error( + `Linux Canvas must embed the synced native A2UI resources.\nMissing references:\n${formatList(missing)}`, + ); + } +} + function normalizeRelativePath(filePath) { return filePath.split(path.sep).join("/"); } @@ -170,6 +185,7 @@ async function main() { await withFreshBundleCheckSource(paths.sourceDir, async (sourceDir) => { await checkNativeA2uiResources({ sourceDir, nativeDir: paths.nativeDir }); }); + await checkLinuxCanvasA2uiReferences(paths); console.log("[canvas] native A2UI resources up to date."); } diff --git a/src/agents/node-plugin-tools.test.ts b/src/agents/node-plugin-tools.test.ts index fc0dc045fe33..b317c5905bef 100644 --- a/src/agents/node-plugin-tools.test.ts +++ b/src/agents/node-plugin-tools.test.ts @@ -65,7 +65,10 @@ describe("createNodePluginTools", () => { }, }); - const tools = createNodePluginTools({ existingToolNames: new Set(["read"]) }); + const tools = createNodePluginTools({ + existingToolNames: new Set(["read"]), + agentSessionKey: "agent:main:canvas", + }); const result = await expectDefined(tools[0], "tools[0] test invariant").execute("call-1", { text: "ping", }); @@ -88,6 +91,7 @@ describe("createNodePluginTools", () => { command: "remote.echo", params: { text: "ping" }, idempotencyKey: "call-1", + sessionKey: "agent:main:canvas", }, { scopes: ["operator.write"] }, ); diff --git a/src/agents/node-plugin-tools.ts b/src/agents/node-plugin-tools.ts index 30b6840be296..e81cd232ef92 100644 --- a/src/agents/node-plugin-tools.ts +++ b/src/agents/node-plugin-tools.ts @@ -170,6 +170,7 @@ export function createNodePluginTools(params: { existingToolNames?: Set; toolAllowlist?: string[]; toolDenylist?: string[]; + agentSessionKey?: string; }): AnyAgentTool[] { const existingNormalized = new Set( [...(params.existingToolNames ?? [])].map((name) => normalizeToolName(name)), @@ -245,6 +246,7 @@ export function createNodePluginTools(params: { : toolParams, ...(mcpTool ? { timeoutMs: NODE_MCP_TOOL_CALL_TIMEOUT_MS } : {}), idempotencyKey: toolCallId, + ...(params.agentSessionKey ? { sessionKey: params.agentSessionKey } : {}), }, { scopes: ["operator.write"] }, ); diff --git a/src/agents/openclaw-plugin-tools.ts b/src/agents/openclaw-plugin-tools.ts index d9611418f18f..44841a1ccd96 100644 --- a/src/agents/openclaw-plugin-tools.ts +++ b/src/agents/openclaw-plugin-tools.ts @@ -140,6 +140,7 @@ export function resolveOpenClawPluginToolsForOptions(params: { existingToolNames, toolAllowlist: params.options?.pluginToolAllowlist, toolDenylist: params.options?.pluginToolDenylist, + agentSessionKey: params.options?.agentSessionKey, }), ); diff --git a/src/agents/tools/nodes-tool-commands.ts b/src/agents/tools/nodes-tool-commands.ts index b6a999f17932..ee451a6a0a38 100644 --- a/src/agents/tools/nodes-tool-commands.ts +++ b/src/agents/tools/nodes-tool-commands.ts @@ -41,6 +41,7 @@ export async function executeNodeCommandAction(params: { action: NodeCommandAction; input: Record; gatewayOpts: GatewayCallOptions; + agentSessionKey?: string; allowMediaInvokeCommands?: boolean; mediaInvokeActions: Record; }): Promise< @@ -184,6 +185,7 @@ export async function executeNodeCommandAction(params: { params: invokeParams, timeoutMs: invokeTimeoutMs, idempotencyKey: crypto.randomUUID(), + ...(params.agentSessionKey ? { sessionKey: params.agentSessionKey } : {}), }); return jsonResult(raw ?? {}); } diff --git a/src/agents/tools/nodes-tool.test.ts b/src/agents/tools/nodes-tool.test.ts index 11e98eebafd1..02caa1925d86 100644 --- a/src/agents/tools/nodes-tool.test.ts +++ b/src/agents/tools/nodes-tool.test.ts @@ -827,6 +827,26 @@ describe("createNodesTool screen_record duration guardrails", () => { ).rejects.toThrow('invokeCommand "system.run" is reserved for shell execution'); }); + it("forwards the owning agent session for generic node invokes", async () => { + gatewayMocks.callGatewayTool.mockResolvedValue({ payload: { ok: true } }); + const tool = createNodesTool({ agentSessionKey: "agent:main:canvas" }); + + await tool.execute("call-1", { + action: "invoke", + node: "macbook", + invokeCommand: "device.status", + }); + + expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith( + "node.invoke", + {}, + expect.objectContaining({ + command: "device.status", + sessionKey: "agent:main:canvas", + }), + ); + }); + it("blocks raw computer.act so desktop input uses the dedicated safety contract", async () => { const tool = createNodesTool(); diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index c6056e9ee49a..a3ae9d26e492 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -260,6 +260,7 @@ export function createNodesTool(options?: { action: action as NodeCommandAction, input: params, gatewayOpts, + agentSessionKey: options?.agentSessionKey, allowMediaInvokeCommands: options?.allowMediaInvokeCommands, mediaInvokeActions: MEDIA_INVOKE_ACTIONS, }); @@ -269,6 +270,7 @@ export function createNodesTool(options?: { action, input: params, gatewayOpts, + agentSessionKey: options?.agentSessionKey, allowMediaInvokeCommands: options?.allowMediaInvokeCommands, mediaInvokeActions: MEDIA_INVOKE_ACTIONS, }); @@ -305,6 +307,7 @@ export function createNodesTool(options?: { action, input: params, gatewayOpts, + agentSessionKey: options?.agentSessionKey, allowMediaInvokeCommands: options?.allowMediaInvokeCommands, mediaInvokeActions: MEDIA_INVOKE_ACTIONS, }); @@ -314,6 +317,7 @@ export function createNodesTool(options?: { action, input: params, gatewayOpts, + agentSessionKey: options?.agentSessionKey, allowMediaInvokeCommands: options?.allowMediaInvokeCommands, mediaInvokeActions: MEDIA_INVOKE_ACTIONS, }); @@ -323,6 +327,7 @@ export function createNodesTool(options?: { action, input: params, gatewayOpts, + agentSessionKey: options?.agentSessionKey, allowMediaInvokeCommands: options?.allowMediaInvokeCommands, mediaInvokeActions: MEDIA_INVOKE_ACTIONS, }); diff --git a/src/gateway/client.ts b/src/gateway/client.ts index c1a97c969d93..37d081823530 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -93,4 +93,8 @@ export class GatewayClient { getConnectionMetadata(): GatewayClientConnectionMetadata { return this.#client.getConnectionMetadata(); } + + updateNodeManifest(manifest: { caps: string[]; commands: string[] }): void { + this.#client.updateNodeManifest(manifest); + } } diff --git a/src/gateway/node-normalize.ts b/src/gateway/node-normalize.ts new file mode 100644 index 000000000000..f3a8ebb41435 --- /dev/null +++ b/src/gateway/node-normalize.ts @@ -0,0 +1,4 @@ +/** Normalize optional string-ish websocket fields. Leaf module (no gateway imports). */ +export function normalizeString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} diff --git a/src/gateway/node-registry.test.ts b/src/gateway/node-registry.test.ts index d1c5aedb8a82..d7b391a67c81 100644 --- a/src/gateway/node-registry.test.ts +++ b/src/gateway/node-registry.test.ts @@ -624,6 +624,31 @@ describe("gateway/node-registry", () => { } }); + it("forwards the agent session that owns a stateful node invoke", async () => { + const registry = createNodeRegistry(); + const frames = registerNode(registry); + const invoke = registry.invoke({ + nodeId: "node-1", + command: "debug.ping", + timeoutMs: 0, + sessionKey: "agent:main:canvas", + }); + const request = JSON.parse(frames[0] ?? "{}") as { + payload?: { id?: string; sessionKey?: string }; + }; + + expect(request.payload?.sessionKey).toBe("agent:main:canvas"); + expect( + registry.handleInvokeResult({ + id: request.payload?.id ?? "", + nodeId: "node-1", + connId: "conn-1", + ok: true, + }), + ).toBe(true); + await expect(invoke).resolves.toMatchObject({ ok: true }); + }); + it("rejects zero-timeout invokes when the node disconnects", async () => { const registry = createNodeRegistry(); registerNode(registry); diff --git a/src/gateway/node-registry.ts b/src/gateway/node-registry.ts index 22db98e8800f..af8ef92135fe 100644 --- a/src/gateway/node-registry.ts +++ b/src/gateway/node-registry.ts @@ -17,6 +17,7 @@ import type { import { setActiveNodeContext } from "../infra/active-node-context.js"; import { NODE_MCP_TOOLS_CALL_COMMAND } from "../infra/node-commands.js"; import { logRejectedLargePayload } from "../logging/diagnostic-payload.js"; +import { normalizeString } from "./node-normalize.js"; import { createRegisteredNodePluginToolDescriptorMap, normalizeNodePluginToolDescriptors, @@ -75,10 +76,6 @@ type AuthorizedSystemRunEvent = PendingSystemRunEvent & { connId: string; expiresAtMs: number | null; }; -/** Normalize optional string-ish websocket fields. */ -function normalizeString(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; -} /** Extract system.run event auth metadata from invoke params. */ function resolvePendingSystemRunEvent(params: { @@ -689,6 +686,7 @@ export class NodeRegistry { onProgress?: (chunk: string) => void; signal?: AbortSignal; idempotencyKey?: string; + sessionKey?: string; /** Receives the id synchronously after send; the terminal relay depends on this timing. */ onInvokeId?: (invokeId: string) => void; }): Promise { @@ -723,6 +721,7 @@ export class NodeRegistry { "params" in params && invokeParams !== undefined ? JSON.stringify(invokeParams) : null, timeoutMs, idempotencyKey: params.idempotencyKey, + sessionKey: normalizeString(params.sessionKey) || undefined, }; const systemRunEvent = resolvePendingSystemRunEvent({ command: params.command, diff --git a/src/gateway/server-methods/node-browser-proxy.ts b/src/gateway/server-methods/node-browser-proxy.ts new file mode 100644 index 000000000000..e702637cf1ca --- /dev/null +++ b/src/gateway/server-methods/node-browser-proxy.ts @@ -0,0 +1,34 @@ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; + +function normalizeBrowserProxyPath(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return trimmed; + } + const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + if (withLeadingSlash.length <= 1) { + return withLeadingSlash; + } + return withLeadingSlash.replace(/\/+$/, ""); +} + +function isPersistentBrowserProxyMutation(method: string, path: string): boolean { + const normalizedPath = normalizeBrowserProxyPath(path); + if ( + method === "POST" && + (normalizedPath === "/profiles/create" || normalizedPath === "/reset-profile") + ) { + return true; + } + return method === "DELETE" && /^\/profiles\/[^/]+$/.test(normalizedPath); +} + +export function isForbiddenBrowserProxyMutation(params: unknown): boolean { + if (!params || typeof params !== "object") { + return false; + } + const candidate = params as { method?: unknown; path?: unknown }; + const method = (normalizeOptionalString(candidate.method) ?? "").toUpperCase(); + const path = normalizeOptionalString(candidate.path) ?? ""; + return Boolean(method && path && isPersistentBrowserProxyMutation(method, path)); +} diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 477d91c98b61..95377c42f935 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -77,6 +77,7 @@ import { type DeviceManagementAuthz, } from "./device-management-authz.js"; import { emitDeviceManagementSecurityEvent } from "./device-management-security.js"; +import { isForbiddenBrowserProxyMutation } from "./node-browser-proxy.js"; import { buildNodeCommandRejectionHint } from "./node-command-rejection-hint.js"; import { nodeInvokePolicy } from "./nodes-policy.js"; import { @@ -184,39 +185,6 @@ function listNodesForClient(params: { return nodes.map((node) => safeNodeReadProjection(node, ownDeviceId)).filter(isVisibleNode); } -function normalizeBrowserProxyPath(value: string): string { - const trimmed = value.trim(); - if (!trimmed) { - return trimmed; - } - const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; - if (withLeadingSlash.length <= 1) { - return withLeadingSlash; - } - return withLeadingSlash.replace(/\/+$/, ""); -} - -function isPersistentBrowserProxyMutation(method: string, path: string): boolean { - const normalizedPath = normalizeBrowserProxyPath(path); - if ( - method === "POST" && - (normalizedPath === "/profiles/create" || normalizedPath === "/reset-profile") - ) { - return true; - } - return method === "DELETE" && /^\/profiles\/[^/]+$/.test(normalizedPath); -} - -function isForbiddenBrowserProxyMutation(params: unknown): boolean { - if (!params || typeof params !== "object") { - return false; - } - const candidate = params as { method?: unknown; path?: unknown }; - const method = (normalizeOptionalString(candidate.method) ?? "").toUpperCase(); - const path = normalizeOptionalString(candidate.path) ?? ""; - return Boolean(method && path && isPersistentBrowserProxyMutation(method, path)); -} - function normalizePluginSurfaceRefreshParams(params: unknown): { surface: string } | undefined { if (!params || typeof params !== "object") { return undefined; @@ -1289,6 +1257,7 @@ export const nodeHandlers: GatewayRequestHandlers = { params?: unknown; timeoutMs?: number; idempotencyKey: string; + sessionKey?: string; turnSourceChannel?: string; turnSourceTo?: string; turnSourceAccountId?: string; @@ -1296,6 +1265,7 @@ export const nodeHandlers: GatewayRequestHandlers = { }; const nodeId = normalizeOptionalString(p.nodeId) ?? ""; const command = normalizeOptionalString(p.command) ?? ""; + const sessionKey = normalizeOptionalString(p.sessionKey); if (!nodeId || !command) { respond( false, @@ -1574,6 +1544,7 @@ export const nodeHandlers: GatewayRequestHandlers = { params: forwardedParams.params, timeoutMs: p.timeoutMs, idempotencyKey: p.idempotencyKey, + ...(sessionKey ? { sessionKey } : {}), }); if (!res.ok) { if ( diff --git a/src/node-host/invoke-agent-cli-claude-handler.ts b/src/node-host/invoke-agent-cli-claude-handler.ts index e686d8e31489..463bd436fd82 100644 --- a/src/node-host/invoke-agent-cli-claude-handler.ts +++ b/src/node-host/invoke-agent-cli-claude-handler.ts @@ -1,5 +1,6 @@ import { createExecApprovalPolicySnapshot } from "../infra/exec-approvals.js"; import type { OpenClawPluginNodeHostCommandIo } from "../plugins/types.js"; +import type { OpenClawPluginNodeHostCommandContext } from "../plugins/types.node-host.js"; import type { NodeHostClient } from "./client.js"; import { decodeClaudeCliNodeRunParams, @@ -19,6 +20,7 @@ export type NodeHostInvokeRuntime = { handleSystemRun?: typeof handleSystemRunInvoke; signal?: AbortSignal; pluginCommandIo?: OpenClawPluginNodeHostCommandIo; + pluginCommandContext?: OpenClawPluginNodeHostCommandContext; }; type ClaudeCliNodeInvokeDeps = Pick< diff --git a/src/node-host/invoke-types.ts b/src/node-host/invoke-types.ts index 0d8532d18f0c..b816cf9bfb36 100644 --- a/src/node-host/invoke-types.ts +++ b/src/node-host/invoke-types.ts @@ -15,6 +15,7 @@ export type NodeInvokeRequestPayload = { paramsJSON?: string | null; timeoutMs?: number | null; idempotencyKey?: string | null; + sessionKey?: string | null; }; /** Input payload for a node-host system.run invocation. */ diff --git a/src/node-host/invoke.test.ts b/src/node-host/invoke.test.ts index 12d101da3591..369f69788ebe 100644 --- a/src/node-host/invoke.test.ts +++ b/src/node-host/invoke.test.ts @@ -6,12 +6,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import type { GatewayClient } from "../gateway/client.js"; import { saveExecApprovals, type ExecApprovalsSnapshot } from "../infra/exec-approvals.js"; +import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; import { withEnvAsync } from "../test-utils/env.js"; import type { SkillBinsProvider } from "./invoke-types.js"; import { handleInvoke } from "./invoke.js"; const tempDirs = useAutoCleanupTempDirTracker(afterEach); +afterEach(() => { + resetPluginRuntimeStateForTest(); +}); + const approvalResolutionFailure = vi.hoisted(() => ({ error: null as Error | null })); type ExecApprovalsUpdate = Parameters< typeof import("../infra/exec-approvals.js").updateExecApprovals @@ -152,6 +158,41 @@ describe("node host invoke", () => { execApprovalsStoreMock.updateParams = undefined; }); + it("passes the owning agent session to plugin node commands", async () => { + const handle = vi.fn(async () => '{"ok":true}'); + const registry = createEmptyPluginRegistry(); + registry.nodeHostCommands = [ + { + pluginId: "canvas", + pluginName: "Canvas", + command: { command: "canvas.present", cap: "canvas", handle }, + source: "test", + }, + ]; + setActivePluginRegistry(registry); + const request = vi.fn().mockResolvedValue(null); + const sendNodeEvent = vi.fn(async () => undefined); + + await handleInvoke( + { + id: "invoke-canvas", + nodeId: "node-1", + command: "canvas.present", + paramsJSON: "{}", + sessionKey: "agent:main:canvas", + }, + { request } as unknown as GatewayClient, + { current: async () => [] }, + undefined, + { pluginCommandContext: { sendNodeEvent } }, + ); + + expect(handle).toHaveBeenCalledWith("{}", undefined, { + sendNodeEvent, + sessionKey: "agent:main:canvas", + }); + }); + it("lists node-host directories for the folder browser", async () => { const root = fs.realpathSync(tempDirs.make("openclaw-node-fs-listdir-")); fs.mkdirSync(path.join(root, "Projects")); diff --git a/src/node-host/invoke.ts b/src/node-host/invoke.ts index ac53790900af..9dd5d9191ec6 100644 --- a/src/node-host/invoke.ts +++ b/src/node-host/invoke.ts @@ -62,6 +62,7 @@ import type { SystemRunParams, } from "./invoke-types.js"; import { NodeHostMcpError, type NodeHostMcpManager } from "./mcp.js"; +import { buildNodeEventParams } from "./node-event-params.js"; import { invokeRegisteredNodeHostCommand as invokePlugin } from "./plugin-node-host.js"; import { resolveNodeHostedSkillDirectory } from "./skills.js"; @@ -693,9 +694,11 @@ async function dispatchInvoke( }); return; } - try { - const pluginResult = await invokePlugin(command, frame.paramsJSON, runtime.pluginCommandIo); + const { pluginCommandIo: io, pluginCommandContext: context } = runtime; + const invokeContext = + context && frame.sessionKey ? { ...context, sessionKey: frame.sessionKey } : context; + const pluginResult = await invokePlugin(command, frame.paramsJSON, io, invokeContext); if (pluginResult !== null) { await sendRawPayloadResult(client, frame, pluginResult); return; @@ -1064,17 +1067,6 @@ function buildNodeInvokeResultParams( return params; } -function buildNodeEventParams( - event: string, - payload: unknown, -): { event: string; payloadJSON: string | null } { - const payloadJSON = payload === undefined ? undefined : JSON.stringify(payload); - return { - event, - payloadJSON: typeof payloadJSON === "string" ? payloadJSON : null, - }; -} - async function sendNodeEvent(client: NodeHostClient, event: string, payload: unknown) { try { await client.request("node.event", buildNodeEventParams(event, payload)); diff --git a/src/node-host/node-event-params.ts b/src/node-host/node-event-params.ts new file mode 100644 index 000000000000..87aac1711379 --- /dev/null +++ b/src/node-host/node-event-params.ts @@ -0,0 +1,11 @@ +/** Build node.event params, shared by the invoke dispatcher and the runtime. */ +export function buildNodeEventParams( + event: string, + payload: unknown, +): { event: string; payloadJSON: string | null } { + const payloadJSON = payload === undefined ? undefined : JSON.stringify(payload); + return { + event, + payloadJSON: typeof payloadJSON === "string" ? payloadJSON : null, + }; +} diff --git a/src/node-host/plugin-node-host.test.ts b/src/node-host/plugin-node-host.test.ts index 6ad8a5183632..e43ff34a728a 100644 --- a/src/node-host/plugin-node-host.test.ts +++ b/src/node-host/plugin-node-host.test.ts @@ -5,6 +5,7 @@ import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plug import { invokeRegisteredNodeHostCommand, listRegisteredNodeHostCapsAndCommands, + watchRegisteredNodeHostCommandAvailability, } from "./plugin-node-host.js"; const availabilityContext = { config: {}, env: {} }; @@ -161,6 +162,36 @@ describe("plugin node-host registry", () => { }); }); + it("owns plugin availability watcher cleanup", () => { + let notify: (() => void) | undefined; + const cleanup = vi.fn(); + const onChange = vi.fn(); + const registry = createEmptyPluginRegistry(); + registry.nodeHostCommands = [ + { + pluginId: "browser", + pluginName: "Browser", + command: { + command: "browser.proxy", + cap: "browser", + watchAvailability: (_context, callback) => { + notify = callback; + return cleanup; + }, + handle: vi.fn(async () => "{}"), + }, + source: "test", + }, + ]; + setActivePluginRegistry(registry); + + const stop = watchRegisteredNodeHostCommandAvailability(availabilityContext, onChange); + notify?.(); + expect(onChange).toHaveBeenCalledOnce(); + stop(); + expect(cleanup).toHaveBeenCalledOnce(); + }); + it("dispatches plugin-declared node-host commands", async () => { const handle = vi.fn(async (paramsJSON?: string | null) => paramsJSON ?? ""); const registry = createEmptyPluginRegistry(); @@ -178,11 +209,15 @@ describe("plugin node-host registry", () => { ]; setActivePluginRegistry(registry); - await expect(invokeRegisteredNodeHostCommand("browser.proxy", '{"ok":true}')).resolves.toBe( - '{"ok":true}', - ); + const context = { + sendNodeEvent: vi.fn(async () => undefined), + sessionKey: "agent:main:canvas", + }; + await expect( + invokeRegisteredNodeHostCommand("browser.proxy", '{"ok":true}', undefined, context), + ).resolves.toBe('{"ok":true}'); await expect(invokeRegisteredNodeHostCommand("missing.command", null)).resolves.toBeNull(); - expect(handle).toHaveBeenCalledWith('{"ok":true}'); + expect(handle).toHaveBeenCalledWith('{"ok":true}', undefined, context); }); it("gates duplex commands from embedded-worker manifests and supplies their IO context", async () => { diff --git a/src/node-host/plugin-node-host.ts b/src/node-host/plugin-node-host.ts index f07c257fb371..b3e1f19a3819 100644 --- a/src/node-host/plugin-node-host.ts +++ b/src/node-host/plugin-node-host.ts @@ -7,6 +7,7 @@ import type { OpenClawPluginNodeHostCommandAvailabilityContext, OpenClawPluginNodeHostCommandIo, } from "../plugins/types.js"; +import type { OpenClawPluginNodeHostCommandContext } from "../plugins/types.node-host.js"; import { createLazyRuntimeModule } from "../shared/lazy-runtime.js"; /** @@ -74,6 +75,26 @@ export function listRegisteredNodeHostCapsAndCommands( }; } +/** Watch plugin-owned availability inputs that can change during this process. */ +export function watchRegisteredNodeHostCommandAvailability( + context: OpenClawPluginNodeHostCommandAvailabilityContext, + onChange: () => void, +): () => void { + const registry = getActivePluginRegistry(); + const cleanups: Array<() => void> = []; + for (const entry of registry?.nodeHostCommands ?? []) { + const cleanup = entry.command.watchAvailability?.(context, onChange); + if (cleanup) { + cleanups.push(cleanup); + } + } + return () => { + for (const cleanup of cleanups.splice(0)) { + cleanup(); + } + }; +} + function normalizeString(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } @@ -121,6 +142,7 @@ export async function invokeRegisteredNodeHostCommand( command: string, paramsJSON?: string | null, io?: OpenClawPluginNodeHostCommandIo, + context?: OpenClawPluginNodeHostCommandContext, ): Promise { const registry = getActivePluginRegistry(); const match = (registry?.nodeHostCommands ?? []).find( @@ -133,9 +155,13 @@ export async function invokeRegisteredNodeHostCommand( if (!io) { throw new Error(`node command requires duplex transport: ${command}`); } - return await match.command.handle(paramsJSON, io); + return context + ? await match.command.handle(paramsJSON, io, context) + : await match.command.handle(paramsJSON, io); } - return await match.command.handle(paramsJSON); + return context + ? await match.command.handle(paramsJSON, undefined, context) + : await match.command.handle(paramsJSON); } export function isRegisteredNodeHostCommandDuplex(command: string): boolean { diff --git a/src/node-host/runner.test.ts b/src/node-host/runner.test.ts index 93843a3792a1..e4da27ca2f22 100644 --- a/src/node-host/runner.test.ts +++ b/src/node-host/runner.test.ts @@ -12,12 +12,17 @@ const mocks = vi.hoisted(() => ({ capturedGatewayClients: [] as Array<{ request: ReturnType; stop: ReturnType; + updateNodeManifest: ReturnType; }>, mcpConfiguredServerCount: 0, mcpDescriptors: [] as Array>, nodeSkillDescriptors: [] as Array>, runtimeSteps: [] as string[], useFakeRuntime: false, + nodeHostCommands: [] as string[], + nodeHostCaps: [] as string[], + availabilityOnWatch: undefined as { caps: string[]; commands: string[] } | undefined, + availabilityChanged: undefined as (() => void) | undefined, normalizedPath: null as string | null, resolvedExecutables: new Map(), closeMcpManager: vi.fn(async () => undefined), @@ -64,6 +69,7 @@ vi.mock("../gateway/client.js", () => ({ const client = { request: vi.fn(async () => ({})), stop: vi.fn(), + updateNodeManifest: vi.fn(), }; mocks.capturedGatewayClientOptions.push(opts); mocks.capturedGatewayClients.push(client); @@ -110,8 +116,8 @@ vi.mock("./plugin-node-host.js", () => ({ listRegisteredNodeHostCapsAndCommands: vi.fn((context: { env: NodeJS.ProcessEnv }) => { mocks.runtimeSteps.push(`commands:${context.env.PATH ?? ""}`); return { - caps: [], - commands: [], + commands: [...mocks.nodeHostCommands], + caps: [...mocks.nodeHostCaps], nodePluginTools: [ { pluginId: "test-plugin", @@ -123,6 +129,16 @@ vi.mock("./plugin-node-host.js", () => ({ ], }; }), + watchRegisteredNodeHostCommandAvailability: vi.fn((_context: unknown, onChange: () => void) => { + mocks.availabilityChanged = onChange; + if (mocks.availabilityOnWatch) { + mocks.nodeHostCaps = [...mocks.availabilityOnWatch.caps]; + mocks.nodeHostCommands = [...mocks.availabilityOnWatch.commands]; + } + return () => { + mocks.availabilityChanged = undefined; + }; + }), })); vi.mock("./mcp.js", () => ({ @@ -171,6 +187,10 @@ describe("runNodeHost", () => { mocks.nodeSkillDescriptors = []; mocks.runtimeSteps = []; mocks.useFakeRuntime = false; + mocks.nodeHostCommands = []; + mocks.nodeHostCaps = []; + mocks.availabilityOnWatch = undefined; + mocks.availabilityChanged = undefined; mocks.normalizedPath = null; mocks.resolvedExecutables.clear(); vi.clearAllMocks(); @@ -286,7 +306,58 @@ describe("runNodeHost", () => { process.env.PATH = originalPath; } - expect(mocks.runtimeSteps).toEqual(["path", "commands:/normalized/node/path"]); + expect(mocks.runtimeSteps).toEqual([ + "path", + "commands:/normalized/node/path", + "commands:/normalized/node/path", + ]); + }); + + it("reconciles the manifest after watch attachment and on later changes", async () => { + mocks.startGatewayClientWhenEventLoopReady.mockResolvedValueOnce({ + ready: true, + aborted: false, + elapsedMs: 0, + }); + mocks.availabilityOnWatch = { + caps: ["canvas"], + commands: ["canvas.present"], + }; + const processOnceSpy = vi.spyOn(process, "once"); + const previousExitCode = process.exitCode; + try { + const running = runNodeHost({ gatewayHost: "127.0.0.1", gatewayPort: 18789 }); + await vi.waitFor(() => + expect(mocks.capturedGatewayClients[0]?.updateNodeManifest).toHaveBeenCalledWith( + expect.objectContaining({ + caps: expect.arrayContaining(["canvas"]), + commands: expect.arrayContaining(["canvas.present"]), + }), + ), + ); + + mocks.nodeHostCaps = []; + mocks.nodeHostCommands = []; + mocks.availabilityChanged?.(); + expect(mocks.capturedGatewayClients[0]?.updateNodeManifest).toHaveBeenLastCalledWith( + expect.objectContaining({ + caps: expect.not.arrayContaining(["canvas"]), + commands: expect.not.arrayContaining(["canvas.present"]), + }), + ); + + const onSigterm = processOnceSpy.mock.calls.find(([event]) => event === "SIGTERM")?.[1]; + onSigterm?.("SIGTERM"); + await running; + } finally { + for (const [event, listener] of processOnceSpy.mock.calls) { + if ((event === "SIGINT" || event === "SIGTERM") && typeof listener === "function") { + process.off(event, listener); + } + } + process.exitCode = previousExitCode; + processOnceSpy.mockRestore(); + } }); it("keeps a ref'd lifetime handle until a ready foreground host stops", async () => { diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index 729f32349b2f..3ac20d922834 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -297,6 +297,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { }); }, onClose: (code, reason) => { + gatewayHelloReceived = false; activeRuntime.cancelAll(); writeStderrLine(`node host gateway closed (${code}): ${reason}`); }, @@ -307,6 +308,10 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { inventory = nextInventory; publishInventory(); }, + onManifestChanged: (manifest) => { + gatewayHelloReceived = false; + client.updateNodeManifest(manifest); + }, }); let stopping = false; diff --git a/src/node-host/runtime.ts b/src/node-host/runtime.ts index 7bac250208d4..ea2c6979619c 100644 --- a/src/node-host/runtime.ts +++ b/src/node-host/runtime.ts @@ -17,21 +17,24 @@ import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { ensureTerminalUploadCleanup } from "../infra/terminal-file-upload.js"; import { logDebug } from "../logger.js"; import type { OpenClawPluginNodeHostCommandIo } from "../plugins/types.js"; +import type { OpenClawPluginNodeHostCommandContext } from "../plugins/types.node-host.js"; import { BoundedBuffer } from "../shared/bounded-buffer.js"; import type { NodeHostClient } from "./client.js"; import { handleInvoke, type NodeInvokeRequestPayload, type SkillBinsProvider } from "./invoke.js"; import { startNodeHostMcpManager, type NodeHostMcpManager } from "./mcp.js"; +import { buildNodeEventParams } from "./node-event-params.js"; import { createNodeInvokeProgressWriter } from "./node-invoke-progress.js"; import { ensureNodeHostPluginRegistry, isRegisteredNodeHostCommandDuplex, listRegisteredNodeHostCapsAndCommands, + watchRegisteredNodeHostCommandAvailability, } from "./plugin-node-host.js"; import { scanNodeHostedSkills } from "./skills.js"; const DEFAULT_NODE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; -type NodeHostManifest = { +export type NodeHostManifest = { caps: string[]; commands: string[]; pathEnv: string; @@ -48,6 +51,7 @@ type PreparedNodeHostRuntime = { start(params: { client: NodeHostClient; onInventoryChanged?: (inventory: NodeHostInventory) => void; + onManifestChanged?: (manifest: NodeHostManifest) => void; }): ActiveNodeHostRuntime; }; @@ -209,6 +213,18 @@ function createInventory(params: { return { skills: params.skills, pluginTools }; } +function sameStringList(left: string[], right: string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function sameManifest(left: NodeHostManifest, right: NodeHostManifest): boolean { + return ( + left.pathEnv === right.pathEnv && + sameStringList(left.caps, right.caps) && + sameStringList(left.commands, right.commands) + ); +} + export async function prepareNodeHostRuntime(params?: { config?: OpenClawConfig; env?: NodeJS.ProcessEnv; @@ -225,10 +241,12 @@ export async function prepareNodeHostRuntime(params?: { env.PATH = pathEnv; const duplexEnabled = params?.enableAgentRuns === true || params?.enableDuplexPluginCommands === true; - const pluginNodeHost = listRegisteredNodeHostCapsAndCommands( - { config, env }, - { includeDuplex: duplexEnabled }, - ); + const availabilityContext = { config, env }; + const resolvePluginNodeHost = () => + listRegisteredNodeHostCapsAndCommands(availabilityContext, { + includeDuplex: duplexEnabled, + }); + const pluginNodeHost = resolvePluginNodeHost(); // Opt-in and binary resolution are node-local enforcement points. A Gateway // cannot advertise or enable this command on the host's behalf. const claudePath = @@ -236,8 +254,8 @@ export async function prepareNodeHostRuntime(params?: { ? resolveExecutableTrustPathFromEnv("claude", pathEnv) : null; const skills = config.nodeHost?.skills?.enabled === false ? null : scanNodeHostedSkills(); - const manifest: NodeHostManifest = { - caps: [...new Set(["system", "mcp", ...pluginNodeHost.caps])].toSorted(), + const buildManifest = (pluginManifest: typeof pluginNodeHost): NodeHostManifest => ({ + caps: [...new Set(["system", "mcp", ...pluginManifest.caps])].toSorted(), commands: [ ...new Set([ ...NODE_SYSTEM_RUN_COMMANDS, @@ -246,11 +264,12 @@ export async function prepareNodeHostRuntime(params?: { NODE_TERMINAL_UPLOAD_COMMAND, NODE_MCP_TOOLS_CALL_COMMAND, ...(claudePath ? [NODE_AGENT_CLI_CLAUDE_RUN_COMMAND] : []), - ...pluginNodeHost.commands, + ...pluginManifest.commands, ]), ].toSorted(), pathEnv, - }; + }); + const manifest = buildManifest(pluginNodeHost); const initialInventory = createInventory({ skills, pluginTools: pluginNodeHost.nodePluginTools, @@ -259,13 +278,19 @@ export async function prepareNodeHostRuntime(params?: { return { manifest, initialInventory, - start({ client, onInventoryChanged }) { + start({ client, onInventoryChanged, onManifestChanged }) { const mcpAbort = new AbortController(); const skillBins = new SkillBinsCache(client, pathEnv); const activeInvokes = new Map< string, NodeInvokeInputTarget & { controller: AbortController } >(); + const pluginCommandContext: OpenClawPluginNodeHostCommandContext = { + sendNodeEvent: async (event, payload) => + await client.request("node.event", buildNodeEventParams(event, payload)), + }; + let currentPluginNodeHost = pluginNodeHost; + let currentManifest = manifest; let manager: NodeHostMcpManager | undefined; const startup = startNodeHostMcpManager(config.nodeHost?.mcp?.servers, { signal: mcpAbort.signal, @@ -274,12 +299,36 @@ export async function prepareNodeHostRuntime(params?: { onInventoryChanged?.( createInventory({ skills, - pluginTools: pluginNodeHost.nodePluginTools, + pluginTools: currentPluginNodeHost.nodePluginTools, mcpManager: manager, }), ); return resolved; }); + const refreshAvailability = () => { + const nextPluginNodeHost = resolvePluginNodeHost(); + const nextManifest = buildManifest(nextPluginNodeHost); + currentPluginNodeHost = nextPluginNodeHost; + onInventoryChanged?.( + createInventory({ + skills, + pluginTools: currentPluginNodeHost.nodePluginTools, + mcpManager: manager, + }), + ); + if (!sameManifest(currentManifest, nextManifest)) { + currentManifest = nextManifest; + onManifestChanged?.(nextManifest); + } + }; + const stopAvailabilityWatch = onManifestChanged + ? watchRegisteredNodeHostCommandAvailability(availabilityContext, refreshAvailability) + : () => {}; + // The watcher cannot replay a socket change between preparation and + // registration. Resolve once after attachment to close that race. + if (onManifestChanged) { + refreshAvailability(); + } return { async invoke(frame) { const duplexCommand = duplexEnabled && isRegisteredNodeHostCommandDuplex(frame.command); @@ -335,6 +384,7 @@ export async function prepareNodeHostRuntime(params?: { ...(claudePath ? { claudePath } : {}), ...(controller ? { signal: controller.signal } : {}), ...(pluginCommandIo ? { pluginCommandIo } : {}), + pluginCommandContext, }); } finally { progress?.stop(); @@ -361,6 +411,7 @@ export async function prepareNodeHostRuntime(params?: { }, async close() { this.cancelAll(); + stopAvailabilityWatch(); mcpAbort.abort(); const resolved = manager ?? (await startup.catch(() => undefined)); await resolved?.close(); diff --git a/src/plugins/types.node-host.ts b/src/plugins/types.node-host.ts index 9a8852fa8c75..7cb0c3aa2fab 100644 --- a/src/plugins/types.node-host.ts +++ b/src/plugins/types.node-host.ts @@ -14,12 +14,24 @@ export type OpenClawPluginNodeHostCommandIo = { signal: AbortSignal; }; +export type OpenClawPluginNodeHostCommandContext = { + /** Emit one node-owned event through the active Gateway connection. */ + sendNodeEvent(event: string, payload: unknown): Promise; + /** Agent session that owns this invocation, when the caller supplied one. */ + sessionKey?: string; +}; + type OpenClawPluginNodeHostCommandBase = { command: string; cap?: string; dangerous?: boolean; /** Return false to omit this command and capability from the node declaration. */ isAvailable?: (context: OpenClawPluginNodeHostCommandAvailabilityContext) => boolean; + /** Watch node-local availability and request a fresh Gateway declaration. */ + watchAvailability?: ( + context: OpenClawPluginNodeHostCommandAvailabilityContext, + onChange: () => void, + ) => (() => void) | void; agentTool?: { name: string; description: string; @@ -35,5 +47,9 @@ export type OpenClawPluginNodeHostCommand = OpenClawPluginNodeHostCommandBase & // plain `command.handle(params)` uncallable for consumers holding the union. // The node host enforces io presence for duplex commands at runtime. duplex?: boolean; - handle: (paramsJSON?: string | null, io?: OpenClawPluginNodeHostCommandIo) => Promise; + handle: ( + paramsJSON?: string | null, + io?: OpenClawPluginNodeHostCommandIo, + context?: OpenClawPluginNodeHostCommandContext, + ) => Promise; }; diff --git a/test/scripts/run-vitest.test.ts b/test/scripts/run-vitest.test.ts index 263a413eb015..a77af454d12c 100644 --- a/test/scripts/run-vitest.test.ts +++ b/test/scripts/run-vitest.test.ts @@ -169,6 +169,21 @@ describe("scripts/run-vitest", () => { expect(resolveImplicitVitestArgs(argv)).toBe(argv); }); + it("isolates mixed explicit directory targets across Vitest projects", () => { + expect(resolveImplicitVitestArgs(["extensions/linux-canvas", "src/node-host"])).toEqual([ + "extensions/linux-canvas", + "src/node-host", + "--isolate", + ]); + expect(resolveImplicitVitestArgs(["src/node-host"])).toEqual(["src/node-host"]); + expect( + resolveImplicitVitestArgs(["extensions/linux-canvas", "src/node-host", "--no-isolate"]), + ).toEqual(["extensions/linux-canvas", "src/node-host", "--no-isolate"]); + expect( + resolveImplicitVitestArgs(["extensions/linux-canvas", "src/node-host", "--", "--no-isolate"]), + ).toEqual(["extensions/linux-canvas", "src/node-host", "--isolate", "--", "--no-isolate"]); + }); + it("routes explicit tooling tests through the tooling config", () => { expect(resolveImplicitVitestArgs(["run", "test/scripts/run-vitest.test.ts"])).toEqual([ "run", diff --git a/test/scripts/sync-native-a2ui.test.ts b/test/scripts/sync-native-a2ui.test.ts index fa67cf6771d0..b3dfa9094187 100644 --- a/test/scripts/sync-native-a2ui.test.ts +++ b/test/scripts/sync-native-a2ui.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { + checkLinuxCanvasA2uiReferences, checkNativeA2uiResources, getNativeA2uiResourcePaths, syncNativeA2uiResources, @@ -45,9 +46,28 @@ describe("scripts/sync-native-a2ui.mjs", () => { "Resources", "CanvasA2UI", ), + linuxConsumerFile: path.join("/repo", "apps", "linux", "src-tauri", "src", "canvas.rs"), }); }); + it("requires Linux Canvas to embed the plugin-owned resources", async () => { + const root = await makeTempDir(); + const linuxConsumerFile = path.join(root, "canvas.rs"); + await fs.writeFile( + linuxConsumerFile, + [ + 'include_bytes!("../../../../apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasA2UI/index.html");', + 'include_bytes!("../../../../apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasA2UI/a2ui.bundle.js");', + ].join("\n"), + ); + + await expect(checkLinuxCanvasA2uiReferences({ linuxConsumerFile })).resolves.toBeUndefined(); + await fs.writeFile(linuxConsumerFile, 'const OTHER: &[u8] = b"stale";\n'); + await expect(checkLinuxCanvasA2uiReferences({ linuxConsumerFile })).rejects.toThrow( + "Linux Canvas must embed the synced native A2UI resources", + ); + }); + it("replaces stale native resources with the generated source files", async () => { const root = await makeTempDir(); const sourceDir = path.join(root, "source"); diff --git a/test/vitest/vitest.extension-misc-paths.mjs b/test/vitest/vitest.extension-misc-paths.mjs index 85e3cde88e4e..32b0b0206344 100644 --- a/test/vitest/vitest.extension-misc-paths.mjs +++ b/test/vitest/vitest.extension-misc-paths.mjs @@ -11,6 +11,7 @@ export const miscExtensionTestRoots = [ "extensions/kilocode", "extensions/litellm", "extensions/llm-task", + "extensions/linux-canvas", "extensions/lobster", "extensions/opencode", "extensions/opencode-go",