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
141 changes: 110 additions & 31 deletions docs/architecture/computer-use-control.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions scripts/test-macos-input-controls.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ try {
const hostReady = join(directory, 'host-ready');
const testEnvironment = {
...process.env, OPENBITFUN_INPUT_FIXTURE_PID: pid,
OPENBITFUN_INPUT_FIXTURE_APP: join(directory, 'OpenBitFun Input Target.app'),
OPENBITFUN_INPUT_FIXTURE_RESULT: result, OPENBITFUN_INPUT_OBSERVER_RESULT: `${result}.observer`,
OPENBITFUN_INPUT_HOST_READY: hostReady,
};
Expand Down
3 changes: 2 additions & 1 deletion scripts/test-windows-computer-use-native.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ windows = { version = "0.61", features = [
"Win32_System_Com", "Win32_System_Ole", "Win32_System_Variant", "Win32_System_LibraryLoader",
"Win32_System_Threading", "Win32_System_WinRT", "Win32_System_WinRT_Direct3D11",
"Win32_System_WinRT_Graphics_Capture", "Win32_UI_Accessibility", "Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging"
"Win32_UI_WindowsAndMessaging", "Win32_UI_Shell"
] }
`);
await copyFile(join(root, 'Cargo.lock'), join(dir, 'Cargo.lock'));
Expand Down Expand Up @@ -75,6 +75,7 @@ mod computer_use {
mod desktop_host {
use crate::util::errors::{OpenBitFunError, OpenBitFunResult};
#[path = ${source('src/apps/desktop/src/computer_use/desktop_host/background_guard.rs')}] mod background_guard;
#[path = ${source('src/apps/desktop/src/computer_use/desktop_host/windows_launch.rs')}] mod windows_launch;
}
}
`);
Expand Down
11 changes: 7 additions & 4 deletions src/apps/desktop/src/computer_use/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,10 +180,13 @@ legacy payloads, advertise `observation-id-v1`, and reject explicit stale IDs
before input. New observations invalidate prior AX/view indices even when the
content digest is unchanged. Do not replace namespace identity with a digest.

New native fixture setup must use the production target inventory, selected-share
capture and explicit grant. Tool `start_control` only requests sharing. Do not
bypass that gate to make a native fixture pass. Permission probes must never
prompt, and remembered grants must never select or share a window automatically.
Agent native fixtures must discover and address their target through production
tools without manual share selection or a separate input grant. Cover explicit
`start_control` and implicit first-action startup. Optional manual-share fixtures
exercise target inventory, observe-first capture and grants separately; they must
not hide an Agent regression by performing those UI steps in fixture setup.
Permission probes never prompt. Desktop-state witnesses record concurrent human
activity without revoking Agent control or replaying input.

The Web preview's deterministic cursor is verified against Rust-generated
`computer-use-rust` samples retained in the repository. The reference source is
Expand Down
1 change: 1 addition & 0 deletions src/apps/desktop/src/computer_use/control_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ fn projected(r: &Resource) -> ControlSnapshot {
s.supported = true;
s.capabilities = vec![
"control-session-v1".into(),
"agent-directed-control-v1".into(),
"stop-control".into(),
"action-feedback".into(),
"observation-scope-v1".into(),
Expand Down
29 changes: 26 additions & 3 deletions src/apps/desktop/src/computer_use/control_session/admission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ impl<E: Copy + PartialEq> Admission<E> {
.map_or(0, |sharing| sharing.authorization_revision)
}

/// A tool can request sharing, but cannot select a window or grant input.
/// Optional manual sharing has its own selection and input grant. Normal
/// Agent sessions use begin_start/admit and never enter this workflow.
pub(super) fn request_share(&mut self, owner: &str, mode: ControlMode) -> Result<(), String> {
if self.snapshot.owner.as_deref() != Some(owner) || self.busy || self.cleaning {
return Err("[CONTROL_BUSY] Another task or action owns desktop control".into());
Expand Down Expand Up @@ -401,7 +402,10 @@ impl<E: Copy + PartialEq> Admission<E> {
}
let initial = self.snapshot.state.is_empty() || self.snapshot.state == "idle";
if self.snapshot.state == "awaiting_share" {
return Err("[SHARE_REQUIRED] Select an App in the Computer Use sharing panel on the executing Desktop or its connected peer controller. Mobile/IM/headless surfaces cannot select a native window; this call is not waiting for a hidden dialog.".into());
if self.snapshot.owner.as_deref() != Some(owner) {
return Err("[CONTROL_BUSY] Desktop control is reserved by another session".into());
}
return Err("[SHARE_REQUIRED] This optional manual share has no target. Agent tasks use start_control to select applications through their tool requests.".into());
}
if !initial && !self.active() {
return Err("[CONTROL_STOPPED] Use start_control to begin a new session".into());
Expand All @@ -415,7 +419,8 @@ impl<E: Copy + PartialEq> Admission<E> {
self.snapshot.mode
};
if self.snapshot.sharing.is_some()
&& (policy.foreground && !policy.target || matches!(action, "clipboard_set"))
&& (policy.foreground && !policy.target
|| matches!(action, "clipboard_set" | "open_app"))
{
return Err("[SHARED_TARGET_ONLY] Window sharing does not authorize global clipboard, launching or scripts".into());
}
Expand Down Expand Up @@ -471,6 +476,24 @@ impl<E: Copy + PartialEq> Admission<E> {
mod tests {
use super::*;
#[test]
fn agent_selects_targets_and_operates_without_a_manual_share() {
let mut state = Admission::<u8>::default();
let observe = state.admit("task", "get_app_state", 1).unwrap();
assert_eq!(state.snapshot.mode, ControlMode::Background);
assert!(state.snapshot.sharing.is_none());
state.bind("pid:1/window:10".into()).unwrap();
state.release(observe, true);
let input = state.admit("task", "app_type_text", 1).unwrap();
assert!(state.check_input(input).is_ok());
state.release(input, true);
let next = state.admit("task", "get_app_state", 1).unwrap();
state.bind("pid:2/window:20".into()).unwrap();
assert_eq!(state.snapshot.target_revision, 2);
state.release(next, true);
let launch = state.admit("task", "open_app", 1).unwrap();
assert!(state.check_input(launch).is_ok());
}
#[test]
fn sharing_requires_selection_capture_and_a_separate_revocable_grant() {
let mut state = running();
let generation = state.snapshot.generation;
Expand Down
106 changes: 68 additions & 38 deletions src/apps/desktop/src/computer_use/desktop_host/background_guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,47 +51,34 @@ pub(super) async fn run<T>(
return input.await;
}
let scope = ObservationScope::from_control(&control);
let before = capture().await.map_err(|error| {
OpenBitFunError::tool(format!(
"[BACKGROUND_OBSERVATION_UNAVAILABLE] No input sent: {error}"
))
})?;
let result = input.await;
// Once an operation may have been submitted, any failure must preserve
// ambiguity. A fallback click or a foreground repair can duplicate effects.
let after = capture().await;
match after {
Ok(after) => {
let observation = evidence(&before, &after, control.sequence);
let changed = !observation.foreground_unchanged
|| observation.cursor_unchanged == Some(false)
|| observation.window_order_unchanged == Some(false);
crate::computer_use::control_session::record_background_observation(
&scope,
observation,
);
if !changed {
return result;
}
revoke(&scope, "background_desktop_changed");
Err(OpenBitFunError::tool(format!("[BACKGROUND_INTERFERENCE] Desktop focus, physical pointer or window ordering changed during the operation; control was revoked. Cause may be application behavior or concurrent user/system input. Effects may already have occurred. No restoration or retry was sent. Operation error: {}", result.err().map(|error| error.to_string()).unwrap_or_else(|| "none".into()))))
}
Err(error) => {
revoke(&scope, "background_observation_failed");
Err(OpenBitFunError::tool(format!("[BACKGROUND_OBSERVATION_FAILED] Could not verify the desktop after input; control was revoked. Effects may already have occurred; observe before deciding another action. {error}")))
}
let (result, observation) = observe_operation(input, capture, control.sequence).await;
if let Some(observation) = observation {
crate::computer_use::control_session::record_background_observation(&scope, observation);
}
result
}

fn revoke(scope: &ObservationScope, reason: &str) {
if let Some(target) = &scope.target {
crate::computer_use::control_session::native_stopped_target(
scope.generation,
scope.target_revision,
target,
reason,
);
}
/// Global desktop changes cannot be attributed to this action: the user is
/// allowed to keep working while directed input runs. Witnesses are diagnostic
/// evidence, never admission or revocation. Native target/lease checks still own
/// input validity. Preserve the action's exact outcome and never replay it.
async fn observe_operation<T, F, Fut>(
input: impl std::future::Future<Output = OpenBitFunResult<T>>,
mut read: F,
sequence: u64,
) -> (OpenBitFunResult<T>, Option<BackgroundObservation>)
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<Witness, String>>,
{
let before = read().await;
let result = input.await;
let after = read().await;
let observation = before
.ok()
.zip(after.ok())
.map(|(before, after)| evidence(&before, &after, sequence));
(result, observation)
}

#[cfg(any(target_os = "macos", target_os = "windows"))]
Expand Down Expand Up @@ -203,6 +190,49 @@ mod tests {
activation_epoch: Some(4),
}
}
#[tokio::test]
async fn concurrent_human_input_does_not_cancel_or_replay_directed_input() {
let before = witness(&["human", "target"]);
let mut after = witness(&["other", "target", "human"]);
after.foreground = vec!["other".into()];
after.pointer = Some((900.0, 500.0));
after.activation_epoch = Some(5);
let mut witnesses = [Ok(before), Ok(after)].into_iter();
let mut calls = 0;
let (result, evidence) = observe_operation(
async {
calls += 1;
Ok("delivered")
},
|| std::future::ready(witnesses.next().unwrap()),
7,
)
.await;
assert_eq!(result.unwrap(), "delivered");
assert_eq!(calls, 1);
let evidence = evidence.unwrap();
assert!(!evidence.foreground_unchanged);
assert_eq!(evidence.cursor_unchanged, Some(false));
}
#[tokio::test]
async fn missing_diagnostics_preserve_the_native_operation_result() {
let mut calls = 0;
let (result, evidence) = observe_operation(
async {
calls += 1;
Err::<(), _>(OpenBitFunError::tool("native target closed"))
},
|| std::future::ready(Err("desktop witness unavailable".into())),
8,
)
.await;
assert_eq!(
result.unwrap_err().to_string(),
OpenBitFunError::tool("native target closed").to_string()
);
assert_eq!(calls, 1);
assert!(evidence.is_none());
}
#[test]
fn detects_raise_pointer_and_transient_activation_without_blame_or_repair() {
let before = witness(&["human", "target", "other"]);
Expand Down
Loading
Loading