diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 65d9dab215..c660e3d76b 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -111,6 +111,7 @@ pub use subscription::{EventSubscription, LifecycleSubscription}; /// Minimum protocol version this SDK can communicate with. const MIN_PROTOCOL_VERSION: u32 = 3; const RUNTIME_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10); +const MANAGED_SETTINGS_READ_METHOD: &str = "managedSettings.read"; /// How the SDK communicates with the CLI server. #[derive(Debug, Default)] @@ -2181,6 +2182,18 @@ impl Client { Ok(serde_json::from_value(result)?) } + /// Read validated, canonical device managed settings from the runtime. + /// + /// This is a server-scoped RPC and does not create or require an agent + /// session. Successful reads return `settings_json`; runtime discovery + /// failures are reported through `error_message`. + pub async fn read_managed_settings(&self) -> Result { + let result = self + .call(MANAGED_SETTINGS_READ_METHOD, Some(serde_json::json!({}))) + .await?; + Ok(serde_json::from_value(result)?) + } + /// List available models. /// /// When [`ClientOptions::on_list_models`] is set, returns the handler's diff --git a/rust/src/types.rs b/rust/src/types.rs index 81eac81ca2..51bf294a79 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -107,6 +107,20 @@ pub struct SessionLifecycleEvent { pub metadata: Option, } +/// Result returned by +/// [`Client::read_managed_settings`](crate::Client::read_managed_settings). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct ManagedSettingsReadResult { + /// Validated, canonical managed settings JSON, when settings are available. + #[serde(skip_serializing_if = "Option::is_none")] + pub settings_json: Option, + /// Runtime-provided error message, when settings could not be read. + #[serde(skip_serializing_if = "Option::is_none")] + pub error_message: Option, +} + /// Opaque session identifier assigned by the CLI. /// /// A newtype wrapper around `String` that provides type safety — prevents diff --git a/rust/tests/managed_settings_test.rs b/rust/tests/managed_settings_test.rs new file mode 100644 index 0000000000..4378a642b8 --- /dev/null +++ b/rust/tests/managed_settings_test.rs @@ -0,0 +1,109 @@ +#![allow(clippy::unwrap_used)] + +use github_copilot_sdk::Client; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, duplex}; + +async fn write_framed(writer: &mut (impl AsyncWrite + Unpin), body: &[u8]) { + let header = format!("Content-Length: {}\r\n\r\n", body.len()); + writer.write_all(header.as_bytes()).await.unwrap(); + writer.write_all(body).await.unwrap(); + writer.flush().await.unwrap(); +} + +async fn read_framed(reader: &mut (impl AsyncRead + Unpin)) -> serde_json::Value { + let mut header = String::new(); + loop { + let mut byte = [0u8; 1]; + reader.read_exact(&mut byte).await.unwrap(); + header.push(byte[0] as char); + if header.ends_with("\r\n\r\n") { + break; + } + } + let length: usize = header + .trim() + .strip_prefix("Content-Length: ") + .unwrap() + .parse() + .unwrap(); + let mut buf = vec![0u8; length]; + reader.read_exact(&mut buf).await.unwrap(); + serde_json::from_slice(&buf).unwrap() +} + +#[tokio::test] +async fn read_managed_settings_calls_server_scoped_method_before_session_create() { + let tempdir = tempfile::tempdir().unwrap(); + let (client_write, mut server_read) = duplex(8192); + let (mut server_write, client_read) = duplex(8192); + let client = Client::from_streams(client_read, client_write, tempdir.path().to_path_buf()) + .expect("create client"); + + let read_handle = tokio::spawn({ + let client = client.clone(); + async move { client.read_managed_settings().await } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "managedSettings.read"); + assert_eq!(request["params"], serde_json::json!({})); + + let settings = serde_json::json!({ + "source": "device", + "canonical": true, + "policies": { + "bypassPermissions": ["shell.read"] + } + }); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": { + "settingsJson": settings.to_string(), + }, + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + + let result = tokio::time::timeout(std::time::Duration::from_secs(2), read_handle) + .await + .unwrap() + .unwrap() + .expect("read managed settings"); + assert_eq!(result.settings_json, Some(settings.to_string())); + assert_eq!(result.error_message, None); +} + +#[tokio::test] +async fn read_managed_settings_returns_runtime_error_message() { + let tempdir = tempfile::tempdir().unwrap(); + let (client_write, mut server_read) = duplex(8192); + let (mut server_write, client_read) = duplex(8192); + let client = Client::from_streams(client_read, client_write, tempdir.path().to_path_buf()) + .expect("create client"); + + let read_handle = tokio::spawn({ + let client = client.clone(); + async move { client.read_managed_settings().await } + }); + + let request = read_framed(&mut server_read).await; + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": { + "errorMessage": "Managed settings are unavailable", + }, + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + + let result = tokio::time::timeout(std::time::Duration::from_secs(2), read_handle) + .await + .unwrap() + .unwrap() + .expect("read managed settings"); + assert_eq!(result.settings_json, None); + assert_eq!( + result.error_message, + Some("Managed settings are unavailable".to_string()) + ); +}