diff --git a/crates/app-tauri/src/openai_tools.rs b/crates/app-tauri/src/openai_tools.rs index 7b368a61..8e95b003 100644 --- a/crates/app-tauri/src/openai_tools.rs +++ b/crates/app-tauri/src/openai_tools.rs @@ -565,19 +565,20 @@ mod tests { #[derive(Default)] struct FakeManagedTempTools { - calls: Mutex>, + calls: Mutex>, } #[async_trait] impl ManagedTempToolProvider for FakeManagedTempTools { async fn handle_managed_temp_tool( &self, - _project: &Project, + project: &Project, requester: &str, name: &str, arguments: Value, ) -> Result { self.calls.lock().unwrap().push(( + project.root.as_str().to_owned(), requester.to_owned(), name.to_owned(), arguments.clone(), @@ -953,4 +954,176 @@ mod tests { let _ = std::fs::remove_dir_all(temp); } + + #[tokio::test] + async fn openai_managed_temp_read_tool_routes_to_provider_with_project_and_requester() { + let temp = std::env::temp_dir().join(format!( + "idea-app-tauri-openai-managed-temp-read-{}", + Uuid::new_v4() + )); + let core = backend::BackendCore::build(temp.clone()); + let requester = AgentId::from_uuid(Uuid::from_u128(85)).to_string(); + let managed_temp_tools = fake_managed_temp_tools(); + let invoker = AppOpenAiToolInvoker::new( + Arc::clone(&core.orchestrator_service), + Arc::new(FakeProjects::with(project())) as Arc, + Arc::new(FakePolicies::default()), + mcp_permissions(ProjectMcpToolPermissions::default()), + Arc::new(FakeTicketTools::default()), + Arc::new(FakeTemplateTools::default()), + managed_temp_tools.clone(), + ); + + let result = invoker + .call( + "idea_managed_temp_resolve", + &json!({ + PROJECT_ROOT_ARG: "/tmp/project", + REQUESTER_ARG: requester, + "handle": "lease_123", + }) + .to_string(), + ) + .await + .expect("managed temp resolve must route to the dedicated provider"); + let result: Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["requester"], json!(requester)); + assert_eq!(result["handle"], "lease_123"); + + let calls = managed_temp_tools.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, "/tmp/project"); + assert_eq!(calls[0].1, requester); + assert_eq!(calls[0].2, "idea_managed_temp_resolve"); + assert_eq!( + calls[0].3, + json!({ + PROJECT_ROOT_ARG: "/tmp/project", + REQUESTER_ARG: requester, + "handle": "lease_123" + }) + ); + + let _ = std::fs::remove_dir_all(temp); + } + + #[tokio::test] + async fn openai_managed_temp_write_tools_route_only_when_explicitly_allowed() { + let temp = std::env::temp_dir().join(format!( + "idea-app-tauri-openai-managed-temp-write-{}", + Uuid::new_v4() + )); + let core = backend::BackendCore::build(temp.clone()); + let agent = AgentId::from_uuid(Uuid::from_u128(86)); + let requester = agent.to_string(); + let managed_temp_tools = fake_managed_temp_tools(); + let invoker = AppOpenAiToolInvoker::new( + Arc::clone(&core.orchestrator_service), + Arc::new(FakeProjects::with(project())) as Arc, + Arc::new(FakePolicies::default()), + mcp_permissions(allow_doc( + agent, + &[ + "idea_managed_temp_allocate", + "idea_managed_temp_release", + "idea_managed_temp_gc", + ], + )), + Arc::new(FakeTicketTools::default()), + Arc::new(FakeTemplateTools::default()), + managed_temp_tools.clone(), + ); + + let allocate = invoker + .call( + "idea_managed_temp_allocate", + &json!({ + PROJECT_ROOT_ARG: "/tmp/project", + REQUESTER_ARG: requester, + "kind": "directory", + "purpose": "scratch", + }) + .to_string(), + ) + .await + .expect("explicit allowlist should route allocate"); + let allocate: Value = serde_json::from_str(&allocate).unwrap(); + assert_eq!(allocate["requester"], json!(requester)); + + invoker + .call( + "idea_managed_temp_release", + &json!({ + PROJECT_ROOT_ARG: "/tmp/project", + REQUESTER_ARG: requester, + "handle": "lease_123", + }) + .to_string(), + ) + .await + .expect("explicit allowlist should route release"); + + invoker + .call( + "idea_managed_temp_gc", + &json!({ + PROJECT_ROOT_ARG: "/tmp/project", + REQUESTER_ARG: requester, + }) + .to_string(), + ) + .await + .expect("explicit allowlist should route gc"); + + let calls = managed_temp_tools.calls.lock().unwrap(); + assert_eq!(calls.len(), 3); + assert_eq!(calls[0].0, "/tmp/project"); + assert_eq!(calls[0].1, requester); + assert_eq!(calls[0].2, "idea_managed_temp_allocate"); + assert_eq!( + calls[0].3, + json!({ + PROJECT_ROOT_ARG: "/tmp/project", + REQUESTER_ARG: requester, + "kind": "directory", + "purpose": "scratch" + }) + ); + assert_eq!(calls[1].2, "idea_managed_temp_release"); + assert_eq!( + calls[1].3, + json!({ + PROJECT_ROOT_ARG: "/tmp/project", + REQUESTER_ARG: requester, + "handle": "lease_123" + }) + ); + assert_eq!(calls[2].2, "idea_managed_temp_gc"); + assert_eq!( + calls[2].3, + json!({ + PROJECT_ROOT_ARG: "/tmp/project", + REQUESTER_ARG: requester + }) + ); + + let denied = invoker + .call( + "idea_managed_temp_resolve", + &json!({ + PROJECT_ROOT_ARG: "/tmp/project", + REQUESTER_ARG: requester, + "handle": "lease_123", + }) + .to_string(), + ) + .await + .expect_err("non-allowlisted managed temp tool must be rejected"); + assert!( + matches!(denied, ToolInvocationError::Rejected(ref message) if message.contains("idea_managed_temp_resolve")), + "expected readable rejection, got {denied:?}" + ); + + let _ = std::fs::remove_dir_all(temp); + } } diff --git a/crates/app-tauri/tests/managed_temp_handlers.rs b/crates/app-tauri/tests/managed_temp_handlers.rs new file mode 100644 index 00000000..45e5f450 --- /dev/null +++ b/crates/app-tauri/tests/managed_temp_handlers.rs @@ -0,0 +1,111 @@ +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use app_tauri_lib::{commands, state::AppState}; +use serde_json::{json, Value}; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; + +fn test_app_data_dir(label: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!( + "idea-managed-temp-handlers-{label}-{}-{nanos}", + std::process::id() + )) +} + +fn invoke_command>>( + webview: &W, + command: &str, + body: Value, +) -> Result { + get_ipc_response( + webview, + tauri::webview::InvokeRequest { + cmd: command.to_owned(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body: tauri::ipc::InvokeBody::Json(body), + headers: Default::default(), + invoke_key: INVOKE_KEY.to_owned(), + }, + ) + .map(|body| body.deserialize::().unwrap()) +} + +#[test] +fn managed_temp_handlers_round_trip_via_tauri_invoke_handler() { + let app_data = test_app_data_dir("round-trip"); + let app = mock_builder() + .manage(AppState::build(app_data.clone())) + .invoke_handler(tauri::generate_handler![ + commands::managed_temp_allocate, + commands::managed_temp_resolve, + commands::managed_temp_release, + commands::managed_temp_gc, + ]) + .build(mock_context(noop_assets())) + .expect("mock app builds"); + let webview = tauri::WebviewWindowBuilder::new(&app, "main", Default::default()) + .build() + .expect("mock webview builds"); + + let allocated = invoke_command( + &webview, + "managed_temp_allocate", + json!({ + "request": { + "kind": "directory", + "purpose": "scratch" + } + }), + ) + .expect("allocate must succeed"); + let handle = allocated["lease"]["handle"] + .as_str() + .expect("opaque handle") + .to_owned(); + let path = allocated["lease"]["path"] + .as_str() + .expect("lease path") + .to_owned(); + assert!( + path.contains("/managed-tmp/"), + "real AppState store should allocate under managed-tmp, got {path}" + ); + + let resolved = invoke_command( + &webview, + "managed_temp_resolve", + json!({ "request": { "handle": handle } }), + ) + .expect("resolve must succeed while lease is active"); + assert_eq!(resolved["lease"]["path"], allocated["lease"]["path"]); + assert_eq!(resolved["lease"]["handle"], allocated["lease"]["handle"]); + + let released = invoke_command( + &webview, + "managed_temp_release", + json!({ "request": { "handle": handle } }), + ) + .expect("release must succeed"); + assert_eq!(released["lease"]["handle"], allocated["lease"]["handle"]); + assert_eq!(released["lease"]["state"], json!("released")); + + let err = invoke_command( + &webview, + "managed_temp_resolve", + json!({ "request": { "handle": handle } }), + ) + .expect_err("resolve after release must fail"); + assert_eq!(err["code"], "NOT_FOUND"); + + let gc = invoke_command(&webview, "managed_temp_gc", json!({})) + .expect("gc must succeed after release"); + assert_eq!(gc["deleted"], 1); + + std::fs::remove_dir_all(app_data).ok(); +}