Skip to content

Commit ca26819

Browse files
Phase D: connection consolidation
Folds previously-scattered transport configuration into the Transport enum: Transport::Tcp { port, connection_token } Transport::External { host, port, connection_token } Removes: - ClientOptions::tcp_connection_token + with_tcp_connection_token - the runtime check that rejected (Stdio + token) -- now a type error - public ConnectionState enum and Client::state() accessor Renames: - ClientOptions::copilot_home -> base_directory + with_base_directory - ClientOptions::remote -> enable_remote_sessions + with_enable_remote_sessions - the runtime env var passed to the child stays COPILOT_HOME - the spawn arg stays --remote ConnectionState is demoted to pub(crate) and loses its serde derives; nothing outside the crate reads it. The internal state-tracking field on ClientInner is unchanged. All call sites in tests, examples, and e2e support helpers updated to the new Transport shape. Adds a unit test for empty-string connection_token on External transport (the existing Tcp test was adapted, the External one is new). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 56ec07a commit ca26819

11 files changed

Lines changed: 169 additions & 216 deletions

rust/examples/lifecycle_observer.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ use github_copilot_sdk::{Client, ClientOptions};
3939
#[tokio::main]
4040
async fn main() -> Result<(), github_copilot_sdk::Error> {
4141
let client = Client::start(ClientOptions::default()).await?;
42-
println!("[client] state: {:?}", client.state());
42+
println!("[client] started, pid: {:?}", client.pid());
4343

4444
// Wildcard lifecycle subscriber: see every session.lifecycle event,
4545
// counting deletions inline by filtering on event_type.
@@ -65,7 +65,7 @@ async fn main() -> Result<(), github_copilot_sdk::Error> {
6565

6666
let config = SessionConfig::default().with_handler(Arc::new(ApproveAllHandler));
6767
let session = client.create_session(config).await?;
68-
println!("[client] state after create: {:?}", client.state());
68+
println!("[client] session created: {}", session.id());
6969

7070
// Per-session observer: see every assistant message, tool call, etc.
7171
// Subscribers fire alongside the constructor handler; they're great for
@@ -103,7 +103,7 @@ async fn main() -> Result<(), github_copilot_sdk::Error> {
103103
// where you don't have an async runtime available to await `stop()`.
104104
// For graceful shutdown in normal flow, prefer `client.stop().await`.
105105
client.force_stop();
106-
println!("[client] state after force_stop: {:?}", client.state());
106+
println!("[client] force-stopped");
107107

108108
// Stopping the client closes the broadcast senders, so the consumer
109109
// tasks observe `RecvError::Closed` and exit cleanly.

rust/src/lib.rs

Lines changed: 92 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -289,13 +289,20 @@ pub enum Transport {
289289
Tcp {
290290
/// Port to listen on (0 for OS-assigned).
291291
port: u16,
292+
/// Optional connection token. When `None` and the SDK is spawning
293+
/// the CLI, the SDK auto-generates a 128-bit hex token so the
294+
/// loopback listener is safe by default.
295+
connection_token: Option<String>,
292296
},
293297
/// Connect to an already-running CLI server (no process spawning).
294298
External {
295299
/// Hostname or IP of the running server.
296300
host: String,
297301
/// Port of the running server.
298302
port: u16,
303+
/// Optional connection token. Required when the external server
304+
/// was started with a token, ignored otherwise.
305+
connection_token: Option<String>,
299306
},
300307
}
301308

@@ -393,23 +400,13 @@ pub struct ClientOptions {
393400
/// auth, telemetry buffers). When set, exported as `COPILOT_HOME` to
394401
/// the spawned CLI process. Useful for sandboxing test runs or
395402
/// running multiple isolated SDK instances side-by-side.
396-
pub copilot_home: Option<PathBuf>,
397-
/// Optional connection token for TCP transport. Sent to the CLI in
398-
/// the `connect` handshake and exported as `COPILOT_CONNECTION_TOKEN`
399-
/// to spawned CLI processes. Required when the CLI server was started
400-
/// with a token, ignored otherwise.
401-
///
402-
/// When the SDK spawns its own CLI in TCP mode and this is left
403-
/// `None`, a UUID is generated automatically so the loopback listener
404-
/// is safe by default. Combining with [`Transport::Stdio`] is invalid
405-
/// and surfaces as an error from [`Client::start`].
406-
pub tcp_connection_token: Option<String>,
403+
pub base_directory: Option<PathBuf>,
407404
/// Enable remote session support (Mission Control integration).
408405
/// When `true`, the SDK passes `--remote` to the spawned CLI process so
409406
/// sessions in a GitHub repository working directory are accessible from
410407
/// GitHub web and mobile. Ignored when connecting to an external server
411408
/// via [`Transport::External`].
412-
pub remote: bool,
409+
pub enable_remote_sessions: bool,
413410
}
414411

415412
impl std::fmt::Debug for ClientOptions {
@@ -442,12 +439,8 @@ impl std::fmt::Debug for ClientOptions {
442439
&self.on_get_trace_context.as_ref().map(|_| "<set>"),
443440
)
444441
.field("telemetry", &self.telemetry)
445-
.field("copilot_home", &self.copilot_home)
446-
.field(
447-
"tcp_connection_token",
448-
&self.tcp_connection_token.as_ref().map(|_| "<redacted>"),
449-
)
450-
.field("remote", &self.remote)
442+
.field("base_directory", &self.base_directory)
443+
.field("enable_remote_sessions", &self.enable_remote_sessions)
451444
.finish()
452445
}
453446
}
@@ -652,9 +645,8 @@ impl Default for ClientOptions {
652645
session_fs: None,
653646
on_get_trace_context: None,
654647
telemetry: None,
655-
copilot_home: None,
656-
tcp_connection_token: None,
657-
remote: false,
648+
base_directory: None,
649+
enable_remote_sessions: false,
658650
}
659651
}
660652
}
@@ -800,23 +792,15 @@ impl ClientOptions {
800792

801793
/// Override the directory where the CLI persists its state. Set as
802794
/// `COPILOT_HOME` on the spawned CLI process.
803-
pub fn with_copilot_home(mut self, home: impl Into<PathBuf>) -> Self {
804-
self.copilot_home = Some(home.into());
805-
self
806-
}
807-
808-
/// Set the connection token for TCP transport. Sent in the `connect`
809-
/// handshake and exported as `COPILOT_CONNECTION_TOKEN` to spawned
810-
/// CLI processes.
811-
pub fn with_tcp_connection_token(mut self, token: impl Into<String>) -> Self {
812-
self.tcp_connection_token = Some(token.into());
795+
pub fn with_base_directory(mut self, dir: impl Into<PathBuf>) -> Self {
796+
self.base_directory = Some(dir.into());
813797
self
814798
}
815799

816800
/// Enable remote session support (Mission Control). Passes `--remote`
817801
/// to the spawned CLI process.
818-
pub fn with_remote(mut self, enabled: bool) -> Self {
819-
self.remote = enabled;
802+
pub fn with_enable_remote_sessions(mut self, enabled: bool) -> Self {
803+
self.enable_remote_sessions = enabled;
820804
self
821805
}
822806
}
@@ -930,39 +914,48 @@ impl Client {
930914
));
931915
}
932916
}
933-
// Validate token + transport combination. Stdio cannot use a
934-
// connection token; auto-generate a UUID when the SDK spawns
935-
// its own CLI in TCP mode and no explicit token was set.
936-
if let Some(token) = &options.tcp_connection_token {
937-
if token.is_empty() {
938-
return Err(Error::InvalidConfig(
939-
"tcp_connection_token must be a non-empty string".to_string(),
940-
));
917+
// Validate token shape. Stdio variants no longer carry a token
918+
// (enforced by the type). For Tcp/External, empty-string is
919+
// rejected eagerly.
920+
match &options.transport {
921+
Transport::Tcp {
922+
connection_token: Some(t),
923+
..
941924
}
942-
if matches!(options.transport, Transport::Stdio) {
925+
| Transport::External {
926+
connection_token: Some(t),
927+
..
928+
} if t.is_empty() => {
943929
return Err(Error::InvalidConfig(
944-
"tcp_connection_token cannot be used with Transport::Stdio".to_string(),
930+
"connection_token must be a non-empty string".to_string(),
945931
));
946932
}
933+
_ => {}
947934
}
948-
let effective_connection_token: Option<String> = match &options.transport {
949-
Transport::Stdio => None,
950-
Transport::Tcp { .. } => Some(
951-
options
952-
.tcp_connection_token
953-
.clone()
954-
.unwrap_or_else(generate_connection_token),
955-
),
956-
Transport::External { .. } => options.tcp_connection_token.clone(),
935+
// Capture (and where needed, auto-generate) the token actually sent
936+
// to the server. For Tcp, the SDK auto-generates one when the
937+
// caller leaves it unset so the loopback listener is safe by
938+
// default.
939+
let (mut options, effective_connection_token) = {
940+
let mut options = options;
941+
let effective = match &mut options.transport {
942+
Transport::Stdio => None,
943+
Transport::Tcp {
944+
connection_token, ..
945+
} => {
946+
if connection_token.is_none() {
947+
*connection_token = Some(generate_connection_token());
948+
}
949+
connection_token.clone()
950+
}
951+
Transport::External {
952+
connection_token, ..
953+
} => connection_token.clone(),
954+
};
955+
(options, effective)
957956
};
958-
let mut options = options;
959-
if matches!(options.transport, Transport::Tcp { .. })
960-
&& options.tcp_connection_token.is_none()
961-
{
962-
// Auto-generated tokens flow to the spawned CLI via env, so
963-
// make the field reflect what we'll actually send.
964-
options.tcp_connection_token = effective_connection_token.clone();
965-
}
957+
let _ = &mut options;
958+
let effective_connection_token: Option<String> = effective_connection_token;
966959
let session_fs_config = options.session_fs.clone();
967960
let session_fs_sqlite_declared = session_fs_config
968961
.as_ref()
@@ -994,7 +987,11 @@ impl Client {
994987
};
995988

996989
let client = match options.transport {
997-
Transport::External { ref host, port } => {
990+
Transport::External {
991+
ref host,
992+
port,
993+
connection_token: _,
994+
} => {
998995
info!(host = %host, port = %port, "connecting to external CLI server");
999996
let connect_start = Instant::now();
1000997
let stream = TcpStream::connect((host.as_str(), port)).await?;
@@ -1017,7 +1014,10 @@ impl Client {
10171014
effective_connection_token.clone(),
10181015
)?
10191016
}
1020-
Transport::Tcp { port } => {
1017+
Transport::Tcp {
1018+
port,
1019+
connection_token: _,
1020+
} => {
10211021
let (mut child, actual_port) = Self::spawn_tcp(&program, &options, port).await?;
10221022
let connect_start = Instant::now();
10231023
let stream = TcpStream::connect(("127.0.0.1", actual_port)).await?;
@@ -1281,10 +1281,14 @@ impl Client {
12811281
);
12821282
}
12831283
}
1284-
if let Some(home) = &options.copilot_home {
1285-
command.env("COPILOT_HOME", home);
1284+
if let Some(dir) = &options.base_directory {
1285+
command.env("COPILOT_HOME", dir);
12861286
}
1287-
if let Some(token) = &options.tcp_connection_token {
1287+
if let Transport::Tcp {
1288+
connection_token: Some(token),
1289+
..
1290+
} = &options.transport
1291+
{
12881292
command.env("COPILOT_CONNECTION_TOKEN", token);
12891293
}
12901294
for (key, value) in &options.env {
@@ -1343,7 +1347,7 @@ impl Client {
13431347
}
13441348

13451349
fn remote_args(options: &ClientOptions) -> Vec<String> {
1346-
if options.remote {
1350+
if options.enable_remote_sessions {
13471351
vec!["--remote".to_string()]
13481352
} else {
13491353
Vec::new()
@@ -1581,8 +1585,8 @@ impl Client {
15811585
///
15821586
/// # Handshake sequence
15831587
///
1584-
/// 1. Sends the `connect` JSON-RPC method, forwarding
1585-
/// [`ClientOptions::tcp_connection_token`] (or the auto-generated
1588+
/// 1. Sends the `connect` JSON-RPC method, forwarding the
1589+
/// [`Transport`]'s `connection_token` (or the auto-generated
15861590
/// token for SDK-spawned TCP servers) as the `token` param. This
15871591
/// is the canonical handshake used by all SDK languages and is
15881592
/// what the CLI uses to enforce loopback authentication when
@@ -1647,7 +1651,7 @@ impl Client {
16471651

16481652
/// Send the `connect` JSON-RPC handshake. Returns the server's
16491653
/// reported protocol version, or `None` if the server omits it.
1650-
/// Forwards [`ClientOptions::tcp_connection_token`] (or the
1654+
/// Forwards the [`Transport`]'s `connection_token` (or the
16511655
/// auto-generated token for SDK-spawned TCP servers) as the `token`
16521656
/// param. Server-side, the token is required when the server was
16531657
/// started with `COPILOT_CONNECTION_TOKEN`.
@@ -1981,16 +1985,6 @@ impl Client {
19811985
pub fn subscribe_lifecycle(&self) -> LifecycleSubscription {
19821986
LifecycleSubscription::new(self.inner.lifecycle_tx.subscribe())
19831987
}
1984-
1985-
/// Return the current [`ConnectionState`].
1986-
///
1987-
/// The state advances to [`Connected`](ConnectionState::Connected) once
1988-
/// [`Client::start`] / [`Client::from_streams`] returns successfully and
1989-
/// drops to [`Disconnected`](ConnectionState::Disconnected) after
1990-
/// [`stop`](Self::stop) or [`force_stop`](Self::force_stop).
1991-
pub fn state(&self) -> ConnectionState {
1992-
*self.inner.state.lock()
1993-
}
19941988
}
19951989

19961990
impl Drop for ClientInner {
@@ -2050,7 +2044,7 @@ mod tests {
20502044
.with_use_logged_in_user(false)
20512045
.with_log_level(LogLevel::Debug)
20522046
.with_session_idle_timeout_seconds(120)
2053-
.with_remote(true);
2047+
.with_enable_remote_sessions(true);
20542048
assert!(matches!(opts.program, CliProgram::Path(_)));
20552049
assert_eq!(opts.prefix_args, vec![std::ffi::OsString::from("node")]);
20562050
assert_eq!(opts.cwd, PathBuf::from("/tmp"));
@@ -2067,7 +2061,7 @@ mod tests {
20672061
assert_eq!(opts.use_logged_in_user, Some(false));
20682062
assert!(matches!(opts.log_level, Some(LogLevel::Debug)));
20692063
assert_eq!(opts.session_idle_timeout_seconds, Some(120));
2070-
assert!(opts.remote);
2064+
assert!(opts.enable_remote_sessions);
20712065
}
20722066

20732067
#[test]
@@ -2270,7 +2264,7 @@ mod tests {
22702264

22712265
#[test]
22722266
fn build_command_sets_copilot_home_env_when_configured() {
2273-
let opts = ClientOptions::new().with_copilot_home(PathBuf::from("/custom/copilot"));
2267+
let opts = ClientOptions::new().with_base_directory(PathBuf::from("/custom/copilot"));
22742268
let cmd = Client::build_command(Path::new("/bin/echo"), &opts);
22752269
assert_eq!(
22762270
env_value(&cmd, "COPILOT_HOME"),
@@ -2284,7 +2278,10 @@ mod tests {
22842278

22852279
#[test]
22862280
fn build_command_sets_connection_token_env_when_configured() {
2287-
let opts = ClientOptions::new().with_tcp_connection_token("secret-token");
2281+
let opts = ClientOptions::new().with_transport(Transport::Tcp {
2282+
port: 0,
2283+
connection_token: Some("secret-token".to_string()),
2284+
});
22882285
let cmd = Client::build_command(Path::new("/bin/echo"), &opts);
22892286
assert_eq!(
22902287
env_value(&cmd, "COPILOT_CONNECTION_TOKEN"),
@@ -2297,26 +2294,25 @@ mod tests {
22972294
}
22982295

22992296
#[tokio::test]
2300-
async fn start_rejects_token_with_stdio_transport() {
2297+
async fn start_rejects_empty_connection_token() {
23012298
let opts = ClientOptions::new()
2302-
.with_tcp_connection_token("token-123")
2299+
.with_transport(Transport::Tcp {
2300+
port: 0,
2301+
connection_token: Some(String::new()),
2302+
})
23032303
.with_program(CliProgram::Path(PathBuf::from("/bin/echo")));
23042304
let err = Client::start(opts).await.unwrap_err();
23052305
assert!(matches!(err, Error::InvalidConfig(_)), "got {err:?}");
2306-
let Error::InvalidConfig(msg) = err else {
2307-
unreachable!()
2308-
};
2309-
assert!(
2310-
msg.contains("Stdio"),
2311-
"error should explain the stdio incompatibility: {msg}"
2312-
);
23132306
}
23142307

23152308
#[tokio::test]
2316-
async fn start_rejects_empty_connection_token() {
2309+
async fn start_rejects_empty_external_connection_token() {
23172310
let opts = ClientOptions::new()
2318-
.with_tcp_connection_token("")
2319-
.with_transport(Transport::Tcp { port: 0 })
2311+
.with_transport(Transport::External {
2312+
host: "127.0.0.1".to_string(),
2313+
port: 1,
2314+
connection_token: Some(String::new()),
2315+
})
23202316
.with_program(CliProgram::Path(PathBuf::from("/bin/echo")));
23212317
let err = Client::start(opts).await.unwrap_err();
23222318
assert!(matches!(err, Error::InvalidConfig(_)), "got {err:?}");
@@ -2392,7 +2388,7 @@ mod tests {
23922388
#[test]
23932389
fn remote_args_emit_flag_when_enabled() {
23942390
let opts = ClientOptions {
2395-
remote: true,
2391+
enable_remote_sessions: true,
23962392
..Default::default()
23972393
};
23982394
assert_eq!(Client::remote_args(&opts), vec!["--remote".to_string()]);

0 commit comments

Comments
 (0)