diff --git a/CHANGELOG.md b/CHANGELOG.md index d71e9b8248..c3b89944db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,145 @@ See [GitHub Releases](https://github.com/github/copilot-sdk/releases) for the fu ## [Unreleased] +### Breaking: Java process settings moved onto out-of-process connections (#2523) + +Java `CopilotClientOptions` no longer carries client-wide `cwd` or `environment`. Those process-scoped settings now live directly on `StdioRuntimeConnection` and `TcpRuntimeConnection` as `workingDirectory` and `environment`, because they only apply when the SDK spawns and manages a runtime process. Unlike the equivalent change in .NET, Go, Python, Node.js, and the broader cross-SDK plan, Java had no shared child-process base class to rename, so this is a smaller-scoped move onto the two concrete out-of-process connection types. + +Migration: + +```java +// Before +var client = new CopilotClient(new CopilotClientOptions() + .setCwd("/srv/app") + .setEnvironment(Map.of("KEY", "value")) + .setConnection(RuntimeConnection.forStdio("/usr/local/bin/copilot"))); + +// After +var client = new CopilotClient(new CopilotClientOptions() + .setConnection(RuntimeConnection.forStdio("/usr/local/bin/copilot") + .setWorkingDirectory("/srv/app") + .setEnvironment(Map.of("KEY", "value")))); +``` + +### Breaking: Rust process-scoped options moved onto out-of-process transports (#2523) + +`ClientOptions` no longer carries `program`, `prefix_args`, `working_directory`, `env`, `env_remove`, or `extra_args`. These settings never applied to `Transport::InProcess` or `Transport::External` (there's no SDK-managed CLI subprocess to configure in either case), so keeping them on the shared options struct made it possible to set values that were silently ignored. They now live on a new `OutOfProcessOptions` struct carried directly by the transport variants that spawn a CLI process: `Transport::Stdio(OutOfProcessOptions)` and `Transport::Tcp { process: OutOfProcessOptions, .. }`. `Transport::External` intentionally has no such field, since the SDK connects to a server it doesn't own. + +This also fixes a long-standing inconsistency: Rust's `env` previously merged into the inherited process environment (`Command::env(k, v)` without clearing first), while every other SDK replaced the environment when the caller supplied one. A non-empty `OutOfProcessOptions::env` now calls `env_clear()` before applying it, matching the other SDKs' replace semantics. SDK-managed variables (auth token, telemetry, `COPILOT_HOME`, keytar-disable, TCP connection token) are still injected before user `env`/`env_remove`, so callers can continue to override or strip them — this ordering is unchanged from before. + +Migration: + +```rust +// Before +let options = ClientOptions::new() + .with_program(CliProgram::Path("/usr/local/bin/copilot".into())) + .with_cwd("/srv/app") + .with_env([("KEY", "value")]) + .with_transport(Transport::Stdio); + +// After +let options = ClientOptions::new().with_transport(Transport::Stdio( + OutOfProcessOptions::new() + .with_program(CliProgram::Path("/usr/local/bin/copilot".into())) + .with_working_directory("/srv/app") + .with_env([("KEY", "value")]), +)); +``` + +`Transport::stdio()` is a new convenience constructor for `Transport::Stdio(OutOfProcessOptions::default())`. `OutOfProcessOptions::with_cwd` was renamed `with_working_directory` for clarity and consistency with the field name. + +### Breaking: .NET out-of-process launch settings moved (#2523) + +`.NET` `CopilotClientOptions` no longer carries `WorkingDirectory` or `Environment`. Those process-scoped settings now live on `StdioRuntimeConnection` and `TcpRuntimeConnection` through the renamed `OutOfProcessRuntimeConnection` base class, because they only apply when the SDK spawns and manages a runtime process. `ChildProcessRuntimeConnection` was renamed to `OutOfProcessRuntimeConnection`. + +Migration: + +```csharp +// Before +var client = new CopilotClient(new CopilotClientOptions +{ + WorkingDirectory = "/srv/app", + Environment = new Dictionary { ["KEY"] = "value" }, + Connection = RuntimeConnection.ForStdio(path: "/usr/local/bin/copilot"), +}); + +// After +var client = new CopilotClient(new CopilotClientOptions +{ + Connection = RuntimeConnection.ForStdio(path: "/usr/local/bin/copilot") + { + WorkingDirectory = "/srv/app", + Environment = new Dictionary { ["KEY"] = "value" }, + }, +}); +``` + +### Breaking: Python and Node.js out-of-process launch settings moved (#2523) + +Python `CopilotClient` and Node.js `CopilotClientOptions` no longer carry client-wide `working_directory` / `workingDirectory` or `env` launch settings. Those process-scoped settings now live on the renamed `OutOfProcessRuntimeConnection` base for stdio/TCP connections, because they only apply when the SDK spawns and manages a runtime process. `ChildProcessRuntimeConnection` was renamed to `OutOfProcessRuntimeConnection` in both packages. + +Migration: + +```python +# Before +client = CopilotClient( + working_directory="/srv/app", + env={"KEY": "value"}, + connection=RuntimeConnection.for_stdio(path="/usr/local/bin/copilot"), +) + +# After +client = CopilotClient( + connection=RuntimeConnection.for_stdio( + path="/usr/local/bin/copilot", + working_directory="/srv/app", + env={"KEY": "value"}, + ), +) +``` + +```ts +// Before +const client = new CopilotClient({ + workingDirectory: "/srv/app", + env: { KEY: "value" }, + connection: RuntimeConnection.forStdio({ path: "/usr/local/bin/copilot" }), +}); + +// After +const client = new CopilotClient({ + connection: RuntimeConnection.forStdio({ + path: "/usr/local/bin/copilot", + workingDirectory: "/srv/app", + env: { KEY: "value" }, + }), +}); +``` + +### Breaking: Go out-of-process launch settings moved (#2523) + +Go `ClientOptions` no longer carries `WorkingDirectory` or `Env`. Those process-scoped settings now live on `StdioConnection` and `TCPConnection`, because they only apply when the SDK spawns and manages an out-of-process runtime. The internal unexported `childProcessConnection` helper was renamed to `outOfProcessConnection`; this does not change the public Go API surface. + +Migration: + +```go +// Before +client := copilot.NewClient(&copilot.ClientOptions{ + Connection: copilot.StdioConnection{Path: "/usr/local/bin/copilot"}, + WorkingDirectory: "/srv/app", + Env: []string{"KEY=value"}, +}) + +// After +client := copilot.NewClient(&copilot.ClientOptions{ + Connection: copilot.StdioConnection{ + Path: "/usr/local/bin/copilot", + WorkingDirectory: "/srv/app", + Env: []string{"KEY=value"}, + }, +}) +``` + ## [v1.0.13](https://github.com/github/copilot-sdk/releases/tag/v1.0.13) (2026-09-04) ### Feature: cancellation for host-owned external tools diff --git a/docs/auth/server-to-server-tokens.md b/docs/auth/server-to-server-tokens.md index b7b4fcf409..0a5676bc56 100644 --- a/docs/auth/server-to-server-tokens.md +++ b/docs/auth/server-to-server-tokens.md @@ -61,15 +61,15 @@ const token = process.env.INSTALLATION_TOKEN; if (!token) throw new Error("INSTALLATION_TOKEN is required"); const client = new CopilotClient({ - connection: RuntimeConnection.forStdio(), - env: { - ...process.env, - COPILOT_GITHUB_TOKEN: token, - }, + connection: RuntimeConnection.forStdio({ + env: { + ...process.env, + COPILOT_GITHUB_TOKEN: token, + }, + }), useLoggedInUser: false, }); ``` -
Python @@ -80,8 +80,9 @@ import os from copilot import CopilotClient, RuntimeConnection client = CopilotClient( - connection=RuntimeConnection.for_stdio(), - env={**os.environ, "COPILOT_GITHUB_TOKEN": os.environ["INSTALLATION_TOKEN"]}, + connection=RuntimeConnection.for_stdio( + env={**os.environ, "COPILOT_GITHUB_TOKEN": os.environ["INSTALLATION_TOKEN"]}, + ), use_logged_in_user=False, ) ``` @@ -106,8 +107,9 @@ func main() { log.Fatal("INSTALLATION_TOKEN is required") } client := copilot.NewClient(&copilot.ClientOptions{ - Connection: copilot.StdioConnection{}, - Env: append(os.Environ(), "COPILOT_GITHUB_TOKEN="+token), + Connection: copilot.StdioConnection{ + Env: append(os.Environ(), "COPILOT_GITHUB_TOKEN="+token), + }, UseLoggedInUser: copilot.Bool(false), }) _ = client @@ -119,13 +121,14 @@ func main() { Rust ```rust -use github_copilot_sdk::{ClientOptions, Transport}; +use github_copilot_sdk::{ClientOptions, OutOfProcessOptions, Transport}; fn main() { let token = std::env::var("INSTALLATION_TOKEN").expect("INSTALLATION_TOKEN is required"); let options = ClientOptions::new() - .with_transport(Transport::Stdio) - .with_env([("COPILOT_GITHUB_TOKEN", token)]) + .with_transport(Transport::Stdio( + OutOfProcessOptions::new().with_env([("COPILOT_GITHUB_TOKEN", token)]), + )) .with_use_logged_in_user(false); drop(options); } @@ -146,10 +149,12 @@ var environment = Environment.GetEnvironmentVariables() .ToDictionary(entry => (string)entry.Key, entry => entry.Value?.ToString() ?? ""); environment["COPILOT_GITHUB_TOKEN"] = token; +var connection = RuntimeConnection.ForStdio(); +connection.Environment = environment; + await using var client = new CopilotClient(new CopilotClientOptions { - Connection = RuntimeConnection.ForStdio(), - Environment = environment, + Connection = connection, UseLoggedInUser = false, }); ``` @@ -161,6 +166,7 @@ await using var client = new CopilotClient(new CopilotClientOptions ```java import com.github.copilot.CopilotClient; import com.github.copilot.rpc.CopilotClientOptions; +import com.github.copilot.rpc.RuntimeConnection; import java.util.HashMap; import java.util.Objects; @@ -170,7 +176,7 @@ var token = Objects.requireNonNull( environment.put("COPILOT_GITHUB_TOKEN", token); try (var client = new CopilotClient(new CopilotClientOptions() - .setEnvironment(environment) + .setConnection(RuntimeConnection.forStdio().setEnvironment(environment)) .setUseLoggedInUser(false))) { // Use the client. } diff --git a/dotnet/README.md b/dotnet/README.md index c518a40326..6982582db9 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -91,10 +91,8 @@ new CopilotClient(CopilotClientOptions? options = null) - `Connection` - How to connect to the Copilot runtime. Defaults to `null` (equivalent to `RuntimeConnection.ForStdio()` with the bundled runtime). See "RuntimeConnection" below. - `LogLevel` - Runtime log level. Accepts well-known values `CopilotLogLevel.None`, `Error`, `Warning`, `Info`, `Debug`, `All`. Defaults to null (the runtime's own default). -- `WorkingDirectory` - Working directory for the runtime process. When not set, the spawned runtime inherits the calling application's current working directory. - `BaseDirectory` - Base directory for Copilot data (session state, config, etc.). Sets `COPILOT_HOME` on the spawned runtime process. When not set, the runtime defaults to `~/.copilot`. Useful in restricted environments where only specific directories are writable. Ignored when connecting via `RuntimeConnection.ForUri(...)`. - `EnableRemoteSessions` - Enables remote-session features. -- `Environment` - Environment variables to pass to the runtime process. - `Logger` - `ILogger` instance for SDK logging. - `GitHubToken` - GitHub token for authentication. When provided, takes priority over other auth methods. - `UseLoggedInUser` - Whether to use logged-in user for authentication (default: true, but false when `GitHubToken` is provided). Cannot be used with `RuntimeConnection.ForUri(...)`. @@ -108,6 +106,26 @@ new CopilotClient(CopilotClientOptions? options = null) - `RuntimeConnection.ForTcp(port = 0, connectionToken?, path?, args?)` — spawns the runtime as a child process listening on a TCP port. `port = 0` auto-allocates; if a non-zero port is already in use, startup fails (no fallback). Use `CopilotClient.RuntimePort` after `StartAsync` to read the assigned port. `connectionToken` is required if other clients will connect via `RuntimeConnection.ForUri(...)`. - `RuntimeConnection.ForUri(url, connectionToken?)` — connects to an already-running runtime at `url` (e.g., `"localhost:8080"`). Does not spawn a process. +`StdioRuntimeConnection` and `TcpRuntimeConnection` both inherit from `OutOfProcessRuntimeConnection`, which carries process-scoped launch settings for SDK-managed runtime processes: + +- `Path` - Path to the runtime executable. When not set, the bundled runtime is used. +- `Args` - Extra command-line arguments to pass to the runtime process. +- `WorkingDirectory` - Working directory for the runtime process. When not set, the spawned runtime inherits the calling application's current working directory. +- `Environment` - Environment variables to pass to the runtime process, replacing the inherited environment. + +These settings live on the out-of-process connection instead of `CopilotClientOptions` because they do not apply to `RuntimeConnection.ForInProcess()` and do not affect `RuntimeConnection.ForUri(...)`. + +```csharp +var client = new CopilotClient(new CopilotClientOptions +{ + Connection = RuntimeConnection.ForStdio(path: "/usr/local/bin/copilot") + { + WorkingDirectory = "/srv/app", + Environment = new Dictionary { ["KEY"] = "value" }, + }, +}); +``` + Managed stdio and TCP connections use the bundled `copilot-runtime[.exe]` and adjacent `runtime.node` by default. An explicit connection path or `COPILOT_CLI_PATH` overrides the bundled runtime. diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index dff4681a80..37a333f760 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -139,7 +139,7 @@ private sealed record LifecycleSubscription(Type EventType, Action !IsFullyQualifiedPath(path))) { @@ -185,7 +185,7 @@ public CopilotClient(CopilotClientOptions? options = null) throw new ArgumentException($"Unsupported RuntimeConnection type: {_connection.GetType().Name}", nameof(options)); } - ValidateEnvironmentOptions(_options, _connection); + ValidateTransportOptions(_options, _connection); _logger = _options.Logger ?? NullLogger.Instance; _onListModels = _options.OnListModels; @@ -216,27 +216,16 @@ _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. + /// Validates options against the resolved transport. + /// Process-scoped settings live on , + /// so the remaining transport-specific client validation is that in-process + /// hosting rejects telemetry configuration, which lowers to environment + /// variables on the shared host process. /// - private static void ValidateEnvironmentOptions(CopilotClientOptions options, RuntimeConnection connection) + private static void ValidateTransportOptions(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( @@ -244,32 +233,9 @@ private static void ValidateEnvironmentOptions(CopilotClientOptions options, Run $"{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.", + "environment, or use an out-of-process transport.", nameof(options)); } - - if (options.WorkingDirectory is not null) - { - throw new ArgumentException( - $"{nameof(CopilotClientOptions)}.{nameof(CopilotClientOptions.WorkingDirectory)} is not supported with " + - $"{nameof(RuntimeConnection)}.{nameof(RuntimeConnection.ForInProcess)}(): the in-process transport hosts " + - "the native runtime in the shared host process and spawns the worker without a working-directory " + - "parameter, so a per-client working directory cannot be honored in-process. Use a child-process " + - "transport, or set the process working directory before creating the client.", - 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)); } } @@ -286,12 +252,9 @@ private static void ValidateEnvironmentOptions(CopilotClientOptions options, Run /// Resolves the default for the no-Connection case, /// honoring . /// - private static RuntimeConnection ResolveDefaultConnection(CopilotClientOptions options) + private static RuntimeConnection ResolveDefaultConnection() { - var value = options.Environment is not null - && options.Environment.TryGetValue(DefaultConnectionEnvVar, out var fromOptions) - ? fromOptions - : Environment.GetEnvironmentVariable(DefaultConnectionEnvVar); + var value = Environment.GetEnvironmentVariable(DefaultConnectionEnvVar); if (string.IsNullOrEmpty(value) || string.Equals(value, "stdio", StringComparison.OrdinalIgnoreCase)) { @@ -2267,18 +2230,18 @@ private static void ApplyTelemetryEnvironment(IDictionary envir { var options = _options; var logger = _logger; - var childProcessConnection = (ChildProcessRuntimeConnection)_connection; + var outOfProcessConnection = (OutOfProcessRuntimeConnection)_connection; var tcpConnection = _connection as TcpRuntimeConnection; var useStdio = _connection is StdioRuntimeConnection; // Explicit CLI paths preserve the legacy launch contract. Otherwise use // the bundled native runtime pair. - var configuredEnvironment = childProcessConnection.Environment ?? options.Environment; + var configuredEnvironment = outOfProcessConnection.Environment; var envCliPath = configuredEnvironment is not null ? configuredEnvironment.TryGetValue("COPILOT_CLI_PATH", out var configuredCliPath) ? configuredCliPath : null : System.Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"); - var launch = childProcessConnection.Path is not null - ? new RuntimeLaunch(childProcessConnection.Path, "Options") + var launch = outOfProcessConnection.Path is not null + ? new RuntimeLaunch(outOfProcessConnection.Path, "Options") : envCliPath is not null ? new RuntimeLaunch(envCliPath, "Environment") : GetBundledRuntimeLaunch(); @@ -2286,9 +2249,9 @@ private static void ApplyTelemetryEnvironment(IDictionary envir var cliPathSource = launch.Source; var args = new List(); - if (childProcessConnection.Args != null) + if (outOfProcessConnection.Args != null) { - args.AddRange(childProcessConnection.Args); + args.AddRange(outOfProcessConnection.Args); } args.AddRange(["--headless", "--no-auto-update"]); @@ -2341,11 +2304,11 @@ private static void ApplyTelemetryEnvironment(IDictionary envir RedirectStandardInput = useStdio, RedirectStandardOutput = true, RedirectStandardError = true, - WorkingDirectory = options.WorkingDirectory, + WorkingDirectory = outOfProcessConnection.WorkingDirectory, CreateNoWindow = true }; - var childEnvironment = options.Environment ?? childProcessConnection.Environment; + var childEnvironment = outOfProcessConnection.Environment; if (childEnvironment != null) { startInfo.Environment.Clear(); diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 1cc4919093..27cad59575 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -162,11 +162,12 @@ public static InProcessRuntimeConnection ForInProcess() } /// -/// Base for kinds that spawn a runtime child process. +/// Base for kinds that the SDK hosts out of process +/// by spawning a runtime process. /// -public abstract class ChildProcessRuntimeConnection : RuntimeConnection +public abstract class OutOfProcessRuntimeConnection : RuntimeConnection { - internal ChildProcessRuntimeConnection() { } + internal OutOfProcessRuntimeConnection() { } /// Path to the runtime executable. When null, the bundled runtime is used. public string? Path { get; set; } @@ -174,14 +175,16 @@ internal ChildProcessRuntimeConnection() { } /// Extra command-line arguments to pass to the runtime process. public IList? Args { get; set; } + /// + /// Working directory for the spawned runtime process. When null, the + /// spawned runtime inherits the calling process's current working directory. + /// + public string? WorkingDirectory { 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; } } @@ -189,7 +192,7 @@ internal ChildProcessRuntimeConnection() { } /// Spawns a runtime child process and communicates over stdin/stdout. Construct via /// . /// -public sealed class StdioRuntimeConnection : ChildProcessRuntimeConnection +public sealed class StdioRuntimeConnection : OutOfProcessRuntimeConnection { internal StdioRuntimeConnection() { } } @@ -211,7 +214,7 @@ internal InProcessRuntimeConnection() { } /// Spawns a runtime child process listening on a TCP socket. Construct via /// . /// -public sealed class TcpRuntimeConnection : ChildProcessRuntimeConnection +public sealed class TcpRuntimeConnection : OutOfProcessRuntimeConnection { internal TcpRuntimeConnection() { } @@ -291,6 +294,14 @@ public enum CopilotClientMode /// /// Configuration options for creating a instance. /// +/// +/// Process-scoped settings are configured on +/// (for example +/// and +/// ), not on +/// , because they do not apply to +/// . +/// public sealed class CopilotClientOptions { /// @@ -307,10 +318,8 @@ private CopilotClientOptions(CopilotClientOptions? other) if (other is null) return; Connection = other.Connection; - WorkingDirectory = other.WorkingDirectory; BaseDirectory = other.BaseDirectory; BuiltinPluginDirectories = other.BuiltinPluginDirectories is null ? null : [.. other.BuiltinPluginDirectories]; - Environment = other.Environment; GitHubToken = other.GitHubToken; Logger = other.Logger; LogLevel = other.LogLevel; @@ -346,11 +355,6 @@ private CopilotClientOptions(CopilotClientOptions? other) /// public RuntimeConnection? Connection { get; set; } - /// - /// Working directory for the runtime process. - /// - public string? WorkingDirectory { get; set; } - /// /// Base directory for Copilot data (session state, config, etc.). /// Sets the COPILOT_HOME environment variable on the spawned runtime. @@ -377,17 +381,6 @@ private CopilotClientOptions(CopilotClientOptions? other) /// public CopilotLogLevel? LogLevel { get; set; } - /// - /// 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. public ILogger? Logger { get; set; } diff --git a/dotnet/test/E2E/ClientOptionsE2ETests.cs b/dotnet/test/E2E/ClientOptionsE2ETests.cs index 1ff473bd01..5092a79301 100644 --- a/dotnet/test/E2E/ClientOptionsE2ETests.cs +++ b/dotnet/test/E2E/ClientOptionsE2ETests.cs @@ -31,15 +31,17 @@ public async Task Should_Listen_On_Configured_Tcp_Port() } [Fact] - public async Task Should_Use_Client_Cwd_For_Default_WorkingDirectory() + public async Task Should_Use_OutOfProcess_Connection_WorkingDirectory() { var clientCwd = Path.Join(Ctx.WorkDir, "client-cwd"); Directory.CreateDirectory(clientCwd); await File.WriteAllTextAsync(Path.Join(clientCwd, "marker.txt"), "I am in the client cwd"); + var connection = RuntimeConnection.ForStdio(); + connection.WorkingDirectory = clientCwd; await using var client = Ctx.CreateClient(options: new CopilotClientOptions { - WorkingDirectory = clientCwd, + Connection = connection, }); var session = await Ctx.CreateSessionAsync(client, new SessionConfig diff --git a/dotnet/test/Harness/E2ETestContext.cs b/dotnet/test/Harness/E2ETestContext.cs index 7bdfeca124..80b36cebda 100644 --- a/dotnet/test/Harness/E2ETestContext.cs +++ b/dotnet/test/Harness/E2ETestContext.cs @@ -298,26 +298,16 @@ public CopilotClient CreateClient( options.Logger ??= Logger; - // Resolve the working directory the worker should run in. Child-process and - // URI transports take it as a per-client option; the in-process transport - // rejects a per-client WorkingDirectory (the native host spawns the worker - // without a cwd parameter), so — mirroring the Node/Rust harnesses — we point - // THIS process's cwd at the desired directory before the worker spawns and - // clear the per-client option. InProcessEnvIsolationAttribute.After restores - // the cwd after the test. - var desiredWorkingDirectory = options.WorkingDirectory ?? WorkDir; + // Resolve the working directory the worker should run in. Out-of-process + // transports carry it on the connection itself; in-process still inherits + // this process's cwd, so the harness points THIS process at WorkDir before + // the worker spawns. InProcessEnvIsolationAttribute.After restores the cwd + // after the test. + var desiredWorkingDirectory = (options.Connection as OutOfProcessRuntimeConnection)?.WorkingDirectory ?? WorkDir; // 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( - "Do not set options.Environment in E2E tests; pass the 'environment' parameter to CreateClient instead.", - nameof(options)); - } + // out-of-process transports, the host process for in-process). // The full environment the client runs with: harness defaults (proxy // redirect, isolated home, cleared HMAC/tokens, etc.) unless the test @@ -342,27 +332,21 @@ public CopilotClient CreateClient( // In-process default: leave Connection unset so CopilotClient's // ResolveDefaultConnection honors COPILOT_SDK_DEFAULT_CONNECTION. break; - case ChildProcessRuntimeConnection child when child.Path is null: + case OutOfProcessRuntimeConnection child when child.Path is null: child.Path = GetCliPath(); break; } if (IsInProcess(options.Connection)) { - options.WorkingDirectory = null; ApplyInProcessEnvironment(env, desiredWorkingDirectory); } - else if (options.Connection is ChildProcessRuntimeConnection child) + else if (options.Connection is OutOfProcessRuntimeConnection child) { - // Child-process transport: hand the environment to the spawned child + // Out-of-process transport: hand the environment to the spawned child // via the connection, where per-client environment is coherent. child.Environment = env; - options.WorkingDirectory = desiredWorkingDirectory; - } - else - { - // URI / existing-runtime transport: per-client WorkingDirectory applies normally. - options.WorkingDirectory = desiredWorkingDirectory; + child.WorkingDirectory ??= desiredWorkingDirectory; } // Auto-inject auth token unless connecting to an existing runtime via URI. diff --git a/dotnet/test/Unit/CloneTests.cs b/dotnet/test/Unit/CloneTests.cs index 20d5a1c296..6a5ceb90a5 100644 --- a/dotnet/test/Unit/CloneTests.cs +++ b/dotnet/test/Unit/CloneTests.cs @@ -11,12 +11,14 @@ public class CloneTests [Fact] public void CopilotClientOptions_Clone_CopiesAllProperties() { + var connection = RuntimeConnection.ForTcp(port: 8080, connectionToken: "tok", path: "/usr/bin/copilot", args: ["--verbose", "--debug"]); + connection.WorkingDirectory = "/home/user"; + connection.Environment = new Dictionary { ["KEY"] = "value" }; + var original = new CopilotClientOptions { - Connection = RuntimeConnection.ForTcp(port: 8080, connectionToken: "tok", path: "/usr/bin/copilot", args: ["--verbose", "--debug"]), - WorkingDirectory = "/home/user", + Connection = connection, LogLevel = CopilotLogLevel.Debug, - Environment = new Dictionary { ["KEY"] = "value" }, GitHubToken = "ghp_test", UseLoggedInUser = false, BaseDirectory = "/custom/copilot/home", @@ -33,11 +35,13 @@ public void CopilotClientOptions_Clone_CopiesAllProperties() }; var clone = original.Clone(); + var originalConnection = Assert.IsType(original.Connection); + var cloneConnection = Assert.IsType(clone.Connection); Assert.Same(original.Connection, clone.Connection); - Assert.Equal(original.WorkingDirectory, clone.WorkingDirectory); + Assert.Equal(originalConnection.WorkingDirectory, cloneConnection.WorkingDirectory); Assert.Equal(original.LogLevel, clone.LogLevel); - Assert.Equal(original.Environment, clone.Environment); + Assert.Equal(originalConnection.Environment, cloneConnection.Environment); Assert.Equal(original.GitHubToken, clone.GitHubToken); Assert.Equal(original.UseLoggedInUser, clone.UseLoggedInUser); Assert.Equal(original.BaseDirectory, clone.BaseDirectory); @@ -60,14 +64,21 @@ public void CopilotClientOptions_Clone_ConnectionIsShared() } [Fact] - public void CopilotClientOptions_Clone_EnvironmentIsShared() + public void OutOfProcessConnection_EnvironmentReference_IsShared_By_ClientOptionsClone() { var env = new Dictionary { ["key"] = "value" }; - var original = new CopilotClientOptions { Environment = env }; + var connection = RuntimeConnection.ForStdio(); + connection.Environment = env; + var original = new CopilotClientOptions + { + Connection = connection, + }; var clone = original.Clone(); + var originalConnection = Assert.IsType(original.Connection); + var cloneConnection = Assert.IsType(clone.Connection); - Assert.Same(original.Environment, clone.Environment); + Assert.Same(originalConnection.Environment, cloneConnection.Environment); } [Fact] diff --git a/dotnet/test/Unit/RuntimeWrapperTests.cs b/dotnet/test/Unit/RuntimeWrapperTests.cs index e7c77ab92d..b3818238c4 100644 --- a/dotnet/test/Unit/RuntimeWrapperTests.cs +++ b/dotnet/test/Unit/RuntimeWrapperTests.cs @@ -29,10 +29,11 @@ public async Task Managed_Launch_Fails_When_Bundled_Runtime_Pair_Is_Missing() try { AppContext.SetData("APP_CONTEXT_BASE_DIRECTORY", emptyBaseDirectory); + var connection = RuntimeConnection.ForStdio(); + connection.Environment = new Dictionary(); await using var client = new CopilotClient(new CopilotClientOptions { - Connection = RuntimeConnection.ForStdio(), - Environment = new Dictionary(), + Connection = connection, }); var exception = await Assert.ThrowsAsync(() => client.StartAsync()); @@ -53,10 +54,11 @@ public async Task Explicit_Path_Does_Not_Require_Adjacent_Runtime_Node() var explicitPath = Path.Combine( Path.GetTempPath(), $"missing-explicit-copilot-{Guid.NewGuid():N}"); + var connection = RuntimeConnection.ForStdio(path: explicitPath); + connection.Environment = new Dictionary(); await using var client = new CopilotClient(new CopilotClientOptions { - Connection = RuntimeConnection.ForStdio(path: explicitPath), - Environment = new Dictionary(), + Connection = connection, }); var exception = await Assert.ThrowsAnyAsync(() => client.StartAsync()); @@ -70,10 +72,11 @@ public async Task Copilot_Cli_Path_Does_Not_Require_Adjacent_Runtime_Node() var explicitPath = Path.Combine( Path.GetTempPath(), $"missing-environment-copilot-{Guid.NewGuid():N}"); + var connection = RuntimeConnection.ForStdio(); + connection.Environment = new Dictionary { ["COPILOT_CLI_PATH"] = explicitPath }; await using var client = new CopilotClient(new CopilotClientOptions { - Connection = RuntimeConnection.ForStdio(), - Environment = new Dictionary { ["COPILOT_CLI_PATH"] = explicitPath }, + Connection = connection, }); var exception = await Assert.ThrowsAnyAsync(() => client.StartAsync()); @@ -99,10 +102,11 @@ public async Task Marked_Bundled_Explicit_Cli_Does_Not_Require_Runtime_Pair() try { AppContext.SetData("APP_CONTEXT_BASE_DIRECTORY", baseDirectory); + var connection = RuntimeConnection.ForStdio(); + connection.Environment = new Dictionary(); await using var client = new CopilotClient(new CopilotClientOptions { - Connection = RuntimeConnection.ForStdio(), - Environment = new Dictionary(), + Connection = connection, }); var exception = await Assert.ThrowsAnyAsync(() => client.StartAsync()); diff --git a/go/README.md b/go/README.md index 2eb2c720fc..beac72f7b1 100644 --- a/go/README.md +++ b/go/README.md @@ -110,7 +110,7 @@ Follow these steps to embed the CLI: 1. Run `go get -tool github.com/github/copilot-sdk/go/cmd/bundler`. This is a one-time setup step per project. 2. Run `go tool bundler` in your build environment just before building your application. -That's it! When your application calls `copilot.NewClient` without a `Connection` field (or with an empty `StdioConnection{}`), the SDK automatically installs the embedded `copilot-runtime` executable and adjacent `runtime.node` to a cache directory for managed child-process connections. +That's it! When your application calls `copilot.NewClient` without a `Connection` field (or with an empty `StdioConnection{}`), the SDK automatically installs the embedded `copilot-runtime` executable and adjacent `runtime.node` to a cache directory for managed out-of-process connections. The bundler prepares the native runtime library required by the [in-process transport](#in-process-transport-experimental). It is included in the application only when building with the `copilot_inprocess` build tag. @@ -118,7 +118,7 @@ The bundler prepares the native runtime library required by the [in-process tran > **Experimental:** the in-process API may change in a future release. -By default the SDK starts the runtime as a child process and talks JSON-RPC over stdio or TCP. The **in-process** transport instead loads a native runtime library directly into your process. +By default the SDK starts the runtime out of process and talks JSON-RPC over stdio or TCP. The **in-process** transport instead loads a native runtime library directly into your process. Build your application with the `copilot_inprocess` build tag: @@ -144,7 +144,7 @@ Resolution and requirements: always takes precedence. - Set `COPILOT_CLI_PATH` only when using an externally provisioned compatible runtime package; otherwise the bundled runtime is used. No `PATH` lookup is performed. - Embedded runtime versions are isolated in separate cache directories. Start fails loudly if the native runtime is unavailable. -- Managed child-process start fails if the embedded `copilot-runtime` and +- Managed out-of-process start fails if the embedded `copilot-runtime` and `runtime.node` pair is unavailable; explicit paths and `COPILOT_CLI_PATH` remain direct overrides. - Linux in-process bundles include both glibc and musl runtime packages and select the matching package automatically at startup. @@ -152,9 +152,7 @@ Resolution and requirements: The in-process transport rejects options that cannot be honored by a runtime hosted in your shared process (each panics at `NewClient`): -- `Env` — the host process has a single environment block. Set variables on the host process environment instead. -- `WorkingDirectory` — the runtime shares the host process's working directory. Change the process working directory before creating the client. -- `Telemetry` — per-client telemetry is lowered to native-runtime environment variables. Use a child-process transport for per-client telemetry. +- `Telemetry` — per-client telemetry is lowered to native-runtime environment variables. Use an out-of-process connection for per-client telemetry. Implemented with pure-Go FFI (via [purego](https://github.com/ebitengine/purego)), so `CGO_ENABLED=0` and cross-compilation are preserved; no C toolchain is required. @@ -199,18 +197,16 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec **ClientOptions:** - `Connection` (RuntimeConnection): How the SDK connects to the runtime. Construct via one of: - - `StdioConnection{Path, Args, Env}` — spawn a runtime over stdio (the default if `Connection` is nil) - - `TCPConnection{Port, ConnectionToken, Path, Args, Env}` — spawn a runtime that listens on TCP + - `StdioConnection{Path, Args, WorkingDirectory, Env}` — spawn and manage an out-of-process runtime over stdio (the default if `Connection` is nil) + - `TCPConnection{Port, ConnectionToken, Path, Args, WorkingDirectory, Env}` — spawn and manage an out-of-process runtime that listens on TCP - `URIConnection{URL, ConnectionToken}` — connect to an already-running runtime (no process spawned) - - `InProcessConnection{}` — **Experimental.** Host the runtime in-process via the native FFI library instead of spawning a child process. See [In-process transport](#in-process-transport-experimental) below. + - `InProcessConnection{}` — **Experimental.** Host the runtime in-process via the native FFI library instead of spawning an out-of-process runtime. See [In-process transport](#in-process-transport-experimental) below. When `Path` is empty for stdio/tcp, the SDK uses `COPILOT_CLI_PATH` when set, then the bundled `copilot-runtime` and adjacent `runtime.node`. - `StdioConnection` and `TCPConnection` accept an optional connection-level `Env`. Set environment variables via **either** the client-level `Env` option or the connection's `Env`, not both (setting both panics); prefer the connection-level `Env`. -- `WorkingDirectory` (string): Working directory for the runtime process (default: current process working directory) + Process-scoped settings are configured on `StdioConnection` and `TCPConnection`, not on `ClientOptions`, because they do not apply to `InProcessConnection`. - `BaseDirectory` (string): Base directory for Copilot data (session state, config, etc.). Sets `COPILOT_HOME` on the spawned runtime. When empty, the runtime defaults to `~/.copilot`. Ignored with `URIConnection`. This does **not** affect where the Go SDK extracts the embedded CLI binary; use `embeddedcli.Config.Dir` for the extraction/cache location. - `LogLevel` (string): Log level. When empty (default), the runtime uses its own default level (the SDK does not pass `--log-level`). -- `Env` ([]string): Environment variables for the runtime process (default: inherits from current process) - `GitHubToken` (string): GitHub token for authentication. When provided, takes priority over other auth methods. - `UseLoggedInUser` (\*bool): Whether to use logged-in user for authentication (default: true, but false when `GitHubToken` is provided). Cannot be used with `URIConnection`. - `EnableRemoteSessions` (bool): Enable remote session support (Mission Control integration). Ignored with `URIConnection`. diff --git a/go/client.go b/go/client.go index e450c595f2..e325f42ca6 100644 --- a/go/client.go +++ b/go/client.go @@ -95,33 +95,20 @@ func validateSessionFSConfig(config *SessionFSConfig) error { return nil } -// validateEnvironmentOptions enforces the transport-specific rules for -// per-client environment, working directory, and telemetry. It panics (fails -// loud) on a misconfiguration, matching the other SDKs. -// -// The in-process transport loads the native runtime into this process, whose -// single environment block and process-global working directory cannot carry -// per-client values, and whose telemetry lowers to shared process-global env -// vars — so options that depend on them are rejected there. Child-process -// transports each own their OS process, so per-connection env is allowed, but -// setting it in both the client-level option and the connection is rejected. -func validateEnvironmentOptions(connection RuntimeConnection, opts *ClientOptions) { +// validateTransportOptions enforces transport-specific rules for client-wide +// options. It panics (fails loud) on a misconfiguration, matching the other +// SDKs. +// +// The in-process transport loads the native runtime into this process, so +// client-wide telemetry cannot be represented because it lowers to shared +// process-global environment variables. Process-scoped settings such as +// environment and working directory are configured on out-of-process +// connections instead of [ClientOptions], so they are structurally excluded +// from [InProcessConnection]. +func validateTransportOptions(connection RuntimeConnection, opts *ClientOptions) { if _, ok := connection.(InProcessConnection); ok { - if opts.Env != nil { - panic("Env is not supported with InProcessConnection: 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.") - } - if opts.WorkingDirectory != "" { - panic("WorkingDirectory is not supported with InProcessConnection: the native runtime shares the host process working directory. Use a child-process transport, or set the process working directory before creating the client.") - } if opts.Telemetry != nil { - panic("Telemetry is not supported with InProcessConnection: 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.") - } - return - } - - if cp, ok := connection.(childProcessConnection); ok { - if cp.connEnv() != nil && opts.Env != nil { - panic("Set environment variables via either the client-level Env option or the connection's Env, not both. Prefer the connection-level Env for child-process transports.") + panic("Telemetry is not supported with InProcessConnection: 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 an out-of-process connection.") } } } @@ -164,10 +151,12 @@ type Client struct { useInProcess bool // true for InProcessConnection (FFI transport) ffiHost inProcessHost // resolved process options for the spawned runtime (zero values for URIConnection) - cliPath string - cliArgs []string - port int - tcpConnectionToken string + cliPath string + cliArgs []string + processWorkingDirectory string + processEnv []string + port int + tcpConnectionToken string modelsCache []ModelInfo modelsCacheMux sync.Mutex @@ -248,11 +237,7 @@ func NewClient(options *ClientOptions) *Client { // honor the same process/environment override as the other SDKs. connection := opts.Connection if connection == nil { - env := opts.Env - if env == nil { - env = os.Environ() - } - connection = resolveDefaultConnection(env) + connection = resolveDefaultConnection(os.Environ()) } switch conn := connection.(type) { case StdioConnection: @@ -261,12 +246,20 @@ func NewClient(options *ClientOptions) *Client { if len(conn.Args) > 0 { client.cliArgs = append([]string{}, conn.Args...) } + client.processWorkingDirectory = conn.WorkingDirectory + if conn.Env != nil { + client.processEnv = append([]string{}, conn.Env...) + } case TCPConnection: client.useStdio = false client.cliPath = conn.Path if len(conn.Args) > 0 { client.cliArgs = append([]string{}, conn.Args...) } + client.processWorkingDirectory = conn.WorkingDirectory + if conn.Env != nil { + client.processEnv = append([]string{}, conn.Env...) + } client.port = conn.Port client.tcpConnectionToken = conn.ConnectionToken case URIConnection: @@ -286,35 +279,26 @@ func NewClient(options *ClientOptions) *Client { panic(fmt.Sprintf("unknown RuntimeConnection type: %T", connection)) } - // Validate transport-specific option constraints (fail loud). The in-process - // transport loads the runtime into this process, whose single environment - // block, process-global working directory, and shared telemetry state cannot - // carry per-client values. Child-process transports may set env via either - // the client-level option or the connection, but not both. - validateEnvironmentOptions(connection, &opts) + // Validate transport-specific option constraints (fail loud). + validateTransportOptions(connection, &opts) // Validate auth options when connecting to an external runtime. if client.isExternalServer && (opts.GitHubToken != "" || opts.UseLoggedInUser != nil) { panic("GitHubToken and UseLoggedInUser cannot be used with URIConnection (external runtime manages its own auth)") } - // For child-process transports, a connection-level env takes precedence over - // the client-level env (setting both was rejected above). Resolve it before - // defaulting so an explicit empty connection env stays authoritative. - if cp, ok := connection.(childProcessConnection); ok { - if env := cp.connEnv(); env != nil { - opts.Env = env - } - } - - // Default Env to current environment if not set - if opts.Env == nil { - opts.Env = os.Environ() + // Default the environment to the current process if not set. This mirrors + // pre-v2 behavior: the effective env is always resolved (it's harmless for + // in-process transport, since it's never applied there), and an explicit + // empty slice on an out-of-process connection stays authoritative and + // yields a cleared child env. + if client.processEnv == nil { + client.processEnv = os.Environ() } - // Check the effective environment for a child-process runtime override. - if client.cliPath == "" && !client.useInProcess { - if cliPath := getEnvValue(opts.Env, "COPILOT_CLI_PATH"); cliPath != "" { + // Check the effective environment for an out-of-process runtime override. + if client.cliPath == "" && !client.useInProcess && !client.isExternalServer { + if cliPath := getEnvValue(client.processEnv, "COPILOT_CLI_PATH"); cliPath != "" { client.cliPath = cliPath } } @@ -628,7 +612,7 @@ func (c *Client) Stop() error { c.process = nil // Tear down the in-process FFI host (closes the connection and shuts down the - // native runtime). No child process to reap in this mode. + // native runtime). No out-of-process runtime to reap in this mode. if c.ffiHost != nil { c.ffiHost.Dispose() c.ffiHost = nil @@ -2118,11 +2102,11 @@ func (c *Client) startCLIServer(ctx context.Context) error { configureProcAttr(c.process) // Set working directory if specified - if c.options.WorkingDirectory != "" { - c.process.Dir = c.options.WorkingDirectory + if c.processWorkingDirectory != "" { + c.process.Dir = c.processWorkingDirectory } - c.process.Env = append([]string{}, c.options.Env...) + c.process.Env = append([]string{}, c.processEnv...) if c.options.GitHubToken != "" { c.process.Env = setEnvValue(c.process.Env, "COPILOT_SDK_AUTH_TOKEN", c.options.GitHubToken) } @@ -2265,7 +2249,7 @@ func (c *Client) startInProcess(ctx context.Context) error { cliEntrypoint := c.cliPath if cliEntrypoint == "" { - cliEntrypoint = getEnvValue(c.options.Env, "COPILOT_CLI_PATH") + cliEntrypoint = os.Getenv("COPILOT_CLI_PATH") } runtimePath := cliEntrypoint if runtimePath == "" { diff --git a/go/client_test.go b/go/client_test.go index 52587c8462..06606befb8 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -1375,48 +1375,70 @@ func TestClient_BaseDirectory(t *testing.T) { } func TestClient_EnvOptions(t *testing.T) { - t.Run("should store custom environment variables", func(t *testing.T) { + t.Run("should store custom stdio connection environment variables", func(t *testing.T) { client := NewClient(&ClientOptions{ - Env: []string{"FOO=bar", "BAZ=qux"}, + Connection: StdioConnection{Env: []string{"FOO=bar", "BAZ=qux"}}, }) - if len(client.options.Env) != 2 { - t.Errorf("Expected 2 environment variables, got %d", len(client.options.Env)) + if len(client.processEnv) != 2 { + t.Errorf("Expected 2 environment variables, got %d", len(client.processEnv)) } - if client.options.Env[0] != "FOO=bar" { - t.Errorf("Expected first env var to be 'FOO=bar', got %q", client.options.Env[0]) + if client.processEnv[0] != "FOO=bar" { + t.Errorf("Expected first env var to be 'FOO=bar', got %q", client.processEnv[0]) } - if client.options.Env[1] != "BAZ=qux" { - t.Errorf("Expected second env var to be 'BAZ=qux', got %q", client.options.Env[1]) + if client.processEnv[1] != "BAZ=qux" { + t.Errorf("Expected second env var to be 'BAZ=qux', got %q", client.processEnv[1]) } }) t.Run("should default to inherit from current process", func(t *testing.T) { client := NewClient(&ClientOptions{}) - if want := os.Environ(); !reflect.DeepEqual(client.options.Env, want) { - t.Errorf("Expected Env to be %v, got %v", want, client.options.Env) + if want := os.Environ(); !reflect.DeepEqual(client.processEnv, want) { + t.Errorf("Expected Env to be %v, got %v", want, client.processEnv) } }) t.Run("should default to inherit from current process with nil options", func(t *testing.T) { client := NewClient(nil) - if want := os.Environ(); !reflect.DeepEqual(client.options.Env, want) { - t.Errorf("Expected Env to be %v, got %v", want, client.options.Env) + if want := os.Environ(); !reflect.DeepEqual(client.processEnv, want) { + t.Errorf("Expected Env to be %v, got %v", want, client.processEnv) } }) t.Run("should allow empty environment", func(t *testing.T) { client := NewClient(&ClientOptions{ - Env: []string{}, + Connection: StdioConnection{Env: []string{}}, }) - if client.options.Env == nil { + if client.processEnv == nil { t.Error("Expected Env to be non-nil empty slice") } - if len(client.options.Env) != 0 { - t.Errorf("Expected 0 environment variables, got %d", len(client.options.Env)) + if len(client.processEnv) != 0 { + t.Errorf("Expected 0 environment variables, got %d", len(client.processEnv)) + } + }) +} + +func TestClient_WorkingDirectoryOptions(t *testing.T) { + t.Run("should store stdio connection working directory", func(t *testing.T) { + client := NewClient(&ClientOptions{ + Connection: StdioConnection{WorkingDirectory: "/tmp/stdio"}, + }) + + if client.processWorkingDirectory != "/tmp/stdio" { + t.Errorf("Expected process working directory to be %q, got %q", "/tmp/stdio", client.processWorkingDirectory) + } + }) + + t.Run("should store tcp connection working directory", func(t *testing.T) { + client := NewClient(&ClientOptions{ + Connection: TCPConnection{Port: 1234, WorkingDirectory: "/tmp/tcp"}, + }) + + if client.processWorkingDirectory != "/tmp/tcp" { + t.Errorf("Expected process working directory to be %q, got %q", "/tmp/tcp", client.processWorkingDirectory) } }) } @@ -1458,30 +1480,6 @@ func TestClient_InProcessConnection(t *testing.T) { } }) - t.Run("panics when Env is set", func(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Error("Expected panic when Env is set with InProcessConnection") - } - }() - NewClient(&ClientOptions{ - Connection: InProcessConnection{}, - Env: []string{"FOO=bar"}, - }) - }) - - t.Run("panics when WorkingDirectory is set", func(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Error("Expected panic when WorkingDirectory is set with InProcessConnection") - } - }() - NewClient(&ClientOptions{ - Connection: InProcessConnection{}, - WorkingDirectory: "/tmp/work", - }) - }) - t.Run("panics when Telemetry is set", func(t *testing.T) { defer func() { if r := recover(); r == nil { @@ -1582,24 +1580,12 @@ func TestClient_DefaultConnection(t *testing.T) { } func TestClient_ConnectionLevelEnv(t *testing.T) { - t.Run("rejects env set on both client and connection", func(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Error("Expected panic when env is set on both client and connection") - } - }() - NewClient(&ClientOptions{ - Connection: StdioConnection{Env: []string{"A=1"}}, - Env: []string{"B=2"}, - }) - }) - t.Run("stdio connection env is used when client env is unset", func(t *testing.T) { client := NewClient(&ClientOptions{ Connection: StdioConnection{Env: []string{"ONLY=conn"}}, }) - if len(client.options.Env) != 1 || client.options.Env[0] != "ONLY=conn" { - t.Errorf("Expected connection-level Env to be used, got %v", client.options.Env) + if len(client.processEnv) != 1 || client.processEnv[0] != "ONLY=conn" { + t.Errorf("Expected connection-level Env to be used, got %v", client.processEnv) } }) @@ -1607,8 +1593,8 @@ func TestClient_ConnectionLevelEnv(t *testing.T) { client := NewClient(&ClientOptions{ Connection: TCPConnection{Port: 9000, Env: []string{"ONLY=conn"}}, }) - if len(client.options.Env) != 1 || client.options.Env[0] != "ONLY=conn" { - t.Errorf("Expected connection-level Env to be used, got %v", client.options.Env) + if len(client.processEnv) != 1 || client.processEnv[0] != "ONLY=conn" { + t.Errorf("Expected connection-level Env to be used, got %v", client.processEnv) } }) } diff --git a/go/internal/e2e/client_options_e2e_test.go b/go/internal/e2e/client_options_e2e_test.go index 54461443ca..534a76bde0 100644 --- a/go/internal/e2e/client_options_e2e_test.go +++ b/go/internal/e2e/client_options_e2e_test.go @@ -23,7 +23,14 @@ func TestClientOptionsE2E(t *testing.T) { port := getAvailableTCPPort(t) client := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Connection = copilot.TCPConnection{Path: ctx.CLIPath, Port: port} + stdio := opts.Connection.(copilot.StdioConnection) + opts.Connection = copilot.TCPConnection{ + Path: stdio.Path, + Args: append([]string{}, stdio.Args...), + WorkingDirectory: stdio.WorkingDirectory, + Env: append([]string{}, stdio.Env...), + Port: port, + } }) t.Cleanup(func() { client.ForceStop() }) @@ -57,7 +64,9 @@ func TestClientOptionsE2E(t *testing.T) { } client := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.WorkingDirectory = clientCwd + conn := opts.Connection.(copilot.StdioConnection) + conn.WorkingDirectory = clientCwd + opts.Connection = conn }) t.Cleanup(func() { client.ForceStop() }) @@ -99,13 +108,13 @@ func TestClientOptionsE2E(t *testing.T) { } client := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Connection = copilot.StdioConnection{ - Path: cliPath, - Args: []string{"--capture-file", capturePath}, - } + conn := opts.Connection.(copilot.StdioConnection) + conn.Path = cliPath + conn.Args = []string{"--capture-file", capturePath} + conn.Env = append([]string{}, conn.Env...) + conn.Env = append(conn.Env, "COPILOT_HOME="+filepath.Join(ctx.WorkDir, "copilot-home-from-env")) + opts.Connection = conn opts.BaseDirectory = filepath.Join(ctx.WorkDir, "copilot-home-from-option") - opts.Env = append([]string{}, opts.Env...) - opts.Env = append(opts.Env, "COPILOT_HOME="+filepath.Join(ctx.WorkDir, "copilot-home-from-env")) opts.GitHubToken = "process-option-token" opts.LogLevel = "debug" opts.SessionIdleTimeoutSeconds = 17 diff --git a/go/internal/e2e/commands_and_elicitation_e2e_test.go b/go/internal/e2e/commands_and_elicitation_e2e_test.go index af7520a4cc..de8fb1cb43 100644 --- a/go/internal/e2e/commands_and_elicitation_e2e_test.go +++ b/go/internal/e2e/commands_and_elicitation_e2e_test.go @@ -14,7 +14,14 @@ import ( func TestCommandsE2E(t *testing.T) { ctx := testharness.NewTestContext(t) client1 := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Connection = copilot.TCPConnection{Path: opts.Connection.(copilot.StdioConnection).Path, ConnectionToken: sharedTCPToken} + stdio := opts.Connection.(copilot.StdioConnection) + opts.Connection = copilot.TCPConnection{ + Path: stdio.Path, + Args: append([]string{}, stdio.Args...), + WorkingDirectory: stdio.WorkingDirectory, + Env: append([]string{}, stdio.Env...), + ConnectionToken: sharedTCPToken, + } }) t.Cleanup(func() { client1.ForceStop() }) @@ -706,7 +713,14 @@ func schemaHasProperty(schema *copilot.ElicitationSchema, name string) bool { func TestUIElicitationMultiClientE2E(t *testing.T) { ctx := testharness.NewTestContext(t) client1 := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Connection = copilot.TCPConnection{Path: opts.Connection.(copilot.StdioConnection).Path, ConnectionToken: sharedTCPToken} + stdio := opts.Connection.(copilot.StdioConnection) + opts.Connection = copilot.TCPConnection{ + Path: stdio.Path, + Args: append([]string{}, stdio.Args...), + WorkingDirectory: stdio.WorkingDirectory, + Env: append([]string{}, stdio.Env...), + ConnectionToken: sharedTCPToken, + } }) t.Cleanup(func() { client1.ForceStop() }) diff --git a/go/internal/e2e/connection_token_test.go b/go/internal/e2e/connection_token_test.go index 6d36000b3b..072772eae4 100644 --- a/go/internal/e2e/connection_token_test.go +++ b/go/internal/e2e/connection_token_test.go @@ -13,9 +13,13 @@ func TestConnectionToken(t *testing.T) { t.Run("explicit token round-trips successfully", func(t *testing.T) { ctx := testharness.NewTestContext(t) client := ctx.NewClient(func(opts *copilot.ClientOptions) { + stdio := opts.Connection.(copilot.StdioConnection) opts.Connection = copilot.TCPConnection{ - Path: ctx.CLIPath, - ConnectionToken: "right-token", + Path: stdio.Path, + Args: append([]string{}, stdio.Args...), + WorkingDirectory: stdio.WorkingDirectory, + Env: append([]string{}, stdio.Env...), + ConnectionToken: "right-token", } }) t.Cleanup(func() { client.ForceStop() }) @@ -36,7 +40,13 @@ func TestConnectionToken(t *testing.T) { t.Run("auto-generated token round-trips successfully", func(t *testing.T) { ctx := testharness.NewTestContext(t) client := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Connection = copilot.TCPConnection{Path: ctx.CLIPath} + stdio := opts.Connection.(copilot.StdioConnection) + opts.Connection = copilot.TCPConnection{ + Path: stdio.Path, + Args: append([]string{}, stdio.Args...), + WorkingDirectory: stdio.WorkingDirectory, + Env: append([]string{}, stdio.Env...), + } }) t.Cleanup(func() { client.ForceStop() }) @@ -56,9 +66,13 @@ func TestConnectionToken(t *testing.T) { t.Run("sibling client with wrong token is rejected", func(t *testing.T) { ctx := testharness.NewTestContext(t) good := ctx.NewClient(func(opts *copilot.ClientOptions) { + stdio := opts.Connection.(copilot.StdioConnection) opts.Connection = copilot.TCPConnection{ - Path: ctx.CLIPath, - ConnectionToken: "right-token", + Path: stdio.Path, + Args: append([]string{}, stdio.Args...), + WorkingDirectory: stdio.WorkingDirectory, + Env: append([]string{}, stdio.Env...), + ConnectionToken: "right-token", } }) t.Cleanup(func() { good.ForceStop() }) @@ -91,9 +105,13 @@ func TestConnectionToken(t *testing.T) { t.Run("sibling client with no token is rejected", func(t *testing.T) { ctx := testharness.NewTestContext(t) good := ctx.NewClient(func(opts *copilot.ClientOptions) { + stdio := opts.Connection.(copilot.StdioConnection) opts.Connection = copilot.TCPConnection{ - Path: ctx.CLIPath, - ConnectionToken: "right-token", + Path: stdio.Path, + Args: append([]string{}, stdio.Args...), + WorkingDirectory: stdio.WorkingDirectory, + Env: append([]string{}, stdio.Env...), + ConnectionToken: "right-token", } }) t.Cleanup(func() { good.ForceStop() }) diff --git a/go/internal/e2e/copilot_request_helpers_test.go b/go/internal/e2e/copilot_request_helpers_test.go index cd1ac63cf7..87bca70a44 100644 --- a/go/internal/e2e/copilot_request_helpers_test.go +++ b/go/internal/e2e/copilot_request_helpers_test.go @@ -282,7 +282,9 @@ func newCopilotRequestClient(ctx *testharness.TestContext, handler *copilot.Copi return ctx.NewClient(func(o *copilot.ClientOptions) { o.RequestHandler = handler if len(extraEnv) > 0 { - o.Env = append(o.Env, extraEnv...) + conn := o.Connection.(copilot.StdioConnection) + conn.Env = append(conn.Env, extraEnv...) + o.Connection = conn } }) } diff --git a/go/internal/e2e/mode_handlers_e2e_test.go b/go/internal/e2e/mode_handlers_e2e_test.go index e7471fbd06..164cfcceb4 100644 --- a/go/internal/e2e/mode_handlers_e2e_test.go +++ b/go/internal/e2e/mode_handlers_e2e_test.go @@ -21,7 +21,9 @@ func TestModeHandlersE2E(t *testing.T) { ctx := testharness.NewTestContext(t) client := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Env = append(opts.Env, "COPILOT_DEBUG_GITHUB_API_URL="+ctx.ProxyURL) + conn := opts.Connection.(copilot.StdioConnection) + conn.Env = append(conn.Env, "COPILOT_DEBUG_GITHUB_API_URL="+ctx.ProxyURL) + opts.Connection = conn }) t.Cleanup(func() { client.ForceStop() }) diff --git a/go/internal/e2e/multi_client_e2e_test.go b/go/internal/e2e/multi_client_e2e_test.go index 742145536c..cfc813a15d 100644 --- a/go/internal/e2e/multi_client_e2e_test.go +++ b/go/internal/e2e/multi_client_e2e_test.go @@ -18,7 +18,14 @@ func TestMultiClientE2E(t *testing.T) { // Use TCP mode so a second client can connect to the same CLI process ctx := testharness.NewTestContext(t) client1 := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Connection = copilot.TCPConnection{Path: opts.Connection.(copilot.StdioConnection).Path, ConnectionToken: sharedTCPToken} + stdio := opts.Connection.(copilot.StdioConnection) + opts.Connection = copilot.TCPConnection{ + Path: stdio.Path, + Args: append([]string{}, stdio.Args...), + WorkingDirectory: stdio.WorkingDirectory, + Env: append([]string{}, stdio.Env...), + ConnectionToken: sharedTCPToken, + } }) t.Cleanup(func() { client1.ForceStop() }) diff --git a/go/internal/e2e/pending_work_resume_e2e_test.go b/go/internal/e2e/pending_work_resume_e2e_test.go index 58de8644ac..45d779e6d9 100644 --- a/go/internal/e2e/pending_work_resume_e2e_test.go +++ b/go/internal/e2e/pending_work_resume_e2e_test.go @@ -470,7 +470,12 @@ func TestPendingWorkResumeE2E(t *testing.T) { if scenario.disconnectOriginalClient { lockObserver := ctx.NewClient(func(opts *copilot.ClientOptions) { stdio := opts.Connection.(copilot.StdioConnection) - opts.Connection = copilot.TCPConnection{Path: stdio.Path} + opts.Connection = copilot.TCPConnection{ + Path: stdio.Path, + Args: append([]string{}, stdio.Args...), + WorkingDirectory: stdio.WorkingDirectory, + Env: append([]string{}, stdio.Env...), + } }) t.Cleanup(func() { lockObserver.ForceStop() }) if err := lockObserver.Start(t.Context()); err != nil { @@ -694,7 +699,14 @@ const sharedTCPToken = "tcp-shared-test-token" func startTCPServer(t *testing.T, ctx *testharness.TestContext) (*copilot.Client, string) { t.Helper() server := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Connection = copilot.TCPConnection{Path: opts.Connection.(copilot.StdioConnection).Path, ConnectionToken: sharedTCPToken} + stdio := opts.Connection.(copilot.StdioConnection) + opts.Connection = copilot.TCPConnection{ + Path: stdio.Path, + Args: append([]string{}, stdio.Args...), + WorkingDirectory: stdio.WorkingDirectory, + Env: append([]string{}, stdio.Env...), + ConnectionToken: sharedTCPToken, + } }) t.Cleanup(func() { server.ForceStop() }) // Trigger connection so we can read the port. CreateSession+Disconnect is the diff --git a/go/internal/e2e/per_session_auth_e2e_test.go b/go/internal/e2e/per_session_auth_e2e_test.go index e004fa6b5a..c71b035642 100644 --- a/go/internal/e2e/per_session_auth_e2e_test.go +++ b/go/internal/e2e/per_session_auth_e2e_test.go @@ -14,7 +14,9 @@ func TestPerSessionAuthE2E(t *testing.T) { // Create client with COPILOT_DEBUG_GITHUB_API_URL redirected to the proxy // so per-session auth token resolution (fetchCopilotUser) is intercepted. client := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Env = append(opts.Env, "COPILOT_DEBUG_GITHUB_API_URL="+ctx.ProxyURL) + conn := opts.Connection.(copilot.StdioConnection) + conn.Env = append(conn.Env, "COPILOT_DEBUG_GITHUB_API_URL="+ctx.ProxyURL) + opts.Connection = conn }) t.Cleanup(func() { client.ForceStop() }) // Register per-token user configs on the proxy @@ -101,10 +103,12 @@ func TestPerSessionAuthE2E(t *testing.T) { ctx.ConfigureForTest(t) noTokenClient := copilot.NewClient(&copilot.ClientOptions{ - Connection: copilot.StdioConnection{Path: ctx.CLIPath}, - WorkingDirectory: ctx.WorkDir, - Env: withoutAuthEnv(append(ctx.Env(), "COPILOT_DEBUG_GITHUB_API_URL="+ctx.ProxyURL)), - UseLoggedInUser: copilot.Bool(false), + Connection: copilot.StdioConnection{ + Path: ctx.CLIPath, + WorkingDirectory: ctx.WorkDir, + Env: withoutAuthEnv(append(ctx.Env(), "COPILOT_DEBUG_GITHUB_API_URL="+ctx.ProxyURL)), + }, + UseLoggedInUser: copilot.Bool(false), }) t.Cleanup(func() { noTokenClient.ForceStop() }) diff --git a/go/internal/e2e/provider_endpoint_e2e_test.go b/go/internal/e2e/provider_endpoint_e2e_test.go index aad02ca2b5..36bde6cc5b 100644 --- a/go/internal/e2e/provider_endpoint_e2e_test.go +++ b/go/internal/e2e/provider_endpoint_e2e_test.go @@ -20,7 +20,9 @@ func TestProviderEndpointE2E(t *testing.T) { ctx := testharness.NewTestContext(t) client := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Env = append(opts.Env, "COPILOT_ALLOW_GET_PROVIDER_ENDPOINT=true") + conn := opts.Connection.(copilot.StdioConnection) + conn.Env = append(conn.Env, "COPILOT_ALLOW_GET_PROVIDER_ENDPOINT=true") + opts.Connection = conn }) t.Cleanup(func() { client.ForceStop() }) diff --git a/go/internal/e2e/rpc_mcp_and_skills_e2e_test.go b/go/internal/e2e/rpc_mcp_and_skills_e2e_test.go index 22f53c48a5..c9ab3f65e1 100644 --- a/go/internal/e2e/rpc_mcp_and_skills_e2e_test.go +++ b/go/internal/e2e/rpc_mcp_and_skills_e2e_test.go @@ -572,7 +572,9 @@ func assertSkillState(t *testing.T, list *rpc.SkillList, name string, enabled bo func createMCPAppsClient(ctx *testharness.TestContext) *copilot.Client { return ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Env = append(opts.Env, "COPILOT_MCP_APPS=true", "MCP_APPS=true") + conn := opts.Connection.(copilot.StdioConnection) + conn.Env = append(conn.Env, "COPILOT_MCP_APPS=true", "MCP_APPS=true") + opts.Connection = conn }) } diff --git a/go/internal/e2e/rpc_server_e2e_test.go b/go/internal/e2e/rpc_server_e2e_test.go index fb24309c1d..211990bc4c 100644 --- a/go/internal/e2e/rpc_server_e2e_test.go +++ b/go/internal/e2e/rpc_server_e2e_test.go @@ -194,7 +194,9 @@ func TestRPCServerE2E(t *testing.T) { t.Run("should add secret filter values", func(t *testing.T) { ctx := testharness.NewTestContext(t) client := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Env = append(opts.Env, "COPILOT_ENABLE_SECRET_FILTERING=true") + conn := opts.Connection.(copilot.StdioConnection) + conn.Env = append(conn.Env, "COPILOT_ENABLE_SECRET_FILTERING=true") + opts.Connection = conn }) t.Cleanup(func() { client.ForceStop() }) @@ -717,7 +719,9 @@ func TestRPCServerE2E(t *testing.T) { // newAuthenticatedClient builds a client that resolves auth through the test proxy. func newAuthenticatedClient(ctx *testharness.TestContext, token string) *copilot.Client { return ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Env = append(opts.Env, "COPILOT_DEBUG_GITHUB_API_URL="+ctx.ProxyURL) + conn := opts.Connection.(copilot.StdioConnection) + conn.Env = append(conn.Env, "COPILOT_DEBUG_GITHUB_API_URL="+ctx.ProxyURL) + opts.Connection = conn opts.GitHubToken = token }) } diff --git a/go/internal/e2e/rpc_server_misc_e2e_test.go b/go/internal/e2e/rpc_server_misc_e2e_test.go index 9607994676..1345e7b4a5 100644 --- a/go/internal/e2e/rpc_server_misc_e2e_test.go +++ b/go/internal/e2e/rpc_server_misc_e2e_test.go @@ -268,9 +268,11 @@ func newNoTokenClient(t *testing.T, ctx *testharness.TestContext) *copilot.Clien ) useLoggedInUser := false return copilot.NewClient(&copilot.ClientOptions{ - Connection: copilot.StdioConnection{Path: ctx.CLIPath}, - WorkingDirectory: ctx.WorkDir, - Env: env, - UseLoggedInUser: &useLoggedInUser, + Connection: copilot.StdioConnection{ + Path: ctx.CLIPath, + WorkingDirectory: ctx.WorkDir, + Env: env, + }, + UseLoggedInUser: &useLoggedInUser, }) } diff --git a/go/internal/e2e/rpc_server_plugins_e2e_test.go b/go/internal/e2e/rpc_server_plugins_e2e_test.go index a9d1d243cc..730d7af058 100644 --- a/go/internal/e2e/rpc_server_plugins_e2e_test.go +++ b/go/internal/e2e/rpc_server_plugins_e2e_test.go @@ -318,12 +318,14 @@ func newStartedIsolatedPortedClient(t *testing.T, ctx *testharness.TestContext) t.Fatalf("Failed to create isolated plugin home: %v", err) } return newStartedPortedClient(t, ctx, func(opts *copilot.ClientOptions) { - opts.Env = append(opts.Env, + conn := opts.Connection.(copilot.StdioConnection) + conn.Env = append(conn.Env, "COPILOT_HOME="+home, "GH_CONFIG_DIR="+home, "XDG_CONFIG_HOME="+home, "XDG_STATE_HOME="+home, ) + opts.Connection = conn }) } diff --git a/go/internal/e2e/session_config_e2e_test.go b/go/internal/e2e/session_config_e2e_test.go index f1c267e230..074b1f2fcc 100644 --- a/go/internal/e2e/session_config_e2e_test.go +++ b/go/internal/e2e/session_config_e2e_test.go @@ -452,7 +452,14 @@ func TestSessionConfigNewOptionsCopilotRequestE2E(t *testing.T) { handler := &copilot.CopilotRequestHandler{Transport: transport} const connectionToken = "go-citation-resume-token" server := ctx.NewClient(func(o *copilot.ClientOptions) { - o.Connection = copilot.TCPConnection{Path: ctx.CLIPath, ConnectionToken: connectionToken} + stdio := o.Connection.(copilot.StdioConnection) + o.Connection = copilot.TCPConnection{ + Path: stdio.Path, + Args: append([]string{}, stdio.Args...), + WorkingDirectory: stdio.WorkingDirectory, + Env: append([]string{}, stdio.Env...), + ConnectionToken: connectionToken, + } o.RequestHandler = handler }) t.Cleanup(func() { server.ForceStop() }) diff --git a/go/internal/e2e/session_event_loop_leak_e2e_test.go b/go/internal/e2e/session_event_loop_leak_e2e_test.go index c6974f74d8..cffa8a98b0 100644 --- a/go/internal/e2e/session_event_loop_leak_e2e_test.go +++ b/go/internal/e2e/session_event_loop_leak_e2e_test.go @@ -28,7 +28,9 @@ func TestSessionEventLoopLeakE2E(t *testing.T) { // with a genuine RPC error (401 Unauthorized), exercising the same // failure path a real user would hit — not a mocked transport. client := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Env = append(opts.Env, "COPILOT_DEBUG_GITHUB_API_URL="+ctx.ProxyURL) + conn := opts.Connection.(copilot.StdioConnection) + conn.Env = append(conn.Env, "COPILOT_DEBUG_GITHUB_API_URL="+ctx.ProxyURL) + opts.Connection = conn }) t.Cleanup(func() { client.ForceStop() }) diff --git a/go/internal/e2e/session_fs_e2e_test.go b/go/internal/e2e/session_fs_e2e_test.go index 4236e8e529..570ef45d46 100644 --- a/go/internal/e2e/session_fs_e2e_test.go +++ b/go/internal/e2e/session_fs_e2e_test.go @@ -139,7 +139,13 @@ func TestSessionFSE2E(t *testing.T) { ctx.ConfigureForTest(t) client1 := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Connection = copilot.TCPConnection{Path: ctx.CLIPath} + stdio := opts.Connection.(copilot.StdioConnection) + opts.Connection = copilot.TCPConnection{ + Path: stdio.Path, + Args: append([]string{}, stdio.Args...), + WorkingDirectory: stdio.WorkingDirectory, + Env: append([]string{}, stdio.Env...), + } }) t.Cleanup(func() { client1.ForceStop() }) @@ -157,7 +163,6 @@ func TestSessionFSE2E(t *testing.T) { client2 := copilot.NewClient(&copilot.ClientOptions{ Connection: copilot.URIConnection{URL: fmt.Sprintf("localhost:%d", runtimePort)}, LogLevel: "error", - Env: ctx.Env(), SessionFS: sessionFSConfig, }) t.Cleanup(func() { client2.ForceStop() }) diff --git a/go/internal/e2e/subagent_hooks_e2e_test.go b/go/internal/e2e/subagent_hooks_e2e_test.go index 0e2fde9f86..3fb518d530 100644 --- a/go/internal/e2e/subagent_hooks_e2e_test.go +++ b/go/internal/e2e/subagent_hooks_e2e_test.go @@ -81,7 +81,9 @@ func TestSubagentHooksE2E(t *testing.T) { ctx := testharness.NewTestContext(t) transport := newRecordingForwardingTransport() client := ctx.NewClient(func(o *copilot.ClientOptions) { - o.Env = append(o.Env, "COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS=true") + conn := o.Connection.(copilot.StdioConnection) + conn.Env = append(conn.Env, "COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS=true") + o.Connection = conn o.RequestHandler = &copilot.CopilotRequestHandler{Transport: transport} }) t.Cleanup(func() { client.ForceStop() }) diff --git a/go/internal/e2e/testharness/context.go b/go/internal/e2e/testharness/context.go index 060997d264..ebd1be03cb 100644 --- a/go/internal/e2e/testharness/context.go +++ b/go/internal/e2e/testharness/context.go @@ -406,9 +406,11 @@ func (c *TestContext) Env() []string { // Optional overrides can be applied to the default ClientOptions via the opts function. func (c *TestContext) NewClient(opts ...func(*copilot.ClientOptions)) *copilot.Client { options := &copilot.ClientOptions{ - Connection: copilot.StdioConnection{Path: c.CLIPath}, - WorkingDirectory: c.WorkDir, - Env: c.Env(), + Connection: copilot.StdioConnection{ + Path: c.CLIPath, + WorkingDirectory: c.WorkDir, + Env: c.Env(), + }, } for _, opt := range opts { @@ -421,16 +423,15 @@ func (c *TestContext) NewClient(opts ...func(*copilot.ClientOptions)) *copilot.C } // Under the inprocess matrix cell, host the default stdio connection in-process. - // The worker inherits this process's ambient env/cwd (per-client env and working - // directory are rejected in-process), so mirror the effective (merged) env and - // cwd onto the real process and drop those options. Tests that pin a specific - // transport (TCP/URI/custom stdio) or configure per-client telemetry are left on - // their transport, mirroring the Node/.NET harnesses. + // The worker inherits this process's ambient env/cwd, so mirror the + // out-of-process connection's effective env and cwd onto the real process + // before swapping transports. Tests that pin a specific transport (TCP/URI or + // custom stdio path/args) or configure per-client telemetry are left on their + // transport, mirroring the Node/.NET harnesses. if c.inProcess && c.shouldUseInProcess(options) { - c.applyInProcessEnvironment(options.Env, options.WorkingDirectory) + conn := options.Connection.(copilot.StdioConnection) + c.applyInProcessEnvironment(conn.Env, conn.WorkingDirectory) options.Connection = copilot.InProcessConnection{} - options.Env = nil - options.WorkingDirectory = "" } return copilot.NewClient(options) @@ -438,8 +439,8 @@ func (c *TestContext) NewClient(opts ...func(*copilot.ClientOptions)) *copilot.C // shouldUseInProcess reports whether a client built from options should be hosted // in-process for the inprocess matrix cell. Only the harness default stdio -// connection is swapped; a test that pins a custom stdio path/args/env or a -// TCP/URI connection is exercising behavior that must stay on its own transport. +// connection is swapped; a test that pins a custom stdio path/args or a TCP/URI +// connection is exercising behavior that must stay on its own transport. // // Options the in-process runtime cannot support (per-client telemetry, an LLM // inference provider) are NOT silently downgraded here — the affected tests skip @@ -450,7 +451,7 @@ func (c *TestContext) shouldUseInProcess(options *copilot.ClientOptions) bool { if !ok { return false } - return s.Path == c.CLIPath && len(s.Args) == 0 && s.Env == nil + return s.Path == c.CLIPath && len(s.Args) == 0 } func fileExists(path string) bool { diff --git a/go/types.go b/go/types.go index 7bc5bfb9af..071b9b91d1 100644 --- a/go/types.go +++ b/go/types.go @@ -28,35 +28,26 @@ type RuntimeConnection interface { runtimeConnection() } -// childProcessConnection is implemented by the connection types that spawn a -// runtime child process ([StdioConnection] and [TCPConnection]). It exposes the -// per-connection environment so the client can resolve and validate it uniformly -// regardless of the specific child-process transport. -type childProcessConnection interface { - RuntimeConnection - connEnv() []string -} - -// StdioConnection spawns a runtime child process and communicates over its +// StdioConnection spawns and manages an out-of-process runtime over its // stdin/stdout pipes. This is the default when no connection is configured. type StdioConnection struct { // Path is the runtime executable. When empty, the bundled runtime is used. Path string // Args are extra command-line arguments inserted before SDK-managed args. Args []string + // WorkingDirectory is the working directory for the runtime process. When + // empty, the current process working directory is inherited. + WorkingDirectory string // Env are the environment variables for the runtime process, each of the - // form "KEY=VALUE". When set, these take precedence over - // [ClientOptions.Env]; setting both is rejected. When nil, the client-level - // env (or the current process environment) is used. + // form "KEY=VALUE". When nil, the current process environment is inherited. + // If Env contains duplicate keys, only the last value for each key is used. Env []string } func (StdioConnection) runtimeConnection() {} -func (c StdioConnection) connEnv() []string { return c.Env } - -// TCPConnection spawns a runtime child process that listens on a TCP socket -// and connects to it. +// TCPConnection spawns and manages an out-of-process runtime that listens on a +// TCP socket and connects to it. type TCPConnection struct { // Port is the TCP port the runtime listens on. 0 (the default) lets the // runtime pick a free port; the chosen port is then available via @@ -70,17 +61,17 @@ type TCPConnection struct { Path string // Args are extra command-line arguments inserted before SDK-managed args. Args []string + // WorkingDirectory is the working directory for the runtime process. When + // empty, the current process working directory is inherited. + WorkingDirectory string // Env are the environment variables for the runtime process, each of the - // form "KEY=VALUE". When set, these take precedence over - // [ClientOptions.Env]; setting both is rejected. When nil, the client-level - // env (or the current process environment) is used. + // form "KEY=VALUE". When nil, the current process environment is inherited. + // If Env contains duplicate keys, only the last value for each key is used. Env []string } func (TCPConnection) runtimeConnection() {} -func (c TCPConnection) connEnv() []string { return c.Env } - // URIConnection connects to an already-running runtime at the given URL. // The SDK does not spawn a process in this mode. type URIConnection struct { @@ -96,13 +87,13 @@ func (URIConnection) runtimeConnection() {} // InProcessConnection hosts the Copilot runtime in-process by loading its native // runtime library (a Rust cdylib) and driving JSON-RPC over the library's C ABI, -// instead of spawning a runtime child process. +// instead of spawning and managing an out-of-process runtime. // // Because the runtime is loaded into the calling process, per-client -// environment, working directory, and telemetry cannot be represented and are -// rejected by [NewClient] (see [ClientOptions]). Set those via the host process -// environment instead, or use a child-process transport ([StdioConnection] / -// [TCPConnection]). +// telemetry cannot be represented and is rejected by [NewClient] (see +// [ClientOptions]). Process-scoped settings such as environment variables and +// working directory belong on an out-of-process connection +// ([StdioConnection]/[TCPConnection]) instead. // // Experimental: the in-process transport is experimental and its API and // behavior may change in a future release. Build the application with the @@ -116,11 +107,11 @@ func (InProcessConnection) runtimeConnection() {} type ClientOptions struct { // Connection describes how to connect to the Copilot runtime. When nil, // COPILOT_SDK_DEFAULT_CONNECTION may select "inprocess" or "stdio"; - // when unset, defaults to an empty [StdioConnection]. + // when unset, defaults to an empty [StdioConnection]. Process-scoped + // settings such as WorkingDirectory and Env are configured on the + // out-of-process connection ([StdioConnection] / [TCPConnection]), not on + // ClientOptions, because they do not apply to [InProcessConnection]. Connection RuntimeConnection - // WorkingDirectory is the working directory for the runtime process. - // If empty, inherits the current process's working directory. - WorkingDirectory string // BaseDirectory is the base directory for Copilot data (session state, // config, etc.). Sets the COPILOT_HOME environment variable on the // spawned runtime. When empty, the runtime defaults to ~/.copilot. @@ -138,16 +129,6 @@ type ClientOptions struct { // uses its own default level; the SDK does not pass --log-level. // Recognized values: "none", "error", "warning", "info", "debug", "all". LogLevel string - // Env are the environment variables for the runtime process (default: - // inherits from current process). Each entry is of the form "KEY=VALUE". - // If Env contains duplicate keys, only the last value for each key is used. - // - // For child-process transports ([StdioConnection] / [TCPConnection]) the - // per-connection Env, when set, takes precedence over this field; setting - // both is rejected. Env is not supported with [InProcessConnection] (the - // runtime shares this process's single environment block) and is rejected - // by [NewClient]. - Env []string // GitHubToken is the GitHub token to use for authentication. // When provided, the token is passed to the runtime via environment // variable. This takes priority over other authentication methods. diff --git a/java/README.md b/java/README.md index 4ed310e8ab..f48a4d62da 100644 --- a/java/README.md +++ b/java/README.md @@ -176,7 +176,16 @@ and `setExcludedTools(...)`, prefer the source-qualified filter form `DefaultAgentConfig.setExcludedTools(...)`, use `-` directly. -`CopilotClientOptions.setCwd(...)` sets the runtime process working directory, which otherwise inherits the current process working directory. `SessionConfig.setWorkingDirectory(...)` sets the session working directory, which otherwise defaults to the runtime process working directory. +Process-scoped settings are configured on the out-of-process connection (`StdioRuntimeConnection`/`TcpRuntimeConnection`), not on `CopilotClientOptions`, because they do not apply to in-process (FFI) hosting: + +```java +var options = new CopilotClientOptions().setConnection( + RuntimeConnection.forStdio() + .setWorkingDirectory("/srv/app") + .setEnvironment(Map.of("KEY", "value"))); +``` + +`SessionConfig.setWorkingDirectory(...)` sets the session working directory, which otherwise defaults to the runtime process working directory. `SessionConfig.setAskUserVariant(AskUserVariant.ELICITATION)` selects the structured form-based `ask_user` tool when an elicitation handler is also set. diff --git a/java/sdk/src/main/java/com/github/copilot/CliServerManager.java b/java/sdk/src/main/java/com/github/copilot/CliServerManager.java index ff0d121945..ba8e09fa2d 100644 --- a/java/sdk/src/main/java/com/github/copilot/CliServerManager.java +++ b/java/sdk/src/main/java/com/github/copilot/CliServerManager.java @@ -15,6 +15,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; @@ -22,6 +23,9 @@ import com.github.copilot.ffi.NativeRuntimeLoader; import com.github.copilot.rpc.CopilotClientOptions; +import com.github.copilot.rpc.RuntimeConnection; +import com.github.copilot.rpc.StdioRuntimeConnection; +import com.github.copilot.rpc.TcpRuntimeConnection; /** * Manages the lifecycle of the Copilot CLI server process. @@ -35,12 +39,18 @@ final class CliServerManager { private static final int STDERR_READER_JOIN_TIMEOUT_MS = 5000; private final CopilotClientOptions options; + private final RuntimeConnection runtimeConnection; private final StringBuilder stderrBuffer = new StringBuilder(); private volatile Thread stderrThread; private String connectionToken; CliServerManager(CopilotClientOptions options) { + this(options, options.getConnection()); + } + + CliServerManager(CopilotClientOptions options, RuntimeConnection runtimeConnection) { this.options = options; + this.runtimeConnection = runtimeConnection; } /** @@ -120,8 +130,9 @@ ProcessInfo startCliServer() throws IOException, InterruptedException { // doesn't provide explicit CREATE_NO_WINDOW flags like native Windows APIs, // but the default behavior is appropriate for most use cases. - if (options.getCwd() != null) { - pb.directory(new File(options.getCwd())); + String workingDirectory = getWorkingDirectory(); + if (workingDirectory != null) { + pb.directory(new File(workingDirectory)); } configureProcessEnvironment(pb); @@ -269,9 +280,10 @@ private List resolveCliCommand(String cliPath, List args) { } void configureProcessEnvironment(ProcessBuilder pb) { - if (options.getEnvironment() != null) { + Map environment = getProcessEnvironment(); + if (environment != null) { pb.environment().clear(); - pb.environment().putAll(options.getEnvironment()); + pb.environment().putAll(environment); } pb.environment().remove("NODE_DEBUG"); @@ -325,7 +337,7 @@ RuntimeLaunch resolveCliLaunch(String inheritedCliPath) throws IOException { return new RuntimeLaunch(options.getCliPath()); } - var environment = options.getEnvironment(); + var environment = getProcessEnvironment(); String envCliPath = environment != null ? environment.get(NativeRuntimeLoader.COPILOT_CLI_PATH_ENV) : inheritedCliPath; @@ -337,6 +349,26 @@ RuntimeLaunch resolveCliLaunch(String inheritedCliPath) throws IOException { return new RuntimeLaunch(wrapper.toString()); } + private Map getProcessEnvironment() { + if (runtimeConnection instanceof StdioRuntimeConnection stdio) { + return stdio.getEnvironment(); + } + if (runtimeConnection instanceof TcpRuntimeConnection tcp) { + return tcp.getEnvironment(); + } + return null; + } + + private String getWorkingDirectory() { + if (runtimeConnection instanceof StdioRuntimeConnection stdio) { + return stdio.getWorkingDirectory(); + } + if (runtimeConnection instanceof TcpRuntimeConnection tcp) { + return tcp.getWorkingDirectory(); + } + return null; + } + static URI parseCliUrl(String url) { // If it's just a port number, treat as localhost try { diff --git a/java/sdk/src/main/java/com/github/copilot/CopilotClient.java b/java/sdk/src/main/java/com/github/copilot/CopilotClient.java index 9f7d8ebcf8..c3920e835c 100644 --- a/java/sdk/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/sdk/src/main/java/com/github/copilot/CopilotClient.java @@ -235,7 +235,7 @@ public CopilotClient(CopilotClientOptions options) { this.executor = executorProvider.get(); this.executorCanBeShutdown = executorProvider.canBeShutdown(); - this.serverManager = new CliServerManager(this.options); + this.serverManager = new CliServerManager(this.options, this.runtimeConnection); this.serverManager.setConnectionToken(this.effectiveConnectionToken); } @@ -398,22 +398,20 @@ private static void applyConnectionArgs(CopilotClientOptions options, List 0, "use the typed client options instead"); } diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java b/java/sdk/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java index c67947bf48..30c067f71c 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java @@ -7,9 +7,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.OptionalInt; @@ -35,8 +33,8 @@ *

Example Usage

* *
{@code
- * var options = new CopilotClientOptions().setCliPath("/usr/local/bin/copilot").setLogLevel("debug")
- * 		.setAutoStart(true);
+ * var options = new CopilotClientOptions().setConnection(RuntimeConnection.forStdio("/usr/local/bin/copilot"))
+ * 		.setLogLevel("debug").setAutoStart(true);
  *
  * var client = new CopilotClient(options);
  * }
@@ -57,8 +55,6 @@ public class CopilotClientOptions { private RuntimeConnection connection; private ClientInfo clientInfo; private String copilotHome; - private String cwd; - private Map environment; private Executor executor; private String gitHubToken; private String logLevel = "info"; @@ -267,6 +263,12 @@ public RuntimeConnection getConnection() { * throw {@link IllegalArgumentException}. Values that match what the connection * implies are accepted, so the same options instance can be reused across * multiple client constructions. + *

+ * Process-scoped settings such as {@code workingDirectory} and + * {@code environment} are configured on the out-of-process connection + * ({@link StdioRuntimeConnection}/{@link TcpRuntimeConnection}), not on + * {@code CopilotClientOptions}, because they do not apply to in-process (FFI) + * hosting. * * @param connection * the connection, or {@code null} to infer the transport from the @@ -310,61 +312,6 @@ public CopilotClientOptions setCopilotHome(String copilotHome) { return this; } - /** - * Gets the working directory for the CLI process. - * - * @return the working directory path - */ - public String getCwd() { - return cwd; - } - - /** - * Sets the working directory for the CLI process. - * - * @param cwd - * the working directory path, or {@code null} to clear - * @return this options instance for method chaining - */ - public CopilotClientOptions setCwd(String cwd) { - this.cwd = cwd; - return this; - } - - /** - * Gets the environment variables for the CLI process. - *

- * Returns a shallow copy of the internal map, or {@code null} if no environment - * has been set. - * - * @return a copy of the environment variables map, or {@code null} - */ - public Map getEnvironment() { - return environment != null ? new HashMap<>(environment) : null; - } - - /** - * Sets environment variables to pass to the CLI process. - *

- * When set, these environment variables replace the inherited environment. A - * shallow copy of the provided map is stored. If {@code null} or empty, the - * existing environment is cleared. - * - * @param environment - * the environment variables map, or {@code null}/empty to clear - * @return this options instance for method chaining - */ - public CopilotClientOptions setEnvironment(Map environment) { - if (environment == null || environment.isEmpty()) { - if (this.environment != null) { - this.environment.clear(); - } - } else { - this.environment = new HashMap<>(environment); - } - return this; - } - /** * Gets the executor used for internal asynchronous operations. *

@@ -843,9 +790,8 @@ public CopilotClientOptions setUseStdio(boolean useStdio) { * Creates a shallow clone of this {@code CopilotClientOptions} instance. *

* Array properties (like {@code cliArgs}) are copied into new arrays so that - * modifications to the clone do not affect the original. The - * {@code environment} map is also copied to a new map instance. Other - * reference-type properties are shared between the original and clone. + * modifications to the clone do not affect the original. Other reference-type + * properties are shared between the original and clone. * * @return a clone of this options instance */ @@ -863,8 +809,6 @@ public CopilotClientOptions clone() { copy.connection = this.connection; copy.clientInfo = this.clientInfo; copy.copilotHome = this.copilotHome; - copy.cwd = this.cwd; - copy.environment = this.environment != null ? new java.util.HashMap<>(this.environment) : null; copy.executor = this.executor; copy.gitHubToken = this.gitHubToken; copy.logLevel = this.logLevel; diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/InProcessRuntimeConnection.java b/java/sdk/src/main/java/com/github/copilot/rpc/InProcessRuntimeConnection.java index 274f8b89dc..16fe35fd59 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/InProcessRuntimeConnection.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/InProcessRuntimeConnection.java @@ -12,10 +12,14 @@ * transport. Construct with {@link RuntimeConnection#forInProcess()}. *

* The in-process runtime is self-contained: it carries everything it needs and - * requires no external installation. Because it runs inside the host process, - * per-client process settings ({@code environment}, {@code telemetry}, - * {@code cwd}, and {@code cliArgs}) are rejected; configure those on the host - * process instead, or use a child-process connection. + * requires no external installation. Process-scoped settings such as + * {@code workingDirectory} and {@code environment} are configured on + * out-of-process connections + * ({@link StdioRuntimeConnection}/{@link TcpRuntimeConnection}), not on + * {@link CopilotClientOptions}, because they do not apply to in-process (FFI) + * hosting. Other per-client process settings such as {@code telemetry} and + * {@code cliArgs} are also rejected here; configure those on the host process + * instead, or use a child-process connection. * * @since 1.0.0 */ diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/RuntimeConnection.java b/java/sdk/src/main/java/com/github/copilot/rpc/RuntimeConnection.java index a0c8eec5c6..1a26afe334 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/RuntimeConnection.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/RuntimeConnection.java @@ -15,7 +15,8 @@ * *

{@code
  * // Spawn a runtime child process and talk over stdin/stdout (the default).
- * new CopilotClientOptions().setConnection(RuntimeConnection.forStdio());
+ * new CopilotClientOptions().setConnection(RuntimeConnection.forStdio().setWorkingDirectory("/srv/app")
+ * 		.setEnvironment(java.util.Map.of("KEY", "value")));
  *
  * // Spawn a runtime child process listening on a TCP socket.
  * new CopilotClientOptions().setConnection(RuntimeConnection.forTcp().setPath("/usr/local/bin/copilot"));
diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/StdioRuntimeConnection.java b/java/sdk/src/main/java/com/github/copilot/rpc/StdioRuntimeConnection.java
index 7d0923e0fe..840dd69739 100644
--- a/java/sdk/src/main/java/com/github/copilot/rpc/StdioRuntimeConnection.java
+++ b/java/sdk/src/main/java/com/github/copilot/rpc/StdioRuntimeConnection.java
@@ -5,7 +5,9 @@
 package com.github.copilot.rpc;
 
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 import com.github.copilot.CopilotExperimental;
 
@@ -13,6 +15,10 @@
  * Spawns a runtime child process and communicates over its stdin/stdout.
  * Construct with {@link RuntimeConnection#forStdio()} or
  * {@link RuntimeConnection#forStdio(String)}.
+ * 

+ * Process-scoped settings are configured on this out-of-process connection, not + * on {@link CopilotClientOptions}, because they do not apply to in-process + * (FFI) hosting. * * @since 1.0.0 */ @@ -20,7 +26,9 @@ public final class StdioRuntimeConnection extends RuntimeConnection { private String path; + private String workingDirectory; private List args; + private Map environment; StdioRuntimeConnection() { } @@ -48,6 +56,29 @@ public StdioRuntimeConnection setPath(String path) { return this; } + /** + * Returns the working directory for the spawned runtime process. + * + * @return the working directory path, or {@code null} to inherit the current + * process working directory + */ + public String getWorkingDirectory() { + return workingDirectory; + } + + /** + * Sets the working directory for the spawned runtime process. + * + * @param workingDirectory + * the working directory path, or {@code null} to inherit the current + * process working directory + * @return this instance for method chaining + */ + public StdioRuntimeConnection setWorkingDirectory(String workingDirectory) { + this.workingDirectory = workingDirectory; + return this; + } + /** * Returns the extra command-line arguments passed to the runtime process. * @@ -68,4 +99,38 @@ public StdioRuntimeConnection setArgs(List args) { this.args = args == null ? null : new ArrayList<>(args); return this; } + + /** + * Returns the environment variables for the spawned runtime process. + *

+ * Returns a shallow copy of the internal map, or {@code null} if no environment + * has been set. + * + * @return a copy of the environment variables map, or {@code null} + */ + public Map getEnvironment() { + return environment != null ? new HashMap<>(environment) : null; + } + + /** + * Sets environment variables to pass to the spawned runtime process. + *

+ * When set, these environment variables replace the inherited environment. A + * shallow copy of the provided map is stored. If {@code null} or empty, the + * existing environment is cleared. + * + * @param environment + * the environment variables map, or {@code null}/empty to clear + * @return this instance for method chaining + */ + public StdioRuntimeConnection setEnvironment(Map environment) { + if (environment == null || environment.isEmpty()) { + if (this.environment != null) { + this.environment.clear(); + } + } else { + this.environment = new HashMap<>(environment); + } + return this; + } } diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/TcpRuntimeConnection.java b/java/sdk/src/main/java/com/github/copilot/rpc/TcpRuntimeConnection.java index 648321a219..77bbbe87b4 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/TcpRuntimeConnection.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/TcpRuntimeConnection.java @@ -5,13 +5,19 @@ package com.github.copilot.rpc; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import com.github.copilot.CopilotExperimental; /** * Spawns a runtime child process listening on a TCP socket and connects to it. * Construct with {@link RuntimeConnection#forTcp()}. + *

+ * Process-scoped settings are configured on this out-of-process connection, not + * on {@link CopilotClientOptions}, because they do not apply to in-process + * (FFI) hosting. * * @since 1.0.0 */ @@ -19,9 +25,11 @@ public final class TcpRuntimeConnection extends RuntimeConnection { private String path; + private String workingDirectory; private int port; private String connectionToken; private List args; + private Map environment; TcpRuntimeConnection() { } @@ -49,6 +57,29 @@ public TcpRuntimeConnection setPath(String path) { return this; } + /** + * Returns the working directory for the spawned runtime process. + * + * @return the working directory path, or {@code null} to inherit the current + * process working directory + */ + public String getWorkingDirectory() { + return workingDirectory; + } + + /** + * Sets the working directory for the spawned runtime process. + * + * @param workingDirectory + * the working directory path, or {@code null} to inherit the current + * process working directory + * @return this instance for method chaining + */ + public TcpRuntimeConnection setWorkingDirectory(String workingDirectory) { + this.workingDirectory = workingDirectory; + return this; + } + /** * Returns the TCP port the spawned runtime listens on. * @@ -113,4 +144,38 @@ public TcpRuntimeConnection setArgs(List args) { this.args = args == null ? null : new ArrayList<>(args); return this; } + + /** + * Returns the environment variables for the spawned runtime process. + *

+ * Returns a shallow copy of the internal map, or {@code null} if no environment + * has been set. + * + * @return a copy of the environment variables map, or {@code null} + */ + public Map getEnvironment() { + return environment != null ? new HashMap<>(environment) : null; + } + + /** + * Sets environment variables to pass to the spawned runtime process. + *

+ * When set, these environment variables replace the inherited environment. A + * shallow copy of the provided map is stored. If {@code null} or empty, the + * existing environment is cleared. + * + * @param environment + * the environment variables map, or {@code null}/empty to clear + * @return this instance for method chaining + */ + public TcpRuntimeConnection setEnvironment(Map environment) { + if (environment == null || environment.isEmpty()) { + if (this.environment != null) { + this.environment.clear(); + } + } else { + this.environment = new HashMap<>(environment); + } + return this; + } } diff --git a/java/sdk/src/test/java/com/github/copilot/CliServerManagerTest.java b/java/sdk/src/test/java/com/github/copilot/CliServerManagerTest.java index b41f99adb7..54cf5ef7a5 100644 --- a/java/sdk/src/test/java/com/github/copilot/CliServerManagerTest.java +++ b/java/sdk/src/test/java/com/github/copilot/CliServerManagerTest.java @@ -17,6 +17,7 @@ import com.github.copilot.ffi.NativeRuntimeLoader; import com.github.copilot.rpc.CopilotClientOptions; +import com.github.copilot.rpc.RuntimeConnection; import com.github.copilot.rpc.TelemetryConfig; /** @@ -49,8 +50,8 @@ void inheritedCliPathEnvironmentOverrideDoesNotRequireRuntimeBundle() throws Exc void configuredEnvironmentCliPathOverridesInheritedEnvironment() throws Exception { Path inherited = tempDir.resolve("inherited-copilot-runtime"); Path configured = tempDir.resolve("configured-copilot-runtime"); - var options = new CopilotClientOptions() - .setEnvironment(Map.of(NativeRuntimeLoader.COPILOT_CLI_PATH_ENV, configured.toString())); + var options = new CopilotClientOptions().setConnection(RuntimeConnection.forStdio() + .setEnvironment(Map.of(NativeRuntimeLoader.COPILOT_CLI_PATH_ENV, configured.toString()))); var manager = new CliServerManager(options); assertEquals(configured.toString(), manager.resolveCliLaunch(inherited.toString()).executable()); @@ -60,8 +61,8 @@ void configuredEnvironmentCliPathOverridesInheritedEnvironment() throws Exceptio void explicitCliPathOverridesEnvironment() throws Exception { Path explicit = tempDir.resolve("explicit-copilot-runtime"); Path configured = tempDir.resolve("configured-copilot-runtime"); - var options = new CopilotClientOptions().setCliPath(explicit.toString()) - .setEnvironment(Map.of(NativeRuntimeLoader.COPILOT_CLI_PATH_ENV, configured.toString())); + var options = new CopilotClientOptions().setCliPath(explicit.toString()).setConnection(RuntimeConnection + .forStdio().setEnvironment(Map.of(NativeRuntimeLoader.COPILOT_CLI_PATH_ENV, configured.toString()))); var manager = new CliServerManager(options); assertEquals(explicit.toString(), manager.resolveCliLaunch("inherited-copilot-runtime").executable()); diff --git a/java/sdk/src/test/java/com/github/copilot/ClientOptionsE2ETest.java b/java/sdk/src/test/java/com/github/copilot/ClientOptionsE2ETest.java index a2561434d8..42649749dc 100644 --- a/java/sdk/src/test/java/com/github/copilot/ClientOptionsE2ETest.java +++ b/java/sdk/src/test/java/com/github/copilot/ClientOptionsE2ETest.java @@ -10,6 +10,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Comparator; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -22,6 +23,7 @@ import com.github.copilot.rpc.PermissionHandler; import com.github.copilot.rpc.ProviderConfig; import com.github.copilot.rpc.ResumeSessionConfig; +import com.github.copilot.rpc.RuntimeConnection; import com.github.copilot.rpc.SessionConfig; class ClientOptionsE2ETest { @@ -196,8 +198,8 @@ static FakeStdioCli create() throws IOException { } CopilotClient createClient() { - var options = new CopilotClientOptions().setCliPath(script.toString()) - .setCliArgs(new String[]{"--capture-file", capture.toString()}).setCwd(workDir.toString()) + var options = new CopilotClientOptions().setConnection(RuntimeConnection.forStdio(script.toString()) + .setWorkingDirectory(workDir.toString()).setArgs(List.of("--capture-file", capture.toString()))) .setUseLoggedInUser(false); return new CopilotClient(options); } diff --git a/java/sdk/src/test/java/com/github/copilot/ConfigCloneTest.java b/java/sdk/src/test/java/com/github/copilot/ConfigCloneTest.java index 2433e5f67a..6f61cc6dbb 100644 --- a/java/sdk/src/test/java/com/github/copilot/ConfigCloneTest.java +++ b/java/sdk/src/test/java/com/github/copilot/ConfigCloneTest.java @@ -28,9 +28,12 @@ import com.github.copilot.rpc.MessageOptions; import com.github.copilot.rpc.ModelInfo; import com.github.copilot.rpc.ResumeSessionConfig; +import com.github.copilot.rpc.RuntimeConnection; import com.github.copilot.rpc.SessionConfig; +import com.github.copilot.rpc.StdioRuntimeConnection; import com.github.copilot.rpc.SystemMessageConfig; import com.github.copilot.rpc.TelemetryConfig; +import com.github.copilot.rpc.TcpRuntimeConnection; class ConfigCloneTest { @@ -81,19 +84,16 @@ void copilotClientOptionsArrayIndependence() { } @Test - void copilotClientOptionsEnvironmentIndependence() { - CopilotClientOptions original = new CopilotClientOptions(); + void stdioRuntimeConnectionEnvironmentIndependence() { + StdioRuntimeConnection original = RuntimeConnection.forStdio(); Map env = new HashMap<>(); env.put("KEY1", "value1"); original.setEnvironment(env); - CopilotClientOptions cloned = original.clone(); - - // Mutate the source map after set — should not affect original or clone + // Mutate the source map after set — should not affect the connection env.put("KEY2", "value2"); assertEquals(1, original.getEnvironment().size()); - assertEquals(1, cloned.getEnvironment().size()); // getEnvironment() returns a copy, so mutating it should not affect internals original.getEnvironment().put("KEY3", "value3"); @@ -378,25 +378,25 @@ void copilotClientOptionsSetCliArgsNullClearsExisting() { } @Test - void copilotClientOptionsSetEnvironmentNullClearsExisting() { - CopilotClientOptions opts = new CopilotClientOptions(); - opts.setEnvironment(Map.of("KEY", "VALUE")); - assertNotNull(opts.getEnvironment()); + void tcpRuntimeConnectionSetEnvironmentNullClearsExisting() { + TcpRuntimeConnection connection = RuntimeConnection.forTcp(); + connection.setEnvironment(Map.of("KEY", "VALUE")); + assertNotNull(connection.getEnvironment()); // Setting null should clear the existing map (clears in-place → returns empty // map) - opts.setEnvironment(null); - var env = opts.getEnvironment(); + connection.setEnvironment(null); + var env = connection.getEnvironment(); assertTrue(env == null || env.isEmpty()); } @Test - void copilotClientOptionsSetCwdNullClearsExisting() { - CopilotClientOptions opts = new CopilotClientOptions().setCwd("/tmp"); + void stdioRuntimeConnectionSetWorkingDirectoryNullClearsExisting() { + StdioRuntimeConnection connection = RuntimeConnection.forStdio().setWorkingDirectory("/tmp"); - opts.setCwd(null); + connection.setWorkingDirectory(null); - assertNull(opts.getCwd()); + assertNull(connection.getWorkingDirectory()); } @Test diff --git a/java/sdk/src/test/java/com/github/copilot/CopilotClientTransportTest.java b/java/sdk/src/test/java/com/github/copilot/CopilotClientTransportTest.java index 46223d56d8..858b9735c0 100644 --- a/java/sdk/src/test/java/com/github/copilot/CopilotClientTransportTest.java +++ b/java/sdk/src/test/java/com/github/copilot/CopilotClientTransportTest.java @@ -20,7 +20,6 @@ import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Locale; -import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -208,9 +207,7 @@ private static void assertConflict(CopilotClientOptions options, String optionNa @Test void inProcessRejectsPerProcessOptions() { - assertInProcessRejected(new CopilotClientOptions().setEnvironment(Map.of("FOO", "bar")), "Environment"); assertInProcessRejected(new CopilotClientOptions().setTelemetry(new TelemetryConfig()), "Telemetry"); - assertInProcessRejected(new CopilotClientOptions().setCwd("/tmp"), "Cwd"); assertInProcessRejected(new CopilotClientOptions().setCliArgs(new String[]{"--extra"}), "CliArgs"); } @@ -218,13 +215,10 @@ void inProcessRejectsPerProcessOptions() { void e2eContextClearsInProcessIncompatibleOptions() throws Exception { try (var context = E2ETestContext.create()) { var options = new CopilotClientOptions().setConnection(RuntimeConnection.forInProcess()) - .setEnvironment(Map.of("TEST_KEY", "test-value")).setCwd(context.getWorkDir().toString()) .setCliArgs(new String[]{"--subprocess-only"}); try (var client = context.createClient(options)) { assertInstanceOf(InProcessRuntimeConnection.class, client.getRuntimeConnection()); - assertTrue(options.getEnvironment() == null || options.getEnvironment().isEmpty()); - assertEquals(null, options.getCwd()); assertTrue(options.getCliArgs() == null || options.getCliArgs().length == 0); } } diff --git a/java/sdk/src/test/java/com/github/copilot/CopilotRequestTestSupport.java b/java/sdk/src/test/java/com/github/copilot/CopilotRequestTestSupport.java index fa2a6354be..9bf66700d5 100644 --- a/java/sdk/src/test/java/com/github/copilot/CopilotRequestTestSupport.java +++ b/java/sdk/src/test/java/com/github/copilot/CopilotRequestTestSupport.java @@ -33,6 +33,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.github.copilot.generated.AssistantMessageEvent; import com.github.copilot.rpc.CopilotClientOptions; +import com.github.copilot.rpc.RuntimeConnection; /** * Shared synthetic-upstream helpers for the {@link CopilotRequestHandler} e2e @@ -72,8 +73,9 @@ static CopilotClient newLlmClient(E2ETestContext ctx, CopilotRequestHandler hand env.put(entry.substring(0, eq), entry.substring(eq + 1)); } } - return ctx.createClient( - new CopilotClientOptions().setCliPath(ctx.getCliPath()).setEnvironment(env).setRequestHandler(handler)); + return ctx.createClient(new CopilotClientOptions() + .setConnection(RuntimeConnection.forStdio(ctx.getCliPath()).setEnvironment(env)) + .setRequestHandler(handler)); } /** diff --git a/java/sdk/src/test/java/com/github/copilot/E2ETestContext.java b/java/sdk/src/test/java/com/github/copilot/E2ETestContext.java index a6bec65d2c..c62466531c 100644 --- a/java/sdk/src/test/java/com/github/copilot/E2ETestContext.java +++ b/java/sdk/src/test/java/com/github/copilot/E2ETestContext.java @@ -22,6 +22,8 @@ import com.github.copilot.rpc.CopilotClientOptions; import com.github.copilot.rpc.InProcessRuntimeConnection; import com.github.copilot.rpc.RuntimeConnection; +import com.github.copilot.rpc.StdioRuntimeConnection; +import com.github.copilot.rpc.TcpRuntimeConnection; /** * E2E test context that manages the test environment including the CapiProxy, @@ -419,8 +421,9 @@ public CopilotClient createClient() { * options for this test context. * * @param options - * options to apply; environment and cliPath will be set from the - * context if not already set + * options to apply; the out-of-process connection environment, + * working directory, and CLI path will be set from the context if + * not already set * @return a new CopilotClient */ public CopilotClient createClient(CopilotClientOptions options) { @@ -437,11 +440,9 @@ public CopilotClient createClient(CopilotClientOptions options) { private CopilotClient applyContextOptions(CopilotClientOptions options) { if (isInProcessMode(options)) { - InProcessEnvGuard guard = new InProcessEnvGuard(buildInProcessEnvironment(options)); + InProcessEnvGuard guard = new InProcessEnvGuard(buildInProcessEnvironment()); inProcessEnvGuards.add(guard); try { - options.setEnvironment(null); - options.setCwd(null); options.setCliArgs(null); return new CopilotClient(options, guard::close); } catch (RuntimeException e) { @@ -449,15 +450,7 @@ private CopilotClient applyContextOptions(CopilotClientOptions options) { throw e; } } - if (options.getCliPath() == null) { - options.setCliPath(cliPath); - } - if (options.getCwd() == null) { - options.setCwd(workDir.toString()); - } - if (options.getEnvironment() == null || options.getEnvironment().isEmpty()) { - options.setEnvironment(getEnvironment()); - } + applyOutOfProcessContext(options); return null; } @@ -474,14 +467,49 @@ private boolean isInProcessMode(CopilotClientOptions options) { return defaultConnection != null && "inprocess".equalsIgnoreCase(defaultConnection.trim()); } - private Map buildInProcessEnvironment(CopilotClientOptions options) { - Map env = new HashMap<>(getEnvironment()); - Map optionEnvironment = options.getEnvironment(); - if (optionEnvironment != null && !optionEnvironment.isEmpty()) { - env.putAll(optionEnvironment); - options.setEnvironment(null); + private Map buildInProcessEnvironment() { + return new HashMap<>(getEnvironment()); + } + + private void applyOutOfProcessContext(CopilotClientOptions options) { + RuntimeConnection connection = options.getConnection(); + if (connection == null) { + connection = CopilotClient.resolveDefaultConnection(options, + System.getenv(CopilotClient.DEFAULT_CONNECTION_ENV_VAR)); + if (connection instanceof StdioRuntimeConnection || connection instanceof TcpRuntimeConnection) { + options.setConnection(connection); + } + } + + if (connection instanceof StdioRuntimeConnection stdio) { + if (stdio.getPath() == null) { + stdio.setPath(cliPath); + } + if (stdio.getWorkingDirectory() == null) { + stdio.setWorkingDirectory(workDir.toString()); + } + if (stdio.getEnvironment() == null || stdio.getEnvironment().isEmpty()) { + stdio.setEnvironment(getEnvironment()); + } + return; + } + + if (connection instanceof TcpRuntimeConnection tcp) { + if (tcp.getPath() == null) { + tcp.setPath(cliPath); + } + if (tcp.getWorkingDirectory() == null) { + tcp.setWorkingDirectory(workDir.toString()); + } + if (tcp.getEnvironment() == null || tcp.getEnvironment().isEmpty()) { + tcp.setEnvironment(getEnvironment()); + } + return; + } + + if (options.getCliPath() == null && (options.getCliUrl() == null || options.getCliUrl().isEmpty())) { + options.setCliPath(cliPath); } - return env; } /** diff --git a/java/sdk/src/test/java/com/github/copilot/ModeHandlersTest.java b/java/sdk/src/test/java/com/github/copilot/ModeHandlersTest.java index 942b2efe6d..649c1558d9 100644 --- a/java/sdk/src/test/java/com/github/copilot/ModeHandlersTest.java +++ b/java/sdk/src/test/java/com/github/copilot/ModeHandlersTest.java @@ -27,6 +27,7 @@ import com.github.copilot.rpc.ExitPlanModeResult; import com.github.copilot.rpc.MessageOptions; import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.RuntimeConnection; import com.github.copilot.rpc.SessionConfig; /** @@ -59,7 +60,8 @@ private CopilotClient createAuthenticatedClient() { Map env = new HashMap<>(ctx.getEnvironment()); env.put("COPILOT_DEBUG_GITHUB_API_URL", ctx.getProxyUrl()); - return ctx.createClient(new CopilotClientOptions().setEnvironment(env)); + return ctx.createClient( + new CopilotClientOptions().setConnection(RuntimeConnection.forStdio().setEnvironment(env))); } private void configureAuthenticatedUser(String testName) throws Exception { diff --git a/java/sdk/src/test/java/com/github/copilot/PerSessionAuthTest.java b/java/sdk/src/test/java/com/github/copilot/PerSessionAuthTest.java index 9e5cd1b324..a17deda73b 100644 --- a/java/sdk/src/test/java/com/github/copilot/PerSessionAuthTest.java +++ b/java/sdk/src/test/java/com/github/copilot/PerSessionAuthTest.java @@ -16,6 +16,7 @@ import com.github.copilot.generated.rpc.SessionGitHubAuthGetStatusResult; import com.github.copilot.rpc.CopilotClientOptions; import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.RuntimeConnection; import com.github.copilot.rpc.SessionConfig; /** @@ -50,7 +51,8 @@ static void teardown() throws Exception { private CopilotClient createAuthTestClient() { Map env = new HashMap<>(ctx.getEnvironment()); env.put("COPILOT_DEBUG_GITHUB_API_URL", ctx.getProxyUrl()); - return ctx.createClient(new CopilotClientOptions().setEnvironment(env)); + return ctx.createClient( + new CopilotClientOptions().setConnection(RuntimeConnection.forStdio().setEnvironment(env))); } private void setupCopilotUsers() throws Exception { @@ -123,8 +125,10 @@ void shouldBeUnauthenticatedWithoutToken() throws Exception { // Build the client directly (not via ctx.createClient) so the context's // default GitHub token is not auto-injected and useLoggedInUser is disabled. - CopilotClientOptions options = new CopilotClientOptions().setCliPath(ctx.getCliPath()) - .setCwd(ctx.getWorkDir().toString()).setEnvironment(env).setUseLoggedInUser(false); + CopilotClientOptions options = new CopilotClientOptions() + .setConnection(RuntimeConnection.forStdio(ctx.getCliPath()) + .setWorkingDirectory(ctx.getWorkDir().toString()).setEnvironment(env)) + .setUseLoggedInUser(false); try (CopilotClient client = new CopilotClient(options)) { CopilotSession session = client diff --git a/java/sdk/src/test/java/com/github/copilot/ProviderEndpointE2ETest.java b/java/sdk/src/test/java/com/github/copilot/ProviderEndpointE2ETest.java index 1e302982ef..b2930fb267 100644 --- a/java/sdk/src/test/java/com/github/copilot/ProviderEndpointE2ETest.java +++ b/java/sdk/src/test/java/com/github/copilot/ProviderEndpointE2ETest.java @@ -20,6 +20,7 @@ import com.github.copilot.rpc.CopilotClientOptions; import com.github.copilot.rpc.PermissionHandler; import com.github.copilot.rpc.ProviderConfig; +import com.github.copilot.rpc.RuntimeConnection; import com.github.copilot.rpc.SessionConfig; /** @@ -48,7 +49,8 @@ static void teardown() throws Exception { private CopilotClient createProviderEndpointClient() { Map env = new HashMap<>(ctx.getEnvironment()); env.put("COPILOT_ALLOW_GET_PROVIDER_ENDPOINT", "true"); - return ctx.createClient(new CopilotClientOptions().setEnvironment(env)); + return ctx.createClient( + new CopilotClientOptions().setConnection(RuntimeConnection.forStdio().setEnvironment(env))); } @Test diff --git a/java/sdk/src/test/java/com/github/copilot/RpcServerE2ETest.java b/java/sdk/src/test/java/com/github/copilot/RpcServerE2ETest.java index 20cd05a6bb..db18be245e 100644 --- a/java/sdk/src/test/java/com/github/copilot/RpcServerE2ETest.java +++ b/java/sdk/src/test/java/com/github/copilot/RpcServerE2ETest.java @@ -59,6 +59,7 @@ import com.github.copilot.rpc.CopilotClientOptions; import com.github.copilot.rpc.InfiniteSessionConfig; import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.RuntimeConnection; import com.github.copilot.rpc.SessionConfig; class RpcServerE2ETest { @@ -211,7 +212,8 @@ void testShouldAddSecretFilterValues() throws Exception { var env = new HashMap<>(ctx.getEnvironment()); env.put("COPILOT_ENABLE_SECRET_FILTERING", "true"); - try (var client = ctx.createClient(new CopilotClientOptions().setEnvironment(env))) { + try (var client = ctx.createClient( + new CopilotClientOptions().setConnection(RuntimeConnection.forStdio().setEnvironment(env)))) { client.start().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); var secret = "rpc-secret-" + UUID.randomUUID().toString().replace("-", ""); diff --git a/java/sdk/src/test/java/com/github/copilot/RpcServerMiscE2ETest.java b/java/sdk/src/test/java/com/github/copilot/RpcServerMiscE2ETest.java index 3b06e4d1f7..9eea0f1ad8 100644 --- a/java/sdk/src/test/java/com/github/copilot/RpcServerMiscE2ETest.java +++ b/java/sdk/src/test/java/com/github/copilot/RpcServerMiscE2ETest.java @@ -20,6 +20,7 @@ import com.github.copilot.generated.rpc.UserSettingMetadata; import com.github.copilot.generated.rpc.UserSettingsSetParams; import com.github.copilot.rpc.CopilotClientOptions; +import com.github.copilot.rpc.RuntimeConnection; class RpcServerMiscE2ETest { @@ -82,9 +83,10 @@ void testShouldLoginListGetCurrentAuthAndLogoutAccount() throws Exception { env.put("GITHUB_TOKEN", ""); env.put("COPILOT_SDK_AUTH_TOKEN", ""); - try (var client = new CopilotClient( - new CopilotClientOptions().setCliPath(ctx.getCliPath()).setCwd(ctx.getWorkDir().toString()) - .setEnvironment(env).setGitHubToken("").setUseLoggedInUser(false))) { + try (var client = new CopilotClient(new CopilotClientOptions() + .setConnection(RuntimeConnection.forStdio(ctx.getCliPath()) + .setWorkingDirectory(ctx.getWorkDir().toString()).setEnvironment(env)) + .setGitHubToken("").setUseLoggedInUser(false))) { client.start().get(30, TimeUnit.SECONDS); var initial = client.getRpc().account.getCurrentAuth().get(30, TimeUnit.SECONDS); diff --git a/java/sdk/src/test/java/com/github/copilot/SubagentHooksE2ETest.java b/java/sdk/src/test/java/com/github/copilot/SubagentHooksE2ETest.java index c2ad45ff24..b485f05701 100644 --- a/java/sdk/src/test/java/com/github/copilot/SubagentHooksE2ETest.java +++ b/java/sdk/src/test/java/com/github/copilot/SubagentHooksE2ETest.java @@ -25,6 +25,7 @@ import com.github.copilot.rpc.PermissionHandler; import com.github.copilot.rpc.PostToolUseHookOutput; import com.github.copilot.rpc.PreToolUseHookOutput; +import com.github.copilot.rpc.RuntimeConnection; import com.github.copilot.rpc.SessionConfig; import com.github.copilot.rpc.SessionHooks; @@ -42,8 +43,9 @@ void shouldInvokePreToolUseAndPostToolUseHooksForSubAgentToolCalls() throws Exce HashMap env = new HashMap<>(ctx.getEnvironment()); env.put("COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS", "true"); - try (CopilotClient client = ctx - .createClient(new CopilotClientOptions().setEnvironment(env).setRequestHandler(requestHandler))) { + try (CopilotClient client = ctx.createClient( + new CopilotClientOptions().setConnection(RuntimeConnection.forStdio().setEnvironment(env)) + .setRequestHandler(requestHandler))) { CopilotSession session = client .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> { diff --git a/nodejs/README.md b/nodejs/README.md index 7effb81e95..32eb14bb48 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -105,17 +105,15 @@ new CopilotClient(options?: CopilotClientOptions) **Options:** - `connection?: RuntimeConnection` - How to connect to the Copilot runtime. Construct via the factory functions on `RuntimeConnection`: - - `RuntimeConnection.forStdio({ path?, args?, env? })` (default) — spawn the runtime and communicate over its stdin/stdout. - - `RuntimeConnection.forTcp({ port?, connectionToken?, path?, args?, env? })` — spawn the runtime as a TCP server. + - `RuntimeConnection.forStdio({ path?, args?, workingDirectory?, env? })` (default) — spawn the runtime and communicate over its stdin/stdout. + - `RuntimeConnection.forTcp({ port?, connectionToken?, path?, args?, workingDirectory?, env? })` — spawn the runtime as a TCP server. - `RuntimeConnection.forUri(url, { connectionToken? })` — connect to an already-running runtime (mutually exclusive with `gitHubToken`/`useLoggedInUser`). There is no top-level `cliUrl` shortcut; use this factory for URL-based connections. - - `RuntimeConnection.forInProcess()` — host the runtime in-process over its native C ABI (FFI). **Experimental.** Because the runtime shares this process, `env`, `telemetry`, and `workingDirectory` are rejected with this transport; set them on the host process instead. - - The child-process transports (`forStdio`/`forTcp`) also accept a per-connection `env`. Set it there or via the top-level `env` option — not both (setting both throws). - - Managed child-process connections materialize the bundled `copilot-runtime` and adjacent `runtime.node`, then launch the wrapper by default. An explicit connection `path` or `COPILOT_CLI_PATH` overrides the bundled runtime. + - `RuntimeConnection.forInProcess()` — host the runtime in-process over its native C ABI (FFI). **Experimental.** Client-wide `telemetry` is rejected with this transport; process-scoped launch settings such as `workingDirectory` and `env` belong on out-of-process connections instead. + - `OutOfProcessRuntimeConnection` is the shared shape returned by `forStdio`/`forTcp`. It carries `path`, `args`, `workingDirectory`, and `env`. + - Managed out-of-process connections materialize the bundled `copilot-runtime` and adjacent `runtime.node`, then launch the wrapper by default. An explicit connection `path` or `COPILOT_CLI_PATH` overrides the bundled runtime. - `mode?: "empty" | "copilot-cli"` - Defaulting strategy. Use `"empty"` for multi-user server mode; defaults to `"copilot-cli"`. -- `workingDirectory?: string` - Working directory for the runtime process (default: current process cwd). - `baseDirectory?: string` - Base directory for Copilot data (session state, config, etc.). Sets `COPILOT_HOME` on the spawned runtime. When not set, the runtime defaults to `~/.copilot`. Ignored when connecting via `RuntimeConnection.forUri`. - `logLevel?: "none" | "error" | "warning" | "info" | "debug" | "all"` - Log level. When omitted, the runtime uses its own default (currently `"info"`). -- `env?: Record` - Environment variables for the runtime process. When omitted, inherits `process.env`. - `gitHubToken?: string` - GitHub token for authentication. When provided, takes priority over other auth methods. - `useLoggedInUser?: boolean` - Whether to use logged-in user for authentication (default: true, but false when `gitHubToken` is provided). Cannot be used with `RuntimeConnection.forUri`. - `onListModels?: () => Promise | ModelInfo[]` - Optional model-list provider, useful when using a custom provider. diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 035f0f5e60..abd24d54a2 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -454,12 +454,11 @@ export class CopilotClient { private stderrBuffer: string = ""; // Captures CLI stderr for error messages /** Resolved connection mode chosen in the constructor. */ private connectionConfig: InternalRuntimeConnection; - /** Resolved path to the runtime executable (only used for child-process kinds). */ + /** Resolved path to the runtime executable (only used for out-of-process kinds). */ private resolvedCliPath: string | undefined; - /** Resolved environment passed to the spawned runtime. */ + /** Resolved environment inherited or replaced by the out-of-process runtime. */ private resolvedEnv: Record; private options: { - workingDirectory: string; logLevel?: string; gitHubToken?: string; useLoggedInUser: boolean; @@ -589,7 +588,11 @@ export class CopilotClient { * * // Use a custom runtime binary * const client = new CopilotClient({ - * connection: RuntimeConnection.forStdio({ path: "/usr/local/bin/copilot" }), + * connection: RuntimeConnection.forStdio({ + * path: "/usr/local/bin/copilot", + * workingDirectory: "/srv/app", + * env: { MY_VAR: "value" }, + * }), * logLevel: "debug", * }); * ``` @@ -611,38 +614,12 @@ export class CopilotClient { "gitHubToken and useLoggedInUser cannot be used with RuntimeConnection.forUri (external server manages its own auth)" ); } - if (conn.kind === "inprocess" && options.workingDirectory !== undefined) { - throw new Error( - "workingDirectory is not supported with RuntimeConnection.forInProcess(): the in-process " + - "transport hosts the runtime in this process, so honoring it would require mutating the " + - "shared process-global cwd. Change the host process's working directory before " + - "constructing the client instead." - ); - } - if (conn.kind === "inprocess" && options.env !== undefined) { - throw new Error( - "env is not supported with 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." - ); - } if (conn.kind === "inprocess" && options.telemetry !== undefined) { throw new Error( "telemetry is not supported with 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." - ); - } - if ( - (conn.kind === "stdio" || conn.kind === "tcp") && - conn.env !== undefined && - options.env !== undefined - ) { - throw new Error( - "Set environment variables via either the client-level env option or the connection's env " + - "(RuntimeConnection.forStdio/forTcp), not both. Prefer the connection-level env for " + - "child-process transports." + "via the host process environment, or use an out-of-process transport." ); } if (conn.kind === "tcp" && conn.connectionToken !== undefined) { @@ -692,13 +669,11 @@ export class CopilotClient { this.onGitHubTelemetry = options.onGitHubTelemetry; this.setupClientGlobalHandlers(); - // Connection-level env (child-process transports only) takes precedence - // over the client-level env, which falls back to the ambient process env. - // The constructor guard above rejects setting both, so at most one of the - // first two is defined. Mirrors .NET/Python precedence. + // Out-of-process transports can replace the inherited environment. When + // omitted, they inherit the ambient process environment. Mirrors Python/.NET. const connEnv: Record | undefined = conn.kind === "stdio" || conn.kind === "tcp" ? conn.env : undefined; - const effectiveEnv = connEnv ?? options.env ?? process.env; + const effectiveEnv = connEnv ?? process.env; this.resolvedEnv = effectiveEnv; if (conn.kind === "stdio" || conn.kind === "tcp") { const explicitCliPath = conn.path ?? effectiveEnv.COPILOT_CLI_PATH; @@ -713,7 +688,6 @@ export class CopilotClient { this.connectionExtraArgs = [...connArgs]; this.options = { - workingDirectory: options.workingDirectory ?? process.cwd(), logLevel: options.logLevel, gitHubToken: options.gitHubToken, // Default useLoggedInUser to false when gitHubToken is provided, otherwise true. @@ -2565,7 +2539,7 @@ export class CopilotClient { } /** - * Builds the environment for the spawned runtime child process (stdio/TCP): applies + * Builds the environment for the spawned out-of-process runtime (stdio/TCP): applies * the auth token, connection token, `COPILOT_HOME`, keychain setting, and telemetry * variables on top of the effective env. Not used by the in-process (FFI) transport, * whose worker inherits the host process's ambient environment @@ -2680,17 +2654,21 @@ export class CopilotClient { // For .js files, spawn node explicitly; for executables, spawn directly const isJsFile = this.resolvedCliPath.endsWith(".js"); + const runtimeWorkingDirectory = + ("workingDirectory" in this.connectionConfig + ? this.connectionConfig.workingDirectory + : undefined) ?? process.cwd(); if (isJsFile) { this.cliProcess = spawn(getNodeExecPath(), [this.resolvedCliPath, ...args], { stdio: stdioConfig, - cwd: this.options.workingDirectory, + cwd: runtimeWorkingDirectory, env: envWithoutNodeDebug, windowsHide: true, }); } else { this.cliProcess = spawn(this.resolvedCliPath, args, { stdio: stdioConfig, - cwd: this.options.workingDirectory, + cwd: runtimeWorkingDirectory, env: envWithoutNodeDebug, windowsHide: true, }); diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 6251df4fc7..da1ca97ad6 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -77,7 +77,7 @@ export type { InProcessRuntimeConnection, TcpRuntimeConnection, UriRuntimeConnection, - ChildProcessRuntimeConnection, + OutOfProcessRuntimeConnection, CustomAgentConfig, ElicitationFieldValue, ElicitationHandler, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index efff9b47df..c7058e32da 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -155,55 +155,55 @@ export type RuntimeConnection = | UriRuntimeConnection; /** - * Shared shape for the transports that spawn a runtime **child process** + * Shared shape for the transports that spawn an **out-of-process** runtime * ({@link StdioRuntimeConnection} and {@link TcpRuntimeConnection}). */ -export interface ChildProcessRuntimeConnection { +export interface OutOfProcessRuntimeConnection { /** Path to the runtime executable. When omitted, the bundled runtime is used. */ readonly path?: string; /** Extra command-line arguments to pass to the runtime process. */ readonly args?: readonly string[]; /** - * Environment variables for the spawned runtime child process, replacing the - * inherited environment. Cannot be combined with - * {@link CopilotClientOptions.env}; setting both throws when the client is - * constructed. When omitted, the client-level env (or `process.env`) is used. + * Working directory for the spawned runtime process. When omitted, inherits + * the current process working directory. + */ + readonly workingDirectory?: string; + /** + * Environment variables for the spawned runtime process, replacing the + * inherited environment. When omitted, inherits `process.env`. */ readonly env?: Record; } /** - * Spawns a runtime child process and communicates over its stdin/stdout. + * Spawns an out-of-process runtime and communicates over its stdin/stdout. * This is the default if no {@link CopilotClientOptions.connection} is set. */ -export interface StdioRuntimeConnection extends ChildProcessRuntimeConnection { +export interface StdioRuntimeConnection extends OutOfProcessRuntimeConnection { readonly kind: "stdio"; } /** * Hosts the runtime in-process by loading the native runtime library and speaking - * JSON-RPC over its C ABI (FFI), instead of spawning a runtime child process. The + * JSON-RPC over its C ABI (FFI), instead of spawning the runtime out-of-process. The * native host spawns the CLI worker itself. Construct via * {@link RuntimeConnection.forInProcess}. * * @experimental The in-process (FFI) transport is experimental and its behavior may - * change. Per-client options that are lowered to environment variables — including - * {@link CopilotClientOptions.env}, {@link CopilotClientOptions.telemetry}, - * {@link CopilotClientOptions.gitHubToken}, and - * {@link CopilotClientOptions.baseDirectory} — are **not** honored with this - * transport, because the native runtime loads into the shared host process and its - * worker inherits that process's ambient environment. To configure the in-process - * runtime, set the corresponding environment variables on the host process before - * constructing the client. See https://github.com/github/copilot-sdk/issues/1934. + * change. Client-wide {@link CopilotClientOptions.telemetry} is **not** honored with + * this transport, because it lowers to environment variables read by native runtime + * code running in the shared host process. Process-scoped launch settings such as + * working directory and environment live on {@link OutOfProcessRuntimeConnection}, + * which this transport does not use. */ export interface InProcessRuntimeConnection { readonly kind: "inprocess"; } /** - * Spawns a runtime child process that listens on a TCP socket and connects to it. + * Spawns an out-of-process runtime that listens on a TCP socket and connects to it. */ -export interface TcpRuntimeConnection extends ChildProcessRuntimeConnection { +export interface TcpRuntimeConnection extends OutOfProcessRuntimeConnection { readonly kind: "tcp"; /** * TCP port to listen on. `0` (the default) auto-allocates a free port. @@ -236,16 +236,27 @@ export interface UriRuntimeConnection { /** Factory functions for constructing {@link RuntimeConnection} instances. */ export const RuntimeConnection = { /** - * Spawn a runtime child process and communicate over its stdin/stdout. + * Spawn an out-of-process runtime and communicate over its stdin/stdout. * This is the default if no {@link CopilotClientOptions.connection} is set. */ forStdio( - opts: { path?: string; args?: readonly string[]; env?: Record } = {} + opts: { + path?: string; + args?: readonly string[]; + workingDirectory?: string; + env?: Record; + } = {} ): StdioRuntimeConnection { - return { kind: "stdio", path: opts.path, args: opts.args, env: opts.env }; + return { + kind: "stdio", + path: opts.path, + args: opts.args, + workingDirectory: opts.workingDirectory, + env: opts.env, + }; }, /** - * Spawn a runtime child process that listens on a TCP socket and connect to it. + * Spawn an out-of-process runtime that listens on a TCP socket and connect to it. */ forTcp( opts: { @@ -253,6 +264,7 @@ export const RuntimeConnection = { connectionToken?: string; path?: string; args?: readonly string[]; + workingDirectory?: string; env?: Record; } = {} ): TcpRuntimeConnection { @@ -262,6 +274,7 @@ export const RuntimeConnection = { connectionToken: opts.connectionToken, path: opts.path, args: opts.args, + workingDirectory: opts.workingDirectory, env: opts.env, }; }, @@ -275,11 +288,11 @@ export const RuntimeConnection = { /** * Host the runtime in-process over the native runtime library's C ABI (FFI). * - * @experimental Per-client options lowered to environment variables (`env`, - * `telemetry`, `gitHubToken`, `baseDirectory`) are **not** honored in-process; - * the worker inherits the host process's ambient environment. Set the - * corresponding environment variables on the host process instead. See - * https://github.com/github/copilot-sdk/issues/1934. + * @experimental Client-wide `telemetry` is **not** honored in-process because + * it lowers to environment variables read by native runtime code running in + * the shared host process. Process-scoped launch settings belong on + * `RuntimeConnection.forStdio(...)` / `RuntimeConnection.forTcp(...)`, not + * on the in-process transport. */ forInProcess(): InProcessRuntimeConnection { return { kind: "inprocess" }; @@ -364,12 +377,6 @@ export interface CopilotClientOptions { */ mode?: CopilotClientMode; - /** - * Working directory for the runtime process. - * If not set, inherits the current process's working directory. - */ - workingDirectory?: string; - /** * Base directory for Copilot data (session state, config, etc.). * Sets the COPILOT_HOME environment variable on the spawned runtime. @@ -391,11 +398,6 @@ export interface CopilotClientOptions { */ logLevel?: "none" | "error" | "warning" | "info" | "debug" | "all"; - /** - * Environment variables to pass to the runtime process. If not set, inherits process.env. - */ - env?: Record; - /** * GitHub token to use for authentication. * When provided, the token is passed to the runtime via environment variable. diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 119ed40a10..1eb3029138 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -123,24 +123,24 @@ describe("CopilotClient", () => { it.each([ { source: "connection path", - connection: RuntimeConnection.forStdio({ path: "/explicit/copilot" }), - env: {}, + connection: RuntimeConnection.forStdio({ + path: "/explicit/copilot", + env: {}, + }), expected: "/explicit/copilot", }, { source: "COPILOT_CLI_PATH", - connection: RuntimeConnection.forStdio(), - env: { COPILOT_CLI_PATH: "/environment/copilot" }, + connection: RuntimeConnection.forStdio({ + env: { COPILOT_CLI_PATH: "/environment/copilot" }, + }), expected: "/environment/copilot", }, - ])( - "preserves explicit child-process override from $source", - ({ connection, env, expected }) => { - const client = new CopilotClient({ connection, env }); + ])("preserves explicit out-of-process override from $source", ({ connection, expected }) => { + const client = new CopilotClient({ connection }); - expect((client as any).resolvedCliPath).toBe(expected); - } - ); + expect((client as any).resolvedCliPath).toBe(expected); + }); async function startWithMockConnection( builtinPluginDirectories?: readonly string[] @@ -3019,16 +3019,6 @@ describe("CopilotClient", () => { ); }); - it("should throw error when env is used with forInProcess", () => { - expect(() => { - new CopilotClient({ - connection: RuntimeConnection.forInProcess(), - env: { FOO: "bar" }, - logLevel: "error", - }); - }).toThrow(/env is not supported with RuntimeConnection.forInProcess/); - }); - it("should throw error when telemetry is used with forInProcess", () => { expect(() => { new CopilotClient({ @@ -3039,41 +3029,7 @@ describe("CopilotClient", () => { }).toThrow(/telemetry is not supported with RuntimeConnection.forInProcess/); }); - it("should throw error when workingDirectory is used with forInProcess", () => { - expect(() => { - new CopilotClient({ - connection: RuntimeConnection.forInProcess(), - workingDirectory: "/tmp", - logLevel: "error", - }); - }).toThrow(/workingDirectory is not supported with RuntimeConnection.forInProcess/); - }); - - it("should throw error when env is set on both the client and a stdio connection", () => { - expect(() => { - new CopilotClient({ - connection: RuntimeConnection.forStdio({ env: { FOO: "conn" } }), - env: { FOO: "client" }, - logLevel: "error", - }); - }).toThrow( - /Set environment variables via either the client-level env option or the connection/ - ); - }); - - it("should throw error when env is set on both the client and a tcp connection", () => { - expect(() => { - new CopilotClient({ - connection: RuntimeConnection.forTcp({ env: { FOO: "conn" } }), - env: { FOO: "client" }, - logLevel: "error", - }); - }).toThrow( - /Set environment variables via either the client-level env option or the connection/ - ); - }); - - it("should use the connection-level env for child-process transports", () => { + it("should use the connection-level env for out-of-process transports", () => { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ env: { FOO: "from-conn" } }), logLevel: "error", @@ -3081,13 +3037,12 @@ describe("CopilotClient", () => { expect((client as any).resolvedEnv).toEqual({ FOO: "from-conn" }); }); - it("should allow env on the client alone with a child-process transport", () => { + it("should inherit process.env when env is omitted", () => { const client = new CopilotClient({ connection: RuntimeConnection.forStdio(), - env: { FOO: "from-client" }, logLevel: "error", }); - expect((client as any).resolvedEnv).toEqual({ FOO: "from-client" }); + expect((client as any).resolvedEnv).toBe(process.env); }); }); diff --git a/nodejs/test/e2e/client_options.e2e.test.ts b/nodejs/test/e2e/client_options.e2e.test.ts index 4d261bea52..b846af7933 100644 --- a/nodejs/test/e2e/client_options.e2e.test.ts +++ b/nodejs/test/e2e/client_options.e2e.test.ts @@ -180,9 +180,11 @@ describe("Client options", async () => { it("createSession starts the client lazily", async () => { const client = new CopilotClient({ - workingDirectory: workDir, - env, - connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env, + }), gitHubToken: DEFAULT_GITHUB_TOKEN, }); onTestFinished(async () => { @@ -202,11 +204,11 @@ describe("Client options", async () => { it("should listen on configured tcp port", async () => { const port = await getAvailableTcpPort(); const client = new CopilotClient({ - workingDirectory: workDir, - env, connection: RuntimeConnection.forTcp({ path: process.env.COPILOT_CLI_PATH, port, + workingDirectory: workDir, + env, }), }); onTestFinished(async () => { @@ -235,9 +237,11 @@ describe("Client options", async () => { // a custom cwd to assert that the custom cwd is honored. void defaultClient; const client = new CopilotClient({ - workingDirectory: clientCwd, - env, - connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: clientCwd, + env, + }), gitHubToken: DEFAULT_GITHUB_TOKEN, }); onTestFinished(async () => { @@ -274,11 +278,11 @@ describe("Client options", async () => { fs.writeFileSync(cliPath, FAKE_STDIO_CLI_SCRIPT); const client = new CopilotClient({ - workingDirectory: workDir, - env: { ...env, COPILOT_HOME: copilotHomeFromEnv }, connection: RuntimeConnection.forStdio({ path: cliPath, args: ["--capture-file", capturePath], + workingDirectory: workDir, + env: { ...env, COPILOT_HOME: copilotHomeFromEnv }, }), baseDirectory: copilotHomeFromOption, gitHubToken: "process-option-token", @@ -389,11 +393,11 @@ describe("Client options", async () => { const client = new CopilotClient({ mode: "empty", baseDirectory: workDir, - workingDirectory: workDir, - env, connection: RuntimeConnection.forStdio({ path: cliPath, args: ["--capture-file", capturePath], + workingDirectory: workDir, + env, }), useLoggedInUser: false, }); @@ -446,11 +450,11 @@ describe("Client options", async () => { fs.writeFileSync(cliPath, FAKE_STDIO_CLI_SCRIPT); const client = new CopilotClient({ - workingDirectory: workDir, - env, connection: RuntimeConnection.forStdio({ path: cliPath, args: ["--capture-file", capturePath], + workingDirectory: workDir, + env, }), useLoggedInUser: false, }); @@ -595,11 +599,11 @@ describe("Client options", async () => { fs.writeFileSync(cliPath, FAKE_STDIO_CLI_SCRIPT); const client = new CopilotClient({ - workingDirectory: workDir, - env, connection: RuntimeConnection.forStdio({ path: cliPath, args: ["--capture-file", capturePath], + workingDirectory: workDir, + env, }), useLoggedInUser: false, }); @@ -662,11 +666,11 @@ describe("Client options", async () => { fs.writeFileSync(cliPath, FAKE_STDIO_CLI_SCRIPT); const client = new CopilotClient({ - workingDirectory: workDir, - env, connection: RuntimeConnection.forStdio({ path: cliPath, args: ["--capture-file", capturePath], + workingDirectory: workDir, + env, }), useLoggedInUser: false, }); diff --git a/nodejs/test/e2e/extension_env_access.e2e.test.ts b/nodejs/test/e2e/extension_env_access.e2e.test.ts index f0f84a35ed..a3260f26a2 100644 --- a/nodejs/test/e2e/extension_env_access.e2e.test.ts +++ b/nodejs/test/e2e/extension_env_access.e2e.test.ts @@ -184,14 +184,16 @@ const cliObservations = mkdtempSync(join(tmpdir(), "copilot-env-access-cli-")); const cliResultFile = join(cliObservations, "result"); const cliContext = await createSdkTestContext({ copilotClientOptions: { - connection: RuntimeConnection.forStdio({ path: await getLegacyCliPathForTests() }), - env: { - COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS", - EXTENSION_ENV_REQUEST: "E2E_SDK_TOKEN", - EXTENSION_RESULT_FILE: cliResultFile, - EXTENSION_PREJOIN_FILE: join(cliObservations, "prejoin"), - EXTENSION_POSTJOIN_FILE: join(cliObservations, "postjoin"), - }, + connection: RuntimeConnection.forStdio({ + path: await getLegacyCliPathForTests(), + env: { + COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS", + EXTENSION_ENV_REQUEST: "E2E_SDK_TOKEN", + EXTENSION_RESULT_FILE: cliResultFile, + EXTENSION_PREJOIN_FILE: join(cliObservations, "prejoin"), + EXTENSION_POSTJOIN_FILE: join(cliObservations, "postjoin"), + }, + }), }, }); diff --git a/nodejs/test/e2e/factory.e2e.test.ts b/nodejs/test/e2e/factory.e2e.test.ts index 98004406f9..180163acb6 100644 --- a/nodejs/test/e2e/factory.e2e.test.ts +++ b/nodejs/test/e2e/factory.e2e.test.ts @@ -15,10 +15,12 @@ import { retry } from "./harness/sdkTestHelper.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const factoryTestContext = await createSdkTestContext({ copilotClientOptions: { - connection: RuntimeConnection.forStdio({ path: await getLegacyCliPathForTests() }), - env: { - COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS,AGENT_FACTORIES", - }, + connection: RuntimeConnection.forStdio({ + path: await getLegacyCliPathForTests(), + env: { + COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS,AGENT_FACTORIES", + }, + }), }, }); diff --git a/nodejs/test/e2e/harness/sdkTestContext.ts b/nodejs/test/e2e/harness/sdkTestContext.ts index c4befb148e..4bb183db07 100644 --- a/nodejs/test/e2e/harness/sdkTestContext.ts +++ b/nodejs/test/e2e/harness/sdkTestContext.ts @@ -152,13 +152,25 @@ export async function createSdkTestContext({ connection = RuntimeConnection.forStdio({ path: cliPath }); } - const { - connection: _ignoredConnection, - env: userEnv, - ...remainingClientOptions - } = copilotClientOptions ?? {}; + if (connection.kind === "stdio") { + connection = RuntimeConnection.forStdio({ + ...connection, + workingDirectory: connection.workingDirectory ?? workDir, + env: { ...env, ...connection.env }, + }); + } else if (connection.kind === "tcp") { + connection = RuntimeConnection.forTcp({ + ...connection, + workingDirectory: connection.workingDirectory ?? workDir, + env: { ...env, ...connection.env }, + }); + } - const mergedEnv = { ...env, ...userEnv }; + const { connection: _ignoredConnection, ...remainingClientOptions } = + copilotClientOptions ?? {}; + + const mergedEnv = + connection.kind === "stdio" || connection.kind === "tcp" ? connection.env : env; // The in-process (FFI) transport loads the runtime into this test host process, // and its worker inherits this process's ambient environment rather than a @@ -184,16 +196,11 @@ export async function createSdkTestContext({ // secondary client (e.g. resuming a session from a fresh client) don't have to // reimplement the in-process env/cwd handling. Callers may override the connection // (e.g. pin stdio for telemetry, which the in-process transport cannot carry - // per-client); env is attached to child-process transports and mirrored onto the - // process for in-process (see beforeEach below), never passed per-client for the - // in-process transport where it would be rejected. + // per-client). Out-of-process env/cwd live on the connection object, while + // in-process hosting mirrors the environment onto the real process (see beforeEach + // below) and uses a temporary chdir so the worker inherits the right cwd. function createClient(overrides: Partial = {}): CopilotClient { - const { - connection: overrideConnection, - env: _ignoredEnv, - workingDirectory: overrideWorkingDirectory, - ...rest - } = overrides; + const { connection: overrideConnection, ...rest } = overrides; let effectiveConnection = overrideConnection ?? connection; // Fill in the bundled CLI path for child-process connections that omit it @@ -209,20 +216,21 @@ export async function createSdkTestContext({ path: cliPath, }); } - const effectiveInProcess = effectiveConnection.kind === "inprocess"; + if (effectiveConnection.kind === "stdio") { + effectiveConnection = RuntimeConnection.forStdio({ + ...effectiveConnection, + workingDirectory: effectiveConnection.workingDirectory ?? workDir, + env: { ...env, ...effectiveConnection.env }, + }); + } else if (effectiveConnection.kind === "tcp") { + effectiveConnection = RuntimeConnection.forTcp({ + ...effectiveConnection, + workingDirectory: effectiveConnection.workingDirectory ?? workDir, + env: { ...env, ...effectiveConnection.env }, + }); + } return new CopilotClient({ - // The in-process transport rejects a per-client workingDirectory (it would have to - // mutate the shared host process cwd). Instead the harness changes this process's - // cwd to workDir around the in-process worker's startup (see beforeEach below), so - // the worker still spawns with workDir as its cwd. Out-of-process clients get it - // as a normal per-client option. - workingDirectory: - overrideWorkingDirectory ?? (effectiveInProcess ? undefined : workDir), - // In-process hosting mirrors the environment onto the real process (per test, in - // beforeEach below), so the worker inherits it; passing a per-client env here - // would have no effect (and is rejected by the in-process transport). - env: effectiveInProcess ? undefined : mergedEnv, logLevel: logLevel || "error", connection: effectiveConnection, gitHubToken: authTokenToUse, diff --git a/nodejs/test/e2e/mcp_oauth.e2e.test.ts b/nodejs/test/e2e/mcp_oauth.e2e.test.ts index cd7a6b88c7..f95dcc80dc 100644 --- a/nodejs/test/e2e/mcp_oauth.e2e.test.ts +++ b/nodejs/test/e2e/mcp_oauth.e2e.test.ts @@ -8,7 +8,7 @@ import { createInterface } from "node:readline"; import { fileURLToPath } from "node:url"; import { describe, expect, it, onTestFinished } from "vitest"; import type { CopilotSession, MCPServerConfig, McpAuthRequest } from "../../src/index.js"; -import { approveAll } from "../../src/index.js"; +import { approveAll, RuntimeConnection } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; import { stopChildProcess, waitForCondition } from "./harness/sdkTestHelper.js"; @@ -24,10 +24,12 @@ const CIMD_URL = "https://github.com/copilot/cli/client-metadata.json"; describe("MCP OAuth host auth", async () => { const { copilotClient: client } = await createSdkTestContext({ copilotClientOptions: { - env: { - COPILOT_MCP_APPS: "true", - MCP_APPS: "true", - }, + connection: RuntimeConnection.forStdio({ + env: { + COPILOT_MCP_APPS: "true", + MCP_APPS: "true", + }, + }), }, }); diff --git a/nodejs/test/e2e/pending_work_resume.e2e.test.ts b/nodejs/test/e2e/pending_work_resume.e2e.test.ts index 7c2906c7b9..c961ce2334 100644 --- a/nodejs/test/e2e/pending_work_resume.e2e.test.ts +++ b/nodejs/test/e2e/pending_work_resume.e2e.test.ts @@ -141,12 +141,12 @@ describe("Pending work resume", async () => { function createTcpServer(): CopilotClient { const server = new CopilotClient({ - workingDirectory: workDir, - env, gitHubToken: DEFAULT_GITHUB_TOKEN, connection: RuntimeConnection.forTcp({ path: process.env.COPILOT_CLI_PATH, connectionToken: SHARED_TOKEN, + workingDirectory: workDir, + env, }), }); onTestFinished(async () => { @@ -518,11 +518,11 @@ describe("Pending work resume", async () => { if (scenario.disconnectOriginalClient) { const lockObserver = new CopilotClient({ - workingDirectory: workDir, - env, gitHubToken: DEFAULT_GITHUB_TOKEN, connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env, }), }); try { diff --git a/nodejs/test/e2e/per_session_auth.e2e.test.ts b/nodejs/test/e2e/per_session_auth.e2e.test.ts index 5f55d397d8..dc8b6e0b6e 100644 --- a/nodejs/test/e2e/per_session_auth.e2e.test.ts +++ b/nodejs/test/e2e/per_session_auth.e2e.test.ts @@ -77,13 +77,15 @@ describe("Per-session GitHub auth", async () => { it("should return unauthenticated when no token is provided", async () => { const noTokenClient = new CopilotClient({ - workingDirectory: workDir, - env: withoutAuthEnv({ - ...env, - COPILOT_DEBUG_GITHUB_API_URL: env.COPILOT_API_URL, - }), logLevel: "error", - connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env: withoutAuthEnv({ + ...env, + COPILOT_DEBUG_GITHUB_API_URL: env.COPILOT_API_URL, + }), + }), useLoggedInUser: false, }); diff --git a/nodejs/test/e2e/provider_endpoint.e2e.test.ts b/nodejs/test/e2e/provider_endpoint.e2e.test.ts index 8acf6a2469..047958f17c 100644 --- a/nodejs/test/e2e/provider_endpoint.e2e.test.ts +++ b/nodejs/test/e2e/provider_endpoint.e2e.test.ts @@ -3,14 +3,16 @@ *--------------------------------------------------------------------------------------------*/ import { describe, expect, it } from "vitest"; -import { approveAll } from "../../src/index.js"; +import { approveAll, RuntimeConnection } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; describe("session.provider.getEndpoint RPC", async () => { const { copilotClient: client } = await createSdkTestContext({ copilotClientOptions: { // The provider endpoint API is gated behind an opt-in env var. - env: { COPILOT_ALLOW_GET_PROVIDER_ENDPOINT: "true" }, + connection: RuntimeConnection.forStdio({ + env: { COPILOT_ALLOW_GET_PROVIDER_ENDPOINT: "true" }, + }), }, }); diff --git a/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts b/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts index 4025dc444c..d34c907917 100644 --- a/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts +++ b/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts @@ -63,14 +63,16 @@ describe("Session MCP and skills RPC", async () => { function createMcpAppsClient(): CopilotClient { const mcpAppsClient = new CopilotClient({ - workingDirectory: workDir, - env: { - ...env, - COPILOT_MCP_APPS: "true", - MCP_APPS: "true", - }, logLevel: "error", - connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env: { + ...env, + COPILOT_MCP_APPS: "true", + MCP_APPS: "true", + }, + }), }); onTestFinished(async () => { try { diff --git a/nodejs/test/e2e/rpc_server.e2e.test.ts b/nodejs/test/e2e/rpc_server.e2e.test.ts index cf08f916a2..3aeb6625a6 100644 --- a/nodejs/test/e2e/rpc_server.e2e.test.ts +++ b/nodejs/test/e2e/rpc_server.e2e.test.ts @@ -31,10 +31,12 @@ describe("Server-scoped RPC", async () => { ...extraEnv, }; const extraClient = new CopilotClient({ - workingDirectory: workDir, - env: childEnv, logLevel: "error", - connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env: childEnv, + }), gitHubToken: token, }); onTestFinished(async () => { diff --git a/nodejs/test/e2e/rpc_server_misc.e2e.test.ts b/nodejs/test/e2e/rpc_server_misc.e2e.test.ts index 4f12e507a5..0653b591bb 100644 --- a/nodejs/test/e2e/rpc_server_misc.e2e.test.ts +++ b/nodejs/test/e2e/rpc_server_misc.e2e.test.ts @@ -24,13 +24,15 @@ describe("Miscellaneous server-scoped RPC", async () => { gitHubToken: string | undefined ): CopilotClient { return new CopilotClient({ - workingDirectory: workDir, - env: { - ...env, - ...extraEnv, - }, logLevel: "error", - connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env: { + ...env, + ...extraEnv, + }, + }), gitHubToken, useLoggedInUser: gitHubToken === undefined ? false : undefined, }); diff --git a/nodejs/test/e2e/rpc_server_plugins.e2e.test.ts b/nodejs/test/e2e/rpc_server_plugins.e2e.test.ts index 20575a9114..b3a9b7def6 100644 --- a/nodejs/test/e2e/rpc_server_plugins.e2e.test.ts +++ b/nodejs/test/e2e/rpc_server_plugins.e2e.test.ts @@ -24,16 +24,18 @@ describe("Server-scoped plugin RPC", async () => { function createClient(home: string): CopilotClient { return new CopilotClient({ - workingDirectory: workDir, - env: { - ...env, - COPILOT_HOME: home, - GH_CONFIG_DIR: home, - XDG_CONFIG_HOME: home, - XDG_STATE_HOME: home, - }, logLevel: "error", - connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env: { + ...env, + COPILOT_HOME: home, + GH_CONFIG_DIR: home, + XDG_CONFIG_HOME: home, + XDG_STATE_HOME: home, + }, + }), gitHubToken: DEFAULT_GITHUB_TOKEN, }); } diff --git a/nodejs/test/e2e/rpc_server_remote_control.e2e.test.ts b/nodejs/test/e2e/rpc_server_remote_control.e2e.test.ts index 3094d32577..4afe88a7a7 100644 --- a/nodejs/test/e2e/rpc_server_remote_control.e2e.test.ts +++ b/nodejs/test/e2e/rpc_server_remote_control.e2e.test.ts @@ -13,10 +13,12 @@ describe("Server-scoped remote-control RPC", async () => { function createDedicatedClient(): CopilotClient { return new CopilotClient({ - workingDirectory: workDir, - env, logLevel: "error", - connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env, + }), gitHubToken: DEFAULT_GITHUB_TOKEN, }); } diff --git a/nodejs/test/e2e/rpc_session_state_extras.e2e.test.ts b/nodejs/test/e2e/rpc_session_state_extras.e2e.test.ts index 7b88af7e2d..1cfa8da9c9 100644 --- a/nodejs/test/e2e/rpc_session_state_extras.e2e.test.ts +++ b/nodejs/test/e2e/rpc_session_state_extras.e2e.test.ts @@ -15,13 +15,15 @@ describe("Session-scoped state extras RPC", async () => { token = DEFAULT_GITHUB_TOKEN ): CopilotClient { return new CopilotClient({ - workingDirectory: workDir, - env: { - ...env, - ...extraEnv, - }, logLevel: "error", - connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env: { + ...env, + ...extraEnv, + }, + }), gitHubToken: token, }); } diff --git a/nodejs/test/e2e/session.e2e.test.ts b/nodejs/test/e2e/session.e2e.test.ts index 77a1dec408..0ca1e27554 100644 --- a/nodejs/test/e2e/session.e2e.test.ts +++ b/nodejs/test/e2e/session.e2e.test.ts @@ -28,14 +28,28 @@ describe("Sessions", () => { } it.each([ - ["stdio", () => RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH })], - ["tcp", () => RuntimeConnection.forTcp({ path: process.env.COPILOT_CLI_PATH })], + [ + "stdio", + () => + RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env, + }), + ], + [ + "tcp", + () => + RuntimeConnection.forTcp({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env, + }), + ], ] as const)( "createSession works without onPermissionRequest (%s)", async (_name, makeConnection) => { const standaloneClient = new CopilotClient({ - workingDirectory: workDir, - env, connection: makeConnection(), }); onTestFinished(async () => { @@ -55,11 +69,11 @@ describe("Sessions", () => { const connectionToken = "client-e2e-resume-token"; const tcpClient = new CopilotClient({ - workingDirectory: workDir, - env, connection: RuntimeConnection.forTcp({ path: process.env.COPILOT_CLI_PATH, connectionToken, + workingDirectory: workDir, + env, }), }); onTestFinished(async () => { @@ -78,8 +92,6 @@ describe("Sessions", () => { } const resumeClient = new CopilotClient({ - workingDirectory: workDir, - env, connection: RuntimeConnection.forUri(`localhost:${port}`, { connectionToken }), }); onTestFinished(async () => { @@ -100,9 +112,11 @@ describe("Sessions", () => { const sessionId = `e2e-resume-${Date.now()}`; const marker = "MARKER-7f3ac21e"; const firstClient = new CopilotClient({ - workingDirectory: workDir, - env, - connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env, + }), // Explicit token (matches createClient()/other passing resume tests): without it, // useLoggedInUser defaults to true and the runtime falls back to ambient env-var // auto-detection for the model call, which flakes on some hosts (e.g. Alpine ARM64). @@ -119,7 +133,7 @@ describe("Sessions", () => { const session = await firstClient.createSession({ sessionId, onPermissionRequest: approveAll, - model: "claude-sonnet-4.5", + model: "claude-sonnet-5", }); await session.sendAndWait({ prompt: `Please remember this exact secret marker for later - ${marker}. Reply with only the single word "Acknowledged".`, @@ -128,9 +142,11 @@ describe("Sessions", () => { await firstClient.stop(); const secondClient = new CopilotClient({ - workingDirectory: workDir, - env, - connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env, + }), gitHubToken: isCI ? DEFAULT_GITHUB_TOKEN : undefined, }); onTestFinished(async () => { @@ -142,7 +158,7 @@ describe("Sessions", () => { }); const resumedSession = await secondClient.resumeSession(sessionId, { onPermissionRequest: approveAll, - model: "claude-sonnet-4.5", + model: "claude-sonnet-5", }); const response = await resumedSession.sendAndWait({ prompt: "What was the exact secret marker I asked you to remember earlier? Reply with only that marker value and nothing else.", diff --git a/nodejs/test/e2e/session_config.e2e.test.ts b/nodejs/test/e2e/session_config.e2e.test.ts index 8d041f1ec8..097ef2a6ce 100644 --- a/nodejs/test/e2e/session_config.e2e.test.ts +++ b/nodejs/test/e2e/session_config.e2e.test.ts @@ -554,9 +554,11 @@ describe("Session Configuration", async () => { it("should enable citations for Anthropic file attachments on create", async () => { const handler = new RecordingRequestHandler(); const citationClient = new CopilotClient({ - connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - workingDirectory: workDir, - env, + connection: RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env, + }), gitHubToken: DEFAULT_GITHUB_TOKEN, requestHandler: handler, }); @@ -591,9 +593,9 @@ describe("Session Configuration", async () => { connection: RuntimeConnection.forTcp({ path: process.env.COPILOT_CLI_PATH, connectionToken, + workingDirectory: workDir, + env, }), - workingDirectory: workDir, - env, gitHubToken: DEFAULT_GITHUB_TOKEN, requestHandler: handler, }); diff --git a/nodejs/test/e2e/session_fs.e2e.test.ts b/nodejs/test/e2e/session_fs.e2e.test.ts index 9727de06e1..6b3515497e 100644 --- a/nodejs/test/e2e/session_fs.e2e.test.ts +++ b/nodejs/test/e2e/session_fs.e2e.test.ts @@ -105,8 +105,7 @@ describe("Session Fs", async () => { const tcpConnectionToken = "session-fs-test-token"; const client = new CopilotClient({ // Use TCP so we can connect from a second client - connection: RuntimeConnection.forTcp({ connectionToken: tcpConnectionToken }), - env, + connection: RuntimeConnection.forTcp({ connectionToken: tcpConnectionToken, env }), }); onTestFinished(() => client.stop()); await client.createSession({ onPermissionRequest: approveAll, createSessionFsProvider }); @@ -116,7 +115,6 @@ describe("Session Fs", async () => { // Second client tries to connect with a session fs — should fail // because sessions already exist on the runtime. const client2 = new CopilotClient({ - env, logLevel: "error", connection: RuntimeConnection.forUri(`localhost:${port}`, { connectionToken: tcpConnectionToken, diff --git a/nodejs/test/e2e/subagent_hooks.e2e.test.ts b/nodejs/test/e2e/subagent_hooks.e2e.test.ts index dbc3ca673b..e8dec4a4c8 100644 --- a/nodejs/test/e2e/subagent_hooks.e2e.test.ts +++ b/nodejs/test/e2e/subagent_hooks.e2e.test.ts @@ -12,7 +12,7 @@ import type { PostToolUseHookInput, PostToolUseHookOutput, } from "../../src/index.js"; -import { approveAll, CopilotRequestHandler } from "../../src/index.js"; +import { approveAll, CopilotRequestHandler, RuntimeConnection } from "../../src/index.js"; import { createSdkTestContext, isCI } from "./harness/sdkTestContext.js"; interface RequestRecord { @@ -79,7 +79,9 @@ describe("Subagent hooks", async () => { copilotClientOptions: { ...(recordToken ? { gitHubToken: recordToken } : {}), requestHandler, - env: { COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS: "true" }, + connection: RuntimeConnection.forStdio({ + env: { COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS: "true" }, + }), }, }); diff --git a/nodejs/test/e2e/suspend.e2e.test.ts b/nodejs/test/e2e/suspend.e2e.test.ts index 2c8639ad38..5ecd9c9164 100644 --- a/nodejs/test/e2e/suspend.e2e.test.ts +++ b/nodejs/test/e2e/suspend.e2e.test.ts @@ -63,12 +63,12 @@ describe("Suspend RPC", async () => { function createTcpServer(): CopilotClient { const server = new CopilotClient({ - workingDirectory: workDir, - env, gitHubToken: DEFAULT_GITHUB_TOKEN, connection: RuntimeConnection.forTcp({ path: process.env.COPILOT_CLI_PATH, connectionToken: SHARED_TOKEN, + workingDirectory: workDir, + env, }), }); onTestFinishedStop(server); diff --git a/python/README.md b/python/README.md index 2d1d8ca153..1fc223e309 100644 --- a/python/README.md +++ b/python/README.md @@ -233,9 +233,7 @@ All options are kw-only parameters: `RuntimeConnection.for_stdio(...)`, `RuntimeConnection.for_tcp(...)`, `RuntimeConnection.for_uri(...)`, or `RuntimeConnection.for_inprocess(...)`. Defaults to a stdio connection with the bundled binary. -- `working_directory` (str | None): Working directory for the CLI process (default: current dir). - `log_level` (str): Log level (default: "info"). -- `env` (dict | None): Environment variables for the CLI process. - `github_token` (str | None): GitHub token for authentication. When provided, takes priority over other auth methods. - `base_directory` (str | None): Base directory for Copilot data (session state, config, etc.). Sets `COPILOT_HOME` on the spawned CLI process. When `None`, the CLI defaults to `~/.copilot`. Useful in restricted environments where only specific directories are writable. Ignored when using a `UriRuntimeConnection`. - `use_logged_in_user` (bool | None): Whether to use logged-in user for authentication (default: True, but False when `github_token` is provided). @@ -248,8 +246,8 @@ All options are kw-only parameters: **RuntimeConnection variants:** -- `RuntimeConnection.for_stdio(path=None, args=None)` — spawn a local CLI process and talk over stdio. -- `RuntimeConnection.for_tcp(port=0, connection_token=None, path=None, args=None)` — spawn a local CLI in TCP mode. +- `RuntimeConnection.for_stdio(path=None, args=None, working_directory=None, env=None)` — spawn a local CLI process and talk over stdio. +- `RuntimeConnection.for_tcp(port=0, connection_token=None, path=None, args=None, working_directory=None, env=None)` — spawn a local CLI in TCP mode. - `RuntimeConnection.for_uri(url, connection_token=None)` — connect to an existing CLI server (e.g. `"localhost:8080"`). - `RuntimeConnection.for_inprocess()` — host the runtime in-process via its native C ABI (FFI). See [In-process (FFI) transport](#in-process-ffi-transport). @@ -257,14 +255,15 @@ Managed stdio and TCP connections use the downloaded `copilot-runtime` executable with adjacent `runtime.node` by default. An explicit connection path or `COPILOT_CLI_PATH` overrides the downloaded runtime. -Child-process connections (`for_stdio`/`for_tcp`) also expose a per-connection -`env` field for the spawned process. Set it on the returned connection instead of -the client-level `env` — setting both raises: +The shared `OutOfProcessRuntimeConnection` base for `for_stdio`/`for_tcp` +connections carries the spawned process's `working_directory` and `env`: ```python -conn = RuntimeConnection.for_stdio() -conn.env = {"MY_VAR": "value"} -client = CopilotClient(connection=conn) # do NOT also pass env=... here +conn = RuntimeConnection.for_stdio( + working_directory="/srv/app", + env={"MY_VAR": "value"}, +) +client = CopilotClient(connection=conn) ``` ### In-process (FFI) transport @@ -293,11 +292,10 @@ finally: - Set `COPILOT_CLI_PATH` only when using an externally provisioned compatible runtime package. In-process connections do not accept per-connection paths or raw process arguments. -- Because the runtime shares this single host process, per-client options that - lower to environment variables or a working directory **cannot** be honored and - are rejected: `env`, `telemetry`, and `working_directory` all raise `ValueError` - with `for_inprocess()`. Set the corresponding values on the host process - environment / working directory before creating the client instead. +- Because the runtime shares this single host process, client-wide `telemetry` + **cannot** be honored and is rejected with `for_inprocess()`. Process-scoped + launch settings such as working directory and environment live on + `OutOfProcessRuntimeConnection`, which the in-process transport does not use. - Set `COPILOT_SDK_DEFAULT_CONNECTION=inprocess` to select the in-process transport by default when no explicit `connection` is supplied. diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 0bf7ed954e..fe05a87368 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -32,7 +32,6 @@ AskUserVariant, AutoTier, CapiSessionOptions, - ChildProcessRuntimeConnection, ClientInfo, CloudSessionOptions, CloudSessionRepository, @@ -59,6 +58,7 @@ ModelPolicy, ModelSupports, ModelVisionLimits, + OutOfProcessRuntimeConnection, PingResponse, RemoteSessionMode, RuntimeConnection, @@ -257,7 +257,6 @@ "CanvasJsonSchema", "CanvasProviderIdentity", "CapiSessionOptions", - "ChildProcessRuntimeConnection", "ClientInfo", "CloudSessionOptions", "CloudSessionRepository", @@ -337,6 +336,7 @@ "ModelVisionLimitsOverride", "NamedProviderConfig", "OpenCanvasInstance", + "OutOfProcessRuntimeConnection", "PermissionHandler", "PermissionNoResult", "PermissionRequest", diff --git a/python/copilot/client.py b/python/copilot/client.py index d8d2f7e3f4..39b0b15e3f 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -568,8 +568,10 @@ def for_stdio( *, path: str | None = None, args: Sequence[str] = (), + working_directory: str | None = None, + env: dict[str, str] | None = None, ) -> StdioRuntimeConnection: - """Spawn a runtime child process and communicate over its stdin/stdout. + """Spawn an out-of-process runtime and communicate over its stdin/stdout. This is the default when no :attr:`CopilotClientOptions.connection` is supplied. @@ -578,8 +580,18 @@ def for_stdio( path: Path to the runtime executable. When ``None``, uses the bundled binary. args: Extra command-line arguments passed to the runtime process. + working_directory: Working directory for the spawned runtime + process. When ``None``, inherits the current process working + directory. + env: Environment variables for the spawned runtime process. When + ``None``, inherits the current process environment. """ - return StdioRuntimeConnection(path=path, args=tuple(args)) + return StdioRuntimeConnection( + path=path, + args=tuple(args), + working_directory=working_directory, + env=env, + ) @staticmethod def for_tcp( @@ -588,8 +600,10 @@ def for_tcp( connection_token: str | None = None, path: str | None = None, args: Sequence[str] = (), + working_directory: str | None = None, + env: dict[str, str] | None = None, ) -> TcpRuntimeConnection: - """Spawn a runtime child process listening on a TCP socket. + """Spawn an out-of-process runtime listening on a TCP socket. Args: port: TCP port to listen on. ``0`` (the default) auto-allocates @@ -602,10 +616,17 @@ def for_tcp( path: Path to the runtime executable. When ``None``, uses the bundled binary. args: Extra command-line arguments passed to the runtime process. + working_directory: Working directory for the spawned runtime + process. When ``None``, inherits the current process working + directory. + env: Environment variables for the spawned runtime process. When + ``None``, inherits the current process environment. """ return TcpRuntimeConnection( path=path, args=tuple(args), + working_directory=working_directory, + env=env, port=port, connection_token=connection_token, ) @@ -630,18 +651,18 @@ def for_inprocess() -> InProcessRuntimeConnection: **Experimental.** The in-process (FFI) transport is experimental and its behavior may change or be removed in a future release. - Instead of spawning the runtime as a child process, the SDK loads the + Instead of spawning the runtime out-of-process, the SDK loads the runtime's native shared library into this process and drives JSON-RPC over its C ABI. - Because the runtime loads into this single shared process, per-client - options that lower to environment variables or a working directory - cannot be honored: :attr:`CopilotClientOptions.env`, - :attr:`CopilotClientOptions.telemetry`, and - :attr:`CopilotClientOptions.working_directory` are rejected with this - transport. Set those on the host process before creating the client. - Set ``COPILOT_CLI_PATH`` only when using an externally provisioned - compatible runtime package. + Because the runtime loads into this single shared process, client-wide + telemetry configuration cannot be honored here: it lowers to + environment variables read by native runtime code running in the shared + host process, so :attr:`CopilotClientOptions.telemetry` is rejected. + Other process-scoped launch settings live on + :class:`OutOfProcessRuntimeConnection`, which this transport does not + use. Set ``COPILOT_CLI_PATH`` only when using an externally + provisioned compatible runtime package. Note: Pre-provision the native runtime with @@ -652,10 +673,11 @@ def for_inprocess() -> InProcessRuntimeConnection: @dataclass -class ChildProcessRuntimeConnection(RuntimeConnection): - """Base for :class:`RuntimeConnection` variants that spawn a runtime child process. +class OutOfProcessRuntimeConnection(RuntimeConnection): + """Base for :class:`RuntimeConnection` variants that spawn a runtime process. - Construct via :meth:`RuntimeConnection.stdio` or :meth:`RuntimeConnection.tcp`. + Construct via :meth:`RuntimeConnection.for_stdio` or + :meth:`RuntimeConnection.for_tcp`. """ path: str | None = None @@ -664,27 +686,30 @@ class ChildProcessRuntimeConnection(RuntimeConnection): args: Sequence[str] = () """Extra command-line arguments passed to the runtime process.""" + working_directory: str | None = None + """Working directory for the spawned runtime process. + + ``None`` inherits the current process working directory.""" + env: dict[str, str] | None = None - """Per-connection environment variables for the spawned child process. + """Environment variables for the spawned runtime process. - When set, do not also set :attr:`CopilotClientOptions.env` — the client - rejects setting environment in both places. ``None`` inherits the - client-level env (or the current process env).""" + ``None`` inherits the current process environment.""" @dataclass -class StdioRuntimeConnection(ChildProcessRuntimeConnection): - """Spawns a runtime child process and communicates over its stdin/stdout. +class StdioRuntimeConnection(OutOfProcessRuntimeConnection): + """Spawns an out-of-process runtime and communicates over its stdin/stdout. - Construct via :meth:`RuntimeConnection.stdio`. + Construct via :meth:`RuntimeConnection.for_stdio`. """ @dataclass -class TcpRuntimeConnection(ChildProcessRuntimeConnection): - """Spawns a runtime child process listening on a TCP socket. +class TcpRuntimeConnection(OutOfProcessRuntimeConnection): + """Spawns an out-of-process runtime listening on a TCP socket. - Construct via :meth:`RuntimeConnection.tcp`. + Construct via :meth:`RuntimeConnection.for_tcp`. """ port: int = 0 @@ -806,9 +831,7 @@ class _CopilotClientOptions: """ connection: RuntimeConnection | None = None - working_directory: str | None = None log_level: LogLevel = "info" - env: dict[str, str] | None = None github_token: str | None = None base_directory: str | None = None builtin_plugin_directories: tuple[str, ...] = () @@ -1477,51 +1500,24 @@ def _resolve_default_connection(env: Mapping[str, str]) -> RuntimeConnection: def _validate_environment_options( options: _CopilotClientOptions, connection: RuntimeConnection ) -> None: - """Validate env/telemetry/working-directory options against the transport. + """Validate client options that depend on the selected 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 and process-global working directory cannot carry per-client values, - so options that lower to them are rejected there (fail loud, not silent). + The in-process (FFI) transport loads the native runtime into the shared + host process, so client-wide telemetry cannot be honored there: it lowers + to environment variables read by native code in that shared process. Fail + loudly instead of silently ignoring the setting. """ if isinstance(connection, InProcessRuntimeConnection): - if options.env is not None: - raise ValueError( - "env is not supported with RuntimeConnection.for_inprocess(): 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." - ) if options.telemetry is not None: raise ValueError( "telemetry is not supported with RuntimeConnection.for_inprocess(): " "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." - ) - if options.working_directory is not None: - raise ValueError( - "working_directory is not supported with RuntimeConnection.for_inprocess(): " - "the native runtime shares the host process working directory, so a " - "per-client working directory cannot be honored in-process. Use a " - "child-process " - "transport, or set the process working directory before creating the client." + "host process environment, or use an out-of-process transport." ) return - if ( - isinstance(connection, ChildProcessRuntimeConnection) - and connection.env is not None - and options.env is not None - ): - raise ValueError( - "Set environment variables via either the client-level env argument or " - "ChildProcessRuntimeConnection.env, not both. Prefer the connection-level " - "env for child-process transports." - ) - class CopilotClient: """ @@ -1561,9 +1557,7 @@ def __init__( self, *, connection: RuntimeConnection | None = None, - working_directory: str | None = None, log_level: LogLevel = "info", - env: dict[str, str] | None = None, github_token: str | None = None, base_directory: str | None = None, builtin_plugin_directories: Sequence[str] | None = None, @@ -1582,21 +1576,18 @@ def __init__( """ Initialize a new CopilotClient. - Runtime options apply to locally hosted connections. The in-process - transport supports typed runtime options such as ``log_level``, - ``github_token``, and ``base_directory``, but rejects per-client - ``working_directory``, ``env``, and ``telemetry``. Options are ignored - when connecting to an existing runtime via - :meth:`RuntimeConnection.for_uri`. + Runtime options apply to locally hosted connections. Process-scoped + launch settings such as working directory and environment live on + :class:`OutOfProcessRuntimeConnection` for stdio/TCP transports. The + in-process transport supports typed runtime options such as + ``log_level``, ``github_token``, and ``base_directory``, but rejects + client-wide ``telemetry``. Options are ignored when connecting to an + existing runtime via :meth:`RuntimeConnection.for_uri`. Args: connection: How to reach the runtime. Defaults to :meth:`RuntimeConnection.for_stdio` with the bundled binary. - working_directory: Working directory for the runtime process. - ``None`` uses the current directory. log_level: Log level for the runtime process. Defaults to ``"info"``. - env: Environment variables for the runtime process. ``None`` inherits - the current env. github_token: GitHub token for authentication. Takes priority over other auth methods. base_directory: Base directory for Copilot data (session state, @@ -1647,15 +1638,17 @@ def __init__( >>> >>> # Custom runtime path with specific log level >>> client = CopilotClient( - ... connection=RuntimeConnection.for_stdio(path="/usr/local/bin/copilot"), + ... connection=RuntimeConnection.for_stdio( + ... path="/usr/local/bin/copilot", + ... working_directory="/srv/app", + ... env={"MY_VAR": "value"}, + ... ), ... log_level="debug", ... ) """ options = _CopilotClientOptions( connection=connection, - working_directory=working_directory, log_level=log_level, - env=env, github_token=github_token, base_directory=base_directory, builtin_plugin_directories=tuple(builtin_plugin_directories or ()), @@ -1708,14 +1701,14 @@ def __init__( self._runtime_port: int | None = actual_port self._effective_connection_token: str | None = connection.connection_token elif isinstance(connection, InProcessRuntimeConnection): - # In-process (FFI): no child process and no per-connection token. + # In-process (FFI): no out-of-process runtime and no per-connection token. self._runtime_port = None self._effective_connection_token = None self._inprocess_runtime_path = self._resolve_inprocess_runtime() if options.use_logged_in_user is None: options.use_logged_in_user = not bool(options.github_token) else: - assert isinstance(connection, ChildProcessRuntimeConnection) + assert isinstance(connection, OutOfProcessRuntimeConnection) self._runtime_port = None if isinstance(connection, TcpRuntimeConnection): @@ -1739,8 +1732,6 @@ def __init__( # unexpectedly honoring a host COPILOT_CLI_PATH. if connection.env is not None: effective_env: Mapping[str, str] = connection.env - elif options.env is not None: - effective_env = options.env else: effective_env = os.environ connection.path = self._resolve_runtime_entrypoint(connection.path, env=effective_env) @@ -4343,7 +4334,7 @@ async def _start_cli_server(self) -> None: await self._start_inprocess_ffi() return - assert isinstance(self._connection, ChildProcessRuntimeConnection) + assert isinstance(self._connection, OutOfProcessRuntimeConnection) conn = self._connection opts = self._options use_stdio = isinstance(conn, StdioRuntimeConnection) @@ -4395,16 +4386,13 @@ async def _start_cli_server(self) -> None: }, ) - # Get environment variables. Per-connection env (ChildProcessRuntimeConnection.env) - # takes precedence over the client-level env; the constructor already rejects - # setting both. When neither is set, inherit the current process environment. - conn_env = conn.env if isinstance(conn, ChildProcessRuntimeConnection) else None + # Get environment variables from the out-of-process connection, or inherit the + # current process environment when none are supplied. + conn_env = conn.env if isinstance(conn, OutOfProcessRuntimeConnection) else None if conn_env is not None: env = dict(conn_env) - elif opts.env is None: - env = dict(os.environ) else: - env = dict(opts.env) + env = dict(os.environ) # Set auth token in environment if provided if opts.github_token: env["COPILOT_SDK_AUTH_TOKEN"] = opts.github_token @@ -4441,7 +4429,7 @@ async def _start_cli_server(self) -> None: # On Windows, hide the console window to avoid distracting users in GUI apps creationflags = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0 - cwd = opts.working_directory or os.getcwd() + cwd = conn.working_directory or os.getcwd() # Choose transport mode spawn_start = time.perf_counter() diff --git a/python/e2e/_copilot_request_helpers.py b/python/e2e/_copilot_request_helpers.py index d4073dd197..195bbf3d8f 100644 --- a/python/e2e/_copilot_request_helpers.py +++ b/python/e2e/_copilot_request_helpers.py @@ -333,9 +333,11 @@ def build_isolated_client( if extra_env: env = {**env, **extra_env} return CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=env, + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=env, + ), github_token=github_token, request_handler=handler, ) diff --git a/python/e2e/test_client_lifecycle_e2e.py b/python/e2e/test_client_lifecycle_e2e.py index f1196a54e4..b1d7390461 100644 --- a/python/e2e/test_client_lifecycle_e2e.py +++ b/python/e2e/test_client_lifecycle_e2e.py @@ -59,9 +59,11 @@ def _make_isolated_client(ctx: E2ETestContext) -> CopilotClient: "fake-token-for-e2e-tests" if os.environ.get("GITHUB_ACTIONS") == "true" else None ) return CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=github_token, ) diff --git a/python/e2e/test_client_options_e2e.py b/python/e2e/test_client_options_e2e.py index b07e9a5402..9a8ccb7fb4 100644 --- a/python/e2e/test_client_options_e2e.py +++ b/python/e2e/test_client_options_e2e.py @@ -49,22 +49,26 @@ def _make_options( **overrides, ) -> dict[str, object]: """Build CopilotClient kwargs pre-populated for the test harness.""" + connection_working_directory = overrides.pop("working_directory", ctx.work_dir) + connection_env = overrides.pop("env", ctx.get_env()) if use_tcp: connection: RuntimeConnection = RuntimeConnection.for_tcp( port=port, connection_token=connection_token, path=cli_path if cli_path is not None else ctx.cli_path, args=tuple(cli_args or []), + working_directory=connection_working_directory, + env=connection_env, ) else: connection = RuntimeConnection.for_stdio( path=cli_path if cli_path is not None else ctx.cli_path, args=tuple(cli_args or []), + working_directory=connection_working_directory, + env=connection_env, ) base: dict[str, object] = { "connection": connection, - "working_directory": ctx.work_dir, - "env": ctx.get_env(), "github_token": DEFAULT_GITHUB_TOKEN, } base.update(overrides) diff --git a/python/e2e/test_commands_e2e.py b/python/e2e/test_commands_e2e.py index e0a0d63f1d..fcf05cf355 100644 --- a/python/e2e/test_commands_e2e.py +++ b/python/e2e/test_commands_e2e.py @@ -56,10 +56,11 @@ async def setup(self): # Client 1 uses TCP mode so a second client can connect self._client1 = CopilotClient( connection=RuntimeConnection.for_tcp( - path=self.cli_path, connection_token="py-tcp-shared-test-token" + path=self.cli_path, + connection_token="py-tcp-shared-test-token", + working_directory=self.work_dir, + env=self._get_env(), ), - working_directory=self.work_dir, - env=self._get_env(), github_token=github_token, ) diff --git a/python/e2e/test_connection_token.py b/python/e2e/test_connection_token.py index 1c7addbd9a..f0aa9c180b 100644 --- a/python/e2e/test_connection_token.py +++ b/python/e2e/test_connection_token.py @@ -46,9 +46,12 @@ async def setup(self): ) self._client = CopilotClient( - connection=RuntimeConnection.for_tcp(path=self.cli_path, connection_token=self.token), - working_directory=self.work_dir, - env=self.get_env(), + connection=RuntimeConnection.for_tcp( + path=self.cli_path, + connection_token=self.token, + working_directory=self.work_dir, + env=self.get_env(), + ), github_token=github_token, ) diff --git a/python/e2e/test_copilot_request_handler_e2e.py b/python/e2e/test_copilot_request_handler_e2e.py index 1811962e89..6476f25a77 100644 --- a/python/e2e/test_copilot_request_handler_e2e.py +++ b/python/e2e/test_copilot_request_handler_e2e.py @@ -237,9 +237,11 @@ async def handler_fixture(ctx: E2ETestContext): ) env = {**ctx.get_env(), "COPILOT_EXP_COPILOT_CLI_WEBSOCKET_RESPONSES": "true"} client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=env, + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=env, + ), github_token=github_token, request_handler=handler, ) diff --git a/python/e2e/test_github_telemetry_e2e.py b/python/e2e/test_github_telemetry_e2e.py index 976b0b616e..7b0e644c4c 100644 --- a/python/e2e/test_github_telemetry_e2e.py +++ b/python/e2e/test_github_telemetry_e2e.py @@ -23,9 +23,12 @@ def on_github_telemetry(notification: GitHubTelemetryNotification) -> None: received.append(notification) client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=get_cli_path_for_tests(), args=()), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=get_cli_path_for_tests(), + args=(), + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=DEFAULT_GITHUB_TOKEN, on_github_telemetry=on_github_telemetry, ) diff --git a/python/e2e/test_mode_empty_e2e.py b/python/e2e/test_mode_empty_e2e.py index c84613c8a9..56f46483b3 100644 --- a/python/e2e/test_mode_empty_e2e.py +++ b/python/e2e/test_mode_empty_e2e.py @@ -22,9 +22,12 @@ def _make_empty_client(ctx: E2ETestContext) -> CopilotClient: return CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path, args=()), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + args=(), + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=( "fake-token-for-e2e-tests" if os.environ.get("GITHUB_ACTIONS") == "true" else None ), diff --git a/python/e2e/test_multi_client_e2e.py b/python/e2e/test_multi_client_e2e.py index 1938ddfe89..5e569c24c4 100644 --- a/python/e2e/test_multi_client_e2e.py +++ b/python/e2e/test_multi_client_e2e.py @@ -57,10 +57,11 @@ async def setup(self): # Client 1 uses TCP mode so a second client can connect to the same server self._client1 = CopilotClient( connection=RuntimeConnection.for_tcp( - path=self.cli_path, connection_token="py-tcp-shared-test-token" + path=self.cli_path, + connection_token="py-tcp-shared-test-token", + working_directory=self.work_dir, + env=self.get_env(), ), - working_directory=self.work_dir, - env=self.get_env(), github_token=github_token, ) diff --git a/python/e2e/test_pending_work_resume_e2e.py b/python/e2e/test_pending_work_resume_e2e.py index 5b6d978f31..1c65d02f2a 100644 --- a/python/e2e/test_pending_work_resume_e2e.py +++ b/python/e2e/test_pending_work_resume_e2e.py @@ -34,15 +34,20 @@ def _make_subprocess_client(ctx: E2ETestContext, *, use_stdio: bool = True) -> CopilotClient: if use_stdio: - connection = RuntimeConnection.for_stdio(path=ctx.cli_path) + connection = RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ) else: connection = RuntimeConnection.for_tcp( - path=ctx.cli_path, connection_token="py-tcp-shared-test-token" + path=ctx.cli_path, + connection_token="py-tcp-shared-test-token", + working_directory=ctx.work_dir, + env=ctx.get_env(), ) return CopilotClient( connection=connection, - working_directory=ctx.work_dir, - env=ctx.get_env(), github_token=DEFAULT_GITHUB_TOKEN, ) diff --git a/python/e2e/test_per_session_auth_e2e.py b/python/e2e/test_per_session_auth_e2e.py index a8d13dc1de..9dcd48477e 100644 --- a/python/e2e/test_per_session_auth_e2e.py +++ b/python/e2e/test_per_session_auth_e2e.py @@ -97,9 +97,11 @@ async def test_should_return_unauthenticated_when_no_token_provided( env = without_auth_env(auth_ctx.get_env()) env["COPILOT_DEBUG_GITHUB_API_URL"] = auth_ctx.proxy_url no_token_client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=auth_ctx.cli_path), - working_directory=auth_ctx.work_dir, - env=env, + connection=RuntimeConnection.for_stdio( + path=auth_ctx.cli_path, + working_directory=auth_ctx.work_dir, + env=env, + ), use_logged_in_user=False, ) diff --git a/python/e2e/test_provider_endpoint_e2e.py b/python/e2e/test_provider_endpoint_e2e.py index 875a95b91b..3e61d36715 100644 --- a/python/e2e/test_provider_endpoint_e2e.py +++ b/python/e2e/test_provider_endpoint_e2e.py @@ -20,9 +20,11 @@ async def provider_ctx(ctx: E2ETestContext): env = {**ctx.get_env(), "COPILOT_ALLOW_GET_PROVIDER_ENDPOINT": "true"} client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=env, + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=env, + ), github_token=env["GITHUB_TOKEN"], ) try: diff --git a/python/e2e/test_rpc_server_e2e.py b/python/e2e/test_rpc_server_e2e.py index 83dc4a01e2..07ef54499e 100644 --- a/python/e2e/test_rpc_server_e2e.py +++ b/python/e2e/test_rpc_server_e2e.py @@ -93,9 +93,11 @@ def _make_authed_client(ctx: E2ETestContext, token: str) -> CopilotClient: env = ctx.get_env() env["COPILOT_DEBUG_GITHUB_API_URL"] = ctx.proxy_url return CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=env, + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=env, + ), github_token=token, ) @@ -104,9 +106,11 @@ def _make_client_with_env(ctx: E2ETestContext, env_overrides: dict[str, str]) -> env = ctx.get_env() env.update(env_overrides) return CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=env, + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=env, + ), github_token="fake-token-for-e2e-tests", ) diff --git a/python/e2e/test_rpc_server_misc_e2e.py b/python/e2e/test_rpc_server_misc_e2e.py index 2b6b7d9514..52f7038b35 100644 --- a/python/e2e/test_rpc_server_misc_e2e.py +++ b/python/e2e/test_rpc_server_misc_e2e.py @@ -33,9 +33,11 @@ def _create_dedicated_client(ctx: E2ETestContext) -> CopilotClient: return CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=DEFAULT_GITHUB_TOKEN, ) @@ -53,9 +55,11 @@ async def _create_isolated_client( env["GH_TOKEN"] = "" env["GITHUB_TOKEN"] = "" client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=env, + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=env, + ), github_token=github_token, use_logged_in_user=False if github_token is None else None, ) diff --git a/python/e2e/test_rpc_server_plugins_e2e.py b/python/e2e/test_rpc_server_plugins_e2e.py index 538d1692fd..27500e517f 100644 --- a/python/e2e/test_rpc_server_plugins_e2e.py +++ b/python/e2e/test_rpc_server_plugins_e2e.py @@ -93,9 +93,11 @@ async def _create_isolated_client(ctx: E2ETestContext) -> tuple[CopilotClient, P for key in ("COPILOT_HOME", "GH_CONFIG_DIR", "XDG_CONFIG_HOME", "XDG_STATE_HOME"): env[key] = str(home) client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=env, + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=env, + ), github_token=DEFAULT_GITHUB_TOKEN, ) await client.start() diff --git a/python/e2e/test_rpc_server_remote_control_e2e.py b/python/e2e/test_rpc_server_remote_control_e2e.py index 0fe2cc1b37..81dbfaabe9 100644 --- a/python/e2e/test_rpc_server_remote_control_e2e.py +++ b/python/e2e/test_rpc_server_remote_control_e2e.py @@ -29,9 +29,11 @@ def _create_dedicated_client(ctx: E2ETestContext) -> CopilotClient: return CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=DEFAULT_GITHUB_TOKEN, ) diff --git a/python/e2e/test_rpc_session_state_extras_e2e.py b/python/e2e/test_rpc_session_state_extras_e2e.py index 02ee0cd790..af3edac500 100644 --- a/python/e2e/test_rpc_session_state_extras_e2e.py +++ b/python/e2e/test_rpc_session_state_extras_e2e.py @@ -43,9 +43,11 @@ def _make_authed_client(ctx: E2ETestContext, token: str) -> CopilotClient: env = ctx.get_env() env["COPILOT_DEBUG_GITHUB_API_URL"] = ctx.proxy_url return CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=env, + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=env, + ), github_token=token, ) diff --git a/python/e2e/test_session_config_e2e.py b/python/e2e/test_session_config_e2e.py index 4fc78e645d..e8cc630395 100644 --- a/python/e2e/test_session_config_e2e.py +++ b/python/e2e/test_session_config_e2e.py @@ -465,9 +465,11 @@ async def test_should_enable_citations_for_anthropic_file_attachments_on_create( ): handler = _RecordingRequestHandler() client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=DEFAULT_GITHUB_TOKEN, request_handler=handler, ) @@ -501,9 +503,9 @@ async def test_should_enable_citations_for_anthropic_file_attachments_on_resume( connection=RuntimeConnection.for_tcp( path=ctx.cli_path, connection_token=connection_token, + working_directory=ctx.work_dir, + env=ctx.get_env(), ), - working_directory=ctx.work_dir, - env=ctx.get_env(), github_token=DEFAULT_GITHUB_TOKEN, request_handler=handler, ) diff --git a/python/e2e/test_session_e2e.py b/python/e2e/test_session_e2e.py index f57b9f5736..0733b1fb47 100644 --- a/python/e2e/test_session_e2e.py +++ b/python/e2e/test_session_e2e.py @@ -258,9 +258,11 @@ async def test_should_resume_a_session_using_a_new_client(self, ctx: E2ETestCont "fake-token-for-e2e-tests" if os.environ.get("GITHUB_ACTIONS") == "true" else None ) new_client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=github_token, ) @@ -299,9 +301,11 @@ def on_mcp_auth_request(_request, _invocation): github_token = DEFAULT_GITHUB_TOKEN if os.environ.get("GITHUB_ACTIONS") == "true" else None new_client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=github_token, ) @@ -323,9 +327,11 @@ async def test_should_recover_marker_after_cold_resume_with_explicit_session_id( github_token = DEFAULT_GITHUB_TOKEN if os.environ.get("GITHUB_ACTIONS") == "true" else None client1 = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=github_token, ) try: @@ -347,9 +353,11 @@ async def test_should_recover_marker_after_cold_resume_with_explicit_session_id( await client1.force_stop() client2 = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=github_token, ) try: diff --git a/python/e2e/test_session_fs_e2e.py b/python/e2e/test_session_fs_e2e.py index 035db125ba..dcc97dd7fd 100644 --- a/python/e2e/test_session_fs_e2e.py +++ b/python/e2e/test_session_fs_e2e.py @@ -52,9 +52,11 @@ @pytest_asyncio.fixture(scope="module", loop_scope="module") async def session_fs_client(ctx: E2ETestContext): client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=DEFAULT_GITHUB_TOKEN, session_fs=SESSION_FS_CONFIG, ) @@ -122,9 +124,11 @@ async def test_should_load_session_data_from_fs_provider_on_resume( async def test_should_reject_setprovider_when_sessions_already_exist(self, ctx: E2ETestContext): client1 = CopilotClient( - connection=RuntimeConnection.for_tcp(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_tcp( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=DEFAULT_GITHUB_TOKEN, ) session = None diff --git a/python/e2e/test_session_fs_sqlite_e2e.py b/python/e2e/test_session_fs_sqlite_e2e.py index f48bcd2cdc..c70b2ea3ac 100644 --- a/python/e2e/test_session_fs_sqlite_e2e.py +++ b/python/e2e/test_session_fs_sqlite_e2e.py @@ -246,9 +246,11 @@ def factory(session): @pytest_asyncio.fixture(scope="module", loop_scope="module") async def sqlite_client(ctx: E2ETestContext): client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=DEFAULT_GITHUB_TOKEN, session_fs=SESSION_FS_CONFIG, ) diff --git a/python/e2e/test_streaming_fidelity_e2e.py b/python/e2e/test_streaming_fidelity_e2e.py index a25957df10..4b949f4f1c 100644 --- a/python/e2e/test_streaming_fidelity_e2e.py +++ b/python/e2e/test_streaming_fidelity_e2e.py @@ -98,9 +98,11 @@ async def test_should_produce_deltas_after_session_resume(self, ctx: E2ETestCont "fake-token-for-e2e-tests" if os.environ.get("GITHUB_ACTIONS") == "true" else None ) new_client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=github_token, ) @@ -150,9 +152,11 @@ async def test_should_not_produce_deltas_after_session_resume_with_streaming_dis # Resume with streaming disabled new_client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=github_token, ) try: diff --git a/python/e2e/test_subagent_hooks_e2e.py b/python/e2e/test_subagent_hooks_e2e.py index da70265a04..2e0ffcf9e3 100644 --- a/python/e2e/test_subagent_hooks_e2e.py +++ b/python/e2e/test_subagent_hooks_e2e.py @@ -98,9 +98,11 @@ async def on_post_tool_use(input_data, invocation): "fake-token-for-e2e-tests" if os.environ.get("GITHUB_ACTIONS") == "true" else None ) client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=env, + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=env, + ), github_token=github_token, request_handler=request_handler, ) diff --git a/python/e2e/test_suspend_e2e.py b/python/e2e/test_suspend_e2e.py index d0a117fff9..678542b9d8 100644 --- a/python/e2e/test_suspend_e2e.py +++ b/python/e2e/test_suspend_e2e.py @@ -31,15 +31,20 @@ def _make_subprocess_client(ctx: E2ETestContext, *, use_stdio: bool = True) -> C "fake-token-for-e2e-tests" if os.environ.get("GITHUB_ACTIONS") == "true" else None ) if use_stdio: - connection = RuntimeConnection.for_stdio(path=ctx.cli_path) + connection = RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ) else: connection = RuntimeConnection.for_tcp( - path=ctx.cli_path, connection_token="py-tcp-shared-test-token" + path=ctx.cli_path, + connection_token="py-tcp-shared-test-token", + working_directory=ctx.work_dir, + env=ctx.get_env(), ) return CopilotClient( connection=connection, - working_directory=ctx.work_dir, - env=ctx.get_env(), github_token=github_token, ) diff --git a/python/e2e/test_telemetry_e2e.py b/python/e2e/test_telemetry_e2e.py index 8b9c82abef..d542e1e5eb 100644 --- a/python/e2e/test_telemetry_e2e.py +++ b/python/e2e/test_telemetry_e2e.py @@ -72,9 +72,11 @@ def echo(invocation: ToolInvocation) -> ToolResult: "fake-token-for-e2e-tests" if os.environ.get("GITHUB_ACTIONS") == "true" else None ) client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=github_token, telemetry=TelemetryConfig( file_path=str(telemetry_path), diff --git a/python/e2e/test_ui_elicitation_multi_client_e2e.py b/python/e2e/test_ui_elicitation_multi_client_e2e.py index 05589d0d28..f7c178957c 100644 --- a/python/e2e/test_ui_elicitation_multi_client_e2e.py +++ b/python/e2e/test_ui_elicitation_multi_client_e2e.py @@ -63,10 +63,11 @@ async def setup(self): # Client 1 uses TCP mode so additional clients can connect self._client1 = CopilotClient( connection=RuntimeConnection.for_tcp( - path=self.cli_path, connection_token="py-tcp-shared-test-token" + path=self.cli_path, + connection_token="py-tcp-shared-test-token", + working_directory=self.work_dir, + env=self._get_env(), ), - working_directory=self.work_dir, - env=self._get_env(), github_token=github_token, ) diff --git a/python/e2e/testharness/context.py b/python/e2e/testharness/context.py index 12eb9466fa..5cace914a9 100644 --- a/python/e2e/testharness/context.py +++ b/python/e2e/testharness/context.py @@ -15,7 +15,7 @@ from pathlib import Path from typing import Any -from copilot import CopilotClient, RuntimeConnection +from copilot import CopilotClient, OutOfProcessRuntimeConnection, RuntimeConnection from .proxy import CapiProxy @@ -87,7 +87,7 @@ async def setup(self, cli_args: list[str] | None = None): Args: cli_args: Optional extra CLI arguments passed to the CLI process. """ - self.cli_path = get_cli_path_for_tests() + self.cli_path = CLI_PATH self.home_dir = os.path.realpath(tempfile.mkdtemp(prefix="copilot-test-config-")) self.work_dir = os.path.realpath(tempfile.mkdtemp(prefix="copilot-test-work-")) @@ -127,9 +127,9 @@ async def setup(self, cli_args: list[str] | None = None): connection=RuntimeConnection.for_stdio( path=self.cli_path, args=tuple(cli_args or []), + working_directory=self.work_dir, + env=self.get_env(), ), - working_directory=self.work_dir, - env=self.get_env(), github_token=DEFAULT_GITHUB_TOKEN, ) @@ -171,10 +171,11 @@ def add_runtime_env(self, key: str, value: str) -> None: self._restore_env.append((key, os.environ.get(key))) os.environ[key] = value else: - options = self.client._options - if options.env is None: - options.env = {} - options.env[key] = value + connection = self.client._connection + assert isinstance(connection, OutOfProcessRuntimeConnection) + if connection.env is None: + connection.env = {} + connection.env[key] = value def _restore_inprocess_environment(self) -> None: """Undo the in-process environment mirror and cwd change from setup.""" diff --git a/python/test_client.py b/python/test_client.py index 2e3868ef1c..178e84c562 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -64,22 +64,24 @@ def test_inprocess_connection_has_no_child_process_options(): def test_explicit_child_process_path_does_not_require_runtime_bundle(tmp_path): explicit = tmp_path / "copilot" - connection = RuntimeConnection.for_stdio(path=str(explicit)) + connection = RuntimeConnection.for_stdio( + path=str(explicit), + env={"PATH": str(tmp_path)}, + ) - CopilotClient(connection=connection, env={"PATH": str(tmp_path)}) + CopilotClient(connection=connection) assert connection.path == str(explicit) def test_copilot_cli_path_does_not_require_runtime_bundle(tmp_path): explicit = tmp_path / "copilot" - connection = RuntimeConnection.for_stdio() - - CopilotClient( - connection=connection, + connection = RuntimeConnection.for_stdio( env={"PATH": str(tmp_path), "COPILOT_CLI_PATH": str(explicit)}, ) + CopilotClient(connection=connection) + assert connection.path == str(explicit) diff --git a/rust/README.md b/rust/README.md index cdc8e07c05..1cdc295a4a 100644 --- a/rust/README.md +++ b/rust/README.md @@ -816,7 +816,7 @@ opts.telemetry = Some(telem); let client = Client::start(opts).await?; ``` -The SDK injects the appropriate environment variables (`COPILOT_OTEL_EXPORTER_TYPE`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_PROTOCOL`, ...) into the spawned CLI process. The SDK takes no OpenTelemetry dependency; the CLI itself owns the exporter pipeline. Caller-supplied `ClientOptions::env` entries override telemetry-injected values. +The SDK injects the appropriate environment variables (`COPILOT_OTEL_EXPORTER_TYPE`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_PROTOCOL`, ...) into the spawned CLI process. The SDK takes no OpenTelemetry dependency; the CLI itself owns the exporter pipeline. Caller-supplied `OutOfProcessOptions::env` entries (set on `Transport::Stdio`/`Transport::Tcp`) override telemetry-injected values. ### Message Source @@ -1025,8 +1025,8 @@ in the runtime bundle and use `Transport::InProcess`: github-copilot-sdk = { version = "1", features = ["bundled-in-process"] } ``` -`CliProgram::Path` and raw `ClientOptions::extra_args` apply only to -child-process transports. Set `COPILOT_CLI_PATH` only when using an externally +`CliProgram::Path` and `OutOfProcessOptions::extra_args` apply only to +out-of-process transports. Set `COPILOT_CLI_PATH` only when using an externally provisioned compatible runtime package with in-process transport. For builds that prefer a smaller artifact, disable the `bundled-cli` feature: @@ -1037,7 +1037,7 @@ github-copilot-sdk = { version = "1", default-features = false } > **You become responsible for supplying the runtime at deployment.** With > `bundled-cli` disabled, the produced binary does not contain these artifacts -> and will not search the system for them. For managed child-process transports, +> and will not search the system for them. For managed out-of-process transports, > supply a compatible wrapper pair via an explicit [`CliProgram::Path`]. > `COPILOT_CLI_PATH` remains a direct program override. > @@ -1108,13 +1108,13 @@ COPILOT_CLI_EXTRACT_DIR = { value = "vendor/copilot", relative = true, force = t ### Skipping the bundle entirely -Set `COPILOT_SKIP_CLI_DOWNLOAD=1` at build time to disable the entire download / bundle / cache mechanism — `build.rs` returns immediately without touching the network. Use this when you always supply the managed runtime via `ClientOptions::program = CliProgram::Path(...)`. Works regardless of the `bundled-cli` feature state; runtime resolution falls through to `Error::BinaryNotFound` unless an applicable explicit source resolves. +Set `COPILOT_SKIP_CLI_DOWNLOAD=1` at build time to disable the entire download / bundle / cache mechanism — `build.rs` returns immediately without touching the network. Use this when you always supply the managed runtime via `OutOfProcessOptions::program = CliProgram::Path(...)` (on `Transport::Stdio`/`Transport::Tcp`). Works regardless of the `bundled-cli` feature state; runtime resolution falls through to `Error::BinaryNotFound` unless an applicable explicit source resolves. ### Resolution priority -For managed child-process transports, `Client::start` resolves the program in this order: +For managed out-of-process transports, `Client::start` resolves the program in this order: -1. Explicit `CliProgram::Path(path)` on `ClientOptions::program`. +1. Explicit `CliProgram::Path(path)` on `OutOfProcessOptions::program` (`Transport::Stdio`/`Transport::Tcp`). 2. `COPILOT_CLI_PATH` environment variable, if it points at a real file. 3. **`bundled-cli` on:** the embedded wrapper pair, lazily extracted on first call. 4. **`bundled-cli` off:** the build-time-extracted wrapper pair in the per-user cache. @@ -1142,7 +1142,7 @@ if HAS_BUNDLED_CLI { ``` This returns the bundled CLI artifact, preserving the public API's original -meaning. Managed child-process transports resolve `copilot-runtime` instead. +meaning. Managed out-of-process transports resolve `copilot-runtime` instead. The function returns `None` when `bundled-cli` is off or the target is unsupported and does not fall back to the build-time extraction cache. diff --git a/rust/src/lib.rs b/rust/src/lib.rs index c95ed2087a..112301facd 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -141,6 +141,116 @@ fn record_optional_millis(span: &tracing::Span, field: &'static str, value: Opti } } +/// Process-scoped configuration for **out-of-process** transports +/// ([`Transport::Stdio`] and [`Transport::Tcp`]) — the SDK spawns and owns +/// the CLI process for these, so per-connection process configuration is +/// coherent. +/// +/// These settings have no equivalent for [`Transport::InProcess`] (the +/// native runtime shares this process — there is no child process to +/// configure) or [`Transport::External`] (the SDK connects to a server it +/// did not spawn, so process-launch settings do not apply). +/// +/// `#[non_exhaustive]` and `Default`, so this type can grow new fields +/// compatibly. Construct with [`OutOfProcessOptions::new`] or +/// `Default::default()` plus the `with_*` builders. +#[non_exhaustive] +#[derive(Clone, Default)] +pub struct OutOfProcessOptions { + /// How to locate the runtime binary. See [`CliProgram`]. + pub program: CliProgram, + /// Arguments prepended before `--server` (e.g. the script path for node). + pub prefix_args: Vec, + /// Working directory for the CLI process. + pub working_directory: PathBuf, + /// Environment variables set on the CLI process. A nonempty value + /// **replaces** the inherited process environment (instead of adding to + /// it) before SDK-managed variables (auth token, telemetry, + /// `COPILOT_HOME`, etc.) and this map are layered on top, consistent + /// with the other SDKs. Leave empty to inherit the ambient environment + /// unchanged. + pub env: Vec<(OsString, OsString)>, + /// Environment variable names to remove from the CLI process (applied + /// after [`Self::env`], so this can strip SDK-injected variables too). + pub env_remove: Vec, + /// Extra flags appended after the transport-specific arguments. + pub extra_args: Vec, +} + +impl std::fmt::Debug for OutOfProcessOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OutOfProcessOptions") + .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) + .finish() + } +} + +impl OutOfProcessOptions { + /// Construct a new [`OutOfProcessOptions`] with default values. + pub fn new() -> Self { + Self::default() + } + + /// How to locate the runtime binary. See [`CliProgram`]. + pub fn with_program(mut self, program: impl Into) -> Self { + self.program = program.into(); + self + } + + /// Arguments prepended before `--server` (e.g. the script path for node). + pub fn with_prefix_args(mut self, args: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.prefix_args = args.into_iter().map(Into::into).collect(); + self + } + + /// Working directory for the CLI process. + pub fn with_working_directory(mut self, dir: impl Into) -> Self { + self.working_directory = dir.into(); + self + } + + /// Environment variables to set on the CLI process. See [`Self::env`] + /// for replace-vs-merge semantics. + pub fn with_env(mut self, env: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + self.env = env.into_iter().map(|(k, v)| (k.into(), v.into())).collect(); + self + } + + /// Environment variable names to remove from the CLI process. + pub fn with_env_remove(mut self, names: I) -> Self + where + I: IntoIterator, + S: Into, + { + 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(mut self, args: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.extra_args = args.into_iter().map(Into::into).collect(); + self + } +} + /// How the SDK communicates with the CLI server. #[derive(Debug, Default)] #[non_exhaustive] @@ -149,22 +259,25 @@ pub enum Transport { /// back to [`Transport::Stdio`] when the variable is unset. #[default] Default, - /// Communicate over stdin/stdout pipes (default). - Stdio, + /// Communicate over stdin/stdout pipes (default). Carries + /// out-of-process configuration (program, working directory, env, + /// extra args) — see [`OutOfProcessOptions`]. + Stdio(OutOfProcessOptions), /// Host the runtime in-process over FFI (no child process). /// /// Loads the native runtime library and speaks JSON-RPC over its C ABI. - /// This is **experimental**. Per-client [`ClientOptions::program`], - /// [`ClientOptions::extra_args`], [`ClientOptions::working_directory`], - /// [`ClientOptions::env`]/[`ClientOptions::env_remove`], - /// and [`ClientOptions::telemetry`] are not supported because native - /// runtime code shares the host process. Typed runtime options such as - /// authentication, log level, and [`ClientOptions::base_directory`] remain - /// supported. + /// This is **experimental**. [`OutOfProcessOptions`] (program, working + /// directory, env, extra args — not applicable, there is no child + /// process) and [`ClientOptions::telemetry`] are not supported because + /// native runtime code shares the host process. Typed runtime options + /// such as authentication, log level, and [`ClientOptions::base_directory`] + /// remain supported and are forwarded to the native runtime. /// /// Requires the `bundled-in-process` Cargo feature. InProcess, - /// Spawn the CLI with `--port` and connect via TCP. + /// Spawn the CLI with `--port` and connect via TCP. Carries + /// out-of-process configuration like [`Transport::Stdio`] — see + /// [`OutOfProcessOptions`]. Tcp { /// Port to listen on (0 for OS-assigned). port: u16, @@ -172,8 +285,15 @@ pub enum Transport { /// the CLI, the SDK auto-generates a 128-bit hex token so the /// loopback listener is safe by default. connection_token: Option, + /// Process-scoped configuration for the spawned CLI. See + /// [`OutOfProcessOptions`]. + process: OutOfProcessOptions, }, - /// Connect to an already-running CLI server (no process spawning). + /// Connect to an already-running CLI server (no process spawning). The + /// SDK does not own this process, so [`OutOfProcessOptions`] settings + /// (program, working directory, env, extra args) do not apply here — + /// unlike [`Transport::Stdio`]/[`Transport::Tcp`], this variant has no + /// such field. External { /// Hostname or IP of the running server. host: String, @@ -185,10 +305,18 @@ pub enum Transport { }, } +impl Transport { + /// Convenience constructor for [`Transport::Stdio`] with default + /// [`OutOfProcessOptions`]. + pub fn stdio() -> Self { + Transport::Stdio(OutOfProcessOptions::default()) + } +} + /// How the SDK locates the GitHub Copilot CLI binary. #[derive(Debug, Clone, Default)] pub enum CliProgram { - /// Auto-resolve the transport's program. Managed child-process transports + /// Auto-resolve the transport's program. Managed out-of-process transports /// select `COPILOT_CLI_PATH`, then the bundled runtime wrapper. In-process /// transport loads the wrapper's adjacent runtime library directly unless /// `COPILOT_CLI_PATH` explicitly selects a legacy embedded host. @@ -266,31 +394,18 @@ pub fn install_bundled_runtime() -> Option { /// Options for starting a [`Client`]. /// -/// When `program` is [`CliProgram::Resolve`] (the default), [`Client::start`] -/// uses `COPILOT_CLI_PATH` when set to a real file. Managed child-process -/// transports next use the bundled `copilot-runtime` wrapper. In-process -/// transport loads the wrapper's adjacent runtime library. With `bundled-cli` -/// disabled, the corresponding artifact is resolved from the build-time -/// extraction cache. -/// -/// Set `program` to [`CliProgram::Path`] to use an explicit binary instead. -/// This skips auto-resolution entirely. +/// Process-scoped settings — the program/binary path, working directory, +/// environment, and extra CLI args — are no longer configured here. Set them +/// on the out-of-process transport instead: [`Transport::Stdio`] and +/// [`Transport::Tcp`] carry an [`OutOfProcessOptions`] value for exactly this +/// purpose. [`CliProgram::Resolve`] (the default) uses `COPILOT_CLI_PATH` +/// when set to a real file, then falls back to the bundled `copilot-runtime` +/// wrapper (out-of-process) or the wrapper's adjacent runtime library +/// (in-process). With `bundled-cli` disabled, the corresponding artifact is +/// resolved from the build-time extraction cache. #[non_exhaustive] +#[derive(Default)] pub struct ClientOptions { - /// How to locate the child-process runtime. - pub program: CliProgram, - /// Arguments prepended before `--server` (e.g. the script path for node). - pub prefix_args: Vec, - /// Working directory for the CLI process. - /// - /// Setting this option is not supported with [`Transport::InProcess`]. - 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, - /// Extra flags for child-process transports. - pub extra_args: Vec, /// Absolute paths to trusted plugin directories bundled by the host. /// /// When non-empty, [`Client::start`] replaces the runtime's complete @@ -508,12 +623,6 @@ impl ClientInfo { 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( "builtin_plugin_directories", &self.builtin_plugin_directories, @@ -684,7 +793,7 @@ impl OtlpHttpProtocol { /// | [`source_name`] | `COPILOT_OTEL_SOURCE_NAME` | /// | [`capture_content`] | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | /// -/// Caller-supplied entries in [`ClientOptions::env`] override these, so a +/// Caller-supplied entries in [`OutOfProcessOptions::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`]. /// @@ -778,37 +887,6 @@ impl TelemetryConfig { } } -impl Default for ClientOptions { - fn default() -> Self { - Self { - program: CliProgram::Resolve, - prefix_args: Vec::new(), - working_directory: PathBuf::new(), - env: Vec::new(), - env_remove: Vec::new(), - extra_args: Vec::new(), - builtin_plugin_directories: 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, - request_handler: None, - extension_launch_provider: None, - on_github_telemetry: None, - on_get_trace_context: None, - telemetry: None, - base_directory: None, - enable_remote_sessions: false, - bundled_cli_extract_dir: None, - mode: ClientMode::default(), - client_info: None, - } - } -} - impl ClientOptions { /// Construct a new [`ClientOptions`] with default values. /// @@ -829,59 +907,6 @@ impl ClientOptions { Self::default() } - /// How to locate the child-process runtime. See [`CliProgram`]. - pub fn with_program(mut self, program: impl Into) -> Self { - self.program = program.into(); - self - } - - /// Arguments prepended before `--server` (e.g. the script path for node). - pub fn with_prefix_args(mut self, args: I) -> Self - where - I: IntoIterator, - S: Into, - { - 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) -> Self { - self.working_directory = cwd.into(); - self - } - - /// Environment variables to set on the child process. - pub fn with_env(mut self, env: I) -> Self - where - I: IntoIterator, - K: Into, - V: Into, - { - 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(mut self, names: I) -> Self - where - I: IntoIterator, - S: Into, - { - 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(mut self, args: I) -> Self - where - I: IntoIterator, - S: Into, - { - self.extra_args = args.into_iter().map(Into::into).collect(); - self - } - /// Set trusted plugin directories bundled by the host. /// /// Every path must be absolute; invalid paths are rejected by @@ -1086,24 +1111,20 @@ fn generate_connection_token() -> String { /// stdio. Any other value is an error. const DEFAULT_CONNECTION_ENV_VAR: &str = "COPILOT_SDK_DEFAULT_CONNECTION"; -/// Resolve a transport override from [`DEFAULT_CONNECTION_ENV_VAR`]. -fn resolve_default_transport(options: &ClientOptions) -> Result { - let configured = options - .env - .iter() - .find(|(key, _)| { - key.to_string_lossy() - .eq_ignore_ascii_case(DEFAULT_CONNECTION_ENV_VAR) - }) - .map(|(_, value)| value.to_string_lossy().into_owned()); - let process = std::env::var(DEFAULT_CONNECTION_ENV_VAR).ok(); - resolve_default_transport_value(configured.as_deref().or(process.as_deref())) +/// Resolve a transport override from [`DEFAULT_CONNECTION_ENV_VAR`] in the +/// real process environment. There is no longer a way to override this via +/// [`ClientOptions`] — process-scoped env now lives on +/// [`OutOfProcessOptions`], which is only available once a transport has +/// already been chosen, so it cannot influence the choice of transport +/// itself. +fn resolve_default_transport() -> Result { + resolve_default_transport_value(std::env::var(DEFAULT_CONNECTION_ENV_VAR).ok().as_deref()) } fn resolve_default_transport_value(value: Option<&str>) -> Result { match value { - None => Ok(Transport::Stdio), - Some(v) if v.is_empty() || v.eq_ignore_ascii_case("stdio") => Ok(Transport::Stdio), + None => Ok(Transport::stdio()), + Some(v) if v.is_empty() || v.eq_ignore_ascii_case("stdio") => Ok(Transport::stdio()), Some(v) if v.eq_ignore_ascii_case("inprocess") => Ok(Transport::InProcess), Some(v) => Err(Error::with_message( ErrorKind::InvalidConfig, @@ -1115,44 +1136,21 @@ fn resolve_default_transport_value(value: Option<&str>) -> Result { } } +/// Validate options that don't apply to [`Transport::InProcess`]. +/// +/// Process-scoped settings (program, working directory, env, extra args) are +/// no longer reachable here at all — they live on [`OutOfProcessOptions`], +/// which [`Transport::InProcess`] structurally does not carry. Only +/// [`ClientOptions::telemetry`] remains client-wide and still needs a +/// runtime check, since native runtime code loaded in-process shares this +/// process and cannot honor per-client telemetry export configuration. #[cfg(any(feature = "bundled-in-process", test))] fn validate_inprocess_options(options: &ClientOptions) -> Result<()> { - if !matches!(&options.program, CliProgram::Resolve) { - return Err(Error::with_message( - ErrorKind::InvalidConfig, - "ClientOptions::program is not supported with Transport::InProcess; \ - set COPILOT_CLI_PATH only when using an externally provisioned runtime package", - )); - } - if !options.extra_args.is_empty() { - return Err(Error::with_message( - ErrorKind::InvalidConfig, - "ClientOptions::extra_args is not supported with Transport::InProcess; \ - use typed client options instead", - )); - } - - let unsupported = if !options.working_directory.as_os_str().is_empty() { - Some("working_directory") - } else if !options.env.is_empty() { - Some("env") - } else if !options.env_remove.is_empty() { - Some("env_remove") - } else if options.telemetry.is_some() { - Some("telemetry") - } else if !options.prefix_args.is_empty() { - Some("prefix_args") - } else { - None - }; - - if let Some(option) = unsupported { + if options.telemetry.is_some() { return Err(Error::with_message( ErrorKind::InvalidConfig, - format!( - "ClientOptions::{option} is not supported with Transport::InProcess; \ - configure process-global settings on the host process instead" - ), + "ClientOptions::telemetry is not supported with Transport::InProcess; \ + configure process-global telemetry settings on the host process instead", )); } @@ -1243,7 +1241,7 @@ impl Client { let mut timings = StartupTimings::default(); let mut options = options; if matches!(options.transport, Transport::Default) { - options.transport = resolve_default_transport(&options)?; + options.transport = resolve_default_transport()?; } if matches!(options.transport, Transport::InProcess) { #[cfg(not(feature = "bundled-in-process"))] @@ -1336,7 +1334,7 @@ impl Client { // default. let effective_connection_token: Option = match &mut options.transport { Transport::Default => unreachable!("default transport resolved above"), - Transport::Stdio | Transport::InProcess => None, + Transport::Stdio(_) | Transport::InProcess => None, Transport::Tcp { connection_token, .. } => Some( @@ -1355,50 +1353,9 @@ impl Client { .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 resolve_start = Instant::now(); - let resolved = resolve::copilot_binary_with_extract_dir( - options.bundled_cli_extract_dir.as_deref(), - true, - )?; - let resolve_elapsed = resolve_start.elapsed(); - timings.program_resolve_ms = Some(StartupTimings::millis(resolve_elapsed)); - debug!( - elapsed_ms = resolve_elapsed.as_millis(), - "Client::start CLI program resolution complete" - ); - info!(path = %resolved.display(), "resolved copilot runtime"); - #[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 working_directory = { - let cwd = options.working_directory.clone(); - if cwd.as_os_str().is_empty() { - std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) - } else { - cwd - } - }; let transport_setup_start = Instant::now(); + let bundled_cli_extract_dir = options.bundled_cli_extract_dir.clone(); let client = match options.transport { Transport::Default => unreachable!("default transport resolved above"), Transport::External { @@ -1406,6 +1363,8 @@ impl Client { port, connection_token: _, } => { + let working_directory = + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); info!(host = %host, port = %port, "connecting to external CLI server"); let connect_start = Instant::now(); let stream = TcpStream::connect((host.as_str(), port)).await?; @@ -1436,9 +1395,16 @@ impl Client { Transport::Tcp { port, connection_token: _, + ref process, } => { + let program = Self::resolve_cli_program( + &process.program, + bundled_cli_extract_dir.as_deref(), + &mut timings, + )?; + let working_directory = Self::resolve_working_directory(&process.working_directory); let (mut child, tree, actual_port, spawn_elapsed, port_wait_elapsed) = - Self::spawn_tcp(&program, &options, &working_directory, port).await?; + Self::spawn_tcp(&program, &options, process, &working_directory, port).await?; timings.process_spawn_ms = Some(StartupTimings::millis(spawn_elapsed)); timings.port_wait_ms = Some(StartupTimings::millis(port_wait_elapsed)); let connect_start = Instant::now(); @@ -1467,9 +1433,15 @@ impl Client { options.client_info, )? } - Transport::Stdio => { + Transport::Stdio(ref process) => { + let program = Self::resolve_cli_program( + &process.program, + bundled_cli_extract_dir.as_deref(), + &mut timings, + )?; + let working_directory = Self::resolve_working_directory(&process.working_directory); let (mut child, tree, spawn_elapsed) = - Self::spawn_stdio(&program, &options, &working_directory)?; + Self::spawn_stdio(&program, &options, process, &working_directory)?; timings.process_spawn_ms = Some(StartupTimings::millis(spawn_elapsed)); let stdin = child.stdin.take().expect("stdin is piped"); let stdout = child.stdout.take().expect("stdout is piped"); @@ -1494,6 +1466,13 @@ impl Client { Transport::InProcess => { #[cfg(feature = "bundled-in-process")] { + let program = Self::resolve_cli_program( + &CliProgram::Resolve, + bundled_cli_extract_dir.as_deref(), + &mut timings, + )?; + let working_directory = + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); info!(runtime_path = %program.display(), "hosting copilot runtime in-process (FFI)"); let mut environment = Vec::new(); if let Some(base_directory) = &options.base_directory { @@ -1565,6 +1544,7 @@ impl Client { unreachable!("in-process feature validation returned above") } }; + timings.transport_setup_ms = StartupTimings::millis(transport_setup_start.elapsed()); debug!( elapsed_ms = start_time.elapsed().as_millis(), @@ -1976,10 +1956,79 @@ impl Client { }); } - fn build_command(program: &Path, options: &ClientOptions, working_directory: &Path) -> Command { + /// Resolve [`OutOfProcessOptions::program`] (or [`CliProgram::Resolve`] + /// for [`Transport::InProcess`], which cannot carry an + /// [`OutOfProcessOptions`]) to a concrete path, recording the resolution + /// time in `timings`. + fn resolve_cli_program( + program: &CliProgram, + bundled_cli_extract_dir: Option<&Path>, + timings: &mut StartupTimings, + ) -> Result { + match program { + CliProgram::Path(path) => { + info!(path = %path.display(), "using explicit copilot CLI path"); + Ok(path.clone()) + } + CliProgram::Resolve => { + let resolve_start = Instant::now(); + let resolved = + resolve::copilot_binary_with_extract_dir(bundled_cli_extract_dir, true)?; + let resolve_elapsed = resolve_start.elapsed(); + timings.program_resolve_ms = Some(StartupTimings::millis(resolve_elapsed)); + debug!( + elapsed_ms = resolve_elapsed.as_millis(), + "Client::start CLI program resolution complete" + ); + info!(path = %resolved.display(), "resolved copilot runtime"); + #[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" + ); + } + } + Ok(resolved) + } + } + } + + /// Resolve [`OutOfProcessOptions::working_directory`] to a concrete + /// path, falling back to the current process working directory when + /// unset (empty). + fn resolve_working_directory(configured: &Path) -> PathBuf { + if configured.as_os_str().is_empty() { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) + } else { + configured.to_path_buf() + } + } + + fn build_command( + program: &Path, + options: &ClientOptions, + process: &OutOfProcessOptions, + working_directory: &Path, + ) -> Command { let mut command = Command::new(program); command.kill_on_drop(true); - for arg in &options.prefix_args { + // A nonempty explicit `env` replaces the inherited process + // environment instead of layering on top of it, consistent with the + // other SDKs. SDK-managed variables (auth token, telemetry, + // `COPILOT_HOME`, etc.) are still injected below so they remain + // active even when the caller replaces the rest of the environment; + // explicit `env`/`env_remove` entries can still override or strip + // them, same as before this replaced the ambient environment. + if !process.env.is_empty() { + command.env_clear(); + } + for arg in &process.prefix_args { command.arg(arg); } // Inject the SDK auth token first so explicit `env` / `env_remove` @@ -1988,7 +2037,7 @@ impl Client { command.env("COPILOT_SDK_AUTH_TOKEN", token); } // Inject telemetry env vars before user env so callers can still - // override individual variables via `options.env`. + // override individual variables via `process.env`. if let Some(telemetry) = &options.telemetry { command.env("COPILOT_OTEL_ENABLED", "true"); if let Some(endpoint) = &telemetry.otlp_endpoint { @@ -2028,10 +2077,10 @@ impl Client { { command.env("COPILOT_CONNECTION_TOKEN", token); } - for (key, value) in &options.env { + for (key, value) in &process.env { command.env(key, value); } - for key in &options.env_remove { + for key in &process.env_remove { command.env_remove(key); } command @@ -2094,17 +2143,18 @@ impl Client { fn spawn_stdio( program: &Path, options: &ClientOptions, + process: &OutOfProcessOptions, working_directory: &Path, ) -> Result<(Child, Option, Duration)> { info!(cwd = ?working_directory, program = %program.display(), "spawning copilot CLI (stdio)"); - let mut command = Self::build_command(program, options, working_directory); + let mut command = Self::build_command(program, options, process, working_directory); command .args(["--server", "--stdio", "--no-auto-update"]) .args(Self::log_level_args(options)) .args(Self::auth_args(options)) .args(Self::session_idle_timeout_args(options)) .args(Self::remote_args(options)) - .args(&options.extra_args) + .args(&process.extra_args) .stdin(Stdio::piped()); let spawn_start = Instant::now(); let (child, tree) = process_tree::spawn(&mut command)?; @@ -2119,6 +2169,7 @@ impl Client { async fn spawn_tcp( program: &Path, options: &ClientOptions, + process: &OutOfProcessOptions, working_directory: &Path, port: u16, ) -> Result<( @@ -2129,14 +2180,14 @@ impl Client { Duration, )> { info!(cwd = ?working_directory, program = %program.display(), port = %port, "spawning copilot CLI (tcp)"); - let mut command = Self::build_command(program, options, working_directory); + let mut command = Self::build_command(program, options, process, working_directory); command .args(["--server", "--port", &port.to_string(), "--no-auto-update"]) .args(Self::log_level_args(options)) .args(Self::auth_args(options)) .args(Self::session_idle_timeout_args(options)) .args(Self::remote_args(options)) - .args(&options.extra_args) + .args(&process.extra_args) .stdin(Stdio::null()); let spawn_start = Instant::now(); let (mut child, tree) = process_tree::spawn(&mut command)?; @@ -3082,29 +3133,38 @@ mod tests { #[test] fn client_options_builder_composes() { let opts = ClientOptions::new() - .with_program(CliProgram::Path(PathBuf::from("/usr/local/bin/copilot"))) - .with_prefix_args(["node"]) - .with_cwd(PathBuf::from("/tmp")) - .with_env([("KEY", "value")]) - .with_env_remove(["UNWANTED"]) - .with_extra_args(["--quiet"]) + .with_transport(Transport::Stdio( + OutOfProcessOptions::new() + .with_program(CliProgram::Path(PathBuf::from("/usr/local/bin/copilot"))) + .with_prefix_args(["node"]) + .with_working_directory(PathBuf::from("/tmp")) + .with_env([("KEY", "value")]) + .with_env_remove(["UNWANTED"]) + .with_extra_args(["--quiet"]), + )) .with_github_token("ghp_test") .with_use_logged_in_user(false) .with_log_level(LogLevel::Debug) .with_session_idle_timeout_seconds(120) .with_enable_remote_sessions(true); - assert!(matches!(opts.program, CliProgram::Path(_))); - assert_eq!(opts.prefix_args, vec![std::ffi::OsString::from("node")]); - assert_eq!(opts.working_directory, PathBuf::from("/tmp")); + let Transport::Stdio(process) = &opts.transport else { + panic!("expected Transport::Stdio"); + }; + assert!(matches!(process.program, CliProgram::Path(_))); + assert_eq!(process.prefix_args, vec![std::ffi::OsString::from("node")]); + assert_eq!(process.working_directory, PathBuf::from("/tmp")); assert_eq!( - opts.env, + process.env, vec![( std::ffi::OsString::from("KEY"), std::ffi::OsString::from("value") )] ); - assert_eq!(opts.env_remove, vec![std::ffi::OsString::from("UNWANTED")]); - assert_eq!(opts.extra_args, vec!["--quiet".to_string()]); + assert_eq!( + process.env_remove, + vec![std::ffi::OsString::from("UNWANTED")] + ); + assert_eq!(process.extra_args, vec!["--quiet".to_string()]); assert_eq!(opts.github_token.as_deref(), Some("ghp_test")); assert_eq!(opts.use_logged_in_user, Some(false)); assert!(matches!(opts.log_level, Some(LogLevel::Debug))); @@ -3116,11 +3176,11 @@ mod tests { fn default_transport_values_resolve_without_process_state() { assert!(matches!( resolve_default_transport_value(None).unwrap(), - Transport::Stdio + Transport::Stdio(_) )); assert!(matches!( resolve_default_transport_value(Some("stdio")).unwrap(), - Transport::Stdio + Transport::Stdio(_) )); assert!(matches!( resolve_default_transport_value(Some("INPROCESS")).unwrap(), @@ -3131,15 +3191,7 @@ mod tests { #[test] fn inprocess_rejects_process_scoped_options() { - let invalid = [ - ClientOptions::new().with_cwd("."), - ClientOptions::new().with_env([("KEY", "value")]), - ClientOptions::new().with_env_remove(["KEY"]), - ClientOptions::new().with_telemetry(TelemetryConfig::default()), - ClientOptions::new().with_prefix_args(["index.js"]), - ClientOptions::new().with_program(CliProgram::Path("copilot".into())), - ClientOptions::new().with_extra_args(["--verbose"]), - ]; + let invalid = [ClientOptions::new().with_telemetry(TelemetryConfig::default())]; for options in invalid { assert!(validate_inprocess_options(&options).is_err()); @@ -3179,10 +3231,13 @@ mod tests { fn build_command_lets_env_remove_strip_injected_token() { let opts = ClientOptions { github_token: Some("secret".to_string()), + ..Default::default() + }; + let process = OutOfProcessOptions { env_remove: vec![std::ffi::OsString::from("COPILOT_SDK_AUTH_TOKEN")], ..Default::default() }; - let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); + let cmd = Client::build_command(Path::new("/bin/echo"), &opts, &process, Path::new("/tmp")); // get_envs() iter yields the latest action per key — None means removed. let action = cmd .as_std() @@ -3200,13 +3255,16 @@ mod tests { fn build_command_lets_env_override_injected_token() { let opts = ClientOptions { github_token: Some("from-options".to_string()), + ..Default::default() + }; + let process = OutOfProcessOptions { env: vec![( std::ffi::OsString::from("COPILOT_SDK_AUTH_TOKEN"), std::ffi::OsString::from("from-env"), )], ..Default::default() }; - let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); + let cmd = Client::build_command(Path::new("/bin/echo"), &opts, &process, Path::new("/tmp")); let value = cmd .as_std() .get_envs() @@ -3221,7 +3279,8 @@ mod tests { github_token: Some("just-the-token".to_string()), ..Default::default() }; - let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); + let process = OutOfProcessOptions::default(); + let cmd = Client::build_command(Path::new("/bin/echo"), &opts, &process, Path::new("/tmp")); let value = cmd .as_std() .get_envs() @@ -3289,7 +3348,12 @@ mod tests { }), ..Default::default() }; - let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); + let cmd = Client::build_command( + Path::new("/bin/echo"), + &opts, + &OutOfProcessOptions::default(), + Path::new("/tmp"), + ); assert_eq!( env_value(&cmd, "COPILOT_OTEL_ENABLED"), Some(std::ffi::OsStr::new("true")), @@ -3323,7 +3387,12 @@ mod tests { #[test] fn build_command_omits_otel_env_when_telemetry_none() { let opts = ClientOptions::default(); - let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); + let cmd = Client::build_command( + Path::new("/bin/echo"), + &opts, + &OutOfProcessOptions::default(), + Path::new("/tmp"), + ); for key in [ "COPILOT_OTEL_ENABLED", "OTEL_EXPORTER_OTLP_ENDPOINT", @@ -3349,7 +3418,12 @@ mod tests { }), ..Default::default() }; - let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); + let cmd = Client::build_command( + Path::new("/bin/echo"), + &opts, + &OutOfProcessOptions::default(), + Path::new("/tmp"), + ); // The one set field plus the implicit enabled flag should propagate. assert_eq!( env_value(&cmd, "COPILOT_OTEL_ENABLED"), @@ -3378,13 +3452,16 @@ mod tests { otlp_endpoint: Some("http://from-config:4318".to_string()), ..Default::default() }), + ..Default::default() + }; + let process = OutOfProcessOptions { env: vec![( std::ffi::OsString::from("OTEL_EXPORTER_OTLP_ENDPOINT"), std::ffi::OsString::from("http://from-user-env:4318"), )], ..Default::default() }; - let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); + let cmd = Client::build_command(Path::new("/bin/echo"), &opts, &process, Path::new("/tmp")); assert_eq!( env_value(&cmd, "OTEL_EXPORTER_OTLP_ENDPOINT"), Some(std::ffi::OsStr::new("http://from-user-env:4318")), @@ -3395,14 +3472,24 @@ mod tests { #[test] fn build_command_sets_copilot_home_env_when_configured() { let opts = ClientOptions::new().with_base_directory(PathBuf::from("/custom/copilot")); - let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); + let cmd = Client::build_command( + Path::new("/bin/echo"), + &opts, + &OutOfProcessOptions::default(), + Path::new("/tmp"), + ); assert_eq!( env_value(&cmd, "COPILOT_HOME"), Some(std::ffi::OsStr::new("/custom/copilot")), ); let opts = ClientOptions::default(); - let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); + let cmd = Client::build_command( + Path::new("/bin/echo"), + &opts, + &OutOfProcessOptions::default(), + Path::new("/tmp"), + ); assert!(env_value(&cmd, "COPILOT_HOME").is_none()); } @@ -3411,26 +3498,37 @@ mod tests { let opts = ClientOptions::new().with_transport(Transport::Tcp { port: 0, connection_token: Some("secret-token".to_string()), + process: OutOfProcessOptions::default(), }); - let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); + let cmd = Client::build_command( + Path::new("/bin/echo"), + &opts, + &OutOfProcessOptions::default(), + Path::new("/tmp"), + ); assert_eq!( env_value(&cmd, "COPILOT_CONNECTION_TOKEN"), Some(std::ffi::OsStr::new("secret-token")), ); let opts = ClientOptions::default(); - let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); + let cmd = Client::build_command( + Path::new("/bin/echo"), + &opts, + &OutOfProcessOptions::default(), + Path::new("/tmp"), + ); assert!(env_value(&cmd, "COPILOT_CONNECTION_TOKEN").is_none()); } #[tokio::test] async fn start_rejects_empty_connection_token() { - let opts = ClientOptions::new() - .with_transport(Transport::Tcp { - port: 0, - connection_token: Some(String::new()), - }) - .with_program(CliProgram::Path(PathBuf::from("/bin/echo"))); + let opts = ClientOptions::new().with_transport(Transport::Tcp { + port: 0, + connection_token: Some(String::new()), + process: OutOfProcessOptions::new() + .with_program(CliProgram::Path(PathBuf::from("/bin/echo"))), + }); let err = Client::start(opts).await.unwrap_err(); assert!( matches!(err.kind(), ErrorKind::InvalidConfig), @@ -3440,13 +3538,11 @@ mod tests { #[tokio::test] async fn start_rejects_empty_external_connection_token() { - let opts = ClientOptions::new() - .with_transport(Transport::External { - host: "127.0.0.1".to_string(), - port: 1, - connection_token: Some(String::new()), - }) - .with_program(CliProgram::Path(PathBuf::from("/bin/echo"))); + let opts = ClientOptions::new().with_transport(Transport::External { + host: "127.0.0.1".to_string(), + port: 1, + connection_token: Some(String::new()), + }); let err = Client::start(opts).await.unwrap_err(); assert!( matches!(err.kind(), ErrorKind::InvalidConfig), @@ -3470,9 +3566,18 @@ mod tests { }), ..Default::default() }; - let cmd_true = Client::build_command(Path::new("/bin/echo"), &opts_true, Path::new("/tmp")); - let cmd_false = - Client::build_command(Path::new("/bin/echo"), &opts_false, Path::new("/tmp")); + let cmd_true = Client::build_command( + Path::new("/bin/echo"), + &opts_true, + &OutOfProcessOptions::default(), + Path::new("/tmp"), + ); + let cmd_false = Client::build_command( + Path::new("/bin/echo"), + &opts_false, + &OutOfProcessOptions::default(), + Path::new("/tmp"), + ); assert_eq!( env_value( &cmd_true, @@ -3734,8 +3839,12 @@ mod tests { fn test_child_command(temp: &Path, ready: &Path, survived: &Path) -> Command { #[cfg(unix)] let mut command = { - let mut command = - Client::build_command(Path::new("sh"), &ClientOptions::default(), temp); + let mut command = Client::build_command( + Path::new("sh"), + &ClientOptions::default(), + &OutOfProcessOptions::default(), + temp, + ); command.args([ "-c", "printf ready > \"$READY\"; sleep 1; printf survived > \"$SURVIVED\"", @@ -3744,8 +3853,12 @@ mod tests { }; #[cfg(windows)] let mut command = { - let mut command = - Client::build_command(Path::new("powershell.exe"), &ClientOptions::default(), temp); + let mut command = Client::build_command( + Path::new("powershell.exe"), + &ClientOptions::default(), + &OutOfProcessOptions::default(), + temp, + ); command.args([ "-NoLogo", "-NoProfile", diff --git a/rust/tests/builtin_plugin_directories_test.rs b/rust/tests/builtin_plugin_directories_test.rs index f1310f9b04..e9a2322ee8 100644 --- a/rust/tests/builtin_plugin_directories_test.rs +++ b/rust/tests/builtin_plugin_directories_test.rs @@ -2,7 +2,9 @@ use std::path::PathBuf; -use github_copilot_sdk::{CliProgram, Client, ClientOptions, ErrorKind, Transport}; +use github_copilot_sdk::{ + CliProgram, Client, ClientOptions, ErrorKind, OutOfProcessOptions, Transport, +}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::net::TcpListener; @@ -73,13 +75,11 @@ async fn run_start(paths: Option>) -> Vec { requests }); - let mut options = ClientOptions::new() - .with_program(CliProgram::Path(std::env::current_exe().unwrap())) - .with_transport(Transport::External { - host: address.ip().to_string(), - port: address.port(), - connection_token: None, - }); + let mut options = ClientOptions::new().with_transport(Transport::External { + host: address.ip().to_string(), + port: address.port(), + connection_token: None, + }); if let Some(paths) = paths { options = options.with_builtin_plugin_directories(paths); } @@ -122,7 +122,10 @@ async fn configured_directories_call_rpc_once_before_start_completes() { #[tokio::test] async fn relative_directory_is_rejected() { let options = ClientOptions::new() - .with_program(CliProgram::Path(std::env::current_exe().unwrap())) + .with_transport(Transport::Stdio( + OutOfProcessOptions::new() + .with_program(CliProgram::Path(std::env::current_exe().unwrap())), + )) .with_builtin_plugin_directories(["plugins/core"]); let error = match Client::start(options).await { diff --git a/rust/tests/cli_resolution_test.rs b/rust/tests/cli_resolution_test.rs index 846fbacd27..5d517f7c0d 100644 --- a/rust/tests/cli_resolution_test.rs +++ b/rust/tests/cli_resolution_test.rs @@ -9,11 +9,10 @@ use std::path::PathBuf; use github_copilot_sdk::{ - CliProgram, Client, ClientOptions, ErrorKind, HAS_BUNDLED_CLI, install_bundled_cli, - install_bundled_runtime, + Client, ClientOptions, ErrorKind, HAS_BUNDLED_CLI, install_bundled_cli, install_bundled_runtime, }; #[cfg(all(feature = "bundled-cli", has_bundled_cli))] -use github_copilot_sdk::{SessionConfig, Transport}; +use github_copilot_sdk::{OutOfProcessOptions, SessionConfig, Transport}; use serial_test::serial; fn unset_env(key: &str) { @@ -50,7 +49,7 @@ async fn env_override_resolves_to_pointed_file() { "COPILOT_CLI_PATH", path.to_str().expect("utf-8 tempfile path"), ); - let opts = ClientOptions::default().with_program(CliProgram::Resolve); + let opts = ClientOptions::default(); // `Client::start` reads the env var via resolve.rs. We don't want to // actually launch a subprocess against our empty temp file, so go @@ -83,7 +82,7 @@ async fn env_override_resolves_to_pointed_file() { #[serial(copilot_cli_path)] async fn stale_env_override_falls_through() { set_env("COPILOT_CLI_PATH", "/definitely/does/not/exist/copilot"); - let opts = ClientOptions::default().with_program(CliProgram::Resolve); + let opts = ClientOptions::default(); let result = Client::start(opts).await; unset_env("COPILOT_CLI_PATH"); @@ -148,7 +147,7 @@ async fn unbundled_resolver_finds_extracted_binary() { unset_env("COPILOT_CLI_PATH"); unset_env("COPILOT_CLI_EXTRACT_DIR"); - let opts = ClientOptions::default().with_program(CliProgram::Resolve); + let opts = ClientOptions::default(); let result = Client::start(opts).await; if let Err(e) = result { assert!( @@ -181,7 +180,7 @@ async fn extract_dir_runtime_override_is_honored() { tmp.path().to_str().expect("utf-8 tempdir path"), ); - let opts = ClientOptions::default().with_program(CliProgram::Resolve); + let opts = ClientOptions::default(); let result = Client::start(opts).await; unset_env("COPILOT_CLI_EXTRACT_DIR"); @@ -378,14 +377,16 @@ async fn bundled_runtime_clean_extract_starts_without_cli_host() { let options = ClientOptions::new() .with_bundled_cli_extract_dir(&extract_dir) - .with_cwd(&working_dir) - .with_env([("PATH", empty_path.as_os_str())]) - .with_env_remove([ - "COPILOT_RUNTIME_HOST_COMMAND", - "COPILOT_CLI_PATH", - "COPILOT_RUNTIME_PROVIDER_LIB", - ]) - .with_transport(Transport::Stdio) + .with_transport(Transport::Stdio( + OutOfProcessOptions::new() + .with_working_directory(&working_dir) + .with_env([("PATH", empty_path.as_os_str())]) + .with_env_remove([ + "COPILOT_RUNTIME_HOST_COMMAND", + "COPILOT_CLI_PATH", + "COPILOT_RUNTIME_PROVIDER_LIB", + ]), + )) .with_use_logged_in_user(false); let client = Client::start(options) .await diff --git a/rust/tests/e2e/client.rs b/rust/tests/e2e/client.rs index 3abedce1a8..db141d58f1 100644 --- a/rust/tests/e2e/client.rs +++ b/rust/tests/e2e/client.rs @@ -3,7 +3,8 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use async_trait::async_trait; use github_copilot_sdk::{ - CliProgram, Client, ClientOptions, Error, ListModelsHandler, Model, Transport, + CliProgram, Client, ClientOptions, Error, ListModelsHandler, Model, OutOfProcessOptions, + Transport, }; use super::support::{is_inprocess_default, with_e2e_context}; @@ -42,6 +43,7 @@ async fn should_start_ping_and_stop_tcp_client() { let client = Client::start(ctx.client_options_with_transport(Transport::Tcp { port: 0, connection_token: Some("tcp-e2e-token".to_string()), + process: OutOfProcessOptions::default(), })) .await .expect("start TCP client"); @@ -162,8 +164,10 @@ async fn should_force_stop_client() { async fn should_report_error_with_stderr_when_cli_fails_to_start() { let err = Client::start( ClientOptions::new() - .with_program(CliProgram::Path(std::path::PathBuf::from( - "definitely-not-copilot-cli-for-rust-e2e", + .with_transport(Transport::Stdio(OutOfProcessOptions::new().with_program( + CliProgram::Path(std::path::PathBuf::from( + "definitely-not-copilot-cli-for-rust-e2e", + )), ))) .with_use_logged_in_user(false), ) diff --git a/rust/tests/e2e/client_lifecycle.rs b/rust/tests/e2e/client_lifecycle.rs index 92bfa6ff6d..c7945fc463 100644 --- a/rust/tests/e2e/client_lifecycle.rs +++ b/rust/tests/e2e/client_lifecycle.rs @@ -1,6 +1,6 @@ -#[cfg(windows)] -use github_copilot_sdk::CliProgram; use github_copilot_sdk::SessionLifecycleEventType; +#[cfg(windows)] +use github_copilot_sdk::{CliProgram, Transport}; use serde_json::json; use super::support::{wait_for_lifecycle_event, with_e2e_context}; @@ -150,7 +150,11 @@ async fn abrupt_host_termination_still_kills_cli_via_job_object() { |ctx| { Box::pin(async move { let options = ctx.client_options(); - let program = match &options.program { + let process = match &options.transport { + Transport::Stdio(process) => process, + _ => panic!("E2E client options should use the Stdio transport"), + }; + let program = match &process.program { CliProgram::Path(path) => path .to_str() .expect("CLI program path is valid UTF-8") @@ -159,12 +163,12 @@ async fn abrupt_host_termination_still_kills_cli_via_job_object() { panic!("E2E client options should resolve to an explicit CLI path") } }; - let prefix_args: Vec = options + let prefix_args: Vec = process .prefix_args .iter() .map(|arg| arg.to_str().expect("prefix arg is valid UTF-8").to_owned()) .collect(); - let env_pairs: Vec<(String, String)> = options + let env_pairs: Vec<(String, String)> = process .env .iter() .map(|(k, v)| { @@ -174,7 +178,7 @@ async fn abrupt_host_termination_still_kills_cli_via_job_object() { ) }) .collect(); - let cwd = options + let cwd = process .working_directory .to_str() .expect("cwd is valid UTF-8") diff --git a/rust/tests/e2e/client_options.rs b/rust/tests/e2e/client_options.rs index 51880803d3..95b57ce88a 100644 --- a/rust/tests/e2e/client_options.rs +++ b/rust/tests/e2e/client_options.rs @@ -5,8 +5,8 @@ use github_copilot_sdk::canvas::CanvasDeclaration; use github_copilot_sdk::rpc::{OpenCanvasInstance, RemoteSessionMode}; use github_copilot_sdk::session_events::{ReasoningSummary, SessionLimitsConfig}; use github_copilot_sdk::{ - CliProgram, Client, ClientOptions, CopilotExpAssignmentResponse, ExtensionInfo, ProviderConfig, - ResumeSessionConfig, SessionConfig, SessionId, Transport, + CliProgram, Client, ClientOptions, CopilotExpAssignmentResponse, ExtensionInfo, + OutOfProcessOptions, ProviderConfig, ResumeSessionConfig, SessionConfig, SessionId, Transport, }; use serde::Deserialize; use serde_json::{Value, json}; @@ -353,16 +353,18 @@ impl FakeCli { fn client_options(&self, token: &str) -> ClientOptions { ClientOptions::new() - .with_program(CliProgram::Path(PathBuf::from("node"))) - .with_prefix_args([self.script_path.as_os_str().to_owned()]) - .with_cwd(&self.work_dir) - .with_extra_args([ - "--capture-file".to_string(), - self.capture_path.to_string_lossy().into_owned(), - ]) + .with_transport(Transport::Stdio( + OutOfProcessOptions::new() + .with_program(CliProgram::Path(PathBuf::from("node"))) + .with_prefix_args([self.script_path.as_os_str().to_owned()]) + .with_working_directory(&self.work_dir) + .with_extra_args([ + "--capture-file".to_string(), + self.capture_path.to_string_lossy().into_owned(), + ]), + )) .with_github_token(token) .with_use_logged_in_user(false) - .with_transport(Transport::Stdio) } fn path(&self, name: &str) -> PathBuf { diff --git a/rust/tests/e2e/multi_client.rs b/rust/tests/e2e/multi_client.rs index f6e573e3e3..eb2f353226 100644 --- a/rust/tests/e2e/multi_client.rs +++ b/rust/tests/e2e/multi_client.rs @@ -9,8 +9,8 @@ use github_copilot_sdk::session_events::{ }; use github_copilot_sdk::tool::ToolHandler; use github_copilot_sdk::{ - Client, PermissionRequestData, RequestId, ResumeSessionConfig, SessionConfig, SessionEvent, - SessionId, Tool, ToolInvocation, ToolResult, Transport, + Client, OutOfProcessOptions, PermissionRequestData, RequestId, ResumeSessionConfig, + SessionConfig, SessionEvent, SessionId, Tool, ToolInvocation, ToolResult, Transport, }; use serde_json::json; @@ -432,6 +432,7 @@ async fn start_tcp_server(ctx: &E2eContext, port: u16) -> Client { Client::start(ctx.client_options_with_transport(Transport::Tcp { port, connection_token: Some(SHARED_TOKEN.to_string()), + process: OutOfProcessOptions::default(), })) .await .expect("start TCP server client") diff --git a/rust/tests/e2e/multi_client_commands_elicitation.rs b/rust/tests/e2e/multi_client_commands_elicitation.rs index 405d39ef59..f5047fb568 100644 --- a/rust/tests/e2e/multi_client_commands_elicitation.rs +++ b/rust/tests/e2e/multi_client_commands_elicitation.rs @@ -10,7 +10,7 @@ use github_copilot_sdk::session_events::{ }; use github_copilot_sdk::{ Client, CommandContext, CommandDefinition, CommandHandler, ElicitationRequest, - ElicitationResult, RequestId, ResumeSessionConfig, SessionId, Transport, + ElicitationResult, OutOfProcessOptions, RequestId, ResumeSessionConfig, SessionId, Transport, }; use super::support::{DEFAULT_TEST_TOKEN, E2eContext, wait_for_event, with_e2e_context}; @@ -205,6 +205,7 @@ async fn start_tcp_server(ctx: &E2eContext, port: u16) -> Client { Client::start(ctx.client_options_with_transport(Transport::Tcp { port, connection_token: Some(SHARED_TOKEN.to_string()), + process: OutOfProcessOptions::default(), })) .await .expect("start TCP server client") diff --git a/rust/tests/e2e/pending_work_resume.rs b/rust/tests/e2e/pending_work_resume.rs index f695e7114d..f3aa6b8353 100644 --- a/rust/tests/e2e/pending_work_resume.rs +++ b/rust/tests/e2e/pending_work_resume.rs @@ -9,8 +9,8 @@ use github_copilot_sdk::session_events::{ }; use github_copilot_sdk::tool::ToolHandler; use github_copilot_sdk::{ - Client, Error, RequestId, ResumeSessionConfig, SessionConfig, SessionId, Tool, ToolInvocation, - ToolResult, Transport, + Client, Error, OutOfProcessOptions, RequestId, ResumeSessionConfig, SessionConfig, SessionId, + Tool, ToolInvocation, ToolResult, Transport, }; use serde_json::json; use tokio::sync::{Mutex, mpsc, oneshot}; @@ -269,6 +269,7 @@ async fn start_tcp_server(ctx: &E2eContext, port: u16) -> Client { Client::start(ctx.client_options_with_transport(Transport::Tcp { port, connection_token: Some(SHARED_TOKEN.to_string()), + process: OutOfProcessOptions::default(), })) .await .expect("start TCP server client") diff --git a/rust/tests/e2e/provider_endpoint.rs b/rust/tests/e2e/provider_endpoint.rs index 6aeff7b1a7..93427118e3 100644 --- a/rust/tests/e2e/provider_endpoint.rs +++ b/rust/tests/e2e/provider_endpoint.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use github_copilot_sdk::handler::ApproveAllHandler; use github_copilot_sdk::rpc::{ProviderEndpointType, ProviderEndpointWireApi}; -use github_copilot_sdk::{ProviderConfig, SessionConfig}; +use github_copilot_sdk::{ClientOptions, ProviderConfig, SessionConfig, Transport}; use super::support::{DEFAULT_TEST_TOKEN, with_e2e_context}; @@ -14,6 +14,16 @@ fn opt_in_env() -> (OsString, OsString) { ("COPILOT_ALLOW_GET_PROVIDER_ENDPOINT".into(), "true".into()) } +/// Pushes an extra environment variable onto the out-of-process transport's +/// env list. No-op for transports without an [`OutOfProcessOptions`] (e.g. +/// [`Transport::InProcess`]/[`Transport::External`]) since they don't spawn +/// a CLI subprocess to configure. +fn push_env(options: &mut ClientOptions, pair: (OsString, OsString)) { + if let Transport::Stdio(process) | Transport::Tcp { process, .. } = &mut options.transport { + process.env.push(pair); + } +} + #[tokio::test] #[allow(deprecated)] async fn byok_provider_endpoint_returns_configured_endpoint() { @@ -24,7 +34,7 @@ async fn byok_provider_endpoint_returns_configured_endpoint() { Box::pin(async move { let mut options = ctx.client_options(); if !super::support::is_inprocess_default() { - options.env.push(opt_in_env()); + push_env(&mut options, opt_in_env()); } let client = github_copilot_sdk::Client::start(options) .await @@ -104,7 +114,7 @@ async fn capi_provider_endpoint_returns_resolved_credentials() { ctx.set_default_copilot_user(); let mut options = ctx.client_options_with_github_token(DEFAULT_TEST_TOKEN); if !super::support::is_inprocess_default() { - options.env.push(opt_in_env()); + push_env(&mut options, opt_in_env()); } let client = github_copilot_sdk::Client::start(options) .await diff --git a/rust/tests/e2e/session_config.rs b/rust/tests/e2e/session_config.rs index a9e162c19e..a34c6a8e85 100644 --- a/rust/tests/e2e/session_config.rs +++ b/rust/tests/e2e/session_config.rs @@ -8,8 +8,8 @@ use bytes::Bytes; use github_copilot_sdk::handler::ApproveAllHandler; use github_copilot_sdk::{ Attachment, Client, CopilotHttpRequest, CopilotHttpResponse, CopilotRequestContext, - CopilotRequestError, CopilotRequestHandler, MessageOptions, ProviderConfig, - ResumeSessionConfig, SessionConfig, SessionLimitsConfig, Transport, + CopilotRequestError, CopilotRequestHandler, MessageOptions, OutOfProcessOptions, + ProviderConfig, ResumeSessionConfig, SessionConfig, SessionLimitsConfig, Transport, }; use http::{HeaderMap, HeaderValue}; use parking_lot::Mutex; @@ -574,6 +574,7 @@ async fn should_enable_citations_for_anthropic_file_attachments_on_resume() { ctx.client_options_with_transport(Transport::Tcp { port, connection_token: Some(token.clone()), + process: OutOfProcessOptions::default(), }) .with_request_handler(handler.clone()), ) diff --git a/rust/tests/e2e/support.rs b/rust/tests/e2e/support.rs index 0b7bbae7a7..d887d855c3 100644 --- a/rust/tests/e2e/support.rs +++ b/rust/tests/e2e/support.rs @@ -16,8 +16,8 @@ use github_copilot_sdk::handler::ApproveAllHandler; use github_copilot_sdk::session::Session; use github_copilot_sdk::subscription::{EventSubscription, LifecycleSubscription}; use github_copilot_sdk::{ - CliProgram, Client, ClientOptions, CopilotRequestHandler, SessionConfig, SessionEvent, - SessionId, SessionLifecycleEvent, Transport, + CliProgram, Client, ClientOptions, CopilotRequestHandler, OutOfProcessOptions, SessionConfig, + SessionEvent, SessionId, SessionLifecycleEvent, Transport, }; use serde_json::json; use tokio::sync::{Mutex, Semaphore}; @@ -497,7 +497,33 @@ impl E2eContext { client_options_for_cli(&self.cli_path, self.work_dir.path(), self.environment()) } + /// The out-of-process configuration (program/cwd/env) this context's + /// tests should use, matching what [`Self::client_options`] wires onto + /// [`Transport::Stdio`]. Callers overriding the transport (e.g. to + /// [`Transport::Tcp`]) must thread this through so the spawned CLI still + /// gets the right binary/cwd/env — see [`Self::client_options_with_transport`]. + fn out_of_process_options(&self) -> OutOfProcessOptions { + if is_inprocess_default() { + return OutOfProcessOptions::default(); + } + out_of_process_options_for_cli(&self.cli_path, self.work_dir.path(), self.environment()) + } + pub fn client_options_with_transport(&self, transport: Transport) -> ClientOptions { + let process = self.out_of_process_options(); + let transport = match transport { + Transport::Stdio(_) => Transport::Stdio(process), + Transport::Tcp { + port, + connection_token, + .. + } => Transport::Tcp { + port, + connection_token, + process, + }, + other => other, + }; self.client_options().with_transport(transport) } @@ -544,6 +570,7 @@ impl E2eContext { Client::start(self.client_options_with_transport(Transport::Tcp { port, connection_token: Some(token.to_string()), + process: OutOfProcessOptions::default(), })) .await .expect("start TCP E2E client") @@ -1214,18 +1241,14 @@ fn cli_path(repo_root: &Path) -> std::io::Result { } #[allow(deprecated)] -fn client_options_for_cli( +fn out_of_process_options_for_cli( cli_path: &Path, cwd: &Path, env: Vec<(OsString, OsString)>, -) -> ClientOptions { - if is_inprocess_default() { - return ClientOptions::new(); - } - let options = ClientOptions::new() - .with_cwd(cwd) - .with_env(env) - .with_use_logged_in_user(false); +) -> OutOfProcessOptions { + let options = OutOfProcessOptions::new() + .with_working_directory(cwd) + .with_env(env); if cli_path .extension() .and_then(|extension| extension.to_str()) @@ -1239,6 +1262,21 @@ fn client_options_for_cli( } } +#[allow(deprecated)] +fn client_options_for_cli( + cli_path: &Path, + cwd: &Path, + env: Vec<(OsString, OsString)>, +) -> ClientOptions { + if is_inprocess_default() { + return ClientOptions::new(); + } + let process = out_of_process_options_for_cli(cli_path, cwd, env); + ClientOptions::new() + .with_transport(Transport::Stdio(process)) + .with_use_logged_in_user(false) +} + fn canonical_temp_path(path: &Path) -> PathBuf { std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } diff --git a/rust/tests/extension_launch_provider_runtime_test.rs b/rust/tests/extension_launch_provider_runtime_test.rs index f08d6af7f0..8ed376247b 100644 --- a/rust/tests/extension_launch_provider_runtime_test.rs +++ b/rust/tests/extension_launch_provider_runtime_test.rs @@ -24,7 +24,9 @@ use github_copilot_sdk::rpc::{ ExtensionSource, ExtensionStatus, ExtensionsDisableRequest, ExtensionsEnableRequest, ToolResult, ToolResultType, ToolsExecuteRequest, }; -use github_copilot_sdk::{CliProgram, Client, ClientOptions, SessionConfig, Transport}; +use github_copilot_sdk::{ + CliProgram, Client, ClientOptions, OutOfProcessOptions, SessionConfig, Transport, +}; use serde_json::{Value, json}; use tokio::sync::mpsc; use tokio::time::{sleep, timeout}; @@ -100,12 +102,14 @@ async fn real_wrapper_installs_and_runs_hostless_extensions() { let (request_tx, mut request_rx) = mpsc::unbounded_channel(); let options = ClientOptions::new() - .with_program(CliProgram::Path(runtime_path)) - .with_transport(Transport::Stdio) - .with_cwd(workspace.path()) + .with_transport(Transport::Stdio( + OutOfProcessOptions::new() + .with_program(CliProgram::Path(runtime_path)) + .with_working_directory(workspace.path()) + .with_env_remove(["COPILOT_CLI_DIST_DIR"]), + )) .with_base_directory(home.path()) .with_use_logged_in_user(false) - .with_env_remove(["COPILOT_CLI_DIST_DIR"]) .with_extension_launch_provider(RecordingProvider { executable: PathBuf::from(env!("CARGO_BIN_EXE_copilot-extension-test-fixture")), state_path: state_path.clone(), diff --git a/rust/tests/extension_launch_provider_test.rs b/rust/tests/extension_launch_provider_test.rs index b1a9592133..0b27974691 100644 --- a/rust/tests/extension_launch_provider_test.rs +++ b/rust/tests/extension_launch_provider_test.rs @@ -11,7 +11,7 @@ use github_copilot_sdk::extension_launch_provider::{ ExtensionLaunchProviderResolveResult, }; use github_copilot_sdk::rpc::ExtensionSource; -use github_copilot_sdk::{CliProgram, Client, ClientOptions, Error, ErrorKind, Transport}; +use github_copilot_sdk::{Client, ClientOptions, Error, ErrorKind, Transport}; use serde_json::{Value, json}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, duplex}; use tokio::net::{TcpListener, TcpStream}; @@ -318,7 +318,6 @@ async fn read_registration(reader: &mut (impl AsyncRead + Unpin)) -> Value { fn external_options(port: u16, provider: AppProvider) -> ClientOptions { ClientOptions::new() - .with_program(CliProgram::Path("unused-for-external-transport".into())) .with_transport(Transport::External { host: "127.0.0.1".to_string(), port, diff --git a/rust/tests/fixtures/host_crash_fixture.rs b/rust/tests/fixtures/host_crash_fixture.rs index c688cf3e87..37526dc8ff 100644 --- a/rust/tests/fixtures/host_crash_fixture.rs +++ b/rust/tests/fixtures/host_crash_fixture.rs @@ -16,7 +16,7 @@ use std::path::PathBuf; -use github_copilot_sdk::{CliProgram, Client, ClientOptions, Transport}; +use github_copilot_sdk::{CliProgram, Client, ClientOptions, OutOfProcessOptions, Transport}; #[tokio::main(flavor = "current_thread")] async fn main() { @@ -36,12 +36,14 @@ async fn main() { ); let options = ClientOptions::new() - .with_program(CliProgram::Path(PathBuf::from(program))) - .with_prefix_args(prefix_args) - .with_cwd(PathBuf::from(cwd)) - .with_env(env_pairs) - .with_use_logged_in_user(false) - .with_transport(Transport::Stdio); + .with_transport(Transport::Stdio( + OutOfProcessOptions::new() + .with_program(CliProgram::Path(PathBuf::from(program))) + .with_prefix_args(prefix_args) + .with_working_directory(PathBuf::from(cwd)) + .with_env(env_pairs), + )) + .with_use_logged_in_user(false); let client = Client::start(options).await.expect("start CLI client"); let pid = client.pid().expect("client reports spawned CLI pid"); diff --git a/rust/tests/integration_test.rs b/rust/tests/integration_test.rs index 9dd71223bf..db6f2e1637 100644 --- a/rust/tests/integration_test.rs +++ b/rust/tests/integration_test.rs @@ -2,12 +2,14 @@ use std::time::Instant; -use github_copilot_sdk::{Client, ClientOptions, SDK_PROTOCOL_VERSION}; +use github_copilot_sdk::{ + Client, ClientOptions, OutOfProcessOptions, SDK_PROTOCOL_VERSION, Transport, +}; fn default_options() -> ClientOptions { - let mut opts = ClientOptions::default(); - opts.working_directory = std::env::current_dir().expect("cwd"); - opts + ClientOptions::default().with_transport(Transport::Stdio( + OutOfProcessOptions::new().with_working_directory(std::env::current_dir().expect("cwd")), + )) } #[tokio::test] diff --git a/test/snapshots/client_options/should_use_outofprocess_connection_workingdirectory.yaml b/test/snapshots/client_options/should_use_outofprocess_connection_workingdirectory.yaml new file mode 100644 index 0000000000..5b80a1fc4a --- /dev/null +++ b/test/snapshots/client_options/should_use_outofprocess_connection_workingdirectory.yaml @@ -0,0 +1,22 @@ +models: + - claude-sonnet-5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Read the file marker.txt and tell me what it says + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: ${shell} + arguments: '{"command":"cat marker.txt","description":"Read marker.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: |- + I am in the client cwd + + - role: assistant + content: 'marker.txt says: **"I am in the client cwd"**'