-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Expand file tree
/
Copy pathlib.rs
More file actions
2558 lines (2398 loc) · 98.5 KB
/
lib.rs
File metadata and controls
2558 lines (2398 loc) · 98.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#![doc = include_str!("../README.md")]
#![warn(missing_docs)]
#![deny(rustdoc::broken_intra_doc_links)]
#![cfg_attr(test, allow(clippy::unwrap_used))]
/// Canvas declarations, provider callbacks, and host-side canvas RPC types.
pub mod canvas;
mod canvas_dispatch;
/// Bundled CLI binary extraction and caching.
#[cfg(feature = "bundled-cli")]
pub(crate) mod embeddedcli;
mod errors;
pub use errors::*;
/// Event handler traits for session lifecycle.
pub mod handler;
/// Lifecycle hook callbacks (pre/post tool use, prompt submission, session start/end).
pub mod hooks;
mod jsonrpc;
/// Permission-policy helpers that produce a [`handler::PermissionHandler`].
pub mod permission;
/// GitHub Copilot CLI binary resolution (env var, embedded, dev cache).
pub(crate) mod resolve;
mod router;
/// Session management — create, resume, send messages, and interact with the agent.
pub mod session;
/// Custom session filesystem provider (virtualizable filesystem layer).
pub mod session_fs;
mod session_fs_dispatch;
/// Event subscription handles returned by `subscribe()` methods.
pub mod subscription;
/// Typed tool definition framework and dispatch router.
pub mod tool;
/// W3C Trace Context propagation for distributed tracing.
pub mod trace_context;
/// System message transform callbacks for customizing agent prompts.
pub mod transforms;
/// Protocol types shared between the SDK and the GitHub Copilot CLI.
pub mod types;
mod wire;
/// Auto-generated protocol types from Copilot JSON Schemas.
pub mod generated;
/// Client-level mode ([`ClientMode`]) and the [`ToolSet`] builder for
/// source-qualified tool filter patterns.
pub mod mode;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, OnceLock};
use std::time::Instant;
use async_trait::async_trait;
// JSON-RPC wire types are internal transport details (like Go SDK's internal/jsonrpc2/).
// External callers interact via Client/Session methods, not raw RPC.
pub(crate) use jsonrpc::{
JsonRpcClient, JsonRpcError, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, error_codes,
};
pub use mode::{BUILTIN_TOOLS_ISOLATED, ClientMode, ToolSet};
/// Re-exported JSON-RPC internals for integration tests (requires `test-support` feature).
#[cfg(feature = "test-support")]
pub mod test_support {
pub use crate::jsonrpc::{
JsonRpcClient, JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse,
error_codes,
};
}
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, BufReader};
use tokio::net::TcpStream;
use tokio::process::{Child, Command};
use tokio::sync::{broadcast, mpsc, oneshot};
use tracing::{Instrument, debug, error, info, warn};
pub use types::*;
mod sdk_protocol_version;
pub use sdk_protocol_version::{SDK_PROTOCOL_VERSION, get_sdk_protocol_version};
pub use subscription::{EventSubscription, LifecycleSubscription};
/// Minimum protocol version this SDK can communicate with.
const MIN_PROTOCOL_VERSION: u32 = 3;
/// How the SDK communicates with the CLI server.
#[derive(Debug, Default)]
#[non_exhaustive]
pub enum Transport {
/// Communicate over stdin/stdout pipes (default).
#[default]
Stdio,
/// Spawn the CLI with `--port` and connect via TCP.
Tcp {
/// Port to listen on (0 for OS-assigned).
port: u16,
/// Optional connection token. When `None` and the SDK is spawning
/// the CLI, the SDK auto-generates a 128-bit hex token so the
/// loopback listener is safe by default.
connection_token: Option<String>,
},
/// Connect to an already-running CLI server (no process spawning).
External {
/// Hostname or IP of the running server.
host: String,
/// Port of the running server.
port: u16,
/// Optional connection token. Required when the external server
/// was started with a token, ignored otherwise.
connection_token: Option<String>,
},
}
/// How the SDK locates the GitHub Copilot CLI binary.
#[derive(Debug, Clone, Default)]
pub enum CliProgram {
/// Auto-resolve: `COPILOT_CLI_PATH` → embedded CLI → dev cache.
/// This is the default.
#[default]
Resolve,
/// Use an explicit binary path (skips resolution).
Path(PathBuf),
}
impl From<PathBuf> for CliProgram {
fn from(path: PathBuf) -> Self {
Self::Path(path)
}
}
/// `true` when this build of the SDK has the Copilot CLI embedded in
/// its binary — i.e. the `bundled-cli` cargo feature is on **and** the
/// target platform is one for which `build.rs` shipped an archive.
///
/// Useful for branching on bundling presence without forcing the lazy
/// extraction triggered by [`install_bundled_cli`].
pub const HAS_BUNDLED_CLI: bool = cfg!(has_bundled_cli);
/// Returns the path to the bundled Copilot CLI, extracting it from the
/// embedded archive on first call.
///
/// This is the same path [`Client::start`] resolves to when
/// [`ClientOptions::program`] is [`CliProgram::Resolve`], no
/// `COPILOT_CLI_PATH` override is set, and no
/// [`ClientOptions::bundled_cli_extract_dir`] is configured — exposing
/// it directly so callers (health checks, diagnostics, version probes)
/// can reach the bundled binary without spinning up a full [`Client`].
///
/// Subsequent calls return the cached result. Extraction is skipped
/// when the target file already exists.
///
/// Returns `None` when the `bundled-cli` feature is off, the target
/// platform isn't supported by `build.rs`, or extraction failed (the
/// failure is logged via `tracing::warn!`). When `None` is returned for
/// the "feature off" reason, [`HAS_BUNDLED_CLI`] is also `false`.
///
/// This deliberately does not fall back to the build-time-extracted
/// dev-cache path used when `bundled-cli` is off — callers that want
/// that resolution should continue to use [`CliProgram::Resolve`].
pub fn install_bundled_cli() -> Option<PathBuf> {
#[cfg(feature = "bundled-cli")]
{
embeddedcli::path()
}
#[cfg(not(feature = "bundled-cli"))]
{
None
}
}
/// Options for starting a [`Client`].
///
/// When `program` is [`CliProgram::Resolve`] (the default), [`Client::start`]
/// uses `COPILOT_CLI_PATH` when set to a real file. Otherwise it uses the
/// bundled Copilot CLI when the default `bundled-cli` cargo feature is enabled,
/// or the build-time extracted dev-cache CLI when that feature is disabled.
///
/// Set `program` to [`CliProgram::Path`] to use an explicit binary instead.
/// This skips auto-resolution entirely.
#[non_exhaustive]
pub struct ClientOptions {
/// How to locate the CLI binary.
pub program: CliProgram,
/// Arguments prepended before `--server` (e.g. the script path for node).
pub prefix_args: Vec<OsString>,
/// Working directory for the CLI process.
pub working_directory: PathBuf,
/// Environment variables set on the child process.
pub env: Vec<(OsString, OsString)>,
/// Environment variable names to remove from the child process.
pub env_remove: Vec<OsString>,
/// Extra CLI flags appended after the transport-specific arguments.
pub extra_args: Vec<String>,
/// Transport mode used to communicate with the CLI server.
pub transport: Transport,
/// GitHub token for authentication. When set, the SDK passes the token
/// to the CLI via `--auth-token-env COPILOT_SDK_AUTH_TOKEN` and exports
/// the token in that env var. When set, the CLI defaults to *not*
/// using the logged-in user (override with [`Self::use_logged_in_user`]).
pub github_token: Option<String>,
/// Whether the CLI should fall back to the logged-in `gh` user when no
/// token is provided. `None` means use the runtime default (true unless
/// [`Self::github_token`] is set, in which case false).
pub use_logged_in_user: Option<bool>,
/// Log level passed to the CLI server via `--log-level`. When `None`,
/// the SDK does not pass `--log-level` to the runtime at all and the
/// CLI uses its built-in default.
pub log_level: Option<LogLevel>,
/// Server-wide idle timeout for sessions, in seconds. When set to a
/// positive value, the SDK passes `--session-idle-timeout <secs>` to
/// the CLI; sessions without activity for this duration are
/// automatically cleaned up. `None` or `Some(0)` leaves sessions
/// running indefinitely (the CLI default).
pub session_idle_timeout_seconds: Option<u64>,
/// Optional override for [`Client::list_models`].
///
/// When set, [`Client::list_models`] returns the handler's result
/// without making a `models.list` RPC. This is the BYOK escape hatch
/// for environments where the model catalog is provisioned separately
/// from the GitHub Copilot CLI (e.g. external inference servers selected via
/// [`Transport::External`]).
pub on_list_models: Option<Arc<dyn ListModelsHandler>>,
/// Custom session filesystem provider configuration.
///
/// When set, the SDK calls `sessionFs.setProvider` during
/// [`Client::start`] to register a virtualizable filesystem layer with
/// the CLI. Each session created on this client must supply its own
/// [`SessionFsProvider`] via
/// [`SessionConfig::with_session_fs_provider`](crate::SessionConfig::with_session_fs_provider).
pub session_fs: Option<SessionFsConfig>,
/// Optional [`TraceContextProvider`] used to inject W3C Trace Context
/// headers (`traceparent` / `tracestate`) on outbound `session.create`,
/// `session.resume`, and `session.send` requests.
///
/// When [`MessageOptions`] carries a per-turn override (set via
/// [`MessageOptions::with_trace_context`](crate::types::MessageOptions::with_trace_context)
/// or the underlying fields), it takes precedence over this provider.
///
/// [`MessageOptions`]: crate::types::MessageOptions
pub on_get_trace_context: Option<Arc<dyn TraceContextProvider>>,
/// OpenTelemetry config forwarded to the spawned CLI process. See
/// [`TelemetryConfig`] for the env-var mapping. The SDK takes no
/// OpenTelemetry dependency — this is pure spawn-time env injection.
pub telemetry: Option<TelemetryConfig>,
/// Override the directory where the CLI persists its state (sessions,
/// auth, telemetry buffers). When set, exported as `COPILOT_HOME` to
/// the spawned CLI process. Useful for sandboxing test runs or
/// running multiple isolated SDK instances side-by-side.
pub base_directory: Option<PathBuf>,
/// Enable remote session support (Mission Control integration).
/// When `true`, the SDK passes `--remote` to the spawned CLI process so
/// sessions in a GitHub repository working directory are accessible from
/// GitHub web and mobile. Ignored when connecting to an external server
/// via [`Transport::External`].
pub enable_remote_sessions: bool,
/// Override the directory where the bundled CLI binary is extracted on
/// first use.
///
/// When `None` (the default), the SDK extracts the embedded CLI to
/// `<platform cache dir>/github-copilot-sdk/cli/<version>/copilot[.exe]`,
/// where the cache dir is [`dirs::cache_dir()`] —
/// `%LOCALAPPDATA%` on Windows, `~/Library/Caches/` on macOS,
/// `$XDG_CACHE_HOME` (or `~/.cache/`) on Linux. Use this knob to
/// redirect the extraction (e.g. to a session-scoped temp directory in
/// CI runners) without changing the global cache layout.
///
/// Only applies when the `bundled-cli` cargo feature is on (the
/// default). With `bundled-cli` disabled (`default-features = false`)
/// there is no archive to re-extract at runtime — the binary lives
/// at a build-time-known conventional path. To relocate that
/// extraction, set `COPILOT_CLI_EXTRACT_DIR` (honored symmetrically
/// at build and runtime); to point the runtime at a different
/// binary altogether, use [`CliProgram::Path`] or `COPILOT_CLI_PATH`.
pub bundled_cli_extract_dir: Option<PathBuf>,
/// SDK-level mode controlling whether sessions get CLI-style defaults
/// (the default) or are stripped to a minimal/safe baseline. See
/// [`ClientMode`] for the contract and trade-offs.
pub mode: ClientMode,
}
impl std::fmt::Debug for ClientOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClientOptions")
.field("program", &self.program)
.field("prefix_args", &self.prefix_args)
.field("working_directory", &self.working_directory)
.field("env", &self.env)
.field("env_remove", &self.env_remove)
.field("extra_args", &self.extra_args)
.field("transport", &self.transport)
.field(
"github_token",
&self.github_token.as_ref().map(|_| "<redacted>"),
)
.field("use_logged_in_user", &self.use_logged_in_user)
.field("log_level", &self.log_level)
.field(
"session_idle_timeout_seconds",
&self.session_idle_timeout_seconds,
)
.field(
"on_list_models",
&self.on_list_models.as_ref().map(|_| "<set>"),
)
.field("session_fs", &self.session_fs)
.field(
"on_get_trace_context",
&self.on_get_trace_context.as_ref().map(|_| "<set>"),
)
.field("telemetry", &self.telemetry)
.field("base_directory", &self.base_directory)
.field("enable_remote_sessions", &self.enable_remote_sessions)
.field("bundled_cli_extract_dir", &self.bundled_cli_extract_dir)
.finish()
}
}
/// Custom handler for [`Client::list_models`].
///
/// Implementations override the default `models.list` RPC, returning a
/// caller-supplied catalog of models. Set via [`ClientOptions::on_list_models`].
///
/// Implementations must be `Send + Sync` because [`Client`] is shared across
/// tasks. Errors returned by [`list_models`](Self::list_models) are propagated
/// from [`Client::list_models`] unchanged.
#[async_trait]
pub trait ListModelsHandler: Send + Sync + 'static {
/// Return the list of available models.
async fn list_models(&self) -> Result<Vec<Model>>;
}
/// Log verbosity for the CLI server (passed via `--log-level`).
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LogLevel {
/// Suppress all CLI logs.
None,
/// Errors only.
Error,
/// Warnings and errors.
Warning,
/// Info and above.
Info,
/// Debug, info, warnings, errors.
Debug,
/// Everything, including trace output.
All,
}
impl LogLevel {
/// CLI argument value (e.g. `"info"`, `"debug"`).
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Error => "error",
Self::Warning => "warning",
Self::Info => "info",
Self::Debug => "debug",
Self::All => "all",
}
}
}
impl std::fmt::Display for LogLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// Backend exporter for the CLI's OpenTelemetry pipeline.
///
/// Maps to the `COPILOT_OTEL_EXPORTER_TYPE` environment variable on the
/// spawned CLI process. Wire values are `"otlp-http"` and `"file"`.
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum OtelExporterType {
/// Export via OTLP HTTP to the endpoint configured by
/// [`TelemetryConfig::otlp_endpoint`].
OtlpHttp,
/// Export to a JSON-lines file at the path configured by
/// [`TelemetryConfig::file_path`].
File,
}
impl OtelExporterType {
/// Environment-variable value (`"otlp-http"` or `"file"`).
pub fn as_str(self) -> &'static str {
match self {
Self::OtlpHttp => "otlp-http",
Self::File => "file",
}
}
}
/// OpenTelemetry configuration forwarded to the spawned GitHub Copilot CLI
/// process.
///
/// When [`ClientOptions::telemetry`] is `Some(...)`, the SDK sets
/// `COPILOT_OTEL_ENABLED=true` plus any populated fields below as the
/// corresponding `OTEL_*` / `COPILOT_OTEL_*` environment variables. The
/// CLI's built-in OpenTelemetry exporter consumes these at startup. The
/// SDK itself takes no OpenTelemetry dependency.
///
/// Environment-variable mapping:
///
/// | Field | Variable |
/// |----------------------|-------------------------------------------------------|
/// | (any field set) | `COPILOT_OTEL_ENABLED=true` |
/// | [`otlp_endpoint`] | `OTEL_EXPORTER_OTLP_ENDPOINT` |
/// | [`file_path`] | `COPILOT_OTEL_FILE_EXPORTER_PATH` |
/// | [`exporter_type`] | `COPILOT_OTEL_EXPORTER_TYPE` |
/// | [`source_name`] | `COPILOT_OTEL_SOURCE_NAME` |
/// | [`capture_content`] | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` |
///
/// Caller-supplied entries in [`ClientOptions::env`] override these, so a
/// developer can pin any individual variable to a different value while
/// keeping the rest of the config managed by [`TelemetryConfig`].
///
/// Marked `#[non_exhaustive]` so future CLI-side telemetry knobs can be
/// added without breaking callers.
///
/// [`otlp_endpoint`]: Self::otlp_endpoint
/// [`file_path`]: Self::file_path
/// [`exporter_type`]: Self::exporter_type
/// [`source_name`]: Self::source_name
/// [`capture_content`]: Self::capture_content
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct TelemetryConfig {
/// OTLP HTTP endpoint URL for trace/metric export.
pub otlp_endpoint: Option<String>,
/// File path for JSON-lines trace output.
pub file_path: Option<PathBuf>,
/// Exporter backend type. Typically [`OtelExporterType::OtlpHttp`] or
/// [`OtelExporterType::File`].
pub exporter_type: Option<OtelExporterType>,
/// Instrumentation scope name. Useful for distinguishing this
/// embedder's traces from other Copilot-CLI consumers exporting to the
/// same backend.
pub source_name: Option<String>,
/// Whether the CLI captures GenAI message content (prompts and
/// responses) on emitted spans. `Some(true)` opts in; `Some(false)`
/// opts out; `None` leaves the CLI default (typically off).
pub capture_content: Option<bool>,
}
impl TelemetryConfig {
/// Construct an empty [`TelemetryConfig`]; all fields default to
/// unset (`is_empty()` returns `true`).
pub fn new() -> Self {
Self::default()
}
/// Set the OTLP HTTP endpoint URL for trace/metric export.
pub fn with_otlp_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.otlp_endpoint = Some(endpoint.into());
self
}
/// Set the file path for JSON-lines trace output.
pub fn with_file_path(mut self, path: impl Into<PathBuf>) -> Self {
self.file_path = Some(path.into());
self
}
/// Set the exporter backend type.
pub fn with_exporter_type(mut self, exporter_type: OtelExporterType) -> Self {
self.exporter_type = Some(exporter_type);
self
}
/// Set the instrumentation scope name. Useful for distinguishing
/// this embedder's traces from other Copilot-CLI consumers
/// exporting to the same backend.
pub fn with_source_name(mut self, source_name: impl Into<String>) -> Self {
self.source_name = Some(source_name.into());
self
}
/// Opt in or out of GenAI message content capture on emitted spans.
/// `true` opts in; `false` opts out. Leaving this unset preserves
/// the CLI default (typically off).
pub fn with_capture_content(mut self, capture: bool) -> Self {
self.capture_content = Some(capture);
self
}
/// Returns `true` if all fields are unset. Used by [`Client::start`]
/// to decide whether to set `COPILOT_OTEL_ENABLED`.
pub fn is_empty(&self) -> bool {
self.otlp_endpoint.is_none()
&& self.file_path.is_none()
&& self.exporter_type.is_none()
&& self.source_name.is_none()
&& self.capture_content.is_none()
}
}
impl Default for ClientOptions {
fn default() -> Self {
Self {
program: CliProgram::Resolve,
prefix_args: Vec::new(),
working_directory: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
env: Vec::new(),
env_remove: Vec::new(),
extra_args: Vec::new(),
transport: Transport::default(),
github_token: None,
use_logged_in_user: None,
log_level: None,
session_idle_timeout_seconds: None,
on_list_models: None,
session_fs: None,
on_get_trace_context: None,
telemetry: None,
base_directory: None,
enable_remote_sessions: false,
bundled_cli_extract_dir: None,
mode: ClientMode::default(),
}
}
}
impl ClientOptions {
/// Construct a new [`ClientOptions`] with default values.
///
/// Equivalent to [`ClientOptions::default`]; provided as a documented
/// construction entry point for the builder chain. The struct is
/// `#[non_exhaustive]`, so external callers cannot use struct-literal
/// syntax — use this builder or [`Default::default`] plus mut-let.
///
/// # Example
///
/// ```
/// # use github_copilot_sdk::{ClientOptions, LogLevel};
/// let opts = ClientOptions::new()
/// .with_log_level(LogLevel::Debug)
/// .with_github_token("ghp_…");
/// ```
pub fn new() -> Self {
Self::default()
}
/// How to locate the CLI binary. See [`CliProgram`].
pub fn with_program(mut self, program: impl Into<CliProgram>) -> Self {
self.program = program.into();
self
}
/// Arguments prepended before `--server` (e.g. the script path for node).
pub fn with_prefix_args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
self.prefix_args = args.into_iter().map(Into::into).collect();
self
}
/// Working directory for the CLI process.
pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
self.working_directory = cwd.into();
self
}
/// Environment variables to set on the child process.
pub fn with_env<I, K, V>(mut self, env: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<OsString>,
V: Into<OsString>,
{
self.env = env.into_iter().map(|(k, v)| (k.into(), v.into())).collect();
self
}
/// Environment variable names to remove from the child process.
pub fn with_env_remove<I, S>(mut self, names: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
self.env_remove = names.into_iter().map(Into::into).collect();
self
}
/// Extra CLI flags appended after the transport-specific arguments.
pub fn with_extra_args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.extra_args = args.into_iter().map(Into::into).collect();
self
}
/// Transport mode used to communicate with the CLI server. See [`Transport`].
pub fn with_transport(mut self, transport: Transport) -> Self {
self.transport = transport;
self
}
/// GitHub token for authentication. The SDK passes the token to the
/// CLI via `--auth-token-env COPILOT_SDK_AUTH_TOKEN`.
pub fn with_github_token(mut self, token: impl Into<String>) -> Self {
self.github_token = Some(token.into());
self
}
/// Whether the CLI should fall back to the logged-in `gh` user when
/// no token is provided. See the field docs for default semantics.
pub fn with_use_logged_in_user(mut self, use_logged_in: bool) -> Self {
self.use_logged_in_user = Some(use_logged_in);
self
}
/// Log level passed to the CLI server via `--log-level`.
pub fn with_log_level(mut self, level: LogLevel) -> Self {
self.log_level = Some(level);
self
}
/// Server-wide idle timeout for sessions (seconds). Pass `0` to leave
/// sessions running indefinitely (the CLI default).
pub fn with_session_idle_timeout_seconds(mut self, seconds: u64) -> Self {
self.session_idle_timeout_seconds = Some(seconds);
self
}
/// Override [`Client::list_models`] with a caller-supplied handler.
/// The handler is wrapped in `Arc` internally.
pub fn with_list_models_handler<H>(mut self, handler: H) -> Self
where
H: ListModelsHandler + 'static,
{
self.on_list_models = Some(Arc::new(handler));
self
}
/// Custom session filesystem provider configuration.
pub fn with_session_fs(mut self, config: SessionFsConfig) -> Self {
self.session_fs = Some(config);
self
}
/// Set the [`TraceContextProvider`] used to inject W3C Trace Context
/// headers on outbound `session.create` / `session.resume` /
/// `session.send` requests. The provider is wrapped in `Arc` internally.
pub fn with_trace_context_provider<P>(mut self, provider: P) -> Self
where
P: TraceContextProvider + 'static,
{
self.on_get_trace_context = Some(Arc::new(provider));
self
}
/// OpenTelemetry config forwarded to the spawned CLI process.
pub fn with_telemetry(mut self, config: TelemetryConfig) -> Self {
self.telemetry = Some(config);
self
}
/// Override the directory where the CLI persists its state. Set as
/// `COPILOT_HOME` on the spawned CLI process.
pub fn with_base_directory(mut self, dir: impl Into<PathBuf>) -> Self {
self.base_directory = Some(dir.into());
self
}
/// Enable remote session support (Mission Control). Passes `--remote`
/// to the spawned CLI process.
pub fn with_enable_remote_sessions(mut self, enabled: bool) -> Self {
self.enable_remote_sessions = enabled;
self
}
/// Override the directory where the bundled CLI binary is extracted on
/// first use. See [`Self::bundled_cli_extract_dir`].
///
/// Only applies when the `bundled-cli` cargo feature is on. With
/// `bundled-cli` disabled (`default-features = false`), set
/// `COPILOT_CLI_EXTRACT_DIR` to relocate the build-time extraction
/// (honored symmetrically at build and runtime), or use
/// [`CliProgram::Path`] / `COPILOT_CLI_PATH` to point at a different
/// binary at runtime.
pub fn with_bundled_cli_extract_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.bundled_cli_extract_dir = Some(dir.into());
self
}
/// Set the SDK [`ClientMode`]. Use [`ClientMode::Empty`] for any
/// scenario where CLI-like ambient behavior is unsafe (e.g. multi-user
/// servers). Empty mode additionally requires [`Self::base_directory`]
/// or [`Self::session_fs`] to be set, validated at [`Client::start`].
pub fn with_mode(mut self, mode: ClientMode) -> Self {
self.mode = mode;
self
}
}
/// Validate a [`SessionFsConfig`] before sending `sessionFs.setProvider`.
fn validate_session_fs_config(cfg: &SessionFsConfig) -> Result<()> {
if cfg.initial_cwd.trim().is_empty() {
return Err(Error::with_message(
ErrorKind::Session(SessionErrorKind::InvalidSessionFsConfig),
"invalid SessionFsConfig: initial_cwd must not be empty",
));
}
if cfg.session_state_path.trim().is_empty() {
return Err(Error::with_message(
ErrorKind::Session(SessionErrorKind::InvalidSessionFsConfig),
"invalid SessionFsConfig: session_state_path must not be empty",
));
}
Ok(())
}
/// Generate a fresh CSPRNG-backed token for authenticating an SDK-spawned
/// loopback CLI server. 128 bits of entropy, lowercase-hex encoded — not
/// a UUID (the schema-shaped IDs in this crate stay `String` per the
/// pre-1.0 review consensus, so adopting a `Uuid` type just for SDK-
/// generated secrets would be inconsistent and semantically misleading;
/// this is opaque random data, not an identifier).
fn generate_connection_token() -> String {
let mut bytes = [0u8; 16];
getrandom::getrandom(&mut bytes)
.expect("OS CSPRNG (getrandom) is unavailable; cannot generate connection token");
let mut hex = String::with_capacity(32);
for byte in bytes {
use std::fmt::Write;
let _ = write!(hex, "{byte:02x}");
}
hex
}
/// Connection to a GitHub Copilot CLI server (stdio, TCP, or external).
///
/// Cheaply cloneable — cloning shares the underlying connection.
/// The child process (if any) is killed when the last clone drops.
#[derive(Clone)]
pub struct Client {
inner: Arc<ClientInner>,
}
impl std::fmt::Debug for Client {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Client")
.field("working_directory", &self.inner.cwd)
.field("pid", &self.pid())
.finish()
}
}
struct ClientInner {
child: parking_lot::Mutex<Option<Child>>,
rpc: JsonRpcClient,
cwd: PathBuf,
request_rx: parking_lot::Mutex<Option<mpsc::UnboundedReceiver<JsonRpcRequest>>>,
notification_tx: broadcast::Sender<JsonRpcNotification>,
router: router::SessionRouter,
negotiated_protocol_version: OnceLock<u32>,
state: parking_lot::Mutex<ConnectionState>,
lifecycle_tx: broadcast::Sender<SessionLifecycleEvent>,
on_list_models: Option<Arc<dyn ListModelsHandler>>,
models_cache: parking_lot::Mutex<Arc<tokio::sync::OnceCell<Vec<Model>>>>,
session_fs_configured: bool,
session_fs_sqlite_declared: bool,
on_get_trace_context: Option<Arc<dyn TraceContextProvider>>,
/// Token sent in the `connect` handshake. Auto-generated when the
/// SDK spawns its own CLI in TCP mode and no explicit token is set;
/// `None` for stdio and for external-server transport without an
/// explicit token.
effective_connection_token: Option<String>,
/// SDK [`ClientMode`] captured at start time. Drives empty-mode safe
/// defaults inside `create_session` / `resume_session`.
pub(crate) mode: ClientMode,
}
impl Client {
/// Start a CLI server process with the given options.
///
/// For [`Transport::Stdio`], spawns the CLI with `--stdio` and communicates
/// over stdin/stdout pipes. For [`Transport::Tcp`], spawns with `--port`
/// and connects via TCP once the server reports it is listening. For
/// [`Transport::External`], connects to an already-running server.
///
/// After establishing the connection, calls [`verify_protocol_version`](Self::verify_protocol_version)
/// to ensure the CLI server speaks a compatible protocol version.
/// When [`ClientOptions::session_fs`] is set, also calls
/// `sessionFs.setProvider` to register the SDK as the filesystem
/// backend.
pub async fn start(options: ClientOptions) -> Result<Self> {
let start_time = Instant::now();
if options.mode == ClientMode::Empty
&& options.base_directory.is_none()
&& options.session_fs.is_none()
{
return Err(Error::with_message(
ErrorKind::InvalidConfig,
"ClientMode::Empty requires either `base_directory` or \
`session_fs` to be set (no implicit ~/.copilot fallback).",
));
}
if let Some(cfg) = &options.session_fs {
validate_session_fs_config(cfg)?;
}
// Auth options only make sense when the SDK spawns the CLI; with an
// external server, the server manages its own auth.
if matches!(options.transport, Transport::External { .. }) {
if options.github_token.is_some() {
return Err(Error::with_message(
ErrorKind::InvalidConfig,
"invalid client configuration: github_token cannot be used with \
Transport::External (external server manages its own auth)",
));
}
if options.use_logged_in_user == Some(true) {
return Err(Error::with_message(
ErrorKind::InvalidConfig,
"invalid client configuration: use_logged_in_user cannot be used with \
Transport::External (external server manages its own auth)",
));
}
}
// Validate token shape. Stdio variants no longer carry a token
// (enforced by the type). For Tcp/External, empty-string is
// rejected eagerly.
match &options.transport {
Transport::Tcp {
connection_token: Some(t),
..
}
| Transport::External {
connection_token: Some(t),
..
} if t.is_empty() => {
return Err(Error::with_message(
ErrorKind::InvalidConfig,
"invalid client configuration: connection_token must be a non-empty string",
));
}
_ => {}
}
// Capture (and where needed, auto-generate) the token actually sent
// to the server. For Tcp, the SDK auto-generates one when the
// caller leaves it unset so the loopback listener is safe by
// default.
let mut options = options;
let effective_connection_token: Option<String> = match &mut options.transport {
Transport::Stdio => None,
Transport::Tcp {
connection_token, ..
} => Some(
connection_token
.get_or_insert_with(generate_connection_token)
.clone(),
),
Transport::External {
connection_token, ..
} => connection_token.clone(),
};
let session_fs_config = options.session_fs.clone();
let session_fs_sqlite_declared = session_fs_config
.as_ref()
.and_then(|c| c.capabilities.as_ref())
.is_some_and(|caps| caps.sqlite);
let program = match &options.program {
CliProgram::Path(path) => {
info!(path = %path.display(), "using explicit copilot CLI path");
path.clone()
}
CliProgram::Resolve => {
let resolved = resolve::copilot_binary_with_extract_dir(
options.bundled_cli_extract_dir.as_deref(),
)?;
info!(path = %resolved.display(), "resolved copilot CLI");
#[cfg(windows)]
{
if let Some(ext) = resolved.extension().and_then(|e| e.to_str()).filter(|ext| {
ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat")
}) {
warn!(
path = %resolved.display(),
ext = %ext,
"resolved copilot CLI is a .cmd/.bat wrapper; \
this may cause console window flashes on Windows"
);
}
}
resolved
}
};
let client = match options.transport {
Transport::External {
ref host,
port,
connection_token: _,
} => {
info!(host = %host, port = %port, "connecting to external CLI server");
let connect_start = Instant::now();
let stream = TcpStream::connect((host.as_str(), port)).await?;
debug!(
elapsed_ms = connect_start.elapsed().as_millis(),
host = %host,
port,
"Client::start TCP connect complete"
);
let (reader, writer) = tokio::io::split(stream);
Self::from_transport(
reader,
writer,
None,
options.working_directory,
options.on_list_models,
session_fs_config.is_some(),
session_fs_sqlite_declared,
options.on_get_trace_context,
effective_connection_token.clone(),
options.mode,
)?
}
Transport::Tcp {
port,
connection_token: _,
} => {
let (mut child, actual_port) = Self::spawn_tcp(&program, &options, port).await?;
let connect_start = Instant::now();
let stream = TcpStream::connect(("127.0.0.1", actual_port)).await?;
debug!(
elapsed_ms = connect_start.elapsed().as_millis(),
port = actual_port,
"Client::start TCP connect complete"
);
let (reader, writer) = tokio::io::split(stream);
Self::drain_stderr(&mut child);
Self::from_transport(
reader,
writer,
Some(child),
options.working_directory,
options.on_list_models,
session_fs_config.is_some(),
session_fs_sqlite_declared,
options.on_get_trace_context,
effective_connection_token.clone(),
options.mode,
)?
}
Transport::Stdio => {
let mut child = Self::spawn_stdio(&program, &options)?;
let stdin = child.stdin.take().expect("stdin is piped");
let stdout = child.stdout.take().expect("stdout is piped");
Self::drain_stderr(&mut child);
Self::from_transport(
stdout,
stdin,
Some(child),
options.working_directory,
options.on_list_models,
session_fs_config.is_some(),
session_fs_sqlite_declared,
options.on_get_trace_context,
effective_connection_token.clone(),
options.mode,
)?
}
};
debug!(
elapsed_ms = start_time.elapsed().as_millis(),
"Client::start transport setup complete"
);
client.verify_protocol_version().await?;
debug!(
elapsed_ms = start_time.elapsed().as_millis(),
"Client::start protocol verification complete"
);
if let Some(cfg) = session_fs_config {
let session_fs_start = Instant::now();
let capabilities = cfg.capabilities.as_ref().map(|c| {
crate::generated::api_types::SessionFsSetProviderCapabilities {
sqlite: Some(c.sqlite),
}
});
let request = crate::generated::api_types::SessionFsSetProviderRequest {
capabilities,
conventions: cfg.conventions.into_wire(),
initial_cwd: cfg.initial_cwd,
session_state_path: cfg.session_state_path,
};
client.rpc().session_fs().set_provider(request).await?;
debug!(
elapsed_ms = session_fs_start.elapsed().as_millis(),
"Client::start session filesystem setup complete"
);
}
debug!(
elapsed_ms = start_time.elapsed().as_millis(),