diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 0e1ec145e..97745e78b 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -174,6 +174,8 @@ public CopilotClient(CopilotClientOptions? options = null) throw new ArgumentException($"Unsupported RuntimeConnection type: {_connection.GetType().Name}", nameof(options)); } + ValidateEnvironmentOptions(_options, _connection); + _logger = _options.Logger ?? NullLogger.Instance; _onListModels = _options.OnListModels; @@ -202,6 +204,53 @@ _options.SessionFs is not null || } } + /// + /// Validates environment-variable options against the resolved transport. + /// Per-client environment is only representable for child-process transports + /// (each client owns its own OS process). The in-process (FFI) transport + /// loads the native runtime into the shared host process, whose single + /// environment block cannot carry per-client values, so environment and + /// telemetry options that lower to environment variables are rejected there. + /// + private static void ValidateEnvironmentOptions(CopilotClientOptions options, RuntimeConnection connection) + { + if (connection is InProcessRuntimeConnection) + { + if (options.Environment is not null) + { + throw new ArgumentException( + $"{nameof(CopilotClientOptions)}.{nameof(CopilotClientOptions.Environment)} is not supported with " + + $"{nameof(RuntimeConnection)}.{nameof(RuntimeConnection.ForInProcess)}(): the in-process transport " + + "loads the native runtime into the shared host process, whose single environment block cannot carry " + + "per-client values. Set the variables on the host process environment instead.", + nameof(options)); + } + + if (options.Telemetry is not null) + { + throw new ArgumentException( + $"{nameof(CopilotClientOptions)}.{nameof(CopilotClientOptions.Telemetry)} is not supported with " + + $"{nameof(RuntimeConnection)}.{nameof(RuntimeConnection.ForInProcess)}(): telemetry configuration is " + + "lowered to environment variables read by native runtime code running in the shared host process, so " + + "per-client telemetry cannot be honored in-process. Configure telemetry via the host process " + + "environment, or use a child-process transport.", + nameof(options)); + } + + return; + } + + if (connection is ChildProcessRuntimeConnection { Environment: not null } && options.Environment is not null) + { + throw new ArgumentException( + $"Set environment variables via either {nameof(CopilotClientOptions)}.{nameof(CopilotClientOptions.Environment)} " + + $"or {nameof(ChildProcessRuntimeConnection)}.{nameof(ChildProcessRuntimeConnection.Environment)}, not both. " + + $"Prefer {nameof(ChildProcessRuntimeConnection)}.{nameof(ChildProcessRuntimeConnection.Environment)} for " + + "child-process transports.", + nameof(options)); + } + } + /// /// Environment variable that overrides the transport used when the caller does not /// specify . Accepts "inprocess" @@ -1943,9 +1992,12 @@ private static void ApplyTelemetryEnvironment(IDictionary envir var tcpConnection = _connection as TcpRuntimeConnection; var useStdio = _connection is StdioRuntimeConnection; - // Use explicit path, COPILOT_CLI_PATH env var (from options.Environment or process env), or bundled runtime - no PATH fallback - var envCliPath = options.Environment is not null && options.Environment.TryGetValue("COPILOT_CLI_PATH", out var envValue) ? envValue - : System.Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"); + // Use explicit path, COPILOT_CLI_PATH env var (from the connection's + // Environment, options.Environment, or process env), or bundled runtime - no PATH fallback + var envCliPath = + (childProcessConnection.Environment is not null && childProcessConnection.Environment.TryGetValue("COPILOT_CLI_PATH", out var connEnvValue) ? connEnvValue : null) + ?? (options.Environment is not null && options.Environment.TryGetValue("COPILOT_CLI_PATH", out var envValue) ? envValue : null) + ?? System.Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"); var cliPath = childProcessConnection.Path ?? envCliPath ?? GetBundledCliPath(out var searchedPath) @@ -2012,10 +2064,11 @@ private static void ApplyTelemetryEnvironment(IDictionary envir CreateNoWindow = true }; - if (options.Environment != null) + var childEnvironment = options.Environment ?? childProcessConnection.Environment; + if (childEnvironment != null) { startInfo.Environment.Clear(); - foreach (var (key, value) in options.Environment) + foreach (var (key, value) in childEnvironment) { startInfo.Environment[key] = value; } diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index bcd8903c8..65295d3d2 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -173,6 +173,16 @@ internal ChildProcessRuntimeConnection() { } /// Extra command-line arguments to pass to the runtime process. public IList? Args { get; set; } + + /// + /// Gets or sets the environment variables passed to the spawned runtime process, + /// replacing the inherited environment. + /// + /// + /// Cannot be combined with ; setting both throws + /// an when the client is constructed. + /// + public IReadOnlyDictionary? Environment { get; set; } } /// @@ -358,7 +368,15 @@ private CopilotClientOptions(CopilotClientOptions? other) /// public CopilotLogLevel? LogLevel { get; set; } - /// Environment variables to pass to the runtime process. + /// + /// Gets or sets environment variables passed to the runtime process. + /// + /// + /// Not supported with the in-process transport (), + /// which runs the runtime in the host process; setting this option there throws an + /// . For child-process transports, prefer + /// ; setting both throws. + /// public IReadOnlyDictionary? Environment { get; set; } /// Logger instance for SDK diagnostic output. diff --git a/dotnet/test/ConnectionTokenTests.cs b/dotnet/test/ConnectionTokenTests.cs index 524ff2586..3192bada6 100644 --- a/dotnet/test/ConnectionTokenTests.cs +++ b/dotnet/test/ConnectionTokenTests.cs @@ -113,7 +113,7 @@ public class ConnectionTokenAutoGeneratedTests : IAsyncLifetime public async Task InitializeAsync() { _ctx = await E2ETestContext.CreateAsync(); - _client = _ctx.CreateClient(useStdio: false); + _client = _ctx.CreateClient(options: new CopilotClientOptions { Connection = RuntimeConnection.ForTcp() }); } public async Task DisposeAsync() diff --git a/dotnet/test/E2E/ClientOptionsE2ETests.cs b/dotnet/test/E2E/ClientOptionsE2ETests.cs index d86b6e477..a8ceda5b0 100644 --- a/dotnet/test/E2E/ClientOptionsE2ETests.cs +++ b/dotnet/test/E2E/ClientOptionsE2ETests.cs @@ -71,7 +71,6 @@ public async Task Should_Propagate_Process_Options_To_Spawned_Cli() { Connection = RuntimeConnection.ForStdio(path: cliPath, args: ["--capture-file", capturePath]), BaseDirectory = copilotHomeFromOption, - Environment = clientEnv, GitHubToken = "process-option-token", LogLevel = CopilotLogLevel.Debug, SessionIdleTimeoutSeconds = 17, @@ -85,7 +84,7 @@ public async Task Should_Propagate_Process_Options_To_Spawned_Cli() CaptureContent = true, }, UseLoggedInUser = false, - }); + }, environment: clientEnv); await client.StartAsync(); diff --git a/dotnet/test/E2E/CopilotRequestWebSocketE2ETests.cs b/dotnet/test/E2E/CopilotRequestWebSocketE2ETests.cs index 464aadb66..9bb19df7d 100644 --- a/dotnet/test/E2E/CopilotRequestWebSocketE2ETests.cs +++ b/dotnet/test/E2E/CopilotRequestWebSocketE2ETests.cs @@ -51,8 +51,7 @@ public async Task Services_A_WebSocket_Turn_End_To_End_Via_The_Request_Handler() { Connection = RuntimeConnection.ForStdio(), RequestHandler = handler, - Environment = env, - }); + }, environment: env); await client.StartAsync(); var session = await client.CreateSessionAsync(new SessionConfig diff --git a/dotnet/test/E2E/ModeHandlersE2ETests.cs b/dotnet/test/E2E/ModeHandlersE2ETests.cs index 40552fa9f..a397999f7 100644 --- a/dotnet/test/E2E/ModeHandlersE2ETests.cs +++ b/dotnet/test/E2E/ModeHandlersE2ETests.cs @@ -155,7 +155,7 @@ private CopilotClient CreateAuthenticatedClient() ["COPILOT_DEBUG_GITHUB_API_URL"] = Ctx.ProxyUrl, }; - return Ctx.CreateClient(options: new CopilotClientOptions { Environment = env }); + return Ctx.CreateClient(environment: env); } private Task ConfigureAuthenticatedUserAsync() diff --git a/dotnet/test/E2E/PerSessionAuthE2ETests.cs b/dotnet/test/E2E/PerSessionAuthE2ETests.cs index 7e104b33d..d1226f373 100644 --- a/dotnet/test/E2E/PerSessionAuthE2ETests.cs +++ b/dotnet/test/E2E/PerSessionAuthE2ETests.cs @@ -22,7 +22,7 @@ private CopilotClient CreateAuthTestClient() }; // Disable the harness's auto-injected client token so the per-session // auth tests validate only session-scoped tokens. - return Ctx.CreateClient(options: new CopilotClientOptions { Environment = env }, autoInjectGitHubToken: false); + return Ctx.CreateClient(environment: env, autoInjectGitHubToken: false); } private CopilotClient CreateNoAuthTestClient() @@ -32,9 +32,8 @@ private CopilotClient CreateNoAuthTestClient() return Ctx.CreateClient(options: new CopilotClientOptions { - Environment = env, UseLoggedInUser = false, - }, autoInjectGitHubToken: false); + }, autoInjectGitHubToken: false, environment: env); } private static Dictionary WithoutAuthEnv(Dictionary env) diff --git a/dotnet/test/E2E/ProviderEndpointE2ETests.cs b/dotnet/test/E2E/ProviderEndpointE2ETests.cs index f7e9a7885..7ea4eecf3 100644 --- a/dotnet/test/E2E/ProviderEndpointE2ETests.cs +++ b/dotnet/test/E2E/ProviderEndpointE2ETests.cs @@ -23,7 +23,7 @@ private CopilotClient CreateProviderEndpointClient() { ["COPILOT_ALLOW_GET_PROVIDER_ENDPOINT"] = "true", }; - return Ctx.CreateClient(options: new CopilotClientOptions { Environment = env }); + return Ctx.CreateClient(environment: env); } [Fact] diff --git a/dotnet/test/E2E/RpcExtensionsLoadedE2ETests.cs b/dotnet/test/E2E/RpcExtensionsLoadedE2ETests.cs index c1e43b09e..af959bd7e 100644 --- a/dotnet/test/E2E/RpcExtensionsLoadedE2ETests.cs +++ b/dotnet/test/E2E/RpcExtensionsLoadedE2ETests.cs @@ -58,8 +58,7 @@ private CopilotClient CreateExtensionsClient() return Ctx.CreateClient(options: new CopilotClientOptions { Connection = RuntimeConnection.ForStdio(args: ["--yolo"]), - Environment = ExtensionsEnabledEnvironment(), - }); + }, environment: ExtensionsEnabledEnvironment()); } /// diff --git a/dotnet/test/E2E/RpcMcpAndSkillsE2ETests.cs b/dotnet/test/E2E/RpcMcpAndSkillsE2ETests.cs index d53f93c9a..350aac427 100644 --- a/dotnet/test/E2E/RpcMcpAndSkillsE2ETests.cs +++ b/dotnet/test/E2E/RpcMcpAndSkillsE2ETests.cs @@ -398,10 +398,7 @@ private CopilotClient CreateMcpAppsClient() environment["COPILOT_MCP_APPS"] = "true"; environment["MCP_APPS"] = "true"; - return Ctx.CreateClient(options: new CopilotClientOptions - { - Environment = environment, - }); + return Ctx.CreateClient(environment: environment); } private static void CreateSkill(string skillsDir, string skillName, string description) diff --git a/dotnet/test/E2E/RpcServerE2ETests.cs b/dotnet/test/E2E/RpcServerE2ETests.cs index 1f240af9e..47df25615 100644 --- a/dotnet/test/E2E/RpcServerE2ETests.cs +++ b/dotnet/test/E2E/RpcServerE2ETests.cs @@ -37,9 +37,8 @@ private CopilotClient CreateAuthenticatedClient(string token) return Ctx.CreateClient(options: new CopilotClientOptions { - Environment = env, GitHubToken = token, - }); + }, environment: env); } private async Task ConfigureAuthenticatedUserAsync( @@ -237,10 +236,7 @@ public async Task Should_Add_Secret_Filter_Values() { var environment = Ctx.GetEnvironment(); environment["COPILOT_ENABLE_SECRET_FILTERING"] = "true"; - await using var client = Ctx.CreateClient(options: new CopilotClientOptions - { - Environment = environment, - }); + await using var client = Ctx.CreateClient(environment: environment); await client.StartAsync(); var secret = $"rpc-secret-{Guid.NewGuid():N}"; @@ -381,7 +377,7 @@ public async Task Should_Check_In_Use_Session_From_Another_Runtime_And_Release_L { var sessionId = Guid.NewGuid().ToString(); var workingDirectory = CreateUniqueWorkDirectory("server-rpc-in-use"); - await using var otherClient = Ctx.CreateClient(useStdio: true); + await using var otherClient = Ctx.CreateClient(options: new CopilotClientOptions { Connection = RuntimeConnection.ForStdio() }); await using var otherSession = await otherClient.CreateSessionAsync(new SessionConfig { SessionId = sessionId, diff --git a/dotnet/test/E2E/RpcServerMiscE2ETests.cs b/dotnet/test/E2E/RpcServerMiscE2ETests.cs index 6cb21f75d..29e560100 100644 --- a/dotnet/test/E2E/RpcServerMiscE2ETests.cs +++ b/dotnet/test/E2E/RpcServerMiscE2ETests.cs @@ -235,7 +235,7 @@ public async Task Should_Reject_Send_Attachments_From_Non_Extension_Connection() env["GITHUB_TOKEN"] = ""; } - var options = new CopilotClientOptions { Environment = env }; + var options = new CopilotClientOptions(); if (!autoInjectGitHubToken) { options.UseLoggedInUser = false; @@ -243,7 +243,8 @@ public async Task Should_Reject_Send_Attachments_From_Non_Extension_Connection() var client = Ctx.CreateClient( options: options, - autoInjectGitHubToken: autoInjectGitHubToken); + autoInjectGitHubToken: autoInjectGitHubToken, + environment: env); await client.StartAsync(); return (client, home); } diff --git a/dotnet/test/E2E/RpcServerPluginsE2ETests.cs b/dotnet/test/E2E/RpcServerPluginsE2ETests.cs index eea409593..64a0f1c26 100644 --- a/dotnet/test/E2E/RpcServerPluginsE2ETests.cs +++ b/dotnet/test/E2E/RpcServerPluginsE2ETests.cs @@ -302,7 +302,7 @@ This skill exists so the plugin reports at least one installed skill. env["XDG_CONFIG_HOME"] = home; env["XDG_STATE_HOME"] = home; - var client = Ctx.CreateClient(options: new CopilotClientOptions { Environment = env }); + var client = Ctx.CreateClient(environment: env); await client.StartAsync(); return (client, home); } diff --git a/dotnet/test/E2E/RpcSessionStateExtrasE2ETests.cs b/dotnet/test/E2E/RpcSessionStateExtrasE2ETests.cs index 130d2e468..f3f3d5d5d 100644 --- a/dotnet/test/E2E/RpcSessionStateExtrasE2ETests.cs +++ b/dotnet/test/E2E/RpcSessionStateExtrasE2ETests.cs @@ -313,9 +313,8 @@ private CopilotClient CreateAuthenticatedClient(string token) return Ctx.CreateClient(options: new CopilotClientOptions { - Environment = env, GitHubToken = token, - }); + }, environment: env); } private async Task ConfigureAuthenticatedUserAsync(string token) diff --git a/dotnet/test/E2E/SessionFsSqliteE2ETests.cs b/dotnet/test/E2E/SessionFsSqliteE2ETests.cs index 1e6175f9c..bf7bdf3b5 100644 --- a/dotnet/test/E2E/SessionFsSqliteE2ETests.cs +++ b/dotnet/test/E2E/SessionFsSqliteE2ETests.cs @@ -117,9 +117,9 @@ await TestHelper.WaitForConditionAsync( private CopilotClient CreateSessionFsClient() { return Ctx.CreateClient( - useStdio: true, options: new CopilotClientOptions { + Connection = RuntimeConnection.ForStdio(), SessionFs = SessionFsConfig, }); } diff --git a/dotnet/test/E2E/SubagentHooksE2ETests.cs b/dotnet/test/E2E/SubagentHooksE2ETests.cs index 23ba8ed3a..4723c19b7 100644 --- a/dotnet/test/E2E/SubagentHooksE2ETests.cs +++ b/dotnet/test/E2E/SubagentHooksE2ETests.cs @@ -20,7 +20,7 @@ public async Task Should_Invoke_PreToolUse_And_PostToolUse_Hooks_For_Sub_Agent_T // Create a client with the session-based subagents feature flag var env = new Dictionary(Ctx.GetEnvironment()); env["COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS"] = "true"; - var client = Ctx.CreateClient(options: new CopilotClientOptions { Environment = env }); + var client = Ctx.CreateClient(environment: env); var session = await client.CreateSessionAsync(new SessionConfig { diff --git a/dotnet/test/E2E/TelemetryExportE2ETests.cs b/dotnet/test/E2E/TelemetryExportE2ETests.cs index 22ed5663d..2dd9d591a 100644 --- a/dotnet/test/E2E/TelemetryExportE2ETests.cs +++ b/dotnet/test/E2E/TelemetryExportE2ETests.cs @@ -24,6 +24,7 @@ public async Task Should_Export_File_Telemetry_For_Sdk_Interactions() await using var client = Ctx.CreateClient(options: new CopilotClientOptions { + Connection = RuntimeConnection.ForStdio(), Telemetry = new TelemetryConfig { FilePath = telemetryPath, diff --git a/dotnet/test/Harness/E2ETestContext.cs b/dotnet/test/Harness/E2ETestContext.cs index 99c58c92c..f4df1749f 100644 --- a/dotnet/test/Harness/E2ETestContext.cs +++ b/dotnet/test/Harness/E2ETestContext.cs @@ -237,61 +237,75 @@ public Dictionary GetEnvironment() } public CopilotClient CreateClient( - bool? useStdio = null, CopilotClientOptions? options = null, bool autoInjectGitHubToken = true, - bool persistent = false) + bool persistent = false, + IReadOnlyDictionary? environment = null) { options ??= new CopilotClientOptions(); options.WorkingDirectory ??= WorkDir; - options.Environment ??= GetEnvironment(); options.Logger ??= Logger; - // Build the connection. If the caller supplied one, just ensure the runtime path is set. - // When neither a Connection nor useStdio is specified, leave Connection null so - // CopilotClient honors COPILOT_SDK_DEFAULT_CONNECTION (defaulting to stdio); useStdio - // is a convenience shortcut to pin stdio/tcp. Passing both a Connection and useStdio is ambiguous. - if (useStdio is not null && options.Connection is not null) + // Tests must supply environment via the 'environment' parameter, which the + // harness routes to the right place per transport (the connection for + // child-process transports, the host process for in-process). Setting + // options.Environment directly bypasses that routing and is unsupported + // in-process, so reject it here. + if (options.Environment is not null) { throw new ArgumentException( - "Specify either useStdio or options.Connection, not both. " + - "Use options.Connection (e.g. RuntimeConnection.ForStdio() / RuntimeConnection.ForTcp()) to control transport when supplying a Connection.", - nameof(useStdio)); + "Do not set options.Environment in E2E tests; pass the 'environment' parameter to CreateClient instead.", + nameof(options)); } + // The full environment the client runs with: harness defaults (proxy + // redirect, isolated home, cleared HMAC/tokens, etc.) unless the test + // supplied a complete replacement. + var env = environment is not null + ? environment.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) + : GetEnvironment(); + + // When the test doesn't pin a transport, leave Connection null so + // CopilotClient honors COPILOT_SDK_DEFAULT_CONNECTION (stdio by default, + // or in-process); the CI matrix uses this to run the suite under both. + // Tests that need a specific transport set options.Connection directly. var cliPath = GetCliPath(_repoRoot); switch (options.Connection) { - case null when useStdio == true: + case null when !IsInProcess(null): + // No explicit connection and not the in-process default: the + // default resolves to stdio, so materialize it here so the + // environment can be attached to the connection below. options.Connection = RuntimeConnection.ForStdio(path: cliPath); break; - case null when useStdio == false: - options.Connection = RuntimeConnection.ForTcp(path: cliPath); - break; case null: - // useStdio is null: leave Connection unset so CopilotClient's - // ResolveDefaultConnection honors COPILOT_SDK_DEFAULT_CONNECTION - // (stdio by default, or in-process). The CLI path flows through - // options.Environment["COPILOT_CLI_PATH"] (GetEnvironment copies - // the process env, where CI's setup-copilot sets it). + // In-process default: leave Connection unset so CopilotClient's + // ResolveDefaultConnection honors COPILOT_SDK_DEFAULT_CONNECTION. break; case ChildProcessRuntimeConnection child when child.Path is null: child.Path = cliPath; break; } - // In-process hosting workaround: several runtime code paths run host-side - // in this process (the loaded cdylib) and read the ambient process - // environment rather than the environment passed to - // copilot_runtime_host_start, so our per-test redirects, cleared tokens, - // cleared HMAC keys, and isolated home in options.Environment are - // invisible to them unless mirrored onto this process's real environment. - // Restored after each test by InProcessEnvIsolationAttribute. Harmless for - // child-process transports, which configure their child's environment. - foreach (var (name, value) in options.Environment) + if (IsInProcess(options.Connection)) { - InProcessEnvIsolation.Apply(name, value); + // In-process hosting: runtime code runs host-side in this process (the + // loaded cdylib) and reads the ambient process environment rather than + // the environment passed to copilot_runtime_host_start, so the per-test + // redirects, cleared tokens/HMAC, and isolated home must be mirrored + // onto this process's real environment. Restored after each test by + // InProcessEnvIsolationAttribute. + foreach (var (name, value) in env) + { + InProcessEnvIsolation.Apply(name, value); + } + } + else if (options.Connection is ChildProcessRuntimeConnection child) + { + // Child-process transport: hand the environment to the spawned child + // via the connection, where per-client environment is coherent. + child.Environment = env; } // Auto-inject auth token unless connecting to an existing runtime via URI. @@ -440,6 +454,28 @@ private static async Task DeleteDirectoryAsync(string path) } } + /// + /// Determines whether the resolved transport is the in-process (FFI) host, + /// mirroring 's own default-connection resolution: + /// an explicit , or (when no connection + /// is given) the COPILOT_SDK_DEFAULT_CONNECTION=inprocess default. + /// + private static bool IsInProcess(RuntimeConnection? connection) + { + if (connection is InProcessRuntimeConnection) + { + return true; + } + if (connection is null) + { + return string.Equals( + Environment.GetEnvironmentVariable("COPILOT_SDK_DEFAULT_CONNECTION"), + "inprocess", + StringComparison.OrdinalIgnoreCase); + } + return false; + } + // Inproc holds the session-store SQLite handle in-process; graceful StopAsync releases it so the temp-dir delete succeeds on Windows. private static async Task StopClientForCleanupAsync(CopilotClient client) {