diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index 7fb4e56152..ecd13b81ba 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -144,6 +144,10 @@ is `node tests/e2e/scripts/run-subscription-pool.mjs` (see its E2E guide). For desktop card stacking, native parenting rollback, and legacy record/request compatibility, run `cargo test --locked -p openbitfun-desktop --no-default-features --lib desktop_cards`. +This also checks host-stamped Peer turn metadata for legacy controllers. Tool +admission is turn-scoped: attached Peer controllers are observers and must not +block local card creation or display. Run the Core guide's `desktop_card` tests +for request-origin propagation; these fixtures do not establish live remote behavior. On a graphical Windows session with Explorer running, also run `cargo test --locked -p openbitfun-desktop --no-default-features --lib live_explorer_attachment_round_trip -- --ignored`. The native fixtures use hidden disposable windows; they do not establish WebView rendering, @@ -154,6 +158,12 @@ start it again) a pinned card must reattach within about 30 s, or come back unpi pin-failure notice; it must never stay "Shown" without a window. UI, hidden-card pinning, and old-host negotiation coverage: `pnpm --dir src/web-ui run test:run src/app/components/DesktopCard/DesktopCardWindow.test.tsx src/infrastructure/api/service-api/DesktopCardAPI.test.ts src/app/scenes/personal-overview/OverviewDesktopCardsSection.test.tsx src/app/scenes/settings/pages/application/DesktopCardsSettingsSection.test.tsx`. +For frontend customization with background Peer subscribers, direct Peer tool +refusal, revision recovery, and legacy state compatibility, run +`cargo test --locked -p openbitfun-desktop --no-default-features --lib frontend_workbench`. +The Core guide's `frontend_workbench` tests cover per-turn origin admission. +These fixtures do not launch the native confirmation controls or a live Peer connection. + For session storage directory access, run `cargo test -p openbitfun-desktop --lib api::session_storage_directory::tests` for session storage directory resolution (these tests do not launch a file manager), diff --git a/src/apps/desktop/src/api/miniapp_agent_api.rs b/src/apps/desktop/src/api/miniapp_agent_api.rs index fa2d05fb27..c558d892ce 100644 --- a/src/apps/desktop/src/api/miniapp_agent_api.rs +++ b/src/apps/desktop/src/api/miniapp_agent_api.rs @@ -176,6 +176,10 @@ pub struct MiniAppAgentContextFile { #[serde(rename_all = "camelCase")] pub struct MiniAppAgentRunRequest { pub app_id: String, + /// Stamped by this host's Peer bridge, independently of client version. + /// Legacy and controller-local calls keep the local presentation context. + #[serde(default)] + pub peer_device_control: bool, /// Full user prompt for the agent turn. The MiniApp owns its own task /// protocol; the host only wraps it into a hidden agent session. pub prompt: String, @@ -551,6 +555,11 @@ pub async fn miniapp_agent_run( request.enable_tools, market_strict, ); + if request.peer_device_control { + submission_plan.metadata[ + openbitfun_agent_runtime::remote_file_delivery::TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY + ] = serde_json::Value::Bool(true); + } let validated_existing_session = if let Some(existing_session_id) = submission_plan.requested_session_id.clone() { diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index 13f4b54035..89e381a2ba 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -355,6 +355,22 @@ pub async fn dispatch(command: &str, args: Value) -> HostInvokeBridgeResult { PeerHostVerdict::Execute => {} } + // Direct tool calls have no dialog turn whose origin can reach + // ToolUseContext. Refuse tools that require the visible local desktop here. + let direct_tool = args + .get("request") + .unwrap_or(&args) + .get("toolName") + .and_then(Value::as_str); + if command == "execute_tool" && matches!(direct_tool, Some("DesktopCard" | "FrontendWorkbench")) + { + return HostInvokeBridgeResult { + ok: false, + value: None, + error: Some(format!("{} is unavailable through Peer Device Mode because it requires the visible local desktop.", direct_tool.unwrap())), + }; + } + if command == "product_control_invoke" { use openbitfun_product_domains::{ product_control::{ProductControlAction, ProductControlRequest}, @@ -409,6 +425,7 @@ pub async fn dispatch(command: &str, args: Value) -> HostInvokeBridgeResult { } } }; + let args = with_peer_turn_origin(command, args); let response_scope = serde_json::json!({"path": args.get("request").unwrap_or(&args).get("path")}); match bridge_via_webview(&app, command, args).await { @@ -437,6 +454,38 @@ pub async fn dispatch(command: &str, args: Value) -> HostInvokeBridgeResult { } } +/// Preserve the caller's payload while recording where this turn was +/// submitted. This also covers older controllers that send no metadata. +fn with_peer_turn_origin(command: &str, mut args: Value) -> Value { + use openbitfun_agent_runtime::remote_file_delivery::TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY; + + if !matches!( + command, + "start_dialog_turn" | "btw_ask_stream" | "miniapp_agent_run" + ) { + return args; + } + let request = if args.get("request").is_some() { + args.get_mut("request").expect("request exists") + } else { + &mut args + }; + if let Some(request) = request.as_object_mut() { + if command == "miniapp_agent_run" { + request.insert("peerDeviceControl".to_string(), Value::Bool(true)); + return args; + } + let metadata = request + .entry("userMessageMetadata") + .or_insert_with(|| serde_json::json!({})); + if !metadata.is_object() { + *metadata = serde_json::json!({ "raw_metadata": metadata.take() }); + } + metadata[TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY] = Value::Bool(true); + } + args +} + async fn bridge_via_webview( app: &AppHandle, command: &str, @@ -478,6 +527,119 @@ async fn bridge_via_webview( mod tests { use super::*; + #[tokio::test] + async fn desktop_cards_direct_peer_calls_fail_before_the_native_bridge() { + let result = dispatch( + "execute_tool", + serde_json::json!({"request": { + "toolName": "DesktopCard", "input": {"action": "create"} + }}), + ) + .await; + assert!(!result.ok); + assert!(result.error.unwrap().contains("Peer Device Mode")); + } + + #[tokio::test] + async fn frontend_workbench_direct_peer_calls_fail_before_the_native_bridge() { + let request = serde_json::json!({ + "toolName": "FrontendWorkbench", "input": {"action": "prepare"} + }); + for args in [request.clone(), serde_json::json!({"request": request})] { + let result = dispatch("execute_tool", args).await; + assert!(!result.ok); + assert!(result.error.unwrap().contains("Peer Device Mode")); + } + } + + #[test] + fn desktop_cards_miniapp_peer_origin_is_host_stamped() { + let original = serde_json::json!({"request": { + "appId": "builtin-app", "prompt": "Create a card" + }}); + let local: super::super::miniapp_agent_api::MiniAppAgentRunRequest = + serde_json::from_value(original["request"].clone()).unwrap(); + assert!(!local.peer_device_control); + let marked = with_peer_turn_origin("miniapp_agent_run", original); + let peer: super::super::miniapp_agent_api::MiniAppAgentRunRequest = + serde_json::from_value(marked["request"].clone()).unwrap(); + assert!(peer.peer_device_control); + assert_eq!(peer.app_id, local.app_id); + assert_eq!(peer.prompt, local.prompt); + } + + #[test] + fn desktop_cards_peer_origin_preserves_legacy_turn_payloads() { + use openbitfun_agent_runtime::remote_file_delivery::TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY; + use serde_json::json; + + for command in ["start_dialog_turn", "btw_ask_stream"] { + for metadata in [ + None, + Some(Value::Null), + Some(json!("legacy metadata")), + Some(json!({ + "surface": "flow_chat", + "requestId": "request-1", + "peer_device_control": false + })), + ] { + let mut original = json!({"request": { + "sessionId": "local-or-peer-session", + "turnId": "turn-1", + "userInput": "Create a card", + "agentType": "Standard" + }}); + if let Some(metadata) = metadata.clone() { + original["request"]["userMessageMetadata"] = metadata; + } + let marked = with_peer_turn_origin(command, original.clone()); + let marked_metadata = &marked["request"]["userMessageMetadata"]; + assert_eq!(marked_metadata[TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY], true); + if command == "start_dialog_turn" { + let decoded: super::super::agentic_api::StartDialogTurnRequest = + serde_json::from_value(marked["request"].clone()).unwrap(); + assert_eq!(decoded.session_id, "local-or-peer-session"); + assert_eq!( + decoded.user_message_metadata.as_ref(), + Some(marked_metadata) + ); + } + match metadata { + Some(Value::Object(_)) => { + assert_eq!(marked_metadata["surface"], "flow_chat"); + assert_eq!(marked_metadata["requestId"], "request-1"); + } + Some(Value::String(raw)) => assert_eq!(marked_metadata["raw_metadata"], raw), + _ => {} + } + let mut restored = marked["request"].clone(); + restored + .as_object_mut() + .unwrap() + .remove("userMessageMetadata"); + let mut original_fields = original["request"].clone(); + original_fields + .as_object_mut() + .unwrap() + .remove("userMessageMetadata"); + assert_eq!(restored, original_fields); + // The marker is ordinary additive metadata and survives queue / + // transcript serialization without changing the request schema. + let round_trip: Value = serde_json::from_str(&marked.to_string()).unwrap(); + assert_eq!(round_trip, marked); + assert_eq!(with_peer_turn_origin(command, marked.clone()), marked); + } + } + + let read = json!({"request": {"sessionId": "session-1"}}); + assert_eq!(with_peer_turn_origin("get_session", read.clone()), read); + assert_eq!( + with_peer_turn_origin("start_dialog_turn", Value::Null), + Value::Null + ); + } + fn control_state(controllers: &[&str], requests: &[&str]) -> PeerControlState { PeerControlState { controllers: controllers diff --git a/src/apps/desktop/src/desktop_cards/mod.rs b/src/apps/desktop/src/desktop_cards/mod.rs index fb50c7721c..9717bad2e7 100644 --- a/src/apps/desktop/src/desktop_cards/mod.rs +++ b/src/apps/desktop/src/desktop_cards/mod.rs @@ -134,14 +134,6 @@ fn pending_countdown(card: &DesktopCardRecord) -> Option { } } -fn ensure_local_display_surface() -> Result<(), String> { - if crate::api::peer_host_invoke::attached_controllers().is_empty() { - Ok(()) - } else { - Err("This OpenBitFun host is being driven from another device through Peer Device Mode. Desktop cards would appear only on this host's screen, which that user cannot see.".to_string()) - } -} - async fn due_notification_body(message: Option<&str>) -> String { if let Some(message) = message.filter(|message| !message.trim().is_empty()) { return message.to_string(); @@ -942,10 +934,12 @@ impl DesktopCards { } async fn handle_tool_request(&self, request: DesktopCardHostRequest) -> Result { + // DesktopCardTool checks the submitting turn's control surface before + // invoking this host. Attached peers are event subscribers, not proof + // that this particular request came from a remote user. let action = request.action(); let result = match request { DesktopCardHostRequest::Create { draft, origin } => { - ensure_local_display_surface()?; json!({ "card": self.create(draft, origin).await? }) } DesktopCardHostRequest::Update { card_id, patch } => { @@ -956,7 +950,6 @@ impl DesktopCards { json!({ "card": self.get(&card_id).await?, "enabled": self.is_enabled() }) } DesktopCardHostRequest::Show { card_id } => { - ensure_local_display_surface()?; self.require_enabled()?; json!({ "card": self.set_visible(&card_id, true).await? }) } diff --git a/src/apps/desktop/src/frontend_workbench.rs b/src/apps/desktop/src/frontend_workbench.rs index 24c1079049..d9249e182d 100644 --- a/src/apps/desktop/src/frontend_workbench.rs +++ b/src/apps/desktop/src/frontend_workbench.rs @@ -348,9 +348,8 @@ impl FrontendWorkbenchManager { self: &Arc, request: FrontendWorkbenchHostRequest, ) -> Result { - ensure_local_confirmation_surface( - crate::api::peer_host_invoke::attached_controllers().len(), - )?; + // Core admits the invoking turn's origin. Peer event subscribers may + // remain attached while the user customizes the visible local desktop. match request.action.as_str() { "prepare" => self.prepare(), "status" => self.status(), @@ -1231,16 +1230,6 @@ impl FrontendWorkbenchManager { } } -fn ensure_local_confirmation_surface(attached_peer_controllers: usize) -> Result<(), String> { - if attached_peer_controllers > 0 { - return Err( - "FrontendWorkbench is unavailable while this OpenBitFun host is controlled through Peer Device Mode; run Creative mode on the visible local desktop instead" - .to_string(), - ); - } - Ok(()) -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FrontendUpdateDecisionRequest { @@ -2025,12 +2014,32 @@ mod tests { assert!(validate_revision_id("creative-38e14f63-30ad-4ad7-9e4e-5ad556450ba3").is_ok()); } - #[test] - fn peer_control_requires_a_visible_local_confirmation_surface() { - assert!(ensure_local_confirmation_surface(0).is_ok()); - let error = ensure_local_confirmation_surface(1) - .expect_err("peer-controlled frontend updates must fail loudly"); - assert!(error.contains("Peer Device Mode")); + #[tokio::test] + async fn local_frontend_workbench_requests_ignore_background_peer_subscribers() { + struct AttachedController(String); + impl Drop for AttachedController { + fn drop(&mut self) { + crate::api::peer_host_invoke::detach_controller(&self.0); + } + } + let controller = AttachedController(format!("workbench-test-{}", Uuid::new_v4())); + crate::api::peer_host_invoke::attach_controller(controller.0.clone()); + assert!(crate::api::peer_host_invoke::attached_controllers().contains(&controller.0)); + + let root = tempfile::tempdir().unwrap(); + let manager = overlay_manager(root.path(), "installed product"); + let result = manager + .handle_tool_request(FrontendWorkbenchHostRequest { + action: "prepare".into(), + draft_id: None, + command_id: None, + arguments: None, + }) + .await + .expect("local customization remains available with Peer subscribers"); + let draft = PathBuf::from(result["draftPath"].as_str().unwrap()); + assert!(draft.join(overrides::CSS).is_file()); + assert!(draft.join(overrides::JS).is_file()); } #[test] diff --git a/src/crates/assembly/core/AGENTS.md b/src/crates/assembly/core/AGENTS.md index 02e71c3e41..08c0c067c4 100644 --- a/src/crates/assembly/core/AGENTS.md +++ b/src/crates/assembly/core/AGENTS.md @@ -195,6 +195,26 @@ Narrower local guides already exist for some subtrees: ## Verification +For desktop card admission, per-turn Peer origin, recovery context and subagent +propagation: + +```bash +cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git --lib -- desktop_card +``` + +These tests use a stub card host and simulated remote contexts; they do not +exercise native card windows or live Peer/mobile/SSH connections. The Desktop +guide owns the HostInvoke compatibility and native host checks. + +For Creative frontend customization origin admission and fresh approval policy: + +```bash +cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git,tools-creation --lib frontend_workbench +``` + +These tests simulate Peer/mobile and remote-workspace origins; they do not +exercise native recovery controls or live remote connections. + For opt-in category/system-prompt previews and session-storage isolation: ```bash diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 9ced0403e3..9bfd4cff03 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -98,7 +98,7 @@ use openbitfun_agent_runtime::prompt_suggestion::{ }; use openbitfun_agent_runtime::remote_file_delivery::{ needs_computer_links_for_source, remote_file_delivery_reminder, - TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY, + TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY, TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY, }; use openbitfun_agent_runtime::sdk::PermissionReply; use openbitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; @@ -605,6 +605,23 @@ fn metadata_bool(metadata: Option<&serde_json::Value>, key: &str) -> Option, + metadata: Option<&serde_json::Value>, +) { + for key in [ + USER_INPUT_AVAILABLE_CONTEXT_KEY, + AUTO_APPROVE_ASK_CONTEXT_KEY, + TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY, + ] { + if let Some(value) = metadata_bool(metadata, key) { + context.insert(key.to_string(), value.to_string()); + } + } +} + fn runtime_tool_restrictions_for_session_lifetime( mut restrictions: ToolRuntimeRestrictions, transient: bool, @@ -4703,14 +4720,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await; let mut child_context = HashMap::new(); - for key in [ - USER_INPUT_AVAILABLE_CONTEXT_KEY, - AUTO_APPROVE_ASK_CONTEXT_KEY, - ] { - if let Some(value) = metadata_bool(Some(&user_message_metadata), key) { - child_context.insert(key.to_string(), value.to_string()); - } - } + copy_turn_control_context(&mut child_context, Some(&user_message_metadata)); // The delegated child runs under the same resolved mode as the // submission that triggered it, so resolve the same three layers // here instead of letting the child fall back to the global @@ -6052,7 +6062,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .get_thread_goal(session_id, storage_path.as_path()) .await?; - let plan = maybe_build_continuation_after_turn( + let mut plan = maybe_build_continuation_after_turn( &self.thread_goal_store(), self.thread_goal_runtime(session_id).as_ref(), session_id, @@ -6062,6 +6072,16 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet turn_completed, ) .await?; + // An automatic continuation still belongs to the originating control + // surface, even after its controller disconnects. + if let Some(plan) = plan.as_mut() { + if let Some(peer) = + metadata_bool(user_message_metadata, TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY) + { + plan.user_message_metadata[TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY] = + serde_json::json!(peer); + } + } let goal_after = self .thread_goal_store() @@ -7598,23 +7618,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet "true".to_string(), ); } - if let Some(user_input_available) = metadata_bool( - user_message_metadata.as_ref(), - USER_INPUT_AVAILABLE_CONTEXT_KEY, - ) { - context_vars.insert( - USER_INPUT_AVAILABLE_CONTEXT_KEY.to_string(), - user_input_available.to_string(), - ); - } - if let Some(auto_approve_ask) = - metadata_bool(user_message_metadata.as_ref(), AUTO_APPROVE_ASK_CONTEXT_KEY) - { - context_vars.insert( - AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), - auto_approve_ask.to_string(), - ); - } + copy_turn_control_context(&mut context_vars, user_message_metadata.as_ref()); Self::copy_output_schema_context(&mut context_vars, user_message_metadata.as_ref()); if let Some(delivery) = openbitfun_core_types::background_delivery::BackgroundDelivery::from_metadata( @@ -8175,24 +8179,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .to_string(), ); } - if let Some(user_input_available) = metadata_bool( - plan.user_message_metadata.as_ref(), - USER_INPUT_AVAILABLE_CONTEXT_KEY, - ) { - context_vars.insert( - USER_INPUT_AVAILABLE_CONTEXT_KEY.to_string(), - user_input_available.to_string(), - ); - } - if let Some(auto_approve_ask) = metadata_bool( - plan.user_message_metadata.as_ref(), - AUTO_APPROVE_ASK_CONTEXT_KEY, - ) { - context_vars.insert( - AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), - auto_approve_ask.to_string(), - ); - } + copy_turn_control_context(&mut context_vars, plan.user_message_metadata.as_ref()); Self::copy_output_schema_context(&mut context_vars, plan.user_message_metadata.as_ref()); let execution_context = ExecutionContext { @@ -16794,6 +16781,41 @@ pub(crate) mod tests { use openbitfun_services_core::permission_store::ProjectPermissionSqliteStore; use std::path::Path; + #[test] + fn desktop_cards_turn_origin_survives_recovery_without_tainting_local_turns() { + use openbitfun_agent_runtime::remote_file_delivery::TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY; + use std::collections::HashMap; + + // A legacy/local payload has no source marker. New host-stamped + // metadata round-trips through persisted turns and queue entries. + for metadata in [ + None, + Some(serde_json::json!({"surface": "flow_chat"})), + Some(serde_json::json!({"peer_device_control": true, "requestId": "peer-turn"})), + Some(serde_json::json!({"peer_device_control": false})), + ] { + let restored: Option = + serde_json::from_str(&serde_json::to_string(&metadata).unwrap()).unwrap(); + let mut original_context = HashMap::new(); + super::copy_turn_control_context(&mut original_context, metadata.as_ref()); + let mut recovered_context = HashMap::new(); + super::copy_turn_control_context(&mut recovered_context, restored.as_ref()); + assert_eq!(original_context, recovered_context); + let expected = metadata + .as_ref() + .and_then(|value| value.get(TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY)) + .and_then(serde_json::Value::as_bool) + .map(|value| value.to_string()); + assert_eq!( + recovered_context.get(TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY), + expected.as_ref() + ); + } + let mut next_local_turn = HashMap::new(); + super::copy_turn_control_context(&mut next_local_turn, None); + assert!(!next_local_turn.contains_key(TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY)); + } + #[test] fn input_image_count_admission_ignores_stale_pool_pointers_for_fixed_models() { let mut config = SessionConfig { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/desktop_card_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/desktop_card_tool.rs index 9370b5bd25..f20b35f76b 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/desktop_card_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/desktop_card_tool.rs @@ -14,7 +14,9 @@ use crate::service::config::global::GlobalConfigManager; use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; use async_trait::async_trait; use chrono::DateTime; -use openbitfun_agent_runtime::remote_file_delivery::TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY; +use openbitfun_agent_runtime::remote_file_delivery::{ + TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY, TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY, +}; use serde_json::{json, Map, Value}; pub const DESKTOP_CARDS_CONFIG_PATH: &str = "app.ai_experience.enable_desktop_cards"; @@ -216,11 +218,12 @@ impl Default for DesktopCardTool { } fn is_remote_control_context(context: &ToolUseContext) -> bool { - context - .custom_data - .get(TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY) - .and_then(Value::as_bool) - .unwrap_or(false) + [ + TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY, + TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY, + ] + .iter() + .any(|key| context.custom_data.get(*key).and_then(Value::as_bool) == Some(true)) } fn now_ms() -> i64 { @@ -888,7 +891,7 @@ Workflow for command sources: 3. Bind template paths to the keys you saw in that output, then create the card. 4. If the result reports a fetch error or unresolved paths, fix the command or the paths with update instead of creating another card. Reported stderr output alone is often harmless progress text; use it to explain missing values. -Cards are shown only on this desktop. They are unavailable for remote mobile or bot control turns because that user cannot see them."#, +Cards are shown only on this desktop. They are unavailable for remote mobile, bot, or Peer Device Mode control turns because that user cannot see them."#, icons = DESKTOP_CARD_ICONS.join(", "), chart_styles = chart_style_summary(), shell = desktop_card_command_shell_label(), @@ -1114,7 +1117,7 @@ Cards are shown only on this desktop. They are unavailable for remote mobile or ) -> OpenBitFunResult> { if is_remote_control_context(context) { return Err(OpenBitFunError::tool( - "DesktopCard cannot run from a remote mobile or bot controller because cards appear only on the host desktop, which that user cannot see. Tell the user in the reply instead, or schedule a Cron job for reminders." + "DesktopCard cannot run from a remote mobile or bot controller, or through Peer Device Mode, because cards appear only on the host desktop, which that user cannot see. Tell the user in the reply instead, or schedule a Cron job for reminders." .to_string(), )); } @@ -1949,21 +1952,97 @@ mod tests { #[tokio::test] async fn remote_control_turns_are_refused() { - let mut remote_control = context(); - remote_control.custom_data.insert( - TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY.to_string(), - Value::Bool(true), + for key in [ + TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY, + TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY, + ] { + let mut remote_control = context(); + remote_control + .custom_data + .insert(key.to_string(), Value::Bool(true)); + assert!( + !DesktopCardTool::new() + .is_available_in_context(Some(&remote_control)) + .await + ); + for action in ["create", "show", "update", "list"] { + let error = DesktopCardTool::new() + .call_impl(&json!({"action": action}), &remote_control) + .await + .unwrap_err(); + assert!(error.to_string().contains("remote mobile or bot")); + assert!(error.to_string().contains("Peer Device Mode")); + } + } + } + + #[tokio::test] + async fn local_turns_reach_the_host_after_a_peer_turn_is_refused() { + use crate::agentic::tools::desktop_card_host::set_desktop_card_handler; + use std::sync::{Arc, Mutex}; + + // The host registration is a process-wide OnceLock; do not leave a + // fake desktop host installed for unrelated catalog/runtime tests. + if std::env::var_os("OPENBITFUN_DESKTOP_CARD_ORIGIN_TEST_CHILD").is_none() { + let mut command = openbitfun_services_core::process_manager::create_tokio_command( + std::env::current_exe().unwrap(), + ); + command + .args([ + "agentic::tools::implementations::desktop_card_tool::tests::local_turns_reach_the_host_after_a_peer_turn_is_refused", + "--exact", "--nocapture", + ]) + .env("OPENBITFUN_DESKTOP_CARD_ORIGIN_TEST_CHILD", "1") + .kill_on_drop(true); + let output = tokio::time::timeout(std::time::Duration::from_secs(30), command.output()) + .await + .unwrap() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed; 0 failed")); + return; + } + + let requests = Arc::new(Mutex::new(Vec::new())); + let received = requests.clone(); + set_desktop_card_handler(Arc::new(move |request| { + received.lock().unwrap().push(request); + Box::pin(async { Ok(json!({"card": {"id": "card-local", "title": "Local"}})) }) + })); + + let create = json!({"action": "create", "card": { + "title": "Local", "template": {"kind": "note", "text": "Local note"} + }}); + let show = json!({"action": "show", "cardId": "card-local"}); + let tool = DesktopCardTool::new(); + let mut peer = context(); + peer.custom_data + .insert(TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY.into(), json!(true)); + for input in [&create, &show] { + assert!(tool.call_impl(input, &peer).await.is_err()); + } + assert!(requests.lock().unwrap().is_empty()); + + // Same session, next local turn: no source marker in legacy/local + // contexts. A previous remote turn must not taint the whole session. + let local = context(); + for input in [&create, &show] { + tool.call_impl(input, &local).await.unwrap(); + } + let requests = requests.lock().unwrap(); + assert!( + matches!(&requests[0], DesktopCardHostRequest::Create { origin, .. } + if origin.session_id == local.session_id) ); assert!( - !DesktopCardTool::new() - .is_available_in_context(Some(&remote_control)) - .await + matches!(&requests[1], DesktopCardHostRequest::Show { card_id } + if card_id == "card-local") ); - let error = DesktopCardTool::new() - .call_impl(&json!({"action": "list"}), &remote_control) - .await - .unwrap_err(); - assert!(error.to_string().contains("remote mobile or bot")); } #[tokio::test] diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/frontend_workbench_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/frontend_workbench_tool.rs index 57a2903ac5..54601e2883 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/frontend_workbench_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/frontend_workbench_tool.rs @@ -6,7 +6,9 @@ use crate::agentic::tools::frontend_workbench_host::{ }; use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; use async_trait::async_trait; -use openbitfun_agent_runtime::remote_file_delivery::TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY; +use openbitfun_agent_runtime::remote_file_delivery::{ + TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY, TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY, +}; use serde_json::{json, Value}; pub struct FrontendWorkbenchTool; @@ -32,11 +34,12 @@ fn creative_local_context(context: Option<&ToolUseContext>) -> bool { } fn is_remote_control_context(context: &ToolUseContext) -> bool { - context - .custom_data - .get(TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY) - .and_then(Value::as_bool) - .unwrap_or(false) + [ + TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY, + TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY, + ] + .iter() + .any(|key| context.custom_data.get(*key).and_then(Value::as_bool) == Some(true)) } #[async_trait] @@ -168,7 +171,7 @@ This tool is unavailable for remote workspaces, remote-control turns, and non-de } if is_remote_control_context(context) { return Err(OpenBitFunError::tool( - "FrontendWorkbench cannot run from a remote mobile or bot controller because the changed local desktop and its recovery controls are not visible there" + "FrontendWorkbench cannot run from a remote mobile or bot controller, or through Peer Device Mode, because the changed local desktop and its recovery controls are not visible there" .to_string(), )); } @@ -314,17 +317,26 @@ mod tests { #[tokio::test] async fn execution_rejects_remote_control_turns_even_for_a_local_workspace() { - let mut remote_control = context("Creative"); - remote_control.custom_data.insert( - TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY.to_string(), - Value::Bool(true), - ); - - let error = FrontendWorkbenchTool::new() - .call_impl(&json!({"action": "status"}), &remote_control) - .await - .expect_err("remote-control Creative calls must fail"); - assert!(error.to_string().contains("remote mobile or bot")); + assert!(creative_local_context(Some(&context("Creative")))); + for key in [ + TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY, + TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY, + ] { + let mut remote_control = context("Creative"); + remote_control.custom_data.insert(key.into(), json!(true)); + assert!(!creative_local_context(Some(&remote_control))); + for action in [ + "prepare", "status", "inspect", "invoke", "apply", "rollback", + ] { + let error = FrontendWorkbenchTool::new() + .call_impl(&json!({"action": action}), &remote_control) + .await + .expect_err("remote-control Creative calls must fail before host access"); + assert!(error.to_string().contains("recovery controls")); + } + remote_control.custom_data.insert(key.into(), json!(false)); + assert!(creative_local_context(Some(&remote_control))); + } } #[test] diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs index 42253d7ffd..4bdf66d74b 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs @@ -48,6 +48,7 @@ impl SessionMessageTool { } fn forwarded_user_input_metadata(context: &ToolUseContext) -> serde_json::Map { + use openbitfun_agent_runtime::remote_file_delivery::TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY; use openbitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; let mut metadata = serde_json::Map::new(); @@ -60,6 +61,15 @@ impl SessionMessageTool { metadata.insert(USER_INPUT_AVAILABLE_CONTEXT_KEY.to_string(), value.clone()); } } + if let Some(value @ Value::Bool(_)) = context + .custom_data + .get(TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY) + { + metadata.insert( + TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY.to_string(), + value.clone(), + ); + } metadata } @@ -942,6 +952,23 @@ mod tests { ); } + #[test] + fn desktop_cards_session_message_preserves_peer_origin() { + use openbitfun_agent_runtime::remote_file_delivery::TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY; + let mut context = empty_context(); + assert!(!SessionMessageTool::forwarded_user_input_metadata(&context) + .contains_key(TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY)); + context.custom_data.insert( + TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY.into(), + Value::Bool(true), + ); + assert_eq!( + SessionMessageTool::forwarded_user_input_metadata(&context) + [TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY], + true + ); + } + #[test] fn session_message_forwards_noninteractive_user_input_fact() { use openbitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs index 2ed5029d31..9f0c581313 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs @@ -78,11 +78,16 @@ fn forward_subagent_invocation_context( use openbitfun_agent_runtime::permission::{ AUTO_APPROVE_ASK_CONTEXT_KEY, PERMISSION_MODE_CONTEXT_KEY, }; + use openbitfun_agent_runtime::remote_file_delivery::{ + TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY, TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY, + }; use openbitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; for key in [ USER_INPUT_AVAILABLE_CONTEXT_KEY, AUTO_APPROVE_ASK_CONTEXT_KEY, + TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY, + TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY, ] { let Some(value) = context.custom_data.get(key) else { continue; @@ -1476,6 +1481,26 @@ mod target_context_tests { assert!(evidence.allows_live_repository_context()); } + #[test] + fn desktop_cards_child_context_preserves_remote_origin() { + use openbitfun_agent_runtime::remote_file_delivery::{ + TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY, TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY, + }; + for key in [ + TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY, + TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY, + ] { + let mut parent = parent_tool_context(); + parent.custom_data.insert(key.into(), Value::Bool(true)); + let mut child = HashMap::new(); + forward_subagent_invocation_context(&parent, &mut child); + assert_eq!(child[key], "true"); + } + let mut local_child = HashMap::new(); + forward_subagent_invocation_context(&parent_tool_context(), &mut local_child); + assert!(!local_child.contains_key(TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY)); + } + #[test] fn child_context_preserves_non_interactive_user_input_boundary() { let mut parent = parent_tool_context(); diff --git a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs index 03715c4de1..805daf5dc6 100644 --- a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs @@ -42,7 +42,9 @@ use openbitfun_agent_runtime::checkpoint::{ use openbitfun_agent_runtime::permission::{ AUTO_APPROVE_ASK_CONTEXT_KEY, PERMISSION_MODE_CONTEXT_KEY, }; -use openbitfun_agent_runtime::remote_file_delivery::TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY; +use openbitfun_agent_runtime::remote_file_delivery::{ + TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY, TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY, +}; use openbitfun_agent_runtime::user_questions::{ UserQuestionController, USER_INPUT_AVAILABLE_CONTEXT_KEY, USER_INPUT_MODEL_ROUND_CONTEXT_KEY, USER_INPUT_PARENT_CONTEXT_KEY, @@ -342,7 +344,18 @@ pub(crate) fn build_tool_description_context( let mut custom_data = HashMap::new(); let primary_model_facts = primary_model_facts.cloned().unwrap_or_default(); for (key, value) in context_vars { - custom_data.insert(key.clone(), Value::String(value.clone())); + // Availability checks see the same origin booleans as execution. + // Other description context values retain their existing string shape. + let value = if matches!( + key.as_str(), + TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY | TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY + ) && matches!(value.as_str(), "true" | "false") + { + Value::Bool(value == "true") + } else { + Value::String(value.clone()) + }; + custom_data.insert(key.clone(), value); } ToolUseContext { @@ -455,6 +468,7 @@ fn build_tool_context_custom_data(context: &ToolExecutionContext) -> HashMap Some(true), @@ -1632,6 +1646,33 @@ mod context_builder_tests { use std::collections::HashMap; use tool_runtime::context::PrimaryModelFacts; + #[test] + fn desktop_cards_listing_context_preserves_control_origin_booleans() { + use openbitfun_agent_runtime::remote_file_delivery::{ + TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY, TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY, + }; + for key in [ + TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY, + TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY, + ] { + for value in ["true", "false"] { + let context_vars = HashMap::from([(key.to_string(), value.to_string())]); + let context = build_tool_description_context( + "Creative", + None, + None, + None, + None, + None, + None, + &context_vars, + &ToolRuntimeRestrictions::default(), + ); + assert_eq!(context.custom_data[key].as_bool(), Some(value == "true")); + } + } + } + #[test] fn tool_description_context_preserves_manifest_custom_data_shape() { let context_vars = HashMap::new(); @@ -1845,6 +1886,26 @@ mod task_context_tests { ); } + #[test] + fn desktop_cards_tool_context_keeps_turn_scoped_peer_origin() { + use openbitfun_agent_runtime::remote_file_delivery::TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY; + for origin in [None, Some("true"), Some("false")] { + let mut task = task_with_context_vars(); + if let Some(origin) = origin { + task.context + .context_vars + .insert(TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY.into(), origin.into()); + } + let context = build_tool_use_context_for_task(&task, None, CancellationToken::new()); + assert_eq!( + context + .custom_data + .get(TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY), + origin.map(|value| json!(value == "true")).as_ref(), + ); + } + } + #[test] fn tool_task_context_materialization_preserves_runtime_fields() { use std::sync::Arc; diff --git a/src/crates/execution/agent-runtime/src/remote_file_delivery.rs b/src/crates/execution/agent-runtime/src/remote_file_delivery.rs index f872157030..5a6257e81b 100644 --- a/src/crates/execution/agent-runtime/src/remote_file_delivery.rs +++ b/src/crates/execution/agent-runtime/src/remote_file_delivery.rs @@ -3,6 +3,12 @@ use std::path::Path; pub const TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY: &str = "remote_file_delivery_channel"; +/// The submitting desktop UI is on a peer device, not on the execution host. +/// Hosts stamp this per-turn metadata at the Peer HostInvoke boundary. It must +/// not be inferred from attached controllers, which may only observe a local +/// turn. Kept separate from mobile/bot file delivery semantics. +pub const TOOL_CONTEXT_PEER_DEVICE_CONTROL_KEY: &str = "peer_device_control"; + pub fn needs_computer_links_for_source(source: DialogTriggerSource) -> bool { matches!( source,