1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4#![cfg_attr(test, allow(clippy::unwrap_used))]
5
6pub mod canvas;
8mod canvas_dispatch;
9#[cfg(feature = "bundled-cli")]
11pub(crate) mod embeddedcli;
12mod errors;
13#[cfg(feature = "bundled-in-process")]
15pub(crate) mod ffi;
16pub use errors::*;
17pub mod copilot_request_handler;
21#[doc(hidden)]
24pub mod github_telemetry;
25pub mod handler;
27pub mod hooks;
29mod jsonrpc;
30pub mod permission;
32pub mod provider_token;
34mod provider_token_dispatch;
35pub(crate) mod resolve;
37mod router;
38pub mod session;
40pub mod session_fs;
42mod session_fs_dispatch;
43pub mod subscription;
45pub mod tool;
47pub mod trace_context;
49pub mod transforms;
51pub mod types;
53mod wire;
54
55pub mod session_events;
57
58pub mod rpc;
61
62pub(crate) mod generated;
67
68pub mod mode;
71
72use std::ffi::OsString;
73use std::path::{Path, PathBuf};
74use std::process::Stdio;
75use std::sync::{Arc, OnceLock};
76use std::time::{Duration, Instant};
77
78use async_trait::async_trait;
79pub use indexmap::IndexMap;
83pub(crate) use jsonrpc::{
86 JsonRpcClient, JsonRpcError, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, error_codes,
87};
88pub use mode::{BUILTIN_TOOLS_ISOLATED, ClientMode, ToolSet};
89pub use provider_token::{BearerTokenError, BearerTokenProvider, ProviderTokenArgs};
90
91#[cfg(feature = "test-support")]
93pub mod test_support {
94 pub use crate::jsonrpc::{
95 JsonRpcClient, JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse,
96 error_codes,
97 };
98}
99use serde::{Deserialize, Serialize};
100use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, BufReader};
101use tokio::net::TcpStream;
102use tokio::process::{Child, Command};
103use tokio::sync::{broadcast, mpsc, oneshot};
104use tracing::{Instrument, debug, error, info, warn};
105pub use types::*;
106
107mod sdk_protocol_version;
108pub use sdk_protocol_version::{SDK_PROTOCOL_VERSION, get_sdk_protocol_version};
109pub use subscription::{EventSubscription, LifecycleSubscription};
110
111const MIN_PROTOCOL_VERSION: u32 = 3;
113const RUNTIME_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
114
115#[derive(Debug, Default)]
117#[non_exhaustive]
118pub enum Transport {
119 #[default]
122 Default,
123 Stdio,
125 InProcess,
138 Tcp {
140 port: u16,
142 connection_token: Option<String>,
146 },
147 External {
149 host: String,
151 port: u16,
153 connection_token: Option<String>,
156 },
157}
158
159#[derive(Debug, Clone, Default)]
161pub enum CliProgram {
162 #[default]
165 Resolve,
166 Path(PathBuf),
168}
169
170impl From<PathBuf> for CliProgram {
171 fn from(path: PathBuf) -> Self {
172 Self::Path(path)
173 }
174}
175
176pub const HAS_BUNDLED_CLI: bool = cfg!(has_bundled_cli);
183
184pub fn install_bundled_cli() -> Option<PathBuf> {
208 #[cfg(feature = "bundled-cli")]
209 {
210 embeddedcli::path()
211 }
212 #[cfg(not(feature = "bundled-cli"))]
213 {
214 None
215 }
216}
217
218#[non_exhaustive]
228pub struct ClientOptions {
229 pub program: CliProgram,
231 pub prefix_args: Vec<OsString>,
233 pub working_directory: PathBuf,
237 pub env: Vec<(OsString, OsString)>,
239 pub env_remove: Vec<OsString>,
241 pub extra_args: Vec<String>,
243 pub transport: Transport,
245 pub github_token: Option<String>,
250 pub use_logged_in_user: Option<bool>,
254 pub log_level: Option<LogLevel>,
258 pub session_idle_timeout_seconds: Option<u64>,
264 pub on_list_models: Option<Arc<dyn ListModelsHandler>>,
272 pub session_fs: Option<SessionFsConfig>,
280 pub request_handler: Option<Arc<dyn crate::copilot_request_handler::CopilotRequestHandler>>,
289 #[doc(hidden)]
297 pub on_github_telemetry: Option<crate::github_telemetry::GitHubTelemetryCallback>,
298 pub on_get_trace_context: Option<Arc<dyn TraceContextProvider>>,
308 pub telemetry: Option<TelemetryConfig>,
312 pub base_directory: Option<PathBuf>,
317 pub enable_remote_sessions: bool,
323 pub bundled_cli_extract_dir: Option<PathBuf>,
342 pub mode: ClientMode,
346}
347
348impl std::fmt::Debug for ClientOptions {
349 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
350 f.debug_struct("ClientOptions")
351 .field("program", &self.program)
352 .field("prefix_args", &self.prefix_args)
353 .field("working_directory", &self.working_directory)
354 .field("env", &self.env)
355 .field("env_remove", &self.env_remove)
356 .field("extra_args", &self.extra_args)
357 .field("transport", &self.transport)
358 .field(
359 "github_token",
360 &self.github_token.as_ref().map(|_| "<redacted>"),
361 )
362 .field("use_logged_in_user", &self.use_logged_in_user)
363 .field("log_level", &self.log_level)
364 .field(
365 "session_idle_timeout_seconds",
366 &self.session_idle_timeout_seconds,
367 )
368 .field(
369 "on_list_models",
370 &self.on_list_models.as_ref().map(|_| "<set>"),
371 )
372 .field("session_fs", &self.session_fs)
373 .field(
374 "request_handler",
375 &self.request_handler.as_ref().map(|_| "<set>"),
376 )
377 .field(
378 "on_github_telemetry",
379 &self.on_github_telemetry.as_ref().map(|_| "<set>"),
380 )
381 .field(
382 "on_get_trace_context",
383 &self.on_get_trace_context.as_ref().map(|_| "<set>"),
384 )
385 .field("telemetry", &self.telemetry)
386 .field("base_directory", &self.base_directory)
387 .field("enable_remote_sessions", &self.enable_remote_sessions)
388 .field("bundled_cli_extract_dir", &self.bundled_cli_extract_dir)
389 .finish()
390 }
391}
392
393#[async_trait]
402pub trait ListModelsHandler: Send + Sync + 'static {
403 async fn list_models(&self) -> Result<Vec<Model>>;
405}
406
407#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
409#[serde(rename_all = "lowercase")]
410pub enum LogLevel {
411 None,
413 Error,
415 Warning,
417 Info,
419 Debug,
421 All,
423}
424
425impl LogLevel {
426 pub fn as_str(self) -> &'static str {
428 match self {
429 Self::None => "none",
430 Self::Error => "error",
431 Self::Warning => "warning",
432 Self::Info => "info",
433 Self::Debug => "debug",
434 Self::All => "all",
435 }
436 }
437}
438
439impl std::fmt::Display for LogLevel {
440 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
441 f.write_str(self.as_str())
442 }
443}
444
445#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
450#[serde(rename_all = "kebab-case")]
451#[non_exhaustive]
452pub enum OtelExporterType {
453 OtlpHttp,
456 File,
459}
460
461impl OtelExporterType {
462 pub fn as_str(self) -> &'static str {
464 match self {
465 Self::OtlpHttp => "otlp-http",
466 Self::File => "file",
467 }
468 }
469}
470
471#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
477#[non_exhaustive]
478pub enum OtlpHttpProtocol {
479 #[serde(rename = "http/json")]
481 HttpJson,
482 #[serde(rename = "http/protobuf")]
484 HttpProtobuf,
485}
486
487impl OtlpHttpProtocol {
488 pub fn as_str(self) -> &'static str {
490 match self {
491 Self::HttpJson => "http/json",
492 Self::HttpProtobuf => "http/protobuf",
493 }
494 }
495}
496
497#[derive(Debug, Clone, Default)]
532#[non_exhaustive]
533pub struct TelemetryConfig {
534 pub otlp_endpoint: Option<String>,
536 pub otlp_protocol: Option<OtlpHttpProtocol>,
538 pub file_path: Option<PathBuf>,
540 pub exporter_type: Option<OtelExporterType>,
543 pub source_name: Option<String>,
547 pub capture_content: Option<bool>,
551}
552
553impl TelemetryConfig {
554 pub fn new() -> Self {
557 Self::default()
558 }
559
560 pub fn with_otlp_endpoint(mut self, endpoint: impl Into<String>) -> Self {
562 self.otlp_endpoint = Some(endpoint.into());
563 self
564 }
565
566 pub fn with_otlp_protocol(mut self, protocol: OtlpHttpProtocol) -> Self {
568 self.otlp_protocol = Some(protocol);
569 self
570 }
571
572 pub fn with_file_path(mut self, path: impl Into<PathBuf>) -> Self {
574 self.file_path = Some(path.into());
575 self
576 }
577
578 pub fn with_exporter_type(mut self, exporter_type: OtelExporterType) -> Self {
580 self.exporter_type = Some(exporter_type);
581 self
582 }
583
584 pub fn with_source_name(mut self, source_name: impl Into<String>) -> Self {
588 self.source_name = Some(source_name.into());
589 self
590 }
591
592 pub fn with_capture_content(mut self, capture: bool) -> Self {
596 self.capture_content = Some(capture);
597 self
598 }
599
600 pub fn is_empty(&self) -> bool {
603 self.otlp_endpoint.is_none()
604 && self.otlp_protocol.is_none()
605 && self.file_path.is_none()
606 && self.exporter_type.is_none()
607 && self.source_name.is_none()
608 && self.capture_content.is_none()
609 }
610}
611
612impl Default for ClientOptions {
613 fn default() -> Self {
614 Self {
615 program: CliProgram::Resolve,
616 prefix_args: Vec::new(),
617 working_directory: PathBuf::new(),
618 env: Vec::new(),
619 env_remove: Vec::new(),
620 extra_args: Vec::new(),
621 transport: Transport::default(),
622 github_token: None,
623 use_logged_in_user: None,
624 log_level: None,
625 session_idle_timeout_seconds: None,
626 on_list_models: None,
627 session_fs: None,
628 request_handler: None,
629 on_github_telemetry: None,
630 on_get_trace_context: None,
631 telemetry: None,
632 base_directory: None,
633 enable_remote_sessions: false,
634 bundled_cli_extract_dir: None,
635 mode: ClientMode::default(),
636 }
637 }
638}
639
640impl ClientOptions {
641 pub fn new() -> Self {
657 Self::default()
658 }
659
660 pub fn with_program(mut self, program: impl Into<CliProgram>) -> Self {
662 self.program = program.into();
663 self
664 }
665
666 pub fn with_prefix_args<I, S>(mut self, args: I) -> Self
668 where
669 I: IntoIterator<Item = S>,
670 S: Into<OsString>,
671 {
672 self.prefix_args = args.into_iter().map(Into::into).collect();
673 self
674 }
675
676 pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
678 self.working_directory = cwd.into();
679 self
680 }
681
682 pub fn with_env<I, K, V>(mut self, env: I) -> Self
684 where
685 I: IntoIterator<Item = (K, V)>,
686 K: Into<OsString>,
687 V: Into<OsString>,
688 {
689 self.env = env.into_iter().map(|(k, v)| (k.into(), v.into())).collect();
690 self
691 }
692
693 pub fn with_env_remove<I, S>(mut self, names: I) -> Self
695 where
696 I: IntoIterator<Item = S>,
697 S: Into<OsString>,
698 {
699 self.env_remove = names.into_iter().map(Into::into).collect();
700 self
701 }
702
703 pub fn with_extra_args<I, S>(mut self, args: I) -> Self
705 where
706 I: IntoIterator<Item = S>,
707 S: Into<String>,
708 {
709 self.extra_args = args.into_iter().map(Into::into).collect();
710 self
711 }
712
713 pub fn with_transport(mut self, transport: Transport) -> Self {
715 self.transport = transport;
716 self
717 }
718
719 pub fn with_github_token(mut self, token: impl Into<String>) -> Self {
722 self.github_token = Some(token.into());
723 self
724 }
725
726 pub fn with_use_logged_in_user(mut self, use_logged_in: bool) -> Self {
729 self.use_logged_in_user = Some(use_logged_in);
730 self
731 }
732
733 pub fn with_log_level(mut self, level: LogLevel) -> Self {
735 self.log_level = Some(level);
736 self
737 }
738
739 pub fn with_session_idle_timeout_seconds(mut self, seconds: u64) -> Self {
742 self.session_idle_timeout_seconds = Some(seconds);
743 self
744 }
745
746 pub fn with_list_models_handler<H>(mut self, handler: H) -> Self
749 where
750 H: ListModelsHandler + 'static,
751 {
752 self.on_list_models = Some(Arc::new(handler));
753 self
754 }
755
756 pub fn with_session_fs(mut self, config: SessionFsConfig) -> Self {
758 self.session_fs = Some(config);
759 self
760 }
761
762 pub fn with_request_handler<H>(mut self, handler: H) -> Self
767 where
768 H: crate::copilot_request_handler::CopilotRequestHandler,
769 {
770 self.request_handler = Some(Arc::new(handler));
771 self
772 }
773
774 #[doc(hidden)]
780 pub fn with_on_github_telemetry<F>(mut self, callback: F) -> Self
781 where
782 F: Fn(crate::github_telemetry::GitHubTelemetryNotification) + Send + Sync + 'static,
783 {
784 self.on_github_telemetry = Some(Arc::new(callback));
785 self
786 }
787
788 pub fn with_trace_context_provider<P>(mut self, provider: P) -> Self
792 where
793 P: TraceContextProvider + 'static,
794 {
795 self.on_get_trace_context = Some(Arc::new(provider));
796 self
797 }
798
799 pub fn with_telemetry(mut self, config: TelemetryConfig) -> Self {
801 self.telemetry = Some(config);
802 self
803 }
804
805 pub fn with_base_directory(mut self, dir: impl Into<PathBuf>) -> Self {
808 self.base_directory = Some(dir.into());
809 self
810 }
811
812 pub fn with_enable_remote_sessions(mut self, enabled: bool) -> Self {
815 self.enable_remote_sessions = enabled;
816 self
817 }
818
819 pub fn with_bundled_cli_extract_dir(mut self, dir: impl Into<PathBuf>) -> Self {
829 self.bundled_cli_extract_dir = Some(dir.into());
830 self
831 }
832
833 pub fn with_mode(mut self, mode: ClientMode) -> Self {
838 self.mode = mode;
839 self
840 }
841}
842
843fn validate_session_fs_config(cfg: &SessionFsConfig) -> Result<()> {
845 if cfg.initial_cwd.trim().is_empty() {
846 return Err(Error::with_message(
847 ErrorKind::Session(SessionErrorKind::InvalidSessionFsConfig),
848 "invalid SessionFsConfig: initial_cwd must not be empty",
849 ));
850 }
851 if cfg.session_state_path.trim().is_empty() {
852 return Err(Error::with_message(
853 ErrorKind::Session(SessionErrorKind::InvalidSessionFsConfig),
854 "invalid SessionFsConfig: session_state_path must not be empty",
855 ));
856 }
857 Ok(())
858}
859
860fn generate_connection_token() -> String {
867 let mut bytes = [0u8; 16];
868 getrandom::getrandom(&mut bytes)
869 .expect("OS CSPRNG (getrandom) is unavailable; cannot generate connection token");
870 let mut hex = String::with_capacity(32);
871 for byte in bytes {
872 use std::fmt::Write;
873 let _ = write!(hex, "{byte:02x}");
874 }
875 hex
876}
877
878const DEFAULT_CONNECTION_ENV_VAR: &str = "COPILOT_SDK_DEFAULT_CONNECTION";
883
884fn resolve_default_transport(options: &ClientOptions) -> Result<Transport> {
886 let configured = options
887 .env
888 .iter()
889 .find(|(key, _)| {
890 key.to_string_lossy()
891 .eq_ignore_ascii_case(DEFAULT_CONNECTION_ENV_VAR)
892 })
893 .map(|(_, value)| value.to_string_lossy().into_owned());
894 let process = std::env::var(DEFAULT_CONNECTION_ENV_VAR).ok();
895 resolve_default_transport_value(configured.as_deref().or(process.as_deref()))
896}
897
898fn resolve_default_transport_value(value: Option<&str>) -> Result<Transport> {
899 match value {
900 None => Ok(Transport::Stdio),
901 Some(v) if v.is_empty() || v.eq_ignore_ascii_case("stdio") => Ok(Transport::Stdio),
902 Some(v) if v.eq_ignore_ascii_case("inprocess") => Ok(Transport::InProcess),
903 Some(v) => Err(Error::with_message(
904 ErrorKind::InvalidConfig,
905 format!(
906 "invalid {DEFAULT_CONNECTION_ENV_VAR} value '{v}'. \
907 Expected 'inprocess', 'stdio', or unset."
908 ),
909 )),
910 }
911}
912
913#[cfg(any(feature = "bundled-in-process", test))]
914fn validate_inprocess_options(options: &ClientOptions) -> Result<()> {
915 if !matches!(&options.program, CliProgram::Resolve) {
916 return Err(Error::with_message(
917 ErrorKind::InvalidConfig,
918 "ClientOptions::program is not supported with Transport::InProcess; \
919 set COPILOT_CLI_PATH only when using an externally provisioned runtime package",
920 ));
921 }
922 if !options.extra_args.is_empty() {
923 return Err(Error::with_message(
924 ErrorKind::InvalidConfig,
925 "ClientOptions::extra_args is not supported with Transport::InProcess; \
926 use typed client options instead",
927 ));
928 }
929
930 let unsupported = if !options.working_directory.as_os_str().is_empty() {
931 Some("working_directory")
932 } else if !options.env.is_empty() {
933 Some("env")
934 } else if !options.env_remove.is_empty() {
935 Some("env_remove")
936 } else if options.telemetry.is_some() {
937 Some("telemetry")
938 } else if !options.prefix_args.is_empty() {
939 Some("prefix_args")
940 } else {
941 None
942 };
943
944 if let Some(option) = unsupported {
945 return Err(Error::with_message(
946 ErrorKind::InvalidConfig,
947 format!(
948 "ClientOptions::{option} is not supported with Transport::InProcess; \
949 configure process-global settings on the host process instead"
950 ),
951 ));
952 }
953
954 Ok(())
955}
956
957#[derive(Clone)]
962pub struct Client {
963 inner: Arc<ClientInner>,
964}
965
966impl std::fmt::Debug for Client {
967 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
968 f.debug_struct("Client")
969 .field("working_directory", &self.inner.cwd)
970 .field("pid", &self.pid())
971 .finish()
972 }
973}
974
975struct ClientInner {
976 child: parking_lot::Mutex<Option<Child>>,
977 #[cfg(feature = "bundled-in-process")]
978 ffi_host: parking_lot::Mutex<Option<Arc<crate::ffi::FfiShared>>>,
981 rpc: JsonRpcClient,
982 cwd: PathBuf,
983 request_rx: parking_lot::Mutex<Option<mpsc::UnboundedReceiver<JsonRpcRequest>>>,
984 notification_tx: broadcast::Sender<JsonRpcNotification>,
985 router: router::SessionRouter,
986 negotiated_protocol_version: OnceLock<u32>,
987 state: parking_lot::Mutex<ConnectionState>,
988 lifecycle_tx: broadcast::Sender<SessionLifecycleEvent>,
989 on_list_models: Option<Arc<dyn ListModelsHandler>>,
990 models_cache: parking_lot::Mutex<Arc<tokio::sync::OnceCell<Vec<Model>>>>,
991 session_fs_configured: bool,
992 session_fs_sqlite_declared: bool,
993 llm_inference: OnceLock<Arc<copilot_request_handler::CopilotRequestDispatcher>>,
996 on_github_telemetry: Option<crate::github_telemetry::GitHubTelemetryCallback>,
1001 on_get_trace_context: Option<Arc<dyn TraceContextProvider>>,
1002 effective_connection_token: Option<String>,
1007 pub(crate) mode: ClientMode,
1010}
1011
1012impl Client {
1013 pub async fn start(options: ClientOptions) -> Result<Self> {
1026 let start_time = Instant::now();
1027 let mut options = options;
1028 if matches!(options.transport, Transport::Default) {
1029 options.transport = resolve_default_transport(&options)?;
1030 }
1031 if matches!(options.transport, Transport::InProcess) {
1032 #[cfg(not(feature = "bundled-in-process"))]
1033 {
1034 return Err(Error::with_message(
1035 ErrorKind::InvalidConfig,
1036 "Transport::InProcess requires the `bundled-in-process` Cargo feature",
1037 ));
1038 }
1039 #[cfg(feature = "bundled-in-process")]
1040 validate_inprocess_options(&options)?;
1041 }
1042 if options.mode == ClientMode::Empty
1043 && options.base_directory.is_none()
1044 && options.session_fs.is_none()
1045 {
1046 return Err(Error::with_message(
1047 ErrorKind::InvalidConfig,
1048 "ClientMode::Empty requires either `base_directory` or \
1049 `session_fs` to be set (no implicit ~/.copilot fallback).",
1050 ));
1051 }
1052 if let Some(cfg) = &options.session_fs {
1053 validate_session_fs_config(cfg)?;
1054 }
1055 if matches!(options.transport, Transport::External { .. }) {
1058 if options.github_token.is_some() {
1059 return Err(Error::with_message(
1060 ErrorKind::InvalidConfig,
1061 "invalid client configuration: github_token cannot be used with \
1062 Transport::External (external server manages its own auth)",
1063 ));
1064 }
1065 if options.use_logged_in_user == Some(true) {
1066 return Err(Error::with_message(
1067 ErrorKind::InvalidConfig,
1068 "invalid client configuration: use_logged_in_user cannot be used with \
1069 Transport::External (external server manages its own auth)",
1070 ));
1071 }
1072 }
1073 match &options.transport {
1077 Transport::Tcp {
1078 connection_token: Some(t),
1079 ..
1080 }
1081 | Transport::External {
1082 connection_token: Some(t),
1083 ..
1084 } if t.is_empty() => {
1085 return Err(Error::with_message(
1086 ErrorKind::InvalidConfig,
1087 "invalid client configuration: connection_token must be a non-empty string",
1088 ));
1089 }
1090 _ => {}
1091 }
1092 let effective_connection_token: Option<String> = match &mut options.transport {
1097 Transport::Default => unreachable!("default transport resolved above"),
1098 Transport::Stdio | Transport::InProcess => None,
1099 Transport::Tcp {
1100 connection_token, ..
1101 } => Some(
1102 connection_token
1103 .get_or_insert_with(generate_connection_token)
1104 .clone(),
1105 ),
1106 Transport::External {
1107 connection_token, ..
1108 } => connection_token.clone(),
1109 };
1110 let session_fs_config = options.session_fs.clone();
1111 let request_handler = options.request_handler.clone();
1112 let session_fs_sqlite_declared = session_fs_config
1113 .as_ref()
1114 .and_then(|c| c.capabilities.as_ref())
1115 .is_some_and(|caps| caps.sqlite);
1116 let program = match &options.program {
1117 CliProgram::Path(path) => {
1118 info!(path = %path.display(), "using explicit copilot CLI path");
1119 path.clone()
1120 }
1121 CliProgram::Resolve => {
1122 let resolved = resolve::copilot_binary_with_extract_dir(
1123 options.bundled_cli_extract_dir.as_deref(),
1124 )?;
1125 info!(path = %resolved.display(), "resolved copilot CLI");
1126 #[cfg(windows)]
1127 {
1128 if let Some(ext) = resolved.extension().and_then(|e| e.to_str()).filter(|ext| {
1129 ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat")
1130 }) {
1131 warn!(
1132 path = %resolved.display(),
1133 ext = %ext,
1134 "resolved copilot CLI is a .cmd/.bat wrapper; \
1135 this may cause console window flashes on Windows"
1136 );
1137 }
1138 }
1139 resolved
1140 }
1141 };
1142 let working_directory = {
1143 let cwd = options.working_directory.clone();
1144 if cwd.as_os_str().is_empty() {
1145 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
1146 } else {
1147 cwd
1148 }
1149 };
1150
1151 let client = match options.transport {
1152 Transport::Default => unreachable!("default transport resolved above"),
1153 Transport::External {
1154 ref host,
1155 port,
1156 connection_token: _,
1157 } => {
1158 info!(host = %host, port = %port, "connecting to external CLI server");
1159 let connect_start = Instant::now();
1160 let stream = TcpStream::connect((host.as_str(), port)).await?;
1161 debug!(
1162 elapsed_ms = connect_start.elapsed().as_millis(),
1163 host = %host,
1164 port,
1165 "Client::start TCP connect complete"
1166 );
1167 let (reader, writer) = tokio::io::split(stream);
1168 Self::from_transport(
1169 reader,
1170 writer,
1171 None,
1172 working_directory,
1173 options.on_list_models,
1174 session_fs_config.is_some(),
1175 session_fs_sqlite_declared,
1176 options.on_get_trace_context,
1177 options.on_github_telemetry,
1178 effective_connection_token.clone(),
1179 options.mode,
1180 )?
1181 }
1182 Transport::Tcp {
1183 port,
1184 connection_token: _,
1185 } => {
1186 let (mut child, actual_port) =
1187 Self::spawn_tcp(&program, &options, &working_directory, port).await?;
1188 let connect_start = Instant::now();
1189 let stream = TcpStream::connect(("127.0.0.1", actual_port)).await?;
1190 debug!(
1191 elapsed_ms = connect_start.elapsed().as_millis(),
1192 port = actual_port,
1193 "Client::start TCP connect complete"
1194 );
1195 let (reader, writer) = tokio::io::split(stream);
1196 Self::drain_stderr(&mut child);
1197 Self::from_transport(
1198 reader,
1199 writer,
1200 Some(child),
1201 working_directory,
1202 options.on_list_models,
1203 session_fs_config.is_some(),
1204 session_fs_sqlite_declared,
1205 options.on_get_trace_context,
1206 options.on_github_telemetry,
1207 effective_connection_token.clone(),
1208 options.mode,
1209 )?
1210 }
1211 Transport::Stdio => {
1212 let mut child = Self::spawn_stdio(&program, &options, &working_directory)?;
1213 let stdin = child.stdin.take().expect("stdin is piped");
1214 let stdout = child.stdout.take().expect("stdout is piped");
1215 Self::drain_stderr(&mut child);
1216 Self::from_transport(
1217 stdout,
1218 stdin,
1219 Some(child),
1220 working_directory,
1221 options.on_list_models,
1222 session_fs_config.is_some(),
1223 session_fs_sqlite_declared,
1224 options.on_get_trace_context,
1225 options.on_github_telemetry,
1226 effective_connection_token.clone(),
1227 options.mode,
1228 )?
1229 }
1230 Transport::InProcess => {
1231 #[cfg(feature = "bundled-in-process")]
1232 {
1233 info!(runtime_path = %program.display(), "hosting copilot runtime in-process (FFI)");
1234 let mut environment = Vec::new();
1235 if let Some(base_directory) = &options.base_directory {
1236 let value = base_directory.to_str().ok_or_else(|| {
1237 Error::with_message(
1238 ErrorKind::InvalidConfig,
1239 "base_directory must be valid UTF-8 for Transport::InProcess",
1240 )
1241 })?;
1242 environment.push(("COPILOT_HOME".to_string(), value.to_string()));
1243 }
1244 if options.mode == ClientMode::Empty {
1245 environment.push(("COPILOT_DISABLE_KEYTAR".to_string(), "1".to_string()));
1246 }
1247 if let Some(github_token) = &options.github_token {
1248 environment
1249 .push(("COPILOT_SDK_AUTH_TOKEN".to_string(), github_token.clone()));
1250 }
1251 let mut args = Vec::new();
1252 args.extend(
1253 Self::log_level_args(&options)
1254 .into_iter()
1255 .map(str::to_string),
1256 );
1257 args.extend(Self::session_idle_timeout_args(&options));
1258 args.extend(Self::remote_args(&options));
1259 if options.github_token.is_some() {
1260 args.extend([
1261 "--auth-token-env".to_string(),
1262 "COPILOT_SDK_AUTH_TOKEN".to_string(),
1263 ]);
1264 }
1265 let use_logged_in_user = options
1266 .use_logged_in_user
1267 .unwrap_or(options.github_token.is_none());
1268 if !use_logged_in_user {
1269 args.push("--no-auto-login".to_string());
1270 }
1271 let host = crate::ffi::FfiHost::create(&program, environment, args)?;
1272 let (reader, writer, shared) = host.start().await?;
1273 let client = Self::from_transport(
1274 reader,
1275 writer,
1276 None,
1277 working_directory,
1278 options.on_list_models,
1279 session_fs_config.is_some(),
1280 session_fs_sqlite_declared,
1281 options.on_get_trace_context,
1282 options.on_github_telemetry,
1283 effective_connection_token.clone(),
1284 options.mode,
1285 )?;
1286 *client.inner.ffi_host.lock() = Some(shared);
1287 client
1288 }
1289 #[cfg(not(feature = "bundled-in-process"))]
1290 unreachable!("in-process feature validation returned above")
1291 }
1292 };
1293 debug!(
1294 elapsed_ms = start_time.elapsed().as_millis(),
1295 "Client::start transport setup complete"
1296 );
1297 client.verify_protocol_version().await?;
1298 debug!(
1299 elapsed_ms = start_time.elapsed().as_millis(),
1300 "Client::start protocol verification complete"
1301 );
1302 if let Some(cfg) = session_fs_config {
1303 let session_fs_start = Instant::now();
1304 let capabilities = cfg.capabilities.as_ref().map(|c| {
1305 crate::generated::api_types::SessionFsSetProviderCapabilities {
1306 sqlite: Some(c.sqlite),
1307 }
1308 });
1309 let request = crate::generated::api_types::SessionFsSetProviderRequest {
1310 capabilities,
1311 conventions: cfg.conventions.into_wire(),
1312 initial_cwd: cfg.initial_cwd,
1313 session_state_path: cfg.session_state_path,
1314 };
1315 client.rpc().session_fs().set_provider(request).await?;
1316 debug!(
1317 elapsed_ms = session_fs_start.elapsed().as_millis(),
1318 "Client::start session filesystem setup complete"
1319 );
1320 }
1321 if let Some(handler) = request_handler {
1322 let llm_inference_start = Instant::now();
1323 let dispatcher = Arc::new(copilot_request_handler::CopilotRequestDispatcher::new(
1324 handler,
1325 ));
1326 dispatcher.set_client(Arc::downgrade(&client.inner));
1327 let _ = client.inner.llm_inference.set(dispatcher.clone());
1328 client.inner.router.ensure_started(
1331 &client.inner.notification_tx,
1332 &client.inner.request_rx,
1333 Some(dispatcher.clone()),
1334 client.inner.on_github_telemetry.clone(),
1335 );
1336 client.rpc().llm_inference().set_provider().await?;
1337 debug!(
1338 elapsed_ms = llm_inference_start.elapsed().as_millis(),
1339 "Client::start Copilot request handler registration complete"
1340 );
1341 }
1342 debug!(
1343 elapsed_ms = start_time.elapsed().as_millis(),
1344 "Client::start complete"
1345 );
1346 Ok(client)
1347 }
1348
1349 pub fn from_streams(
1353 reader: impl AsyncRead + Unpin + Send + 'static,
1354 writer: impl AsyncWrite + Unpin + Send + 'static,
1355 cwd: PathBuf,
1356 ) -> Result<Self> {
1357 Self::from_transport(
1358 reader,
1359 writer,
1360 None,
1361 cwd,
1362 None,
1363 false,
1364 false,
1365 None,
1366 None,
1367 None,
1368 ClientMode::default(),
1369 )
1370 }
1371
1372 #[cfg(any(test, feature = "test-support"))]
1380 pub fn from_streams_with_trace_provider(
1381 reader: impl AsyncRead + Unpin + Send + 'static,
1382 writer: impl AsyncWrite + Unpin + Send + 'static,
1383 cwd: PathBuf,
1384 provider: Arc<dyn TraceContextProvider>,
1385 ) -> Result<Self> {
1386 Self::from_transport(
1387 reader,
1388 writer,
1389 None,
1390 cwd,
1391 None,
1392 false,
1393 false,
1394 Some(provider),
1395 None,
1396 None,
1397 ClientMode::default(),
1398 )
1399 }
1400
1401 #[cfg(any(test, feature = "test-support"))]
1405 pub fn from_streams_with_connection_token(
1406 reader: impl AsyncRead + Unpin + Send + 'static,
1407 writer: impl AsyncWrite + Unpin + Send + 'static,
1408 cwd: PathBuf,
1409 token: Option<String>,
1410 ) -> Result<Self> {
1411 Self::from_transport(
1412 reader,
1413 writer,
1414 None,
1415 cwd,
1416 None,
1417 false,
1418 false,
1419 None,
1420 None,
1421 token,
1422 ClientMode::default(),
1423 )
1424 }
1425
1426 #[doc(hidden)]
1429 #[cfg(any(test, feature = "test-support"))]
1430 pub fn from_streams_with_github_telemetry(
1431 reader: impl AsyncRead + Unpin + Send + 'static,
1432 writer: impl AsyncWrite + Unpin + Send + 'static,
1433 cwd: PathBuf,
1434 on_github_telemetry: crate::github_telemetry::GitHubTelemetryCallback,
1435 ) -> Result<Self> {
1436 Self::from_transport(
1437 reader,
1438 writer,
1439 None,
1440 cwd,
1441 None,
1442 false,
1443 false,
1444 None,
1445 Some(on_github_telemetry),
1446 None,
1447 ClientMode::default(),
1448 )
1449 }
1450
1451 #[cfg(any(test, feature = "test-support"))]
1457 pub fn generate_connection_token_for_test() -> String {
1458 generate_connection_token()
1459 }
1460
1461 #[allow(clippy::too_many_arguments)]
1462 fn from_transport(
1463 reader: impl AsyncRead + Unpin + Send + 'static,
1464 writer: impl AsyncWrite + Unpin + Send + 'static,
1465 child: Option<Child>,
1466 cwd: PathBuf,
1467 on_list_models: Option<Arc<dyn ListModelsHandler>>,
1468 session_fs_configured: bool,
1469 session_fs_sqlite_declared: bool,
1470 on_get_trace_context: Option<Arc<dyn TraceContextProvider>>,
1471 on_github_telemetry: Option<crate::github_telemetry::GitHubTelemetryCallback>,
1472 effective_connection_token: Option<String>,
1473 mode: ClientMode,
1474 ) -> Result<Self> {
1475 let setup_start = Instant::now();
1476 let (request_tx, request_rx) = mpsc::unbounded_channel::<JsonRpcRequest>();
1477 let (notification_broadcast_tx, _) = broadcast::channel::<JsonRpcNotification>(1024);
1478 let rpc = JsonRpcClient::new(
1479 writer,
1480 reader,
1481 notification_broadcast_tx.clone(),
1482 request_tx,
1483 );
1484
1485 let pid = child.as_ref().and_then(|c| c.id());
1486 info!(pid = ?pid, "copilot CLI client ready");
1487
1488 let client = Self {
1489 inner: Arc::new(ClientInner {
1490 child: parking_lot::Mutex::new(child),
1491 #[cfg(feature = "bundled-in-process")]
1492 ffi_host: parking_lot::Mutex::new(None),
1493 rpc,
1494 cwd,
1495 request_rx: parking_lot::Mutex::new(Some(request_rx)),
1496 notification_tx: notification_broadcast_tx,
1497 router: router::SessionRouter::new(),
1498 negotiated_protocol_version: OnceLock::new(),
1499 state: parking_lot::Mutex::new(ConnectionState::Connected),
1500 lifecycle_tx: broadcast::channel(256).0,
1501 on_list_models,
1502 models_cache: parking_lot::Mutex::new(Arc::new(tokio::sync::OnceCell::new())),
1503 session_fs_configured,
1504 session_fs_sqlite_declared,
1505 llm_inference: OnceLock::new(),
1506 on_github_telemetry,
1507 on_get_trace_context,
1508 effective_connection_token,
1509 mode,
1510 }),
1511 };
1512 client.spawn_lifecycle_dispatcher();
1513 debug!(
1514 elapsed_ms = setup_start.elapsed().as_millis(),
1515 pid = ?pid,
1516 "Client::from_transport setup complete"
1517 );
1518 Ok(client)
1519 }
1520
1521 fn spawn_lifecycle_dispatcher(&self) {
1525 let inner = Arc::clone(&self.inner);
1526 let mut notif_rx = inner.notification_tx.subscribe();
1527 tokio::spawn(async move {
1528 loop {
1529 match notif_rx.recv().await {
1530 Ok(notification) => {
1531 if notification.method != "session.lifecycle" {
1532 continue;
1533 }
1534 let Some(params) = notification.params.as_ref() else {
1535 continue;
1536 };
1537 let event: SessionLifecycleEvent =
1538 match serde_json::from_value(params.clone()) {
1539 Ok(e) => e,
1540 Err(e) => {
1541 warn!(
1542 error = %e,
1543 "failed to deserialize session.lifecycle notification"
1544 );
1545 continue;
1546 }
1547 };
1548 let _ = inner.lifecycle_tx.send(event);
1551 }
1552 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
1553 warn!(missed = n, "lifecycle dispatcher lagged");
1554 }
1555 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
1556 }
1557 }
1558 });
1559 }
1560
1561 fn build_command(program: &Path, options: &ClientOptions, working_directory: &Path) -> Command {
1562 let mut command = Command::new(program);
1563 for arg in &options.prefix_args {
1564 command.arg(arg);
1565 }
1566 if let Some(token) = &options.github_token {
1569 command.env("COPILOT_SDK_AUTH_TOKEN", token);
1570 }
1571 if let Some(telemetry) = &options.telemetry {
1574 command.env("COPILOT_OTEL_ENABLED", "true");
1575 if let Some(endpoint) = &telemetry.otlp_endpoint {
1576 command.env("OTEL_EXPORTER_OTLP_ENDPOINT", endpoint);
1577 }
1578 if let Some(protocol) = telemetry.otlp_protocol {
1579 command.env("OTEL_EXPORTER_OTLP_PROTOCOL", protocol.as_str());
1580 }
1581 if let Some(path) = &telemetry.file_path {
1582 command.env("COPILOT_OTEL_FILE_EXPORTER_PATH", path);
1583 }
1584 if let Some(exporter) = telemetry.exporter_type {
1585 command.env("COPILOT_OTEL_EXPORTER_TYPE", exporter.as_str());
1586 }
1587 if let Some(source) = &telemetry.source_name {
1588 command.env("COPILOT_OTEL_SOURCE_NAME", source);
1589 }
1590 if let Some(capture) = telemetry.capture_content {
1591 command.env(
1592 "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT",
1593 if capture { "true" } else { "false" },
1594 );
1595 }
1596 }
1597 if let Some(dir) = &options.base_directory {
1598 command.env("COPILOT_HOME", dir);
1599 }
1600 if options.mode == ClientMode::Empty {
1603 command.env("COPILOT_DISABLE_KEYTAR", "1");
1604 }
1605 if let Transport::Tcp {
1606 connection_token: Some(token),
1607 ..
1608 } = &options.transport
1609 {
1610 command.env("COPILOT_CONNECTION_TOKEN", token);
1611 }
1612 for (key, value) in &options.env {
1613 command.env(key, value);
1614 }
1615 for key in &options.env_remove {
1616 command.env_remove(key);
1617 }
1618 command
1619 .current_dir(working_directory)
1620 .stdout(Stdio::piped())
1621 .stderr(Stdio::piped());
1622
1623 #[cfg(windows)]
1624 {
1625 use std::os::windows::process::CommandExt;
1626 const CREATE_NO_WINDOW: u32 = 0x08000000;
1627 command.as_std_mut().creation_flags(CREATE_NO_WINDOW);
1628 }
1629
1630 command
1631 }
1632
1633 fn auth_args(options: &ClientOptions) -> Vec<&'static str> {
1641 let mut args: Vec<&'static str> = Vec::new();
1642 if options.github_token.is_some() {
1643 args.push("--auth-token-env");
1644 args.push("COPILOT_SDK_AUTH_TOKEN");
1645 }
1646 let use_logged_in = options
1647 .use_logged_in_user
1648 .unwrap_or(options.github_token.is_none());
1649 if !use_logged_in {
1650 args.push("--no-auto-login");
1651 }
1652 args
1653 }
1654
1655 fn session_idle_timeout_args(options: &ClientOptions) -> Vec<String> {
1659 match options.session_idle_timeout_seconds {
1660 Some(secs) if secs > 0 => {
1661 vec!["--session-idle-timeout".to_string(), secs.to_string()]
1662 }
1663 _ => Vec::new(),
1664 }
1665 }
1666
1667 fn remote_args(options: &ClientOptions) -> Vec<String> {
1668 if options.enable_remote_sessions {
1669 vec!["--remote".to_string()]
1670 } else {
1671 Vec::new()
1672 }
1673 }
1674
1675 fn log_level_args(options: &ClientOptions) -> Vec<&'static str> {
1676 match options.log_level {
1677 Some(level) => vec!["--log-level", level.as_str()],
1678 None => Vec::new(),
1679 }
1680 }
1681
1682 fn spawn_stdio(
1683 program: &Path,
1684 options: &ClientOptions,
1685 working_directory: &Path,
1686 ) -> Result<Child> {
1687 info!(cwd = ?working_directory, program = %program.display(), "spawning copilot CLI (stdio)");
1688 let mut command = Self::build_command(program, options, working_directory);
1689 command
1690 .args(["--server", "--stdio", "--no-auto-update"])
1691 .args(Self::log_level_args(options))
1692 .args(Self::auth_args(options))
1693 .args(Self::session_idle_timeout_args(options))
1694 .args(Self::remote_args(options))
1695 .args(&options.extra_args)
1696 .stdin(Stdio::piped());
1697 let spawn_start = Instant::now();
1698 let child = command.spawn()?;
1699 debug!(
1700 elapsed_ms = spawn_start.elapsed().as_millis(),
1701 "Client::spawn_stdio subprocess spawned"
1702 );
1703 Ok(child)
1704 }
1705
1706 async fn spawn_tcp(
1707 program: &Path,
1708 options: &ClientOptions,
1709 working_directory: &Path,
1710 port: u16,
1711 ) -> Result<(Child, u16)> {
1712 info!(cwd = ?working_directory, program = %program.display(), port = %port, "spawning copilot CLI (tcp)");
1713 let mut command = Self::build_command(program, options, working_directory);
1714 command
1715 .args(["--server", "--port", &port.to_string(), "--no-auto-update"])
1716 .args(Self::log_level_args(options))
1717 .args(Self::auth_args(options))
1718 .args(Self::session_idle_timeout_args(options))
1719 .args(Self::remote_args(options))
1720 .args(&options.extra_args)
1721 .stdin(Stdio::null());
1722 let spawn_start = Instant::now();
1723 let mut child = command.spawn()?;
1724 debug!(
1725 elapsed_ms = spawn_start.elapsed().as_millis(),
1726 "Client::spawn_tcp subprocess spawned"
1727 );
1728 let stdout = child.stdout.take().expect("stdout is piped");
1729
1730 let (port_tx, port_rx) = oneshot::channel::<u16>();
1731 let span = tracing::error_span!("copilot_cli_port_scan");
1732 tokio::spawn(
1733 async move {
1734 let port_re = regex::Regex::new(r"listening on port (\d+)").expect("valid regex");
1736 let mut lines = BufReader::new(stdout).lines();
1737 let mut port_tx = Some(port_tx);
1738 while let Ok(Some(line)) = lines.next_line().await {
1739 debug!(line = %line, "CLI stdout");
1740 if let Some(tx) = port_tx.take() {
1741 if let Some(caps) = port_re.captures(&line)
1742 && let Some(p) =
1743 caps.get(1).and_then(|m| m.as_str().parse::<u16>().ok())
1744 {
1745 let _ = tx.send(p);
1746 continue;
1747 }
1748 port_tx = Some(tx);
1750 }
1751 }
1752 }
1753 .instrument(span),
1754 );
1755
1756 let port_wait_start = Instant::now();
1757 let actual_port = tokio::time::timeout(std::time::Duration::from_secs(10), port_rx)
1758 .await
1759 .map_err(|_| Error::from(ErrorKind::Protocol(ProtocolErrorKind::CliStartupTimeout)))?
1760 .map_err(|_| Error::from(ErrorKind::Protocol(ProtocolErrorKind::CliStartupFailed)))?;
1761
1762 debug!(
1763 elapsed_ms = port_wait_start.elapsed().as_millis(),
1764 port = actual_port,
1765 "Client::spawn_tcp TCP port wait complete"
1766 );
1767 info!(port = %actual_port, "CLI server listening");
1768 Ok((child, actual_port))
1769 }
1770
1771 fn drain_stderr(child: &mut Child) {
1772 if let Some(stderr) = child.stderr.take() {
1773 let span = tracing::error_span!("copilot_cli");
1774 tokio::spawn(
1775 async move {
1776 let mut reader = BufReader::new(stderr).lines();
1777 while let Ok(Some(line)) = reader.next_line().await {
1778 warn!(line = %line, "CLI stderr");
1779 }
1780 }
1781 .instrument(span),
1782 );
1783 }
1784 }
1785
1786 pub fn cwd(&self) -> &PathBuf {
1788 &self.inner.cwd
1789 }
1790
1791 pub fn mode(&self) -> ClientMode {
1793 self.inner.mode
1794 }
1795
1796 pub fn rpc(&self) -> crate::generated::rpc::ClientRpc<'_> {
1807 crate::generated::rpc::ClientRpc { client: self }
1808 }
1809
1810 #[allow(dead_code, reason = "convenience for future internal use")]
1812 pub(crate) async fn send_request(
1813 &self,
1814 method: &str,
1815 params: Option<serde_json::Value>,
1816 ) -> Result<JsonRpcResponse> {
1817 self.inner.rpc.send_request(method, params).await
1818 }
1819
1820 pub async fn call(
1840 &self,
1841 method: &str,
1842 params: Option<serde_json::Value>,
1843 ) -> Result<serde_json::Value> {
1844 self.call_with_inline_callback(method, params, None).await
1845 }
1846
1847 pub(crate) async fn call_with_inline_callback(
1862 &self,
1863 method: &str,
1864 params: Option<serde_json::Value>,
1865 inline_callback: Option<crate::jsonrpc::InlineResponseCallback>,
1866 ) -> Result<serde_json::Value> {
1867 let session_id: Option<SessionId> = params
1868 .as_ref()
1869 .and_then(|p| p.get("sessionId"))
1870 .and_then(|v| v.as_str())
1871 .map(SessionId::from);
1872 let response = self
1873 .inner
1874 .rpc
1875 .send_request_with_inline_callback(method, params, inline_callback)
1876 .await?;
1877 if let Some(err) = response.error {
1878 if err.message.contains("Session not found") {
1879 return Err(ErrorKind::Session(SessionErrorKind::NotFound(
1880 session_id.unwrap_or_else(|| "unknown".into()),
1881 ))
1882 .into());
1883 }
1884 return Err(Error::with_message(
1885 ErrorKind::Rpc { code: err.code },
1886 err.message,
1887 ));
1888 }
1889 Ok(response.result.unwrap_or(serde_json::Value::Null))
1890 }
1891
1892 pub(crate) async fn send_response(&self, response: &JsonRpcResponse) -> Result<()> {
1894 self.inner.rpc.write(response).await
1895 }
1896
1897 pub(crate) fn from_inner(inner: Arc<ClientInner>) -> Self {
1899 Self { inner }
1900 }
1901
1902 #[expect(dead_code, reason = "reserved for future pub(crate) use")]
1906 pub(crate) fn take_request_rx(&self) -> Option<mpsc::UnboundedReceiver<JsonRpcRequest>> {
1907 self.inner.request_rx.lock().take()
1908 }
1909
1910 pub(crate) fn register_session(
1918 &self,
1919 session_id: &SessionId,
1920 ) -> crate::router::SessionChannels {
1921 self.inner.router.ensure_started(
1922 &self.inner.notification_tx,
1923 &self.inner.request_rx,
1924 self.inner.llm_inference.get().cloned(),
1925 self.inner.on_github_telemetry.clone(),
1926 );
1927 self.inner.router.register(session_id)
1928 }
1929
1930 pub(crate) fn unregister_session(&self, session_id: &SessionId) {
1932 self.inner.router.unregister(session_id);
1933 }
1934
1935 pub fn protocol_version(&self) -> Option<u32> {
1942 self.inner.negotiated_protocol_version.get().copied()
1943 }
1944
1945 pub async fn verify_protocol_version(&self) -> Result<()> {
1969 let handshake_start = Instant::now();
1970 let mut used_fallback_ping = false;
1971 let server_version = match self.connect_handshake().await {
1975 Ok(v) => v,
1976 Err(ref e) if e.rpc_code() == Some(error_codes::METHOD_NOT_FOUND) => {
1977 used_fallback_ping = true;
1978 self.ping(None).await?.protocol_version
1979 }
1980 Err(e) => return Err(e),
1981 };
1982
1983 match server_version {
1984 None => {
1985 warn!("CLI server did not report protocolVersion; skipping version check");
1986 }
1987 Some(v) if !(MIN_PROTOCOL_VERSION..=SDK_PROTOCOL_VERSION).contains(&v) => {
1988 return Err(ErrorKind::Protocol(ProtocolErrorKind::VersionMismatch {
1989 server: v,
1990 min: MIN_PROTOCOL_VERSION,
1991 max: SDK_PROTOCOL_VERSION,
1992 })
1993 .into());
1994 }
1995 Some(v) => {
1996 if let Some(&existing) = self.inner.negotiated_protocol_version.get() {
1997 if existing != v {
1998 return Err(ErrorKind::Protocol(ProtocolErrorKind::VersionChanged {
1999 previous: existing,
2000 current: v,
2001 })
2002 .into());
2003 }
2004 } else {
2005 let _ = self.inner.negotiated_protocol_version.set(v);
2006 }
2007 }
2008 }
2009
2010 debug!(
2011 elapsed_ms = handshake_start.elapsed().as_millis(),
2012 protocol_version = ?server_version,
2013 used_fallback_ping,
2014 "Client::verify_protocol_version protocol handshake complete"
2015 );
2016 Ok(())
2017 }
2018
2019 async fn connect_handshake(&self) -> Result<Option<u32>> {
2026 let params = crate::generated::api_types::ConnectRequest {
2027 token: self.inner.effective_connection_token.clone(),
2028 enable_git_hub_telemetry_forwarding: self
2029 .inner
2030 .on_github_telemetry
2031 .is_some()
2032 .then_some(true),
2033 };
2034 let value = self
2035 .call(
2036 crate::generated::api_types::rpc_methods::CONNECT,
2037 Some(serde_json::to_value(params)?),
2038 )
2039 .await?;
2040 let result: crate::generated::api_types::ConnectResult = serde_json::from_value(value)?;
2041 Ok(Some(u32::try_from(result.protocol_version).map_err(
2042 |_| ProtocolErrorKind::InvalidProtocolVersion {
2043 server: result.protocol_version,
2044 },
2045 )?))
2046 }
2047
2048 pub async fn ping(&self, message: Option<&str>) -> Result<crate::types::PingResponse> {
2056 let params = match message {
2057 Some(m) => serde_json::json!({ "message": m }),
2058 None => serde_json::json!({}),
2059 };
2060 let value = self
2061 .call(generated::api_types::rpc_methods::PING, Some(params))
2062 .await?;
2063 Ok(serde_json::from_value(value)?)
2064 }
2065
2066 pub async fn list_sessions(
2069 &self,
2070 filter: Option<SessionListFilter>,
2071 ) -> Result<Vec<SessionMetadata>> {
2072 let params = match filter {
2073 Some(f) => serde_json::json!({ "filter": f }),
2074 None => serde_json::json!({}),
2075 };
2076 let result = self.call("session.list", Some(params)).await?;
2077 let response: ListSessionsResponse = serde_json::from_value(result)?;
2078 Ok(response.sessions)
2079 }
2080
2081 pub async fn get_session_metadata(
2099 &self,
2100 session_id: &SessionId,
2101 ) -> Result<Option<SessionMetadata>> {
2102 let result = self
2103 .call(
2104 "session.getMetadata",
2105 Some(serde_json::json!({ "sessionId": session_id })),
2106 )
2107 .await?;
2108 let response: GetSessionMetadataResponse = serde_json::from_value(result)?;
2109 Ok(response.session)
2110 }
2111
2112 pub async fn delete_session(&self, session_id: &SessionId) -> Result<()> {
2114 self.call(
2115 "session.delete",
2116 Some(serde_json::json!({ "sessionId": session_id })),
2117 )
2118 .await?;
2119 Ok(())
2120 }
2121
2122 pub async fn get_last_session_id(&self) -> Result<Option<SessionId>> {
2138 let result = self
2139 .call("session.getLastId", Some(serde_json::json!({})))
2140 .await?;
2141 let response: GetLastSessionIdResponse = serde_json::from_value(result)?;
2142 Ok(response.session_id)
2143 }
2144
2145 pub async fn get_foreground_session_id(&self) -> Result<Option<SessionId>> {
2150 let result = self
2151 .call("session.getForeground", Some(serde_json::json!({})))
2152 .await?;
2153 let response: GetForegroundSessionResponse = serde_json::from_value(result)?;
2154 Ok(response.session_id)
2155 }
2156
2157 pub async fn set_foreground_session_id(&self, session_id: &SessionId) -> Result<()> {
2162 self.call(
2163 "session.setForeground",
2164 Some(serde_json::json!({ "sessionId": session_id })),
2165 )
2166 .await?;
2167 Ok(())
2168 }
2169
2170 pub async fn get_status(&self) -> Result<GetStatusResponse> {
2172 let result = self.call("status.get", Some(serde_json::json!({}))).await?;
2173 Ok(serde_json::from_value(result)?)
2174 }
2175
2176 pub async fn get_auth_status(&self) -> Result<GetAuthStatusResponse> {
2178 let result = self
2179 .call("auth.getStatus", Some(serde_json::json!({})))
2180 .await?;
2181 Ok(serde_json::from_value(result)?)
2182 }
2183
2184 pub async fn list_models(&self) -> Result<Vec<Model>> {
2189 let cache = self.inner.models_cache.lock().clone();
2190 let models = cache
2191 .get_or_try_init(|| async {
2192 if let Some(handler) = &self.inner.on_list_models {
2193 handler.list_models().await
2194 } else {
2195 Ok(self.rpc().models().list().await?.models)
2196 }
2197 })
2198 .await?;
2199 Ok(models.clone())
2200 }
2201
2202 pub(crate) async fn resolve_trace_context(&self) -> TraceContext {
2205 if let Some(provider) = &self.inner.on_get_trace_context {
2206 provider.get_trace_context().await
2207 } else {
2208 TraceContext::default()
2209 }
2210 }
2211
2212 pub fn pid(&self) -> Option<u32> {
2214 self.inner.child.lock().as_ref().and_then(|c| c.id())
2215 }
2216
2217 pub async fn stop(&self) -> std::result::Result<(), StopErrors> {
2244 let pid = self.pid();
2245 info!(pid = ?pid, "stopping CLI process");
2246 let mut errors: Vec<Error> = Vec::new();
2247
2248 for session_id in self.inner.router.session_ids() {
2251 match self
2252 .call(
2253 "session.destroy",
2254 Some(serde_json::json!({ "sessionId": session_id })),
2255 )
2256 .await
2257 {
2258 Ok(_) => {}
2259 Err(e) => {
2260 warn!(
2261 session_id = %session_id,
2262 error = %e,
2263 "session.destroy failed during Client::stop",
2264 );
2265 errors.push(e);
2266 }
2267 }
2268 self.inner.router.unregister(&session_id);
2269 }
2270
2271 let should_shutdown_runtime = self.inner.child.lock().is_some();
2272 #[cfg(feature = "bundled-in-process")]
2273 let should_shutdown_runtime =
2274 should_shutdown_runtime || self.inner.ffi_host.lock().is_some();
2275 if should_shutdown_runtime {
2276 let runtime_shutdown_start = Instant::now();
2277 match tokio::time::timeout(RUNTIME_SHUTDOWN_TIMEOUT, self.rpc().runtime().shutdown())
2278 .await
2279 {
2280 Ok(Ok(())) => {
2281 debug!(
2282 elapsed_ms = runtime_shutdown_start.elapsed().as_millis(),
2283 "Client::stop runtime shutdown complete"
2284 );
2285 }
2286 Ok(Err(e)) => {
2287 warn!(
2288 elapsed_ms = runtime_shutdown_start.elapsed().as_millis(),
2289 error = %e,
2290 "runtime.shutdown failed during Client::stop",
2291 );
2292 errors.push(e);
2293 }
2294 Err(_) => {
2295 let e = std::io::Error::new(
2296 std::io::ErrorKind::TimedOut,
2297 "runtime.shutdown timed out during Client::stop",
2298 );
2299 warn!(
2300 elapsed_ms = runtime_shutdown_start.elapsed().as_millis(),
2301 timeout = ?RUNTIME_SHUTDOWN_TIMEOUT,
2302 error = %e,
2303 "runtime.shutdown timed out during Client::stop",
2304 );
2305 errors.push(e.into());
2306 }
2307 }
2308 }
2309
2310 let child = self.inner.child.lock().take();
2311 *self.inner.state.lock() = ConnectionState::Disconnected;
2312 *self.inner.models_cache.lock() = Arc::new(tokio::sync::OnceCell::new());
2313 if let Some(mut child) = child {
2314 match child.try_wait() {
2315 Ok(Some(_status)) => {}
2316 Ok(None) => {
2317 if let Err(e) = child.kill().await {
2324 errors.push(e.into());
2325 }
2326 }
2327 Err(e) => errors.push(e.into()),
2328 }
2329 }
2330
2331 #[cfg(feature = "bundled-in-process")]
2334 {
2335 if let Some(host) = self.inner.ffi_host.lock().take() {
2336 self.inner.rpc.force_close();
2337 host.close();
2338 }
2339 }
2340
2341 info!(pid = ?pid, errors = errors.len(), "CLI process stopped");
2342 if errors.is_empty() {
2343 Ok(())
2344 } else {
2345 Err(StopErrors(errors))
2346 }
2347 }
2348
2349 pub fn force_stop(&self) {
2379 let pid = self.pid();
2380 info!(pid = ?pid, "force-stopping CLI process");
2381 if let Some(mut child) = self.inner.child.lock().take()
2382 && let Err(e) = child.start_kill()
2383 {
2384 error!(pid = ?pid, error = %e, "failed to send kill signal");
2385 }
2386 self.inner.rpc.force_close();
2387 #[cfg(feature = "bundled-in-process")]
2388 {
2389 if let Some(host) = self.inner.ffi_host.lock().take() {
2390 host.close();
2391 }
2392 }
2393 self.inner.router.clear();
2396 *self.inner.state.lock() = ConnectionState::Disconnected;
2397 *self.inner.models_cache.lock() = Arc::new(tokio::sync::OnceCell::new());
2398 }
2399
2400 pub fn subscribe_lifecycle(&self) -> LifecycleSubscription {
2435 LifecycleSubscription::new(self.inner.lifecycle_tx.subscribe())
2436 }
2437}
2438
2439impl Drop for ClientInner {
2440 fn drop(&mut self) {
2441 if let Some(ref mut child) = *self.child.lock() {
2442 let pid = child.id();
2443 if let Err(e) = child.start_kill() {
2444 error!(pid = ?pid, error = %e, "failed to kill CLI process on drop");
2445 } else {
2446 info!(pid = ?pid, "kill signal sent for CLI process on drop");
2447 }
2448 }
2449 #[cfg(feature = "bundled-in-process")]
2450 {
2451 if let Some(host) = self.ffi_host.lock().take() {
2452 self.rpc.force_close();
2453 host.close();
2454 }
2455 }
2456 }
2457}
2458
2459#[cfg(test)]
2460mod tests {
2461 use super::*;
2462
2463 #[test]
2464 fn is_transport_failure_matches_request_cancelled() {
2465 let err = Error::from(ErrorKind::Protocol(ProtocolErrorKind::RequestCancelled));
2466 assert!(err.is_transport_failure());
2467 }
2468
2469 #[test]
2470 fn is_transport_failure_matches_io_error() {
2471 let err = Error::from(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "gone"));
2472 assert!(err.is_transport_failure());
2473 }
2474
2475 #[test]
2476 fn is_transport_failure_rejects_rpc_error() {
2477 let err = Error::with_message(ErrorKind::Rpc { code: -1 }, "bad");
2478 assert!(!err.is_transport_failure());
2479 }
2480
2481 #[test]
2482 fn is_transport_failure_rejects_session_error() {
2483 let err = Error::from(ErrorKind::Session(SessionErrorKind::NotFound("s1".into())));
2484 assert!(!err.is_transport_failure());
2485 }
2486
2487 #[test]
2488 fn client_options_builder_composes() {
2489 let opts = ClientOptions::new()
2490 .with_program(CliProgram::Path(PathBuf::from("/usr/local/bin/copilot")))
2491 .with_prefix_args(["node"])
2492 .with_cwd(PathBuf::from("/tmp"))
2493 .with_env([("KEY", "value")])
2494 .with_env_remove(["UNWANTED"])
2495 .with_extra_args(["--quiet"])
2496 .with_github_token("ghp_test")
2497 .with_use_logged_in_user(false)
2498 .with_log_level(LogLevel::Debug)
2499 .with_session_idle_timeout_seconds(120)
2500 .with_enable_remote_sessions(true);
2501 assert!(matches!(opts.program, CliProgram::Path(_)));
2502 assert_eq!(opts.prefix_args, vec![std::ffi::OsString::from("node")]);
2503 assert_eq!(opts.working_directory, PathBuf::from("/tmp"));
2504 assert_eq!(
2505 opts.env,
2506 vec![(
2507 std::ffi::OsString::from("KEY"),
2508 std::ffi::OsString::from("value")
2509 )]
2510 );
2511 assert_eq!(opts.env_remove, vec![std::ffi::OsString::from("UNWANTED")]);
2512 assert_eq!(opts.extra_args, vec!["--quiet".to_string()]);
2513 assert_eq!(opts.github_token.as_deref(), Some("ghp_test"));
2514 assert_eq!(opts.use_logged_in_user, Some(false));
2515 assert!(matches!(opts.log_level, Some(LogLevel::Debug)));
2516 assert_eq!(opts.session_idle_timeout_seconds, Some(120));
2517 assert!(opts.enable_remote_sessions);
2518 }
2519
2520 #[test]
2521 fn default_transport_values_resolve_without_process_state() {
2522 assert!(matches!(
2523 resolve_default_transport_value(None).unwrap(),
2524 Transport::Stdio
2525 ));
2526 assert!(matches!(
2527 resolve_default_transport_value(Some("stdio")).unwrap(),
2528 Transport::Stdio
2529 ));
2530 assert!(matches!(
2531 resolve_default_transport_value(Some("INPROCESS")).unwrap(),
2532 Transport::InProcess
2533 ));
2534 assert!(resolve_default_transport_value(Some("tcp")).is_err());
2535 }
2536
2537 #[test]
2538 fn inprocess_rejects_process_scoped_options() {
2539 let invalid = [
2540 ClientOptions::new().with_cwd("."),
2541 ClientOptions::new().with_env([("KEY", "value")]),
2542 ClientOptions::new().with_env_remove(["KEY"]),
2543 ClientOptions::new().with_telemetry(TelemetryConfig::default()),
2544 ClientOptions::new().with_prefix_args(["index.js"]),
2545 ClientOptions::new().with_program(CliProgram::Path("copilot".into())),
2546 ClientOptions::new().with_extra_args(["--verbose"]),
2547 ];
2548
2549 for options in invalid {
2550 assert!(validate_inprocess_options(&options).is_err());
2551 }
2552 }
2553
2554 #[test]
2555 fn inprocess_allows_typed_runtime_options() {
2556 let options = ClientOptions::new()
2557 .with_base_directory("state")
2558 .with_log_level(LogLevel::Debug)
2559 .with_session_idle_timeout_seconds(10)
2560 .with_github_token("token")
2561 .with_use_logged_in_user(false)
2562 .with_enable_remote_sessions(true);
2563
2564 assert!(validate_inprocess_options(&options).is_ok());
2565 }
2566
2567 #[cfg(not(feature = "bundled-in-process"))]
2568 #[tokio::test]
2569 async fn inprocess_requires_cargo_feature() {
2570 let error = Client::start(ClientOptions::new().with_transport(Transport::InProcess))
2571 .await
2572 .unwrap_err();
2573
2574 assert!(error.to_string().contains("bundled-in-process"));
2575 }
2576
2577 #[test]
2578 fn is_transport_failure_rejects_other_protocol_errors() {
2579 let err = Error::from(ErrorKind::Protocol(ProtocolErrorKind::CliStartupTimeout));
2580 assert!(!err.is_transport_failure());
2581 }
2582
2583 #[test]
2584 fn build_command_lets_env_remove_strip_injected_token() {
2585 let opts = ClientOptions {
2586 github_token: Some("secret".to_string()),
2587 env_remove: vec![std::ffi::OsString::from("COPILOT_SDK_AUTH_TOKEN")],
2588 ..Default::default()
2589 };
2590 let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp"));
2591 let action = cmd
2593 .as_std()
2594 .get_envs()
2595 .find(|(k, _)| *k == std::ffi::OsStr::new("COPILOT_SDK_AUTH_TOKEN"))
2596 .map(|(_, v)| v);
2597 assert_eq!(
2598 action,
2599 Some(None),
2600 "env_remove should win over github_token"
2601 );
2602 }
2603
2604 #[test]
2605 fn build_command_lets_env_override_injected_token() {
2606 let opts = ClientOptions {
2607 github_token: Some("from-options".to_string()),
2608 env: vec![(
2609 std::ffi::OsString::from("COPILOT_SDK_AUTH_TOKEN"),
2610 std::ffi::OsString::from("from-env"),
2611 )],
2612 ..Default::default()
2613 };
2614 let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp"));
2615 let value = cmd
2616 .as_std()
2617 .get_envs()
2618 .find(|(k, _)| *k == std::ffi::OsStr::new("COPILOT_SDK_AUTH_TOKEN"))
2619 .and_then(|(_, v)| v);
2620 assert_eq!(value, Some(std::ffi::OsStr::new("from-env")));
2621 }
2622
2623 #[test]
2624 fn build_command_injects_github_token_by_default() {
2625 let opts = ClientOptions {
2626 github_token: Some("just-the-token".to_string()),
2627 ..Default::default()
2628 };
2629 let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp"));
2630 let value = cmd
2631 .as_std()
2632 .get_envs()
2633 .find(|(k, _)| *k == std::ffi::OsStr::new("COPILOT_SDK_AUTH_TOKEN"))
2634 .and_then(|(_, v)| v);
2635 assert_eq!(value, Some(std::ffi::OsStr::new("just-the-token")));
2636 }
2637
2638 fn env_value<'a>(cmd: &'a tokio::process::Command, key: &str) -> Option<&'a std::ffi::OsStr> {
2639 cmd.as_std()
2640 .get_envs()
2641 .find(|(k, _)| *k == std::ffi::OsStr::new(key))
2642 .and_then(|(_, v)| v)
2643 }
2644
2645 #[test]
2646 fn telemetry_config_builder_composes() {
2647 let cfg = TelemetryConfig::new()
2648 .with_otlp_endpoint("http://collector:4318")
2649 .with_otlp_protocol(OtlpHttpProtocol::HttpProtobuf)
2650 .with_file_path(PathBuf::from("/var/log/copilot.jsonl"))
2651 .with_exporter_type(OtelExporterType::OtlpHttp)
2652 .with_source_name("my-app")
2653 .with_capture_content(true);
2654
2655 assert_eq!(cfg.otlp_endpoint.as_deref(), Some("http://collector:4318"));
2656 assert_eq!(cfg.otlp_protocol, Some(OtlpHttpProtocol::HttpProtobuf));
2657 assert_eq!(
2658 cfg.file_path.as_deref(),
2659 Some(Path::new("/var/log/copilot.jsonl")),
2660 );
2661 assert_eq!(cfg.exporter_type, Some(OtelExporterType::OtlpHttp));
2662 assert_eq!(cfg.source_name.as_deref(), Some("my-app"));
2663 assert_eq!(cfg.capture_content, Some(true));
2664 assert!(!cfg.is_empty());
2665 assert!(TelemetryConfig::new().is_empty());
2666 }
2667
2668 #[test]
2669 fn otlp_http_protocol_serde_matches_env_value() {
2670 for (protocol, wire) in [
2671 (OtlpHttpProtocol::HttpJson, "http/json"),
2672 (OtlpHttpProtocol::HttpProtobuf, "http/protobuf"),
2673 ] {
2674 assert_eq!(protocol.as_str(), wire);
2675
2676 let serialized = serde_json::to_string(&protocol).unwrap();
2677 assert_eq!(serialized, format!("\"{wire}\""));
2678
2679 let deserialized: OtlpHttpProtocol = serde_json::from_str(&serialized).unwrap();
2680 assert_eq!(deserialized, protocol);
2681 }
2682 }
2683
2684 #[test]
2685 fn build_command_sets_otel_env_when_telemetry_enabled() {
2686 let opts = ClientOptions {
2687 telemetry: Some(TelemetryConfig {
2688 otlp_endpoint: Some("http://collector:4318".to_string()),
2689 otlp_protocol: Some(OtlpHttpProtocol::HttpProtobuf),
2690 file_path: Some(PathBuf::from("/var/log/copilot.jsonl")),
2691 exporter_type: Some(OtelExporterType::OtlpHttp),
2692 source_name: Some("my-app".to_string()),
2693 capture_content: Some(true),
2694 }),
2695 ..Default::default()
2696 };
2697 let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp"));
2698 assert_eq!(
2699 env_value(&cmd, "COPILOT_OTEL_ENABLED"),
2700 Some(std::ffi::OsStr::new("true")),
2701 );
2702 assert_eq!(
2703 env_value(&cmd, "OTEL_EXPORTER_OTLP_ENDPOINT"),
2704 Some(std::ffi::OsStr::new("http://collector:4318")),
2705 );
2706 assert_eq!(
2707 env_value(&cmd, "OTEL_EXPORTER_OTLP_PROTOCOL"),
2708 Some(std::ffi::OsStr::new("http/protobuf")),
2709 );
2710 assert_eq!(
2711 env_value(&cmd, "COPILOT_OTEL_FILE_EXPORTER_PATH"),
2712 Some(std::ffi::OsStr::new("/var/log/copilot.jsonl")),
2713 );
2714 assert_eq!(
2715 env_value(&cmd, "COPILOT_OTEL_EXPORTER_TYPE"),
2716 Some(std::ffi::OsStr::new("otlp-http")),
2717 );
2718 assert_eq!(
2719 env_value(&cmd, "COPILOT_OTEL_SOURCE_NAME"),
2720 Some(std::ffi::OsStr::new("my-app")),
2721 );
2722 assert_eq!(
2723 env_value(&cmd, "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"),
2724 Some(std::ffi::OsStr::new("true")),
2725 );
2726 }
2727
2728 #[test]
2729 fn build_command_omits_otel_env_when_telemetry_none() {
2730 let opts = ClientOptions::default();
2731 let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp"));
2732 for key in [
2733 "COPILOT_OTEL_ENABLED",
2734 "OTEL_EXPORTER_OTLP_ENDPOINT",
2735 "OTEL_EXPORTER_OTLP_PROTOCOL",
2736 "COPILOT_OTEL_FILE_EXPORTER_PATH",
2737 "COPILOT_OTEL_EXPORTER_TYPE",
2738 "COPILOT_OTEL_SOURCE_NAME",
2739 "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT",
2740 ] {
2741 assert!(
2742 env_value(&cmd, key).is_none(),
2743 "expected {key} to be unset when telemetry is None",
2744 );
2745 }
2746 }
2747
2748 #[test]
2749 fn build_command_omits_unset_telemetry_fields() {
2750 let opts = ClientOptions {
2751 telemetry: Some(TelemetryConfig {
2752 otlp_endpoint: Some("http://collector:4318".to_string()),
2753 ..Default::default()
2754 }),
2755 ..Default::default()
2756 };
2757 let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp"));
2758 assert_eq!(
2760 env_value(&cmd, "COPILOT_OTEL_ENABLED"),
2761 Some(std::ffi::OsStr::new("true")),
2762 );
2763 assert_eq!(
2764 env_value(&cmd, "OTEL_EXPORTER_OTLP_ENDPOINT"),
2765 Some(std::ffi::OsStr::new("http://collector:4318")),
2766 );
2767 for key in [
2769 "OTEL_EXPORTER_OTLP_PROTOCOL",
2770 "COPILOT_OTEL_FILE_EXPORTER_PATH",
2771 "COPILOT_OTEL_EXPORTER_TYPE",
2772 "COPILOT_OTEL_SOURCE_NAME",
2773 "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT",
2774 ] {
2775 assert!(env_value(&cmd, key).is_none(), "{key} should be unset");
2776 }
2777 }
2778
2779 #[test]
2780 fn build_command_lets_user_env_override_telemetry() {
2781 let opts = ClientOptions {
2782 telemetry: Some(TelemetryConfig {
2783 otlp_endpoint: Some("http://from-config:4318".to_string()),
2784 ..Default::default()
2785 }),
2786 env: vec![(
2787 std::ffi::OsString::from("OTEL_EXPORTER_OTLP_ENDPOINT"),
2788 std::ffi::OsString::from("http://from-user-env:4318"),
2789 )],
2790 ..Default::default()
2791 };
2792 let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp"));
2793 assert_eq!(
2794 env_value(&cmd, "OTEL_EXPORTER_OTLP_ENDPOINT"),
2795 Some(std::ffi::OsStr::new("http://from-user-env:4318")),
2796 "user-supplied options.env should override telemetry config",
2797 );
2798 }
2799
2800 #[test]
2801 fn build_command_sets_copilot_home_env_when_configured() {
2802 let opts = ClientOptions::new().with_base_directory(PathBuf::from("/custom/copilot"));
2803 let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp"));
2804 assert_eq!(
2805 env_value(&cmd, "COPILOT_HOME"),
2806 Some(std::ffi::OsStr::new("/custom/copilot")),
2807 );
2808
2809 let opts = ClientOptions::default();
2810 let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp"));
2811 assert!(env_value(&cmd, "COPILOT_HOME").is_none());
2812 }
2813
2814 #[test]
2815 fn build_command_sets_connection_token_env_when_configured() {
2816 let opts = ClientOptions::new().with_transport(Transport::Tcp {
2817 port: 0,
2818 connection_token: Some("secret-token".to_string()),
2819 });
2820 let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp"));
2821 assert_eq!(
2822 env_value(&cmd, "COPILOT_CONNECTION_TOKEN"),
2823 Some(std::ffi::OsStr::new("secret-token")),
2824 );
2825
2826 let opts = ClientOptions::default();
2827 let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp"));
2828 assert!(env_value(&cmd, "COPILOT_CONNECTION_TOKEN").is_none());
2829 }
2830
2831 #[tokio::test]
2832 async fn start_rejects_empty_connection_token() {
2833 let opts = ClientOptions::new()
2834 .with_transport(Transport::Tcp {
2835 port: 0,
2836 connection_token: Some(String::new()),
2837 })
2838 .with_program(CliProgram::Path(PathBuf::from("/bin/echo")));
2839 let err = Client::start(opts).await.unwrap_err();
2840 assert!(
2841 matches!(err.kind(), ErrorKind::InvalidConfig),
2842 "got {err:?}"
2843 );
2844 }
2845
2846 #[tokio::test]
2847 async fn start_rejects_empty_external_connection_token() {
2848 let opts = ClientOptions::new()
2849 .with_transport(Transport::External {
2850 host: "127.0.0.1".to_string(),
2851 port: 1,
2852 connection_token: Some(String::new()),
2853 })
2854 .with_program(CliProgram::Path(PathBuf::from("/bin/echo")));
2855 let err = Client::start(opts).await.unwrap_err();
2856 assert!(
2857 matches!(err.kind(), ErrorKind::InvalidConfig),
2858 "got {err:?}"
2859 );
2860 }
2861
2862 #[test]
2863 fn telemetry_config_capture_content_serializes_as_lowercase_bool() {
2864 let opts_true = ClientOptions {
2865 telemetry: Some(TelemetryConfig {
2866 capture_content: Some(true),
2867 ..Default::default()
2868 }),
2869 ..Default::default()
2870 };
2871 let opts_false = ClientOptions {
2872 telemetry: Some(TelemetryConfig {
2873 capture_content: Some(false),
2874 ..Default::default()
2875 }),
2876 ..Default::default()
2877 };
2878 let cmd_true = Client::build_command(Path::new("/bin/echo"), &opts_true, Path::new("/tmp"));
2879 let cmd_false =
2880 Client::build_command(Path::new("/bin/echo"), &opts_false, Path::new("/tmp"));
2881 assert_eq!(
2882 env_value(
2883 &cmd_true,
2884 "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"
2885 ),
2886 Some(std::ffi::OsStr::new("true")),
2887 );
2888 assert_eq!(
2889 env_value(
2890 &cmd_false,
2891 "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"
2892 ),
2893 Some(std::ffi::OsStr::new("false")),
2894 );
2895 }
2896
2897 #[test]
2898 fn session_idle_timeout_args_are_omitted_by_default() {
2899 let opts = ClientOptions::default();
2900 assert!(Client::session_idle_timeout_args(&opts).is_empty());
2901 }
2902
2903 #[test]
2904 fn session_idle_timeout_args_omitted_for_zero() {
2905 let opts = ClientOptions {
2906 session_idle_timeout_seconds: Some(0),
2907 ..Default::default()
2908 };
2909 assert!(Client::session_idle_timeout_args(&opts).is_empty());
2910 }
2911
2912 #[test]
2913 fn session_idle_timeout_args_emit_flag_for_positive_value() {
2914 let opts = ClientOptions {
2915 session_idle_timeout_seconds: Some(300),
2916 ..Default::default()
2917 };
2918 assert_eq!(
2919 Client::session_idle_timeout_args(&opts),
2920 vec!["--session-idle-timeout".to_string(), "300".to_string()]
2921 );
2922 }
2923
2924 #[test]
2925 fn remote_args_omitted_by_default() {
2926 let opts = ClientOptions::default();
2927 assert!(Client::remote_args(&opts).is_empty());
2928 }
2929
2930 #[test]
2931 fn remote_args_emit_flag_when_enabled() {
2932 let opts = ClientOptions {
2933 enable_remote_sessions: true,
2934 ..Default::default()
2935 };
2936 assert_eq!(Client::remote_args(&opts), vec!["--remote".to_string()]);
2937 }
2938
2939 #[test]
2940 fn log_level_args_omitted_when_unset() {
2941 let opts = ClientOptions::default();
2942 assert!(opts.log_level.is_none());
2943 assert!(
2944 Client::log_level_args(&opts).is_empty(),
2945 "with no caller-supplied log_level the SDK must not pass --log-level"
2946 );
2947 }
2948
2949 #[test]
2950 fn log_level_args_emit_flag_when_set() {
2951 let opts = ClientOptions::default().with_log_level(LogLevel::Debug);
2952 assert_eq!(Client::log_level_args(&opts), vec!["--log-level", "debug"]);
2953 }
2954
2955 #[test]
2956 fn log_level_str_round_trips() {
2957 for level in [
2958 LogLevel::None,
2959 LogLevel::Error,
2960 LogLevel::Warning,
2961 LogLevel::Info,
2962 LogLevel::Debug,
2963 LogLevel::All,
2964 ] {
2965 let s = level.as_str();
2966 let json = serde_json::to_string(&level).unwrap();
2967 assert_eq!(json, format!("\"{s}\""));
2968 let parsed: LogLevel = serde_json::from_str(&json).unwrap();
2969 assert_eq!(parsed, level);
2970 }
2971 }
2972
2973 #[test]
2974 fn client_options_debug_redacts_handler() {
2975 struct StubHandler;
2976 #[async_trait]
2977 impl ListModelsHandler for StubHandler {
2978 async fn list_models(&self) -> Result<Vec<Model>> {
2979 Ok(vec![])
2980 }
2981 }
2982 let opts = ClientOptions {
2983 on_list_models: Some(Arc::new(StubHandler)),
2984 github_token: Some("secret-token".into()),
2985 ..Default::default()
2986 };
2987 let debug = format!("{opts:?}");
2988 assert!(debug.contains("on_list_models: Some(\"<set>\")"));
2989 assert!(debug.contains("github_token: Some(\"<redacted>\")"));
2990 assert!(!debug.contains("secret-token"));
2991 }
2992
2993 #[tokio::test]
2994 async fn list_models_uses_on_list_models_handler_when_set() {
2995 use std::sync::atomic::{AtomicUsize, Ordering};
2996
2997 struct CountingHandler {
2998 calls: Arc<AtomicUsize>,
2999 models: Vec<Model>,
3000 }
3001 #[async_trait]
3002 impl ListModelsHandler for CountingHandler {
3003 async fn list_models(&self) -> Result<Vec<Model>> {
3004 self.calls.fetch_add(1, Ordering::SeqCst);
3005 Ok(self.models.clone())
3006 }
3007 }
3008
3009 let calls = Arc::new(AtomicUsize::new(0));
3010 let model = Model {
3011 id: "byok-gpt-4".into(),
3012 name: "BYOK GPT-4".into(),
3013 ..Default::default()
3014 };
3015 let handler: Arc<dyn ListModelsHandler> = Arc::new(CountingHandler {
3016 calls: Arc::clone(&calls),
3017 models: vec![model.clone()],
3018 });
3019
3020 let client = client_with_list_models_handler(handler);
3021
3022 let result = client.list_models().await.unwrap();
3023 assert_eq!(result.len(), 1);
3024 assert_eq!(result[0].id, "byok-gpt-4");
3025 assert_eq!(calls.load(Ordering::SeqCst), 1);
3026 }
3027
3028 #[tokio::test]
3029 async fn list_models_serializes_concurrent_cache_misses() {
3030 use std::sync::atomic::{AtomicUsize, Ordering};
3031
3032 struct SlowCountingHandler {
3033 calls: Arc<AtomicUsize>,
3034 models: Vec<Model>,
3035 }
3036 #[async_trait]
3037 impl ListModelsHandler for SlowCountingHandler {
3038 async fn list_models(&self) -> Result<Vec<Model>> {
3039 self.calls.fetch_add(1, Ordering::SeqCst);
3040 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
3041 Ok(self.models.clone())
3042 }
3043 }
3044
3045 let calls = Arc::new(AtomicUsize::new(0));
3046 let model = Model {
3047 id: "single-flight-model".into(),
3048 name: "Single Flight Model".into(),
3049 ..Default::default()
3050 };
3051 let handler: Arc<dyn ListModelsHandler> = Arc::new(SlowCountingHandler {
3052 calls: Arc::clone(&calls),
3053 models: vec![model],
3054 });
3055 let client = client_with_list_models_handler(handler);
3056
3057 let (first, second) = tokio::join!(client.list_models(), client.list_models());
3058 assert_eq!(first.unwrap()[0].id, "single-flight-model");
3059 assert_eq!(second.unwrap()[0].id, "single-flight-model");
3060 assert_eq!(calls.load(Ordering::SeqCst), 1);
3061 }
3062
3063 #[tokio::test]
3064 async fn cancelled_resume_session_unregisters_pending_session() {
3065 let (client_write, _server_read) = tokio::io::duplex(8192);
3066 let (_server_write, client_read) = tokio::io::duplex(8192);
3067 let client = Client::from_streams(client_read, client_write, std::env::temp_dir()).unwrap();
3068 let session_id = SessionId::new("resume-cancel-test");
3069 let handle = tokio::spawn({
3070 let client = client.clone();
3071 async move {
3072 client
3073 .resume_session(ResumeSessionConfig::new(session_id))
3074 .await
3075 }
3076 });
3077
3078 wait_for_pending_session_registration(&client).await;
3079 handle.abort();
3080 let _ = handle.await;
3081
3082 assert!(client.inner.router.session_ids().is_empty());
3083 client.force_stop();
3084 }
3085
3086 fn client_with_list_models_handler(handler: Arc<dyn ListModelsHandler>) -> Client {
3087 Client {
3088 inner: Arc::new(ClientInner {
3089 child: parking_lot::Mutex::new(None),
3090 #[cfg(feature = "bundled-in-process")]
3091 ffi_host: parking_lot::Mutex::new(None),
3092 rpc: {
3093 let (req_tx, _req_rx) = mpsc::unbounded_channel();
3094 let (notif_tx, _notif_rx) = broadcast::channel(16);
3095 let (read_pipe, _write_pipe) = tokio::io::duplex(64);
3096 let (_unused_read, write_pipe) = tokio::io::duplex(64);
3097 JsonRpcClient::new(write_pipe, read_pipe, notif_tx, req_tx)
3098 },
3099 cwd: PathBuf::from("."),
3100 request_rx: parking_lot::Mutex::new(None),
3101 notification_tx: broadcast::channel(16).0,
3102 router: router::SessionRouter::new(),
3103 negotiated_protocol_version: OnceLock::new(),
3104 state: parking_lot::Mutex::new(ConnectionState::Connected),
3105 lifecycle_tx: broadcast::channel(16).0,
3106 on_list_models: Some(handler),
3107 models_cache: parking_lot::Mutex::new(Arc::new(tokio::sync::OnceCell::new())),
3108 session_fs_configured: false,
3109 session_fs_sqlite_declared: false,
3110 llm_inference: OnceLock::new(),
3111 on_github_telemetry: None,
3112 on_get_trace_context: None,
3113 effective_connection_token: None,
3114 mode: ClientMode::default(),
3115 }),
3116 }
3117 }
3118
3119 async fn wait_for_pending_session_registration(client: &Client) {
3120 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
3121 while client.inner.router.session_ids().is_empty() {
3122 assert!(
3123 tokio::time::Instant::now() < deadline,
3124 "session was not registered"
3125 );
3126 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
3127 }
3128 }
3129}