From 3832477a7f33d65520d1fc5cef9be7a467b0c62d Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 17 Oct 2026 18:49:16 +0800 Subject: [PATCH 1/2] fix(computer-use): preserve clipboard text across desktop platforms --- .github/workflows/ci.yml | 14 + docs/architecture/computer-use-control.md | 33 + scripts/test-linux-clipboard.sh | 57 ++ src/apps/desktop/README.md | 27 + .../computer_use/control_session/admission.rs | 16 +- .../implementations/computer_use_actions.rs | 81 ++- .../implementations/computer_use_tool.rs | 52 +- .../src/computer_use_control.rs | 17 +- src/crates/services/services-core/AGENTS.md | 20 + .../services-core/src/system/local_actions.rs | 158 +---- .../src/system/local_actions/clipboard.rs | 619 ++++++++++++++++++ 11 files changed, 905 insertions(+), 189 deletions(-) create mode 100644 scripts/test-linux-clipboard.sh create mode 100644 src/crates/services/services-core/src/system/local_actions/clipboard.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94d89a3324..98ba8ba715 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -373,6 +373,20 @@ jobs: - name: Run Page Functions runtime tests run: cargo test --locked -p openbitfun-page-function-runtime + - name: Run Computer Use clipboard contracts + run: cargo test --locked -p openbitfun-services-core --no-default-features --features process-runtime --lib system::local_actions + + - name: Run Windows clipboard round trip + if: runner.os == 'Windows' + run: cargo test --locked -p openbitfun-services-core --no-default-features --features process-runtime --lib system::local_actions::clipboard::tests::native_clipboard_roundtrip -- --ignored --exact + + - name: Run Linux clipboard round trips + if: runner.os == 'Linux' + shell: bash + run: | + sudo apt-get install -y --no-install-recommends xvfb xauth xclip xsel wl-clipboard sway + bash scripts/test-linux-clipboard.sh + - name: Run Relay service tests run: cargo test --locked -p openbitfun-relay-service diff --git a/docs/architecture/computer-use-control.md b/docs/architecture/computer-use-control.md index 10f4f45daa..8406be0452 100644 --- a/docs/architecture/computer-use-control.md +++ b/docs/architecture/computer-use-control.md @@ -404,6 +404,39 @@ than decoding its own JPEG. Unchanged legitimate dark frames are preserved. Explicit action waits subscribe to session changes; Stop, revocation or target replacement interrupts them immediately, including waits between batch steps. +### Host clipboard text + +`clipboard_get` and `clipboard_set` are existing ComputerUse actions backed by +the Services Core local-system provider. They address the runtime host's desktop +clipboard and do not activate an app, require a bound window, or send a paste. +Both take the control owner lease; reads are allowed in observe mode, writes are +not. Stop and owner changes invalidate reads. Window-only sharing authorizes +neither action because the clipboard is a global desktop resource. + +The provider uses AppKit string types on macOS, an STA Windows Forms helper on +Windows, and explicit UTF-8 text helpers for the selected Linux display server. +Wayland uses `wl-copy`/`wl-paste`; X11 uses `xclip` or `xsel`. Missing desktop +sessions/helpers fail explicitly, with no Wayland-to-XWayland fallback. Payloads +travel through stdin/stdout, never interpolated scripts or command arguments. +Reads/writes preserve Unicode and line endings; writes of empty text clear the +text clipboard. NUL and text beyond the 16 MiB transport ceiling are rejected. + +Each helper has a ten-second deadline and bounded cleanup. A failed or timed-out +write is an unknown outcome and is never automatically repeated through another +helper. Linux owner processes may outlive a successful write to serve future +paste requests, so write completion waits for the launcher, not pipe EOF from +its descendants. Dropping the operation kills the launcher if it is still live. + +`clipboard_get.max_output_bytes` defaults to 64 KiB and caps at 1 MiB; the old +`max_bytes` alias remains readable and the canonical field wins when both occur. +The result keeps `text`, original UTF-8 `byte_length`, and `truncated`; truncation +does not add marker text to the clipboard content. Both model schemas expose the +same actions and parameters. No persisted or protocol shape is renamed. + +Remote workspace and headless dispatch paths reject these tools before native +IO. Remote control and peer desktop sessions execute against their runtime host; +they never read/write the driving surface's clipboard as a fallback. + ## Native input rules Background actions capture desktop witnesses before and after submission. diff --git a/scripts/test-linux-clipboard.sh b/scripts/test-linux-clipboard.sh new file mode 100644 index 0000000000..c92c094663 --- /dev/null +++ b/scripts/test-linux-clipboard.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Exercise the production clipboard provider in disposable X11 and Wayland seats. +set -euo pipefail + +for tool in cargo xvfb-run xclip xsel sway wl-copy wl-paste; do + command -v "$tool" >/dev/null || { echo "Missing clipboard fixture dependency: $tool" >&2; exit 1; } +done + +clipboard_fixture_dir="$(mktemp -d)" +clipboard_sway_pid="" +cleanup() { + if [ -n "$clipboard_sway_pid" ]; then + kill "$clipboard_sway_pid" 2>/dev/null || true + wait "$clipboard_sway_pid" 2>/dev/null || true + fi + rm -rf "$clipboard_fixture_dir" +} +trap cleanup EXIT + +clipboard_test=(cargo test --locked -p openbitfun-services-core --no-default-features + --features process-runtime --lib system::local_actions::clipboard::tests::native_clipboard_roundtrip + -- --ignored --exact --nocapture) + +env -u WAYLAND_DISPLAY XDG_SESSION_TYPE=x11 xvfb-run -a "${clipboard_test[@]}" + +mkdir "$clipboard_fixture_dir/runtime" +chmod 700 "$clipboard_fixture_dir/runtime" +cat > "$clipboard_fixture_dir/sway.conf" <<'EOF' +output * resolution 1280x720 +seat seat0 fallback true +EOF +env -u DISPLAY -u WAYLAND_DISPLAY \ + XDG_RUNTIME_DIR="$clipboard_fixture_dir/runtime" WLR_BACKENDS=headless \ + WLR_LIBINPUT_NO_DEVICES=1 WLR_RENDERER=pixman \ + sway --config "$clipboard_fixture_dir/sway.conf" > "$clipboard_fixture_dir/sway.log" 2>&1 & +clipboard_sway_pid=$! + +clipboard_wayland_socket="" +for ((attempt=0; attempt<100; attempt++)); do + if ! kill -0 "$clipboard_sway_pid" 2>/dev/null; then + cat "$clipboard_fixture_dir/sway.log" >&2 + echo "The isolated Wayland compositor exited before the clipboard test" >&2 + exit 1 + fi + for candidate in "$clipboard_fixture_dir/runtime"/wayland-*; do + if [ -S "$candidate" ]; then clipboard_wayland_socket="${candidate##*/}"; break; fi + done + if [ -n "$clipboard_wayland_socket" ]; then break; fi + sleep 0.1 +done +if [ -z "$clipboard_wayland_socket" ]; then + cat "$clipboard_fixture_dir/sway.log" >&2 + echo "The isolated Wayland clipboard socket did not become ready" >&2 + exit 1 +fi +env -u DISPLAY XDG_RUNTIME_DIR="$clipboard_fixture_dir/runtime" \ + XDG_SESSION_TYPE=wayland WAYLAND_DISPLAY="$clipboard_wayland_socket" "${clipboard_test[@]}" diff --git a/src/apps/desktop/README.md b/src/apps/desktop/README.md index 37ff281db2..882ab75e5e 100644 --- a/src/apps/desktop/README.md +++ b/src/apps/desktop/README.md @@ -206,6 +206,33 @@ on Return, combine text with `focus` and a Return key step, then inspect the results before selecting one. Focus-and-type by itself is already one call; batching cannot repair an unavailable input route. +ComputerUse also reads and writes plain text on the **runtime host's clipboard**: + +```json +{"action":"clipboard_set","text":"Text to copy\nincluding another line"} +{"action":"clipboard_get","max_output_bytes":65536} +``` + +These actions work on Windows, macOS and Linux without activating an application. +They do not paste or submit text. `clipboard_set` replaces the clipboard; an empty +string clears its text. Unicode and line endings are preserved. Text containing +NUL or exceeding 16 MiB is rejected. `clipboard_get` returns `text`, the original +`byte_length`, and `truncated`; its output limit defaults to 64 KiB and caps at +1 MiB, ending on a UTF-8 boundary. The older `max_bytes` name remains accepted. + +Windows uses the built-in Windows PowerShell and Windows Forms clipboard APIs; +macOS uses AppKit through the built-in JavaScript bridge. On Linux, install +`wl-clipboard` for Wayland, or `xclip`/`xsel` for X11, and run OpenBitFun in that +desktop session. Wayland does not silently fall back to an XWayland clipboard. +Helpers have a ten-second deadline. A failed write may already have changed the +clipboard; inspect it before retrying. Image/file clipboard formats are not +exposed by these text actions. + +Clipboard access obeys the ComputerUse session and Stop. Window-only sharing +does not grant access to the global clipboard. SSH workspaces and headless hosts +reject these actions; a remote controller uses the executing desktop host's +clipboard, never its own local clipboard as a fallback. + On macOS, persistent ScreenCaptureKit window sharing uses the system's sharing controls. macOS 12.3 or newer is required for capture; system indicator appearance depends on the OS version. Hidden or minimized targets must be restored before diff --git a/src/apps/desktop/src/computer_use/control_session/admission.rs b/src/apps/desktop/src/computer_use/control_session/admission.rs index be595eba6b..29ed8d589a 100644 --- a/src/apps/desktop/src/computer_use/control_session/admission.rs +++ b/src/apps/desktop/src/computer_use/control_session/admission.rs @@ -422,7 +422,7 @@ impl Admission { }; if self.snapshot.sharing.is_some() && (policy.foreground && !policy.target - || matches!(action, "clipboard_set" | "open_app")) + || matches!(action, "clipboard_get" | "clipboard_set" | "open_app")) { return Err("[SHARED_TARGET_ONLY] Window sharing does not authorize global clipboard, launching or scripts".into()); } @@ -581,11 +581,23 @@ mod tests { .unwrap(); state.finish_share(generation, 1).unwrap(); state.set_control(generation, 1, 0, true).unwrap(); - for action in ["clipboard_set", "open_app", "run_jxa"] { + for action in ["clipboard_get", "clipboard_set", "open_app", "run_script"] { assert!(state.admit("owner", action, 1).is_err(), "{action}"); assert!(!state.busy); } } + + #[test] + fn clipboard_reads_obey_owner_stop_and_observe_mode() { + let mut state = running(); + state.snapshot.mode = ControlMode::Observe; + assert!(state.admit("other-owner", "clipboard_get", 1).is_err()); + assert!(state.admit("owner", "clipboard_set", 1).is_err()); + let token = state.admit("owner", "clipboard_get", 1).unwrap(); + state.release(token, true); + state.revoke("user_stopped"); + assert!(state.admit("owner", "clipboard_get", 1).is_err()); + } fn running() -> Admission { let mut state = Admission::default(); let generation = state.begin_start("owner", ControlMode::Background).unwrap(); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs index f896f3b13a..b655a8f0da 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs @@ -31,6 +31,33 @@ const OPEN_FILE_ROUTING_NOTE: &str = "The file is now open in an external applic pub(crate) struct ComputerUseActions; +fn clipboard_output_limit(params: &Value) -> OpenBitFunResult { + match params + .get("max_output_bytes") + .or_else(|| params.get("max_bytes")) + { + None => Ok(64 * 1024), + Some(value) => { + value + .as_u64() + .filter(|value| *value > 0) + .map(|value| value.min(1024 * 1024) as usize) + .ok_or_else(|| { + coded_tool_error(ErrorCode::InvalidParams, + "clipboard_get max_output_bytes (legacy max_bytes) must be a positive integer") + }) + } + } +} + +fn clipboard_text_result(text: &str, max_bytes: usize) -> Value { + let mut end = text.len().min(max_bytes); + while !text.is_char_boundary(end) { + end -= 1; + } + json!({"text": &text[..end], "byte_length": text.len(), "truncated": end < text.len()}) +} + fn include_app_state_image(params: &Value) -> OpenBitFunResult { match params.get("include_image") { None => Ok(true), @@ -1359,23 +1386,13 @@ impl ComputerUseActions { // the GUI. Returns text only — binary clipboard payloads are // out of scope. "clipboard_get" => { - let max_bytes = params - .get("max_bytes") - .and_then(|v| v.as_u64()) - .map(|n| n as usize) - .unwrap_or(64 * 1024) - .clamp(64, 1024 * 1024); + let max_bytes = clipboard_output_limit(params)?; match LocalSystemProvider::new().clipboard_read_text().await { Ok(text) => { - let (truncated, was_truncated) = truncate_with_marker(&text, max_bytes); let len = text.len(); Ok(vec![ToolResult::ok( - json!({ - "text": truncated, - "byte_length": len, - "truncated": was_truncated, - }), + clipboard_text_result(&text, max_bytes), Some(format!("{} bytes on clipboard", len)), )]) } @@ -1510,6 +1527,46 @@ mod tests { use crate::util::errors::OpenBitFunResult; use std::sync::{Arc, Mutex}; + #[test] + fn clipboard_read_limit_honors_schema_and_legacy_payloads() { + assert_eq!( + super::clipboard_output_limit(&json!({})).unwrap(), + 64 * 1024 + ); + assert_eq!( + super::clipboard_output_limit(&json!({"max_bytes": 7})).unwrap(), + 7 + ); + assert_eq!( + super::clipboard_output_limit(&json!({"max_output_bytes": 9, "max_bytes": 7})).unwrap(), + 9 + ); + assert_eq!( + super::clipboard_output_limit(&json!({"max_output_bytes": u64::MAX})).unwrap(), + 1024 * 1024 + ); + for value in [json!(0), json!(-1), json!("9"), json!(null), json!(1.5)] { + assert!(super::clipboard_output_limit(&json!({"max_output_bytes": value})).is_err()); + } + } + + #[test] + fn clipboard_read_truncates_at_utf8_boundaries_without_adding_text() { + let text = "a中文🦀\r\n"; + assert_eq!( + super::clipboard_text_result(text, 5), + json!({"text":"a中", "byte_length":13, "truncated":true}) + ); + assert_eq!( + super::clipboard_text_result(text, 13), + json!({"text":text, "byte_length":13, "truncated":false}) + ); + assert_eq!( + super::clipboard_text_result("", 1), + json!({"text":"", "byte_length":0, "truncated":false}) + ); + } + #[derive(Debug, Default)] struct CachedTargetHost { calls: Mutex>, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs index dc180e9715..4bd12bb937 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs @@ -246,6 +246,7 @@ One batch returns ordered receipts and one final observation. End it when new in Use fresh observed node_idx/ocr_text targets and echo observation_id for node/index input when available. Read capability, OCR and truncation notes; report missing evidence instead of inventing it. \ Verify the intended content, not digest changes. Never replay completed steps after partial failure; check delivery before retrying a submission. \ Keep background mode. Foreground takeover requires the user's explicit request; never activate an app or widen scope to repair an error. Respect Stop, cancellation and permission denial. start_control is needed for an authorized restart or mode choice; control_status is diagnostic, and stop_control releases the session when finished. \ +Use clipboard_get to read plain text from the runtime host clipboard (optional max_output_bytes, default 65536), and clipboard_set with text to replace it; an empty string clears it. Clipboard actions do not activate or paste into an application and are unavailable during window-only sharing. \ Use ExecCommand for genuine shell work, never ad hoc GUI capture, clipboard or input workarounds. Prefer available ControlHub browser actions for web content. Before an irreversible submission verify its target, content and user authorization, then verify the result.", os, keys ) @@ -449,7 +450,7 @@ Use ExecCommand for genuine shell work, never ad hoc GUI capture, clipboard or i "start_y": { "type": "integer", "description": "For `drag`: start Y coordinate." }, "end_x": { "type": "integer", "description": "For `drag`: end X coordinate." }, "end_y": { "type": "integer", "description": "For `drag`: end Y coordinate." }, - "text": { "type": "string", "description": "Required for app_type_text/type_text/paste: the exact text to insert. For background tasks use app_type_text with the app selector and optional focus; do not substitute global clipboard shortcuts." }, + "text": { "type": "string", "description": "Required for clipboard_set/app_type_text/type_text/paste: exact plain text. clipboard_set accepts an empty string to clear the clipboard (maximum 16 MiB; no NUL). For background tasks use app_type_text with the app selector and optional focus; do not substitute global clipboard shortcuts." }, "ms": { "type": "integer", "description": "For `wait`: duration in milliseconds." }, "text_query": { "type": "string", "description": "For `move_to_text`, `move_to_target`, `click_target`: visible text to OCR-match on screen (case-insensitive substring)." }, "identifier_contains": { "type": "string", "description": "For `locate`, `click_element`: case-insensitive substring on AXIdentifier." }, @@ -466,7 +467,8 @@ Use ExecCommand for genuine shell work, never ad hoc GUI capture, clipboard or i ]}, "script_type": { "type": "string", "enum": ["applescript", "shell", "bash", "powershell", "cmd"], "description": "For `run_script`: script interpreter/type." }, "timeout_ms": { "type": "integer", "description": "For run_script/app_wait_for: timeout in milliseconds; app_wait_for defaults to 8000." }, - "max_output_bytes": { "type": "integer", "description": "For `run_script` / `clipboard_get`: maximum bytes to return." }, + "max_output_bytes": { "type": "integer", "minimum": 1, "description": "For run_script/clipboard_get: maximum output bytes. clipboard_get defaults to 65536 and caps at 1048576; text ends at a UTF-8 boundary and truncated reports whether bytes were omitted." }, + "max_bytes": { "type": "integer", "minimum": 1, "description": "Legacy alias for clipboard_get max_output_bytes; max_output_bytes takes precedence when both are supplied." }, "clear_first": { "type": "boolean", "description": "For `paste`: select all before pasting." }, "submit": { "type": "boolean", "description": "For `paste`: press submit keys after pasting." }, "submit_keys": { "type": "array", "items": { "type": "string" }, "description": "For `paste`: key chord to submit, default `[\"return\"]`." }, @@ -1112,10 +1114,10 @@ Use ExecCommand for genuine shell work, never ad hoc GUI capture, clipboard or i fn key_chord_os_hint() -> &'static str { match std::env::consts::OS { - "macos" => "macOS app_key_chord uses command/option/control/shift. Clipboard and global key_chord operate on desktop focus and require foreground mode.", + "macos" => "macOS app_key_chord uses command/option/control/shift. Global key_chord and paste require foreground mode; clipboard_get/clipboard_set do not change focus.", "windows" => "Windows background typing supports validated native editable controls selected by observed node, image point or the bound window thread’s current focus; scrolling requires an observed scrollable node. App-scoped keyboard chords are unavailable; global key_chord requires foreground mode and uses meta, alt, control, shift.", "linux" => "Linux background actions use AT-SPI semantic nodes; text insertion requires an observed EditableText node in focus. Arbitrary background coordinates and app_key_chord are unavailable. Portal keyboard input requires foreground mode and uses control, alt, shift, meta/super.", - _ => "Match modifiers to the host OS. Use app-scoped capabilities where available; desktop clipboard and seat input require foreground mode.", + _ => "Match modifiers to the host OS. Use app-scoped capabilities where available; global seat input requires foreground mode; clipboard_get/clipboard_set do not change focus.", } } @@ -1567,7 +1569,8 @@ One batch returns ordered receipts and one final observation. Use that result fo Set include_image=false when AX text is enough for the next decision; keep the default true for visual, canvas or image-coordinate work. Read tree_text, observation_id, screenshot_id, geometry and errors. Use observed image_xy with its exact screenshot_id; echo observation_id for node/index input when available. AX/OCR are optional precision aids for visible graphical controls. \ A submitted event or changed digest does not prove completion. Verify intended content; never replay completed steps after partial failure, and check delivery before retrying a submission. \ Keep background mode. Foreground takeover requires the user's explicit request; never activate an app or widen scope to repair an error. Respect Stop, cancellation and permission denial. start_control is needed for an authorized restart or mode choice; control_status is diagnostic, and stop_control releases the session when finished. \ -Capabilities describe available routes, not support for every control. Use supported recovery or report the limitation. Text-only models use describe_screen/get_app_state and AX/OCR targets. Use ExecCommand for genuine shell work, never ad hoc GUI capture, clipboard or input workarounds. Prefer available ControlHub browser actions for web content. Before an irreversible submission verify its target, content and user authorization, then verify the result.", +Capabilities describe available routes, not support for every control. Use supported recovery or report the limitation. Text-only models use describe_screen/get_app_state and AX/OCR targets. Use clipboard_get to read plain text from the runtime host clipboard (optional max_output_bytes, default 65536), and clipboard_set with text to replace it; an empty string clears it. Clipboard actions do not activate or paste into an application and are unavailable during window-only sharing. \ +Use ExecCommand for genuine shell work, never ad hoc GUI capture, clipboard or input workarounds. Prefer available ControlHub browser actions for web content. Before an irreversible submission verify its target, content and user authorization, then verify the result.", os, keys, )) } @@ -4223,25 +4226,30 @@ mod tests { "Fixture".into(), identity, )); - let result = ComputerUseTool::new() - .call_impl(&json!({"action":"start_control"}), &remote) - .await; - assert!(result - .unwrap_err() - .to_string() - .contains("workspace is remote")); + for input in [ + json!({"action":"start_control"}), + json!({"action":"clipboard_get"}), + json!({"action":"clipboard_set","text":"must stay on the runtime host"}), + ] { + let result = ComputerUseTool::new().call_impl(&input, &remote).await; + assert!(result + .unwrap_err() + .to_string() + .contains("workspace is remote")); + } assert!(host.events.lock().unwrap().is_empty()); let headless = ToolUseContext::for_tool_listing(None, None); - let result = ComputerUseTool::new() - .call_impl( - &json!({"action":"get_app_state","app":{"pid":421}}), - &headless, - ) - .await; - assert!(result - .unwrap_err() - .to_string() - .contains("provider unavailable")); + for input in [ + json!({"action":"get_app_state","app":{"pid":421}}), + json!({"action":"clipboard_get"}), + json!({"action":"clipboard_set","text":"must not touch the controller"}), + ] { + let result = ComputerUseTool::new().call_impl(&input, &headless).await; + assert!(result + .unwrap_err() + .to_string() + .contains("provider unavailable")); + } } #[tokio::test] diff --git a/src/crates/execution/tool-contracts/src/computer_use_control.rs b/src/crates/execution/tool-contracts/src/computer_use_control.rs index a680b9eb8e..7d1d0b0b81 100644 --- a/src/crates/execution/tool-contracts/src/computer_use_control.rs +++ b/src/crates/execution/tool-contracts/src/computer_use_control.rs @@ -125,9 +125,10 @@ impl ControlActionPolicy { pub fn for_action(action: &str) -> Option { let (observation, foreground, target, lease) = match action { "start_control" | "stop_control" => (false, false, false, false), - "control_status" | "list_apps" | "list_windows" | "list_displays" | "get_os_info" - | "clipboard_get" => (true, false, false, false), - "wait" => (true, false, false, true), + "control_status" | "list_apps" | "list_windows" | "list_displays" | "get_os_info" => { + (true, false, false, false) + } + "wait" | "clipboard_get" => (true, false, false, true), "screenshot" | "describe_screen" | "locate" @@ -240,6 +241,16 @@ mod tests { } } + #[test] + fn clipboard_access_requires_a_lease_without_foreground_or_window_input() { + for action in ["clipboard_get", "clipboard_set"] { + let policy = ControlActionPolicy::for_action(action).unwrap(); + assert!(policy.lease); + assert!(!policy.foreground && !policy.target); + assert_eq!(policy.observation, action == "clipboard_get"); + } + } + #[test] fn returning_to_the_same_target_does_not_restore_old_observation_scope() { let mut control = ControlSnapshot { diff --git a/src/crates/services/services-core/AGENTS.md b/src/crates/services/services-core/AGENTS.md index d7a3dd7f26..24e93e05bd 100644 --- a/src/crates/services/services-core/AGENTS.md +++ b/src/crates/services/services-core/AGENTS.md @@ -109,6 +109,26 @@ pnpm run check:core-boundaries Other capability-specific target names remain in `Cargo.toml`; document a new command here only when it becomes a recurring owner workflow. +For Computer Use clipboard text transport, helper deadlines and native round trips: + +```bash +cargo test --locked -p openbitfun-services-core --no-default-features --features process-runtime --lib system::local_actions +# Linux: disposable Xvfb and headless Sway seats; requires xvfb/xauth, xclip, +# xsel, wl-clipboard and sway. Never uses the user's current clipboard. +bash scripts/test-linux-clipboard.sh +# Windows: opt-in, disposable desktop/CI host only; replaces its clipboard. +cargo test --locked -p openbitfun-services-core --no-default-features --features process-runtime --lib system::local_actions::clipboard::tests::native_clipboard_roundtrip -- --ignored --exact +``` + +The ordinary macOS suite uses a named AppKit pasteboard, not the general user +pasteboard. The OS CI matrix runs all three native routes, including Linux X11 +and Wayland. Windows clipboard helpers must use STA and byte-preserving UTF-8 +stdin/stdout; never put clipboard contents into scripts or command arguments. +Linux clipboard owners may keep serving after the parent exits: do not wait for +inherited stdout/stderr pipes after a write. Only an executable that never +started permits write fallback. A timeout or error after launch has an unknown +write outcome and must not trigger another helper. + For workspace instruction family selection, non-empty fallback, and local/port resolution parity: diff --git a/src/crates/services/services-core/src/system/local_actions.rs b/src/crates/services/services-core/src/system/local_actions.rs index f16d44749d..439e9e6aa6 100644 --- a/src/crates/services/services-core/src/system/local_actions.rs +++ b/src/crates/services/services-core/src/system/local_actions.rs @@ -7,6 +7,8 @@ use crate::process_manager; use std::path::Path; +mod clipboard; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LocalSystemActionErrorKind { InvalidParams, @@ -283,15 +285,15 @@ impl LocalSystemProvider { } pub async fn clipboard_read_text(&self) -> Result { - clipboard_read() + clipboard::read_text() .await - .map_err(|e| clipboard_error(format!("Clipboard read failed: {}", e))) + .map_err(|error| error.with_hints(clipboard_install_hints())) } pub async fn clipboard_write_text(&self, text: &str) -> Result<(), LocalSystemActionError> { - clipboard_write(text) + clipboard::write_text(text) .await - .map_err(|e| clipboard_error(format!("Clipboard write failed: {}", e))) + .map_err(|error| error.with_hints(clipboard_install_hints())) } pub fn open_url(&self, url: &str) -> Result { @@ -624,11 +626,6 @@ fn powershell_invocation(script: &str) -> (String, Vec) { ) } -fn clipboard_error(message: String) -> LocalSystemActionError { - LocalSystemActionError::new(LocalSystemActionErrorKind::NotAvailable, message) - .with_hints(clipboard_install_hints()) -} - fn clipboard_install_hints() -> Vec { match std::env::consts::OS { "linux" => { @@ -638,9 +635,9 @@ fn clipboard_install_hints() -> Vec { match server.as_deref() { Some("wayland") => vec![ "Wayland session detected - install wl-clipboard (e.g. `sudo apt install wl-clipboard` / `sudo dnf install wl-clipboard`)".to_string(), - "Fallback for XWayland apps: also install xclip or xsel".to_string(), + "Run OpenBitFun inside the intended Wayland session; X11 fallback would address a different clipboard".to_string(), ], - Some("x11") | Some("tty") => vec![ + Some("x11") => vec![ "X11 session detected - install xclip (`sudo apt install xclip`) or xsel (`sudo apt install xsel`)".to_string(), ], _ => vec![ @@ -748,145 +745,6 @@ fn hostname() -> std::io::Result { Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) } -async fn clipboard_read() -> Result { - #[cfg(target_os = "macos")] - { - let out = process_manager::create_tokio_command("pbpaste") - .output() - .await - .map_err(|e| format!("spawn pbpaste: {}", e))?; - if !out.status.success() { - return Err(format!("pbpaste exit={:?}", out.status.code())); - } - Ok(String::from_utf8_lossy(&out.stdout).to_string()) - } - #[cfg(target_os = "windows")] - { - let (program, args) = powershell_invocation("Get-Clipboard -Raw"); - let out = process_manager::create_tokio_command(&program) - .args(&args) - .output() - .await - .map_err(|e| format!("spawn {}: {}", program, e))?; - if !out.status.success() { - return Err(format!("Get-Clipboard exit={:?}", out.status.code())); - } - let mut s = String::from_utf8_lossy(&out.stdout).to_string(); - if s.ends_with("\r\n") { - s.truncate(s.len() - 2); - } else if s.ends_with('\n') { - s.truncate(s.len() - 1); - } - Ok(s) - } - #[cfg(target_os = "linux")] - { - let candidates: &[(&str, &[&str])] = if std::env::var("WAYLAND_DISPLAY").is_ok() { - &[ - ("wl-paste", &["--no-newline"]), - ("xclip", &["-selection", "clipboard", "-o"]), - ("xsel", &["--clipboard", "--output"]), - ] - } else { - &[ - ("xclip", &["-selection", "clipboard", "-o"]), - ("xsel", &["--clipboard", "--output"]), - ("wl-paste", &["--no-newline"]), - ] - }; - for (bin, args) in candidates { - if let Ok(out) = process_manager::create_tokio_command(bin) - .args(*args) - .output() - .await - { - if out.status.success() { - return Ok(String::from_utf8_lossy(&out.stdout).to_string()); - } - } - } - Err("no clipboard helper found (install wl-clipboard, xclip, or xsel)".to_string()) - } - #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] - { - Err("clipboard not implemented for this OS".to_string()) - } -} - -async fn clipboard_write(text: &str) -> Result<(), String> { - use tokio::io::AsyncWriteExt; - - async fn pipe(bin: &str, args: &[&str], text: &str) -> Result<(), String> { - let mut child = process_manager::create_tokio_command(bin) - .args(args) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::piped()) - .spawn() - .map_err(|e| format!("spawn {}: {}", bin, e))?; - if let Some(mut stdin) = child.stdin.take() { - stdin - .write_all(text.as_bytes()) - .await - .map_err(|e| format!("write {} stdin: {}", bin, e))?; - } - let out = child - .wait_with_output() - .await - .map_err(|e| format!("wait {}: {}", bin, e))?; - if !out.status.success() { - return Err(format!("{} exit={:?}", bin, out.status.code())); - } - Ok(()) - } - - #[cfg(target_os = "macos")] - { - pipe("pbcopy", &[], text).await - } - #[cfg(target_os = "windows")] - { - pipe( - "powershell", - &["-NoProfile", "-Command", "$input | Set-Clipboard"], - text, - ) - .await - } - #[cfg(target_os = "linux")] - { - let candidates: &[(&str, &[&str])] = if std::env::var("WAYLAND_DISPLAY").is_ok() { - &[ - ("wl-copy", &[]), - ("xclip", &["-selection", "clipboard"]), - ("xsel", &["--clipboard", "--input"]), - ] - } else { - &[ - ("xclip", &["-selection", "clipboard"]), - ("xsel", &["--clipboard", "--input"]), - ("wl-copy", &[]), - ] - }; - let mut last_err = String::new(); - for (bin, args) in candidates { - match pipe(bin, args, text).await { - Ok(()) => return Ok(()), - Err(e) => last_err = e, - } - } - Err(format!( - "no clipboard helper succeeded (install wl-clipboard, xclip, or xsel): {}", - last_err - )) - } - #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] - { - let _ = text; - Err("clipboard not implemented for this OS".to_string()) - } -} - #[cfg(test)] mod tests { use super::{ diff --git a/src/crates/services/services-core/src/system/local_actions/clipboard.rs b/src/crates/services/services-core/src/system/local_actions/clipboard.rs new file mode 100644 index 0000000000..1dd94d7a09 --- /dev/null +++ b/src/crates/services/services-core/src/system/local_actions/clipboard.rs @@ -0,0 +1,619 @@ +//! Text clipboard IO on the runtime host. Payloads travel through stdin/stdout, +//! never command arguments or shell interpolation. + +use super::{LocalSystemActionError, LocalSystemActionErrorKind}; +use crate::process_manager; +use std::process::Stdio; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +const HELPER_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_TEXT_BYTES: usize = 16 * 1024 * 1024; + +struct Helper { + program: String, + args: Vec, +} + +impl Helper { + fn new(program: &str, args: &[&str]) -> Self { + Self { + program: program.into(), + args: args.iter().map(|arg| (*arg).into()).collect(), + } + } +} + +#[derive(Debug)] +enum HelperError { + Missing(String), + Failed(LocalSystemActionError), +} + +fn failure(kind: LocalSystemActionErrorKind, message: impl Into) -> HelperError { + HelperError::Failed(LocalSystemActionError::new(kind, message)) +} + +async fn run_helper( + helper: &Helper, + input: Option<&str>, + timeout: Duration, +) -> Result { + let writing = input.is_some(); + let mut child = process_manager::create_tokio_command(&helper.program) + .args(&helper.args) + .stdin(if writing { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(if writing { + Stdio::null() + } else { + Stdio::piped() + }) + // X11/Wayland owners may fork and keep stderr open to serve future + // pastes. Waiting for EOF on an inherited pipe would hang a successful + // copy. Clipboard contents and helper diagnostics are never logged. + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + HelperError::Missing(helper.program.clone()) + } else { + failure( + LocalSystemActionErrorKind::NotAvailable, + format!( + "Clipboard helper {} could not start: {error}", + helper.program + ), + ) + } + })?; + let operation = async { + let mut bytes = Vec::new(); + if let Some(text) = input { + let mut stdin = child.stdin.take().expect("piped clipboard stdin"); + stdin.write_all(text.as_bytes()).await.map_err(|error| { + failure( + LocalSystemActionErrorKind::Internal, + format!("Clipboard input failed: {error}"), + ) + })?; + stdin.shutdown().await.map_err(|error| { + failure( + LocalSystemActionErrorKind::Internal, + format!("Clipboard input could not close: {error}"), + ) + })?; + drop(stdin); + } else { + child + .stdout + .take() + .expect("piped clipboard stdout") + .take((MAX_TEXT_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .await + .map_err(|error| { + failure( + LocalSystemActionErrorKind::Internal, + format!("Clipboard output failed: {error}"), + ) + })?; + if bytes.len() > MAX_TEXT_BYTES { + return Err(failure( + LocalSystemActionErrorKind::NotAvailable, + "Clipboard text exceeds the 16 MiB transport limit", + )); + } + } + let status = child.wait().await.map_err(|error| { + failure( + LocalSystemActionErrorKind::Internal, + format!("Clipboard helper wait failed: {error}"), + ) + })?; + if !status.success() { + return Err(failure(LocalSystemActionErrorKind::NotAvailable, + format!("Clipboard helper {} exited with {:?}; the desktop clipboard may be unavailable or in use", helper.program, status.code()))); + } + String::from_utf8(bytes).map_err(|_| { + failure( + LocalSystemActionErrorKind::NotAvailable, + "Clipboard helper returned non-UTF-8 text", + ) + }) + }; + let result = match tokio::time::timeout(timeout, operation).await { + Ok(result) => result, + Err(_) => Err(failure( + LocalSystemActionErrorKind::Timeout, + "Clipboard helper timed out", + )), + }; + if result.is_err() { + let _ = child.start_kill(); + let _ = tokio::time::timeout(Duration::from_millis(250), child.wait()).await; + } + result.map_err(|error| match error { + HelperError::Failed(error) if writing => failure(error.kind(), format!( + "[CLIPBOARD_WRITE_OUTCOME_UNKNOWN] {error}. The clipboard may have changed; no alternate helper or retry was used" + )), + other => other, + }) +} + +// Use AppKit's string type explicitly: pbcopy can interpret text beginning with +// an RTF/PostScript header as a different clipboard format. A named pasteboard +// lets native tests exercise this same path without touching the user's board. +#[cfg(target_os = "macos")] +fn macos_helper(writing: bool, board_name: Option<&str>) -> Helper { + let board = match board_name { + Some(name) => format!( + "$.NSPasteboard.pasteboardWithName({})", + serde_json::to_string(name).unwrap() + ), + None => "$.NSPasteboard.generalPasteboard".into(), + }; + let operation = if writing { + "var d=$.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile; var t=$.NSString.alloc.initWithDataEncoding(d,$.NSUTF8StringEncoding); if(t.isNil()) throw Error('Invalid UTF-8 clipboard input'); p.clearContents; if(!p.setStringForType(t,$.NSPasteboardTypeString)) throw Error('Clipboard write failed');" + } else { + "var t=p.stringForType($.NSPasteboardTypeString); if(!t.isNil()) $.NSFileHandle.fileHandleWithStandardOutput.writeData(t.dataUsingEncoding($.NSUTF8StringEncoding));" + }; + Helper::new( + "/usr/bin/osascript", + &[ + "-l", + "JavaScript", + "-e", + &format!( + "ObjC.import('AppKit'); ObjC.import('Foundation'); var p={board}; {operation} void 0;" + ), + ], + ) +} + +#[cfg(target_os = "windows")] +fn windows_helper(writing: bool) -> Helper { + let operation = if writing { + // ReadToEnd preserves empty strings, CRLF/LF, trailing newlines and + // non-ASCII text. $input is a line-oriented PowerShell pipeline. + "$reader=[System.IO.StreamReader]::new([Console]::OpenStandardInput(), [System.Text.UTF8Encoding]::new($false,$true), $false); $text=$reader.ReadToEnd(); if ($text.Length -eq 0) { [System.Windows.Forms.Clipboard]::Clear() } else { [System.Windows.Forms.Clipboard]::SetText($text,[System.Windows.Forms.TextDataFormat]::UnicodeText) }" + } else { + "$text=[System.Windows.Forms.Clipboard]::GetText([System.Windows.Forms.TextDataFormat]::UnicodeText); $bytes=([System.Text.UTF8Encoding]::new($false,$true)).GetBytes($text); [Console]::OpenStandardOutput().Write($bytes,0,$bytes.Length)" + }; + Helper::new( + "powershell.exe", + &[ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-STA", + "-Command", + &format!( + "$ErrorActionPreference='Stop'; Add-Type -AssemblyName System.Windows.Forms; {operation}" + ), + ], + ) +} + +#[cfg(any(target_os = "linux", test))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum LinuxBackend { + Wayland, + X11, +} + +#[cfg(any(target_os = "linux", test))] +fn linux_backend( + wayland: Option<&str>, + display: Option<&str>, + session: Option<&str>, +) -> Option { + if wayland.is_some_and(|value| !value.is_empty()) || session == Some("wayland") { + Some(LinuxBackend::Wayland) + } else if display.is_some_and(|value| !value.is_empty()) || session == Some("x11") { + Some(LinuxBackend::X11) + } else { + None + } +} + +#[cfg(any(target_os = "linux", test))] +fn linux_helpers(backend: LinuxBackend, writing: bool) -> Vec { + match (backend, writing) { + (LinuxBackend::Wayland, true) => vec![Helper::new( + "wl-copy", + &["--type", "text/plain;charset=utf-8"], + )], + (LinuxBackend::Wayland, false) => { + vec![Helper::new("wl-paste", &["--no-newline", "--type", "text"])] + } + (LinuxBackend::X11, true) => vec![ + Helper::new( + "xclip", + &["-selection", "clipboard", "-in", "-target", "UTF8_STRING"], + ), + Helper::new("xsel", &["--clipboard", "--input"]), + ], + (LinuxBackend::X11, false) => vec![ + Helper::new( + "xclip", + &["-selection", "clipboard", "-out", "-target", "UTF8_STRING"], + ), + Helper::new("xsel", &["--clipboard", "--output"]), + ], + } +} + +fn helpers(writing: bool) -> Result, LocalSystemActionError> { + #[cfg(target_os = "macos")] + { + Ok(vec![macos_helper(writing, None)]) + } + #[cfg(target_os = "windows")] + { + Ok(vec![windows_helper(writing)]) + } + #[cfg(target_os = "linux")] + { + let backend = linux_backend( + std::env::var("WAYLAND_DISPLAY").ok().as_deref(), + std::env::var("DISPLAY").ok().as_deref(), + std::env::var("XDG_SESSION_TYPE").ok().as_deref(), + ) + .ok_or_else(|| { + LocalSystemActionError::new( + LocalSystemActionErrorKind::NotAvailable, + "No X11 or Wayland desktop session is available on the runtime host", + ) + })?; + Ok(linux_helpers(backend, writing)) + } + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + let _ = writing; + Err(LocalSystemActionError::new( + LocalSystemActionErrorKind::NotAvailable, + "Clipboard text is unsupported on this OS", + )) + } +} + +async fn run_candidates( + helpers: &[Helper], + input: Option<&str>, + timeout: Duration, +) -> Result { + let mut missing = Vec::new(); + for helper in helpers { + match run_helper(helper, input, timeout).await { + Ok(text) => return Ok(text), + // Only an executable that never started permits a write fallback. + // Never replay input after a timeout, exit error or broken pipe. + Err(HelperError::Missing(program)) => missing.push(program), + Err(HelperError::Failed(error)) => return Err(error), + } + } + Err(LocalSystemActionError::new( + LocalSystemActionErrorKind::NotAvailable, + format!( + "Clipboard helper unavailable on this host: {}", + missing.join(", ") + ), + )) +} + +pub(super) async fn read_text() -> Result { + run_candidates(&helpers(false)?, None, HELPER_TIMEOUT).await +} + +pub(super) async fn write_text(text: &str) -> Result<(), LocalSystemActionError> { + if text.len() > MAX_TEXT_BYTES || text.contains('\0') { + return Err(LocalSystemActionError::new( + LocalSystemActionErrorKind::InvalidParams, + "Clipboard text must be at most 16 MiB and contain no NUL characters", + )); + } + run_candidates(&helpers(true)?, Some(text), HELPER_TIMEOUT) + .await + .map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEXT_CASES: &[&str] = &[ + "plain text", + "中文 café 🦀", + " first\n\nlast\n", + "first\r\nsecond\r\n", + "'\" $() `echo no` ; & |", + "{\\rtf1\\ansi this must stay literal text}", + "", + ]; + + struct Fixture { + dir: tempfile::TempDir, + helper: Helper, + } + + impl Fixture { + fn new(unix: &str, windows: &str) -> Self { + let dir = tempfile::tempdir().unwrap(); + let value = dir.path().join("value"); + #[cfg(unix)] + let helper = { + let _ = windows; + let script = dir.path().join("fixture.sh"); + std::fs::write(&script, unix).unwrap(); + Helper::new("sh", &[script.to_str().unwrap(), value.to_str().unwrap()]) + }; + #[cfg(windows)] + let helper = { + let _ = unix; + let script = dir.path().join("fixture.ps1"); + std::fs::write( + &script, + format!("param([string]$Path)\n$ErrorActionPreference='Stop'\n{windows}"), + ) + .unwrap(); + Helper::new( + "powershell.exe", + &[ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + script.to_str().unwrap(), + value.to_str().unwrap(), + ], + ) + }; + Self { dir, helper } + } + + fn writer() -> Self { + Self::new("cat > \"$1\"", "$file=[IO.File]::Create($Path); [Console]::OpenStandardInput().CopyTo($file); $file.Dispose()") + } + + fn reader() -> Self { + Self::new("cat \"$1\"", "$bytes=[IO.File]::ReadAllBytes($Path); [Console]::OpenStandardOutput().Write($bytes,0,$bytes.Length)") + } + } + + #[tokio::test] + async fn helper_transport_preserves_literal_utf8_and_line_endings() { + let writer = Fixture::writer(); + let reader = Fixture::reader(); + for text in TEXT_CASES { + run_helper(&writer.helper, Some(text), HELPER_TIMEOUT) + .await + .unwrap(); + assert_eq!( + std::fs::read(writer.dir.path().join("value")).unwrap(), + text.as_bytes() + ); + std::fs::write(reader.dir.path().join("value"), text).unwrap(); + assert_eq!( + run_helper(&reader.helper, None, HELPER_TIMEOUT) + .await + .unwrap(), + *text + ); + } + } + + #[tokio::test] + async fn missing_executable_can_fall_back_before_any_input() { + let fixture = Fixture::writer(); + let value = fixture.dir.path().join("value"); + run_candidates( + &[ + Helper::new("openbitfun-clipboard-missing-fixture", &[]), + fixture.helper, + ], + Some("fallback"), + HELPER_TIMEOUT, + ) + .await + .unwrap(); + assert_eq!(std::fs::read_to_string(value).unwrap(), "fallback"); + } + + #[tokio::test] + async fn failed_write_is_not_replayed_even_after_accepting_text() { + let first = Fixture::new("cat > \"$1\"; exit 7", "$file=[IO.File]::Create($Path); [Console]::OpenStandardInput().CopyTo($file); $file.Dispose(); exit 7"); + let second = Fixture::writer(); + let second_value = second.dir.path().join("value"); + let result = run_candidates( + &[first.helper, second.helper], + Some("submitted"), + HELPER_TIMEOUT, + ) + .await + .unwrap_err(); + assert!(result.message().contains("CLIPBOARD_WRITE_OUTCOME_UNKNOWN")); + assert_eq!( + std::fs::read_to_string(first.dir.path().join("value")).unwrap(), + "submitted" + ); + assert!(!second_value.exists()); + } + + #[tokio::test] + async fn timeout_bounds_blocked_stdin_and_never_runs_an_alternate_writer() { + let blocked = Fixture::new( + "while :; do :; done", + "while ($true) { Start-Sleep -Milliseconds 50 }", + ); + let second = Fixture::writer(); + let second_value = second.dir.path().join("value"); + let started = std::time::Instant::now(); + let result = run_candidates( + &[blocked.helper, second.helper], + Some(&"x".repeat(1024 * 1024)), + Duration::from_millis(150), + ) + .await + .unwrap_err(); + assert_eq!(result.kind(), LocalSystemActionErrorKind::Timeout); + assert!(result.message().contains("CLIPBOARD_WRITE_OUTCOME_UNKNOWN")); + assert!(started.elapsed() < Duration::from_secs(5)); + assert!(!second_value.exists()); + } + + #[tokio::test] + async fn read_rejects_invalid_utf8_instead_of_corrupting_text() { + let fixture = Fixture::reader(); + std::fs::write(fixture.dir.path().join("value"), [0xff, 0xfe]).unwrap(); + let error = run_helper(&fixture.helper, None, HELPER_TIMEOUT) + .await + .unwrap_err(); + assert!( + matches!(error, HelperError::Failed(error) if error.message().contains("non-UTF-8")) + ); + } + + #[tokio::test] + async fn read_timeout_is_bounded_without_claiming_a_write() { + let fixture = Fixture::new( + "while :; do :; done", + "while ($true) { Start-Sleep -Milliseconds 50 }", + ); + let started = std::time::Instant::now(); + let error = run_helper(&fixture.helper, None, Duration::from_millis(150)) + .await + .unwrap_err(); + assert!( + matches!(error, HelperError::Failed(error) if error.kind() == LocalSystemActionErrorKind::Timeout && !error.message().contains("WRITE_OUTCOME_UNKNOWN")) + ); + assert!(started.elapsed() < Duration::from_secs(5)); + } + + #[tokio::test] + async fn oversized_read_is_bounded() { + let fixture = Fixture::reader(); + std::fs::write( + fixture.dir.path().join("value"), + vec![b'x'; MAX_TEXT_BYTES + 1], + ) + .unwrap(); + let error = run_helper(&fixture.helper, None, HELPER_TIMEOUT) + .await + .unwrap_err(); + assert!( + matches!(error, HelperError::Failed(error) if error.message().contains("transport limit")) + ); + } + + #[tokio::test] + async fn invalid_write_is_rejected_before_a_desktop_is_needed() { + assert_eq!( + write_text("a\0b").await.unwrap_err().kind(), + LocalSystemActionErrorKind::InvalidParams + ); + assert_eq!( + write_text(&"x".repeat(MAX_TEXT_BYTES + 1)) + .await + .unwrap_err() + .kind(), + LocalSystemActionErrorKind::InvalidParams + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn detached_clipboard_owner_does_not_hold_the_write_reply_open() { + let fixture = Fixture::new("cat > \"$1\"; (sleep 2) >&2 &", ""); + let started = std::time::Instant::now(); + run_helper(&fixture.helper, Some("owned"), Duration::from_secs(1)) + .await + .unwrap(); + assert!(started.elapsed() < Duration::from_secs(1)); + assert_eq!( + std::fs::read_to_string(fixture.dir.path().join("value")).unwrap(), + "owned" + ); + } + + #[test] + fn linux_uses_the_selected_desktop_clipboard_and_explicit_text_formats() { + assert_eq!( + linux_backend(Some("wayland-1"), Some(":1"), None), + Some(LinuxBackend::Wayland) + ); + assert_eq!( + linux_backend(Some(""), Some(":1"), None), + Some(LinuxBackend::X11) + ); + assert_eq!( + linux_backend(None, None, Some("wayland")), + Some(LinuxBackend::Wayland) + ); + assert_eq!(linux_backend(None, None, Some("tty")), None); + assert_eq!(linux_backend(None, None, None), None); + for writing in [false, true] { + let wayland = linux_helpers(LinuxBackend::Wayland, writing); + assert_eq!( + wayland.len(), + 1, + "A Wayland clipboard must not fall back to XWayland" + ); + assert!(wayland[0].program.starts_with("wl-")); + let x11 = linux_helpers(LinuxBackend::X11, writing); + assert_eq!(x11[0].program, "xclip"); + assert!(x11[0].args.iter().any(|arg| arg == "UTF8_STRING")); + assert_eq!(x11[1].program, "xsel"); + } + } + + #[tokio::test] + #[cfg_attr( + not(target_os = "macos"), + ignore = "Requires a disposable desktop clipboard; use the documented isolated native fixture" + )] + async fn native_clipboard_roundtrip() { + #[cfg(target_os = "macos")] + let pairs = { + let board = format!( + "org.openbitfun.clipboard-test.{}.{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + vec![( + macos_helper(true, Some(&board)), + macos_helper(false, Some(&board)), + )] + }; + #[cfg(target_os = "windows")] + let pairs = vec![(windows_helper(true), windows_helper(false))]; + #[cfg(target_os = "linux")] + let pairs = helpers(true) + .unwrap() + .into_iter() + .zip(helpers(false).unwrap()) + .collect::>(); + for (writer, reader) in pairs { + for text in TEXT_CASES { + run_helper(&writer, Some(text), HELPER_TIMEOUT) + .await + .unwrap(); + assert_eq!( + run_helper(&reader, None, HELPER_TIMEOUT).await.unwrap(), + *text, + "{} round trip", + writer.program + ); + } + } + } +} From a7d860f989893329d5dc494180cf47b73aa04de1 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 17 Oct 2026 18:51:22 +0800 Subject: [PATCH 2/2] chore(computer-use): remove obsolete truncation import --- .../src/agentic/tools/implementations/computer_use_actions.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs index b655a8f0da..b349eb6a23 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs @@ -12,7 +12,7 @@ use crate::agentic::tools::computer_use_host::{ use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; use openbitfun_services_core::system::{ - truncate_with_marker, LocalSystemActionError, LocalSystemProvider, RunScriptRequest, + LocalSystemActionError, LocalSystemProvider, RunScriptRequest, }; use serde_json::{json, Value};