diff --git a/libs/cua-driver/rust/crates/cua-driver/src/cli.rs b/libs/cua-driver/rust/crates/cua-driver/src/cli.rs index fa59231cae..7875e34569 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/cli.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/cli.rs @@ -2037,7 +2037,19 @@ pub fn run_call( .clone() .unwrap_or(serde_json::Value::Object(serde_json::Map::new())); cua_driver_core::tool_args::sanitize_reserved_args(&mut args_for_daemon); - let transport_session = format!("cli-{}", uuid::Uuid::new_v4()); + let named_session = args_for_daemon + .get("session") + .and_then(serde_json::Value::as_str) + .is_some_and(|session| !session.is_empty() && session != "default"); + // One-shot CLI processes share one daemon-scoped ownership namespace + // for explicit public labels. The daemon adds its runtime prefix, so + // this cannot attach to another daemon generation or transport kind. + // Anonymous calls keep their disposable per-process lease below. + let transport_session = if named_session { + "cli-explicit".to_owned() + } else { + format!("cli-{}", uuid::Uuid::new_v4()) + }; let req = crate::serve::DaemonRequest { method: "call".into(), name: Some(tool.to_owned()), @@ -2050,17 +2062,19 @@ pub fn run_call( client_kind: Some(cua_driver_core::daemon::DaemonClientKind::Cli), }; let response = crate::serve::send_request(&socket_path, &req); - let cleanup = crate::serve::DaemonRequest { - method: "session_end".into(), - name: None, - args: None, - session_id: Some(transport_session), - observation_origin: None, - client_kind: Some(cua_driver_core::daemon::DaemonClientKind::Cli), - }; - let cleanup_result = crate::serve::send_request(&socket_path, &cleanup); - if let Err(error) = cleanup_result { - eprintln!("warning: disposable session cleanup failed: {error}"); + if !named_session { + let cleanup = crate::serve::DaemonRequest { + method: "session_end".into(), + name: None, + args: None, + session_id: Some(transport_session), + observation_origin: None, + client_kind: Some(cua_driver_core::daemon::DaemonClientKind::Cli), + }; + let cleanup_result = crate::serve::send_request(&socket_path, &cleanup); + if let Err(error) = cleanup_result { + eprintln!("warning: disposable session cleanup failed: {error}"); + } } match response { Ok(resp) => { diff --git a/libs/cua-driver/rust/crates/cua-driver/tests/daemon_required_test.rs b/libs/cua-driver/rust/crates/cua-driver/tests/daemon_required_test.rs index eca85409c7..a3c599003d 100644 --- a/libs/cua-driver/rust/crates/cua-driver/tests/daemon_required_test.rs +++ b/libs/cua-driver/rust/crates/cua-driver/tests/daemon_required_test.rs @@ -149,6 +149,130 @@ fn cli_call_succeeds_through_test_owned_daemon() { assert!(response.structured().is_object()); } +#[test] +fn named_session_survives_across_one_shot_cli_calls() { + let mut driver = CliDriver::new(); + assert!(driver.available(), "test daemon failed to start"); + let session = format!("synthetic-cli-lifecycle-{}", std::process::id()); + + let started = driver.call( + "start_session", + serde_json::json!({"session": session, "capture_scope": "window"}), + ); + assert!(!started.is_error(), "start failed: {}", started.text()); + + let state = driver.call("get_session_state", serde_json::json!({"session": session})); + assert!( + !state.is_error(), + "named session did not survive the next CLI process: {}", + state.text() + ); + assert_eq!(state.structured()["session"], session); + + let sessions = Command::new(env!("CARGO_BIN_EXE_cua-driver")) + .args([ + "sessions", + "list", + "--json", + "--socket", + driver.daemon_socket().expect("test daemon socket"), + ]) + .output() + .expect("list live sessions"); + assert!( + sessions.status.success(), + "session list failed: {}", + String::from_utf8_lossy(&sessions.stderr) + ); + let sessions: serde_json::Value = + serde_json::from_slice(&sessions.stdout).expect("session list JSON"); + assert_eq!(sessions["count"], 1); + + let ended = driver.call("end_session", serde_json::json!({"session": session})); + assert!(!ended.is_error(), "end failed: {}", ended.text()); + + let sessions = Command::new(env!("CARGO_BIN_EXE_cua-driver")) + .args([ + "sessions", + "list", + "--json", + "--socket", + driver.daemon_socket().expect("test daemon socket"), + ]) + .output() + .expect("list ended sessions"); + assert!( + sessions.status.success(), + "session list failed: {}", + String::from_utf8_lossy(&sessions.stderr) + ); + let sessions: serde_json::Value = + serde_json::from_slice(&sessions.stdout).expect("session list JSON"); + assert_eq!(sessions["count"], 0); +} + +#[test] +fn implicitly_started_named_session_survives_across_one_shot_cli_calls() { + let mut driver = CliDriver::new(); + assert!(driver.available(), "test daemon failed to start"); + let session = format!("synthetic-cli-implicit-{}", std::process::id()); + + let first_action = driver.call("get_config", serde_json::json!({"session": session})); + assert!( + !first_action.is_error(), + "implicit first action failed: {}", + first_action.text() + ); + + let state = driver.call("get_session_state", serde_json::json!({"session": session})); + assert!( + !state.is_error(), + "implicitly started session did not survive the next CLI process: {}", + state.text() + ); + assert_eq!(state.structured()["session"], session); + + let ended = driver.call("end_session", serde_json::json!({"session": session})); + assert!(!ended.is_error(), "end failed: {}", ended.text()); +} + +#[test] +fn named_cli_session_cleanup_is_isolated() { + let mut driver = CliDriver::new(); + assert!(driver.available(), "test daemon failed to start"); + let first = format!("synthetic-cli-isolation-a-{}", std::process::id()); + let second = format!("synthetic-cli-isolation-b-{}", std::process::id()); + + for session in [&first, &second] { + let started = driver.call( + "start_session", + serde_json::json!({"session": session, "capture_scope": "window"}), + ); + assert!(!started.is_error(), "start failed: {}", started.text()); + } + + let ended = driver.call("end_session", serde_json::json!({"session": first})); + assert!(!ended.is_error(), "first end failed: {}", ended.text()); + + let anonymous = driver.call("get_config", serde_json::json!({})); + assert!( + !anonymous.is_error(), + "anonymous one-shot call failed: {}", + anonymous.text() + ); + + let state = driver.call("get_session_state", serde_json::json!({"session": second})); + assert!( + !state.is_error(), + "ending another named session or cleaning an anonymous call ended the survivor: {}", + state.text() + ); + assert_eq!(state.structured()["session"], second); + + let ended = driver.call("end_session", serde_json::json!({"session": second})); + assert!(!ended.is_error(), "second end failed: {}", ended.text()); +} + #[test] fn revoke_cli_ends_the_exact_live_session() { let mut driver = CliDriver::new();