Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 33 additions & 0 deletions docs/architecture/computer-use-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
57 changes: 57 additions & 0 deletions scripts/test-linux-clipboard.sh
Original file line number Diff line number Diff line change
@@ -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[@]}"
27 changes: 27 additions & 0 deletions src/apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions src/apps/desktop/src/computer_use/control_session/admission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,7 @@ impl<E: Copy + PartialEq> Admission<E> {
};
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());
}
Expand Down Expand Up @@ -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<u8> {
let mut state = Admission::default();
let generation = state.begin_start("owner", ControlMode::Background).unwrap();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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<usize> {
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<bool> {
match params.get("include_image") {
None => Ok(true),
Expand Down Expand Up @@ -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)),
)])
}
Expand Down Expand Up @@ -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<Vec<String>>,
Expand Down
Loading
Loading