Skip to content

Commit 180ca47

Browse files
Support optional connection token for TCP servers (#1176)
* Node SDK: support optional connection token for TCP servers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * All-language SDK: support optional connection token for TCP servers Mirrors the Node SDK implementation in .NET, Go, and Python: - Add tcpConnectionToken option to client config; auto-generate a UUID when spawning the CLI in TCP mode and forward via COPILOT_CONNECTION_TOKEN. - Send the token via a new \connect\ RPC during the handshake; fall back to \ping\ against legacy servers without \connect\. - e2e coverage for explicit token, auto-generated token, wrong token, and missing token in each language. Codegen: fix scripts/codegen so quicktype's Python/Go renderers don't crash on boolean literal schemas (\const: true\/\�num: [true]\). Adds stripBooleanLiterals helper applied to the schema fed into quicktype. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Hide internal RPC methods from generated public API surface The schema can now flag methods and types as internal. The codegen splits internal RPC methods into parallel structures so they don't appear on the public client API: - TypeScript: createInternalServerRpc / createInternalSessionRpc factories alongside the existing public ones; client.ts wires connect() through a private internalRpc getter. - C#: ConnectAsync and ConnectResult are emitted with the internal access modifier (real assembly-boundary access control). - Python: parallel InternalServerRpc / InternalSessionRpc classes with ':meta private:' docstrings. - Go: parallel InternalServerRpc / InternalSessionRpc types with their own unexported backing struct and NewInternalServerRpc constructor. - Internal type definitions get a per-language doc-comment marker. - New filterNodeByVisibility() helper in scripts/codegen/utils.ts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update codegen output * Python/Go better representation of internal in codegen output * Fix .NET * Update Copilot runtime * CR feedback * Test fixes * Use shared TCP connection token in multi-client E2E tests Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> * Allow tcpConnectionToken when cliUrl is set (Node + Go) CliUrl/CLIUrl always implies TCP mode (it overrides useStdio/UseStdio internally), so rejecting the token when useStdio: true is set should only fire when no cliUrl is provided. Brings Node and Go in line with the .NET behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert dead guard on tcpConnectionToken (Node + Go) The 'cliUrl + useStdio:true' combo is already rejected upfront as mutually exclusive in both Node (client.ts:310) and Go (client.go:165), so the extra '&& !cliUrl' guard added in 496ed7d was unreachable. The bot's suggestion was based on a misreading; .NET only needs the compound check because it silently coerces UseStdio=false instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: make UseStdio nullable to match Node semantics Previously the .NET SDK silently coerced UseStdio = true to false when CliUrl was provided, while Node rejects that combination as mutually exclusive. Make UseStdio a bool? defaulting to null so we can detect 'explicitly true' vs 'unset', and throw on the mutually-exclusive case just like Node does. Defaults are unchanged: unset + no CliUrl resolves to stdio, unset + CliUrl resolves to TCP. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use shared TCP connection token in Node suspend/pending-work tests Mirror the fix already applied to .NET/Go/Python: sibling clients connecting via cliUrl don't have access to the auto-generated token from the server-spawning client, so the suspend and pending-work-resume E2E tests need an explicit shared token. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply ruff format to e2e test files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET test harness: make useStdio parameter nullable So callers that pass options.CliUrl don't trip the new mutually-exclusive guard from CopilotClient. Caller-provided useStdio still wins; null lets the SDK pick the default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d3abfa2 commit 180ca47

48 files changed

Lines changed: 1385 additions & 159 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

dotnet/src/Client.cs

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable
7070
private bool _disposed;
7171
private readonly int? _optionsPort;
7272
private readonly string? _optionsHost;
73+
private readonly string? _effectiveConnectionToken;
7374
private int? _actualPort;
7475
private int? _negotiatedProtocolVersion;
7576
private List<ModelInfo>? _modelsCache;
@@ -123,23 +124,43 @@ public CopilotClient(CopilotClientOptions? options = null)
123124
_options = options ?? new();
124125

125126
// Validate mutually exclusive options
126-
if (!string.IsNullOrEmpty(_options.CliUrl) && _options.CliPath != null)
127+
if (!string.IsNullOrEmpty(_options.CliUrl) && (_options.UseStdio == true || _options.CliPath != null))
127128
{
128-
throw new ArgumentException("CliUrl is mutually exclusive with CliPath");
129+
throw new ArgumentException("CliUrl is mutually exclusive with UseStdio and CliPath");
129130
}
130131

131-
// When CliUrl is provided, disable UseStdio (we connect to an external server, not spawn one)
132+
// When CliUrl is provided, force TCP mode (we connect to an external server, not spawn one)
132133
if (!string.IsNullOrEmpty(_options.CliUrl))
133134
{
134135
_options.UseStdio = false;
135136
}
137+
else
138+
{
139+
_options.UseStdio ??= true;
140+
}
136141

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

148+
if (_options.TcpConnectionToken is not null)
149+
{
150+
if (_options.TcpConnectionToken.Length == 0)
151+
{
152+
throw new ArgumentException("TcpConnectionToken must be a non-empty string");
153+
}
154+
if (_options.UseStdio == true)
155+
{
156+
throw new ArgumentException("TcpConnectionToken cannot be used with UseStdio = true");
157+
}
158+
}
159+
160+
var sdkSpawnsCli = _options.UseStdio == false && string.IsNullOrEmpty(_options.CliUrl);
161+
_effectiveConnectionToken = _options.TcpConnectionToken
162+
?? (sdkSpawnsCli ? Guid.NewGuid().ToString() : null);
163+
143164
_logger = _options.Logger ?? NullLogger.Instance;
144165
_onListModels = _options.OnListModels;
145166

@@ -216,7 +237,7 @@ async Task<Connection> StartCoreAsync(CancellationToken ct)
216237
else
217238
{
218239
// Child process (stdio or TCP)
219-
var (cliProcess, portOrNull, stderrBuffer) = await StartCliServerAsync(_options, _logger, ct);
240+
var (cliProcess, portOrNull, stderrBuffer) = await StartCliServerAsync(_options, _effectiveConnectionToken, _logger, ct);
220241
_actualPort = portOrNull;
221242
result = ConnectToServerAsync(cliProcess, portOrNull is null ? null : "localhost", portOrNull, stderrBuffer, ct);
222243
}
@@ -1124,30 +1145,42 @@ private void ConfigureSessionFsHandlers(CopilotSession session, Func<CopilotSess
11241145
private async Task VerifyProtocolVersionAsync(Connection connection, CancellationToken cancellationToken)
11251146
{
11261147
var maxVersion = SdkProtocolVersion.GetVersion();
1127-
var pingResponse = await InvokeRpcAsync<PingResponse>(
1128-
connection.Rpc, "ping", [new PingRequest()], connection.StderrBuffer, cancellationToken);
1148+
int? serverVersion;
1149+
try
1150+
{
1151+
var connectResponse = await InvokeRpcAsync<ConnectResult>(
1152+
connection.Rpc, "connect", [new ConnectRequest { Token = _effectiveConnectionToken }], connection.StderrBuffer, cancellationToken);
1153+
serverVersion = (int)connectResponse.ProtocolVersion;
1154+
}
1155+
catch (RemoteRpcException ex) when (ex.ErrorCode == RemoteRpcException.MethodNotFoundErrorCode)
1156+
{
1157+
// Legacy server without `connect`; fall back to `ping`. A token, if any,
1158+
// is silently dropped — the legacy server can't enforce one.
1159+
var pingResponse = await InvokeRpcAsync<PingResponse>(
1160+
connection.Rpc, "ping", [new PingRequest()], connection.StderrBuffer, cancellationToken);
1161+
serverVersion = pingResponse.ProtocolVersion;
1162+
}
11291163

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

1138-
var serverVersion = pingResponse.ProtocolVersion.Value;
1139-
if (serverVersion < MinProtocolVersion || serverVersion > maxVersion)
1172+
if (serverVersion.Value < MinProtocolVersion || serverVersion.Value > maxVersion)
11401173
{
11411174
throw new InvalidOperationException(
11421175
$"SDK protocol version mismatch: SDK supports versions {MinProtocolVersion}-{maxVersion}, " +
1143-
$"but server reports version {serverVersion}. " +
1176+
$"but server reports version {serverVersion.Value}. " +
11441177
$"Please update your SDK or server to ensure compatibility.");
11451178
}
11461179

1147-
_negotiatedProtocolVersion = serverVersion;
1180+
_negotiatedProtocolVersion = serverVersion.Value;
11481181
}
11491182

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

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

1168-
if (options.UseStdio)
1201+
if (options.UseStdio == true)
11691202
{
11701203
args.Add("--stdio");
11711204
}
@@ -1199,7 +1232,7 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
11991232
FileName = fileName,
12001233
Arguments = string.Join(" ", processArgs.Select(ProcessArgumentEscaper.Escape)),
12011234
UseShellExecute = false,
1202-
RedirectStandardInput = options.UseStdio,
1235+
RedirectStandardInput = options.UseStdio == true,
12031236
RedirectStandardOutput = true,
12041237
RedirectStandardError = true,
12051238
WorkingDirectory = options.Cwd,
@@ -1223,6 +1256,11 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
12231256
startInfo.Environment["COPILOT_SDK_AUTH_TOKEN"] = options.GitHubToken;
12241257
}
12251258

1259+
if (!string.IsNullOrEmpty(connectionToken))
1260+
{
1261+
startInfo.Environment["COPILOT_CONNECTION_TOKEN"] = connectionToken;
1262+
}
1263+
12261264
// Set telemetry environment variables if configured
12271265
if (options.Telemetry is { } telemetry)
12281266
{
@@ -1260,7 +1298,7 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
12601298
}, cancellationToken);
12611299

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

1329-
if (_options.UseStdio)
1367+
if (_options.UseStdio == true)
13301368
{
13311369
if (cliProcess == null)
13321370
{

dotnet/src/Generated/Rpc.cs

Lines changed: 33 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dotnet/src/JsonRpc.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -831,5 +831,8 @@ internal sealed class ConnectionLostException() : IOException("The JSON-RPC conn
831831
/// </summary>
832832
internal sealed class RemoteRpcException(string message, int errorCode, Exception? innerException = null) : Exception(message, innerException)
833833
{
834+
/// <summary>JSON-RPC 2.0 reserved error code: requested method does not exist.</summary>
835+
public const int MethodNotFoundErrorCode = -32601;
836+
834837
public int ErrorCode { get; } = errorCode;
835838
}

dotnet/src/Types.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ protected CopilotClientOptions(CopilotClientOptions? other)
7070
OnListModels = other.OnListModels;
7171
SessionFs = other.SessionFs;
7272
SessionIdleTimeoutSeconds = other.SessionIdleTimeoutSeconds;
73+
TcpConnectionToken = other.TcpConnectionToken;
7374
}
7475

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

182+
/// <summary>
183+
/// Connection token for the headless CLI server (TCP only). When the SDK spawns its own
184+
/// CLI in TCP mode and this is omitted, a GUID is generated automatically so the loopback
185+
/// listener is safe by default. Cannot be combined with <see cref="UseStdio"/> = true.
186+
/// </summary>
187+
public string? TcpConnectionToken { get; set; }
188+
178189
/// <summary>
179190
/// Creates a shallow clone of this <see cref="CopilotClientOptions"/> instance.
180191
/// </summary>

0 commit comments

Comments
 (0)