use std::sync::Arc; use async_trait::async_trait; use github_copilot_sdk::handler::{PermissionHandler, PermissionResult}; use github_copilot_sdk::rpc::PermissionsSetApproveAllRequest; use github_copilot_sdk::session_events::{SessionEventType, ToolExecutionCompleteData}; use github_copilot_sdk::{ PermissionRequestData, RequestId, ResumeSessionConfig, SessionConfig, SessionId, }; use tokio::sync::{mpsc, oneshot}; use super::support::{ DEFAULT_TEST_TOKEN, assistant_message_content, recv_with_timeout, wait_for_condition, wait_for_event, }; #[tokio::test] async fn should_work_with_approve_all_permission_handler() { super::support::with_shared_e2e_context( &E2E, "permissions", "should_work_with_approve_all_permission_handler", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); let client = ctx.start_client().await; let session = client .create_session(ctx.approve_all_session_config()) .await .expect("create session"); let answer = session .send_and_wait("What is 2+2?") .await .expect("send") .expect("assistant message"); assert!(assistant_message_content(&answer).contains('4')); session.disconnect().await.expect("disconnect session"); client.stop().await.expect("stop client"); }) }, ) .await; } #[tokio::test] async fn should_handle_permission_handler_errors_gracefully() { let result = PermissionResult::user_not_available(); assert!(matches!( result, PermissionResult::Decision { decision: github_copilot_sdk::types::PermissionDecision::UserNotAvailable(_), .. } )); } #[tokio::test] async fn should_handle_concurrent_permission_requests_from_parallel_tools() { let requests = [ RequestId::from("permission-1"), RequestId::from("permission-2"), ]; assert_eq!(requests.len(), 2); assert_ne!(requests[0], requests[1]); } #[tokio::test] async fn should_deny_permission_when_handler_returns_denied() { super::support::with_shared_e2e_context( &E2E, "permissions", "should_deny_permission_when_handler_returns_denied", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); let test_file = ctx.work_dir().join("protected.txt"); std::fs::write(&test_file, "protected content").expect("write protected file"); let client = ctx.start_client().await; let session = client .create_session( SessionConfig::default() .with_github_token(DEFAULT_TEST_TOKEN) .with_permission_handler(Arc::new(StaticPermissionHandler::new( PermissionResult::reject(None), ))), ) .await .expect("create session"); // Regression check for https://github.com/github/copilot-sdk/issues/1194: // the reject decision must round-trip through the CLI with its // discriminator intact so the agent surfaces the user-rejected error // to the model. The CLI emits a kind-specific error message // ("The user rejected this tool call.") for the reject decision, // which lets us assert the decision was honored — not merely that // the operation didn't happen. let events = session.subscribe(); session .send_and_wait("Edit protected.txt and replace 'protected' with 'hacked'.") .await .expect("send"); wait_for_event(events, "user-rejected tool completion", |event| { is_user_rejected_tool_completion(event) }) .await; let content = std::fs::read_to_string(&test_file).expect("read protected file"); assert_eq!(content, "protected content"); session.disconnect().await.expect("disconnect session"); client.stop().await.expect("stop client"); }) }, ) .await; } #[tokio::test] async fn should_deny_tool_operations_when_handler_explicitly_denies() { super::support::with_shared_e2e_context( &E2E, "permissions", "should_deny_tool_operations_when_handler_explicitly_denies", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); let client = ctx.start_client().await; let session = client .create_session( SessionConfig::default() .with_github_token(DEFAULT_TEST_TOKEN) .with_permission_handler(Arc::new(StaticPermissionHandler::new( PermissionResult::user_not_available(), ))), ) .await .expect("create session"); let events = session.subscribe(); session .send_and_wait("Run 'node --version'") .await .expect("send"); wait_for_event(events, "permission-denied tool completion", |event| { is_permission_denied_tool_completion(event) }) .await; session.disconnect().await.expect("disconnect session"); client.stop().await.expect("stop client"); }) }, ) .await; } #[tokio::test] async fn should_handle_async_permission_handler() { super::support::with_shared_e2e_context( &E2E, "permissions", "should_handle_async_permission_handler", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); let client = ctx.start_client().await; let (request_tx, mut request_rx) = mpsc::unbounded_channel(); let session = client .create_session( SessionConfig::default() .with_github_token(DEFAULT_TEST_TOKEN) .with_permission_handler(Arc::new(AsyncPermissionHandler { request_tx, })), ) .await .expect("create session"); session .send_and_wait("Run 'echo test' and tell me what happens") .await .expect("send"); recv_with_timeout(&mut request_rx, "async permission request").await; session.disconnect().await.expect("disconnect session"); client.stop().await.expect("stop client"); }) }, ) .await; } #[tokio::test] async fn should_resume_session_with_permission_handler() { super::support::with_dedicated_e2e_context( "permissions", "should_resume_session_with_permission_handler", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); let client = ctx.start_client().await; let session = client .create_session(ctx.approve_all_session_config()) .await .expect("create session"); session .send_and_wait("What is 1+1?") .await .expect("first send"); let session_id = session.id().clone(); session .disconnect() .await .expect("disconnect first session"); client.stop().await.expect("stop first client"); let new_client = ctx.start_client().await; let (request_tx, mut request_rx) = mpsc::unbounded_channel(); let resumed = new_client .resume_session( ResumeSessionConfig::new(session_id) .with_github_token(DEFAULT_TEST_TOKEN) .with_permission_handler(Arc::new(RecordingPermissionHandler { request_tx, })), ) .await .expect("resume session"); resumed .send_and_wait("Run 'echo resumed' for me") .await .expect("send after resume"); recv_with_timeout(&mut request_rx, "resumed permission request").await; resumed .disconnect() .await .expect("disconnect resumed session"); new_client.stop().await.expect("stop resumed client"); }) }, ) .await; } #[tokio::test] async fn should_deny_tool_operations_when_handler_explicitly_denies_after_resume() { super::support::with_dedicated_e2e_context( "permissions", "should_deny_tool_operations_when_handler_explicitly_denies_after_resume", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); let client = ctx.start_client().await; let session = client .create_session(ctx.approve_all_session_config()) .await .expect("create session"); session .send_and_wait("What is 1+1?") .await .expect("first send"); let session_id = session.id().clone(); session .disconnect() .await .expect("disconnect first session"); client.stop().await.expect("stop first client"); let new_client = ctx.start_client().await; let resumed = new_client .resume_session( ResumeSessionConfig::new(session_id) .with_github_token(DEFAULT_TEST_TOKEN) .with_permission_handler(Arc::new(StaticPermissionHandler::new( PermissionResult::user_not_available(), ))), ) .await .expect("resume session"); let events = resumed.subscribe(); resumed .send_and_wait("Run 'node --version'") .await .expect("send after resume"); wait_for_event( events, "resumed permission-denied tool completion", is_permission_denied_tool_completion, ) .await; resumed .disconnect() .await .expect("disconnect resumed session"); new_client.stop().await.expect("stop resumed client"); }) }, ) .await; } #[tokio::test] async fn should_receive_toolcallid_in_permission_requests() { super::support::with_shared_e2e_context( &E2E, "permissions", "should_receive_toolcallid_in_permission_requests", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); let client = ctx.start_client().await; let (request_tx, mut request_rx) = mpsc::unbounded_channel(); let session = client .create_session( SessionConfig::default() .with_github_token(DEFAULT_TEST_TOKEN) .with_permission_handler(Arc::new(RecordingPermissionHandler { request_tx, })), ) .await .expect("create session"); session .send_and_wait("Run 'echo test'") .await .expect("send"); let request = recv_with_timeout(&mut request_rx, "permission request").await; assert!( permission_request_tool_call_id(&request).is_some(), "expected permission request to include a toolCallId: {request:?}" ); session.disconnect().await.expect("disconnect session"); client.stop().await.expect("stop client"); }) }, ) .await; } #[tokio::test] async fn should_deny_permission_with_noresult_kind() { super::support::with_shared_e2e_context( &E2E, "permissions", "should_deny_permission_with_noresult_kind", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); let client = ctx.start_client().await; let (request_tx, mut request_rx) = mpsc::unbounded_channel(); let session = client .create_session( SessionConfig::default() .with_github_token(DEFAULT_TEST_TOKEN) .with_permission_handler(Arc::new(NotifyingPermissionHandler { request_tx, result: PermissionResult::NoResult, })), ) .await .expect("create session"); session.send("Run 'node --version'").await.expect("send"); recv_with_timeout(&mut request_rx, "no-result permission request").await; session.abort().await.expect("abort no-result turn"); session.disconnect().await.expect("disconnect session"); client.stop().await.expect("stop client"); }) }, ) .await; } #[tokio::test] async fn should_short_circuit_permission_handler_when_set_approve_all_enabled() { super::support::with_shared_e2e_context( &E2E, "permissions", "should_short_circuit_permission_handler_when_set_approve_all_enabled", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); let client = ctx.start_client().await; let (request_tx, mut request_rx) = mpsc::unbounded_channel(); let session = client .create_session( SessionConfig::default() .with_github_token(DEFAULT_TEST_TOKEN) .with_permission_handler(Arc::new(RecordingPermissionHandler { request_tx, })), ) .await .expect("create session"); let set_result = session .rpc() .permissions() .set_approve_all(PermissionsSetApproveAllRequest { enabled: true, source: None, }) .await .expect("set approve all"); assert!(set_result.success); let events = session.subscribe(); session .send_and_wait("Run 'echo test' and tell me what happens") .await .expect("send"); wait_for_event(events, "successful tool completion", |event| { event.parsed_type() == SessionEventType::ToolExecutionComplete && event .typed_data::() .expect("tool.execution_complete data") .success }) .await; assert!( request_rx.try_recv().is_err(), "runtime approve-all should bypass the SDK permission handler" ); let reset_result = session .rpc() .permissions() .set_approve_all(PermissionsSetApproveAllRequest { enabled: false, source: None, }) .await .expect("reset approve all"); assert!(reset_result.success); session.disconnect().await.expect("disconnect session"); client.stop().await.expect("stop client"); }) }, ) .await; } #[tokio::test] async fn should_wait_for_slow_permission_handler() { super::support::with_shared_e2e_context( &E2E, "permissions", "should_wait_for_slow_permission_handler", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); let client = ctx.start_client().await; let (entered_tx, entered_rx) = oneshot::channel(); let (release_tx, release_rx) = oneshot::channel(); let session = client .create_session( SessionConfig::default() .with_github_token(DEFAULT_TEST_TOKEN) .with_permission_handler(Arc::new(SlowPermissionHandler { entered_tx: tokio::sync::Mutex::new(Some(entered_tx)), release_rx: tokio::sync::Mutex::new(Some(release_rx)), })), ) .await .expect("create session"); let events = session.subscribe(); session .send("Run 'echo slow_handler_test'") .await .expect("send"); tokio::time::timeout(std::time::Duration::from_secs(30), entered_rx) .await .expect("permission handler entered timeout") .expect("permission handler entered channel"); assert!( tokio::time::timeout( std::time::Duration::from_millis(250), wait_for_event(events, "premature tool completion", |event| { event.parsed_type() == SessionEventType::ToolExecutionComplete }), ) .await .is_err(), "tool completed before the permission handler returned" ); release_tx.send(()).expect("release slow handler"); wait_for_condition("assistant response after slow permission", || async { session .get_events() .await .expect("get messages") .iter() .any(|event| { event.parsed_type() == SessionEventType::AssistantMessage && assistant_message_content(event).contains("slow_handler_test") }) }) .await; session.disconnect().await.expect("disconnect session"); client.stop().await.expect("stop client"); }) }, ) .await; } #[tokio::test] async fn should_invoke_permission_handler_for_write_operations() { super::support::with_shared_e2e_context( &E2E, "permissions", "should_invoke_permission_handler_for_write_operations", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); let test_file = ctx.work_dir().join("test.txt"); std::fs::write(&test_file, "original content").expect("write test file"); let client = ctx.start_client().await; let (request_tx, mut request_rx) = mpsc::unbounded_channel(); let session = client .create_session( github_copilot_sdk::SessionConfig::default() .with_github_token(super::support::DEFAULT_TEST_TOKEN) .with_permission_handler(Arc::new(RecordingPermissionHandler { request_tx, })), ) .await .expect("create session"); let answer = session .send_and_wait("Edit test.txt and replace 'original' with 'modified'") .await .expect("send") .expect("assistant message"); assert!(!assistant_message_content(&answer).is_empty()); let first = recv_with_timeout(&mut request_rx, "first permission request").await; let second = recv_with_timeout(&mut request_rx, "second permission request").await; assert!( first.extra.is_object() || second.extra.is_object(), "expected permission request payloads to preserve raw CLI fields" ); let updated = std::fs::read_to_string(&test_file).expect("read updated file"); assert_eq!(updated, "modified content"); session.disconnect().await.expect("disconnect session"); client.stop().await.expect("stop client"); }) }, ) .await; } fn is_permission_denied_tool_completion(event: &github_copilot_sdk::SessionEvent) -> bool { if event.parsed_type() != SessionEventType::ToolExecutionComplete { return false; } let data = event .typed_data::() .expect("tool.execution_complete data"); !data.success && data .error .as_ref() .map(|error| error.message.contains("Permission denied")) .unwrap_or(false) } fn is_user_rejected_tool_completion(event: &github_copilot_sdk::SessionEvent) -> bool { if event.parsed_type() != SessionEventType::ToolExecutionComplete { return false; } let data = event .typed_data::() .expect("tool.execution_complete data"); !data.success && data .error .as_ref() .map(|error| error.message.to_lowercase().contains("user rejected")) .unwrap_or(false) } fn permission_request_tool_call_id(request: &PermissionRequestData) -> Option<&str> { request .tool_call_id .as_deref() .or_else(|| { request .extra .get("toolCallId") .and_then(|value| value.as_str()) }) .or_else(|| { request .extra .get("permissionRequest") .and_then(|value| value.get("toolCallId")) .and_then(|value| value.as_str()) }) .or_else(|| { request .extra .get("promptRequest") .and_then(|value| value.get("toolCallId")) .and_then(|value| value.as_str()) }) } #[derive(Clone)] struct StaticPermissionHandler { result: PermissionResult, } impl StaticPermissionHandler { fn new(result: PermissionResult) -> Self { Self { result } } } #[async_trait] impl PermissionHandler for StaticPermissionHandler { async fn handle( &self, _session_id: SessionId, _request_id: RequestId, _data: PermissionRequestData, ) -> PermissionResult { self.result.clone() } } struct RecordingPermissionHandler { request_tx: mpsc::UnboundedSender, } #[async_trait] impl PermissionHandler for RecordingPermissionHandler { async fn handle( &self, _session_id: SessionId, _request_id: RequestId, data: PermissionRequestData, ) -> PermissionResult { let _ = self.request_tx.send(data); PermissionResult::approve_once() } } struct NotifyingPermissionHandler { request_tx: mpsc::UnboundedSender, result: PermissionResult, } #[async_trait] impl PermissionHandler for NotifyingPermissionHandler { async fn handle( &self, _session_id: SessionId, _request_id: RequestId, data: PermissionRequestData, ) -> PermissionResult { let _ = self.request_tx.send(data); self.result.clone() } } struct AsyncPermissionHandler { request_tx: mpsc::UnboundedSender, } #[async_trait] impl PermissionHandler for AsyncPermissionHandler { async fn handle( &self, _session_id: SessionId, _request_id: RequestId, data: PermissionRequestData, ) -> PermissionResult { tokio::task::yield_now().await; let _ = self.request_tx.send(data); PermissionResult::approve_once() } } struct SlowPermissionHandler { entered_tx: tokio::sync::Mutex>>, release_rx: tokio::sync::Mutex>>, } #[async_trait] impl PermissionHandler for SlowPermissionHandler { async fn handle( &self, _session_id: SessionId, _request_id: RequestId, _data: PermissionRequestData, ) -> PermissionResult { if let Some(entered_tx) = self.entered_tx.lock().await.take() { let _ = entered_tx.send(()); } if let Some(release_rx) = self.release_rx.lock().await.take() { let _ = release_rx.await; } PermissionResult::approve_once() } } static E2E: super::support::SharedE2eGroup = super::support::SharedE2eGroup::standard("permissions", 9);