Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 54 additions & 16 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable
private bool _disposed;
private readonly int? _optionsPort;
private readonly string? _optionsHost;
private readonly string? _effectiveConnectionToken;
private int? _actualPort;
private int? _negotiatedProtocolVersion;
private List<ModelInfo>? _modelsCache;
Expand Down Expand Up @@ -123,23 +124,43 @@ public CopilotClient(CopilotClientOptions? options = null)
_options = options ?? new();

// Validate mutually exclusive options
if (!string.IsNullOrEmpty(_options.CliUrl) && _options.CliPath != null)
if (!string.IsNullOrEmpty(_options.CliUrl) && (_options.UseStdio == true || _options.CliPath != null))
{
throw new ArgumentException("CliUrl is mutually exclusive with CliPath");
throw new ArgumentException("CliUrl is mutually exclusive with UseStdio and CliPath");
}

// When CliUrl is provided, disable UseStdio (we connect to an external server, not spawn one)
// When CliUrl is provided, force TCP mode (we connect to an external server, not spawn one)
if (!string.IsNullOrEmpty(_options.CliUrl))
{
_options.UseStdio = false;
}
else
{
_options.UseStdio ??= true;
}

// Validate auth options with external server
if (!string.IsNullOrEmpty(_options.CliUrl) && (!string.IsNullOrEmpty(_options.GitHubToken) || _options.UseLoggedInUser != null))
{
throw new ArgumentException("GitHubToken and UseLoggedInUser cannot be used with CliUrl (external server manages its own auth)");
}

if (_options.TcpConnectionToken is not null)
{
if (_options.TcpConnectionToken.Length == 0)
{
throw new ArgumentException("TcpConnectionToken must be a non-empty string");
}
if (_options.UseStdio == true)
{
throw new ArgumentException("TcpConnectionToken cannot be used with UseStdio = true");
}
}

var sdkSpawnsCli = _options.UseStdio == false && string.IsNullOrEmpty(_options.CliUrl);
_effectiveConnectionToken = _options.TcpConnectionToken
?? (sdkSpawnsCli ? Guid.NewGuid().ToString() : null);

_logger = _options.Logger ?? NullLogger.Instance;
_onListModels = _options.OnListModels;

Expand Down Expand Up @@ -216,7 +237,7 @@ async Task<Connection> StartCoreAsync(CancellationToken ct)
else
{
// Child process (stdio or TCP)
var (cliProcess, portOrNull, stderrBuffer) = await StartCliServerAsync(_options, _logger, ct);
var (cliProcess, portOrNull, stderrBuffer) = await StartCliServerAsync(_options, _effectiveConnectionToken, _logger, ct);
_actualPort = portOrNull;
result = ConnectToServerAsync(cliProcess, portOrNull is null ? null : "localhost", portOrNull, stderrBuffer, ct);
}
Expand Down Expand Up @@ -1124,30 +1145,42 @@ private void ConfigureSessionFsHandlers(CopilotSession session, Func<CopilotSess
private async Task VerifyProtocolVersionAsync(Connection connection, CancellationToken cancellationToken)
{
var maxVersion = SdkProtocolVersion.GetVersion();
var pingResponse = await InvokeRpcAsync<PingResponse>(
connection.Rpc, "ping", [new PingRequest()], connection.StderrBuffer, cancellationToken);
int? serverVersion;
try
{
var connectResponse = await InvokeRpcAsync<ConnectResult>(
connection.Rpc, "connect", [new ConnectRequest { Token = _effectiveConnectionToken }], connection.StderrBuffer, cancellationToken);
serverVersion = (int)connectResponse.ProtocolVersion;
}
catch (RemoteRpcException ex) when (ex.ErrorCode == RemoteRpcException.MethodNotFoundErrorCode)
{
// Legacy server without `connect`; fall back to `ping`. A token, if any,
// is silently dropped — the legacy server can't enforce one.
var pingResponse = await InvokeRpcAsync<PingResponse>(
connection.Rpc, "ping", [new PingRequest()], connection.StderrBuffer, cancellationToken);
serverVersion = pingResponse.ProtocolVersion;
}

if (!pingResponse.ProtocolVersion.HasValue)
if (!serverVersion.HasValue)
{
throw new InvalidOperationException(
$"SDK protocol version mismatch: SDK supports versions {MinProtocolVersion}-{maxVersion}, " +
$"but server does not report a protocol version. " +
$"Please update your server to ensure compatibility.");
}

var serverVersion = pingResponse.ProtocolVersion.Value;
if (serverVersion < MinProtocolVersion || serverVersion > maxVersion)
if (serverVersion.Value < MinProtocolVersion || serverVersion.Value > maxVersion)
{
throw new InvalidOperationException(
$"SDK protocol version mismatch: SDK supports versions {MinProtocolVersion}-{maxVersion}, " +
$"but server reports version {serverVersion}. " +
$"but server reports version {serverVersion.Value}. " +
$"Please update your SDK or server to ensure compatibility.");
}

_negotiatedProtocolVersion = serverVersion;
_negotiatedProtocolVersion = serverVersion.Value;
}

private static async Task<(Process Process, int? DetectedLocalhostTcpPort, StringBuilder StderrBuffer)> StartCliServerAsync(CopilotClientOptions options, ILogger logger, CancellationToken cancellationToken)
private static async Task<(Process Process, int? DetectedLocalhostTcpPort, StringBuilder StderrBuffer)> StartCliServerAsync(CopilotClientOptions options, string? connectionToken, ILogger logger, CancellationToken cancellationToken)
{
// Use explicit path, COPILOT_CLI_PATH env var (from options.Environment or process env), or bundled CLI - no PATH fallback
var envCliPath = options.Environment is not null && options.Environment.TryGetValue("COPILOT_CLI_PATH", out var envValue) ? envValue
Expand All @@ -1165,7 +1198,7 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio

args.AddRange(["--headless", "--no-auto-update", "--log-level", options.LogLevel]);

if (options.UseStdio)
if (options.UseStdio == true)
{
args.Add("--stdio");
}
Expand Down Expand Up @@ -1199,7 +1232,7 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
FileName = fileName,
Arguments = string.Join(" ", processArgs.Select(ProcessArgumentEscaper.Escape)),
UseShellExecute = false,
RedirectStandardInput = options.UseStdio,
RedirectStandardInput = options.UseStdio == true,
RedirectStandardOutput = true,
RedirectStandardError = true,
WorkingDirectory = options.Cwd,
Expand All @@ -1223,6 +1256,11 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
startInfo.Environment["COPILOT_SDK_AUTH_TOKEN"] = options.GitHubToken;
}

if (!string.IsNullOrEmpty(connectionToken))
{
startInfo.Environment["COPILOT_CONNECTION_TOKEN"] = connectionToken;
}

// Set telemetry environment variables if configured
if (options.Telemetry is { } telemetry)
{
Expand Down Expand Up @@ -1260,7 +1298,7 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
}, cancellationToken);

var detectedLocalhostTcpPort = (int?)null;
if (!options.UseStdio)
if (options.UseStdio != true)
{
// Wait for port announcement
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Expand Down Expand Up @@ -1326,7 +1364,7 @@ private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string?
Stream inputStream, outputStream;
NetworkStream? networkStream = null;

if (_options.UseStdio)
if (_options.UseStdio == true)
{
if (cliProcess == null)
{
Expand Down
33 changes: 33 additions & 0 deletions dotnet/src/Generated/Rpc.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions dotnet/src/JsonRpc.cs
Original file line number Diff line number Diff line change
Expand Up @@ -831,5 +831,8 @@ internal sealed class ConnectionLostException() : IOException("The JSON-RPC conn
/// </summary>
internal sealed class RemoteRpcException(string message, int errorCode, Exception? innerException = null) : Exception(message, innerException)
{
/// <summary>JSON-RPC 2.0 reserved error code: requested method does not exist.</summary>
public const int MethodNotFoundErrorCode = -32601;

public int ErrorCode { get; } = errorCode;
}
13 changes: 12 additions & 1 deletion dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ protected CopilotClientOptions(CopilotClientOptions? other)
OnListModels = other.OnListModels;
SessionFs = other.SessionFs;
SessionIdleTimeoutSeconds = other.SessionIdleTimeoutSeconds;
TcpConnectionToken = other.TcpConnectionToken;
}

/// <summary>
Expand All @@ -90,8 +91,11 @@ protected CopilotClientOptions(CopilotClientOptions? other)
public int Port { get; set; }
/// <summary>
/// Whether to use stdio transport for communication with the CLI server.
/// Defaults to <c>true</c> when neither <see cref="CliUrl"/> nor <see cref="Port"/>
/// switches the client into TCP mode. Setting this to <c>true</c> is mutually
/// exclusive with <see cref="CliUrl"/>.
/// </summary>
public bool UseStdio { get; set; } = true;
public bool? UseStdio { get; set; }
/// <summary>
/// URL of an existing CLI server to connect to instead of starting a new one.
/// </summary>
Expand Down Expand Up @@ -175,6 +179,13 @@ public string? GithubToken
/// </summary>
public int? SessionIdleTimeoutSeconds { get; set; }

/// <summary>
/// Connection token for the headless CLI server (TCP only). When the SDK spawns its own
/// CLI in TCP mode and this is omitted, a GUID is generated automatically so the loopback
/// listener is safe by default. Cannot be combined with <see cref="UseStdio"/> = true.
/// </summary>
public string? TcpConnectionToken { get; set; }

/// <summary>
/// Creates a shallow clone of this <see cref="CopilotClientOptions"/> instance.
/// </summary>
Expand Down
Loading
Loading