Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ new CopilotClient(CopilotClientOptions? options = null)
- `LogLevel` - Log level (default: "info")
- `AutoStart` - Auto-start server (default: true)
- `Cwd` - Working directory for the CLI process
- `Environment` - Environment variables to pass to the CLI process
- `Environment` - Environment variables to set for the CLI process. Specified keys override their inherited values; all other variables are inherited from the current process. When null, the CLI process inherits the current environment unchanged.
- `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 `CliUrl`.
Expand Down
1 change: 0 additions & 1 deletion dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1148,7 +1148,6 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio

if (options.Environment != null)
{
startInfo.Environment.Clear();
foreach (var (key, value) in options.Environment)
{
startInfo.Environment[key] = value;
Expand Down
5 changes: 4 additions & 1 deletion dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,10 @@ protected CopilotClientOptions(CopilotClientOptions? other)
[Obsolete("AutoRestart has no effect and will be removed in a future release.")]
public bool AutoRestart { get; set; }
/// <summary>
/// Environment variables to pass to the CLI process.
/// Environment variables to set for the CLI process.
/// When provided, these keys override (or add to) the inherited environment variables;
/// the CLI process still inherits all other variables from the current process.
/// When null, the CLI process inherits the current process environment unchanged.
/// </summary>
public IReadOnlyDictionary<string, string>? Environment { get; set; }
/// <summary>
Expand Down
288 changes: 288 additions & 0 deletions dotnet/test/EnvironmentTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

using Xunit;

namespace GitHub.Copilot.SDK.Test;

/// <summary>
/// Regression tests for the Environment merge-vs-replace bug (Issue #441).
///
/// Background:
/// Before the fix, <see cref="CopilotClientOptions.Environment"/> was handled with:
///
/// startInfo.Environment.Clear(); // ← BUG: wiped PATH, SystemRoot, COMSPEC, TEMP, etc.
/// foreach (var (key, value) in options.Environment)
/// startInfo.Environment[key] = value;
///
/// ProcessStartInfo.Environment is pre-populated with the current process's inherited
/// environment. The Clear() call threw it all away, so supplying even ONE custom key
/// caused the Node.js-based CLI subprocess to crash on Windows because essential system
/// variables (PATH, SystemRoot, COMSPEC) were gone.
///
/// After the fix, user-supplied keys are merged (override or add) into the inherited
/// environment -- the CLI subprocess receives all inherited vars plus any overrides.
///
/// How the tests prove the fix:
/// Every test below that provides a non-null Environment dict would have thrown an
/// IOException ("CLI process exited unexpectedly") BEFORE the fix. After the fix they
/// all pass because PATH/SystemRoot/COMSPEC remain available to the subprocess.
/// </summary>
public class EnvironmentTests
{
// ── Null / empty cases ────────────────────────────────────────────────────

[Fact]
public void Environment_DefaultsToNull()
{
// Verify the documented default: null means "fully inherit from parent process".
var options = new CopilotClientOptions();
Assert.Null(options.Environment);
}

[Fact]
public async Task Should_Start_When_Environment_Is_Null()
{
// Baseline: null Environment → all inherited vars are present → CLI starts.
using var client = new CopilotClient(new CopilotClientOptions
{
UseStdio = true,
Environment = null,
});

try
{
await client.StartAsync();
Assert.Equal(ConnectionState.Connected, client.State);

var pong = await client.PingAsync("null-env");
Assert.Equal("pong: null-env", pong.Message);

await client.StopAsync();
}
finally
{
await client.ForceStopAsync();
}
}

[Fact]
public async Task Should_Start_When_Environment_Is_An_Empty_Dictionary()
{
// An empty dictionary supplies no keys, so the loop in Client.cs runs zero
// iterations -- the inherited environment is completely unchanged.
// Before the fix: Clear() was still called → crash.
// After the fix: no Clear(); inherited env untouched → CLI starts normally.
using var client = new CopilotClient(new CopilotClientOptions
{
UseStdio = true,
Environment = new Dictionary<string, string>(),
});

try
{
await client.StartAsync();
Assert.Equal(ConnectionState.Connected, client.State);

var pong = await client.PingAsync("empty-env");
Assert.Equal("pong: empty-env", pong.Message);

await client.StopAsync();
}
finally
{
await client.ForceStopAsync();
}
}

// ── Partial-dict merge cases ──────────────────────────────────────────────

[Fact]
public async Task Should_Start_When_Environment_Has_One_Custom_Key()
{
// This is the canonical regression test for Issue #441.
//
// The user provides a single custom environment variable -- a perfectly
// reasonable thing to do (e.g. to set COPILOT_API_URL, a proxy, etc.).
//
// Before the fix:
// startInfo.Environment.Clear() ← removes PATH, SystemRoot, COMSPEC …
// startInfo.Environment["MY_KEY"] = "value"
// → CLI subprocess starts with only MY_KEY → crashes immediately
// → StartAsync() throws IOException
//
// After the fix:
// startInfo.Environment["MY_KEY"] = "value" (merged)
// → CLI subprocess retains all inherited vars + MY_KEY → starts normally
using var client = new CopilotClient(new CopilotClientOptions
{
UseStdio = true,
Environment = new Dictionary<string, string>
{
["MY_CUSTOM_SDK_VAR"] = "hello_world",
},
});

try
{
// This line would throw before the fix:
// System.IO.IOException: CLI process exited unexpectedly …
await client.StartAsync();
Assert.Equal(ConnectionState.Connected, client.State);

var pong = await client.PingAsync("one-key-env");
Assert.Equal("pong: one-key-env", pong.Message);

await client.StopAsync();
}
finally
{
await client.ForceStopAsync();
}
}

[Fact]
public async Task Should_Start_When_Environment_Has_Multiple_Custom_Keys()
{
// Multiple custom keys, none of them system variables.
// Proves that the merge works for an arbitrary number of custom entries.
using var client = new CopilotClient(new CopilotClientOptions
{
UseStdio = true,
Environment = new Dictionary<string, string>
{
["SDK_TEST_VAR_A"] = "alpha",
["SDK_TEST_VAR_B"] = "beta",
["SDK_TEST_VAR_C"] = "gamma",
},
});

try
{
await client.StartAsync();
Assert.Equal(ConnectionState.Connected, client.State);

var pong = await client.PingAsync("multi-key-env");
Assert.Equal("pong: multi-key-env", pong.Message);

await client.StopAsync();
}
finally
{
await client.ForceStopAsync();
}
}

[Fact]
public async Task Should_Start_When_Environment_Overrides_An_Inherited_Key()
{
// Overriding an EXISTING env var (e.g. COPILOT_LOG_LEVEL) should work:
// the override takes effect, and all other inherited vars remain.
using var client = new CopilotClient(new CopilotClientOptions
{
UseStdio = true,
Environment = new Dictionary<string, string>
{
// Override a var that is almost certainly already present in the
// parent process environment so we exercise the "override" code path.
["PATH"] = System.Environment.GetEnvironmentVariable("PATH") ?? "/usr/bin",
},
});

try
{
await client.StartAsync();
Assert.Equal(ConnectionState.Connected, client.State);

var pong = await client.PingAsync("override-inherited-key");
Assert.Equal("pong: override-inherited-key", pong.Message);

await client.StopAsync();
}
finally
{
await client.ForceStopAsync();
}
}

// ── Verifying the merge semantics via the harness pattern ──────────────

[Fact]
public async Task TestHarness_GetEnvironment_Pattern_Works_After_Fix()
{
// The E2E test harness (E2ETestContext.GetEnvironment) follows this pattern:
//
// var env = Environment.GetEnvironmentVariables()
// .Cast<DictionaryEntry>()
// .ToDictionary(...);
// env["COPILOT_API_URL"] = proxyUrl; // ← override
// env["XDG_CONFIG_HOME"] = homeDir; // ← override
// env["XDG_STATE_HOME"] = homeDir; // ← override
// return env;
//
// This pattern always supplied the FULL environment, so it happened to work
// even before the fix. Here we verify the same pattern continues to work.
var fullEnvWithOverrides = System.Environment.GetEnvironmentVariables()
.Cast<System.Collections.DictionaryEntry>()
.ToDictionary(e => (string)e.Key, e => e.Value?.ToString() ?? "");

fullEnvWithOverrides["SDK_HARNESS_STYLE_OVERRIDE"] = "harness_value";

using var client = new CopilotClient(new CopilotClientOptions
{
UseStdio = true,
Environment = fullEnvWithOverrides,
});

try
{
await client.StartAsync();
Assert.Equal(ConnectionState.Connected, client.State);

var pong = await client.PingAsync("harness-pattern");
Assert.Equal("pong: harness-pattern", pong.Message);

await client.StopAsync();
}
finally
{
await client.ForceStopAsync();
}
}

// ── NODE_DEBUG is always stripped ─────────────────────────────────────────

[Fact]
public async Task Should_Strip_NODE_DEBUG_Even_When_Environment_Is_Null()
{
// Client.cs always calls startInfo.Environment.Remove("NODE_DEBUG") after
// the merge step, so the CLI subprocess never sees NODE_DEBUG regardless of
// whether the parent process has it set. The CLI must start normally.
var envWithNodeDebug = System.Environment.GetEnvironmentVariables()
.Cast<System.Collections.DictionaryEntry>()
.ToDictionary(e => (string)e.Key, e => e.Value?.ToString() ?? "");
envWithNodeDebug["NODE_DEBUG"] = "http,net"; // would pollute CLI stdout if kept

using var client = new CopilotClient(new CopilotClientOptions
{
UseStdio = true,
Environment = envWithNodeDebug,
});
Comment on lines +256 to +271
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test name says Environment_Is_Null, but the test sets Environment = envWithNodeDebug (non-null). Rename the test (or change it to actually pass Environment = null and set NODE_DEBUG on the parent process) so the intent matches the behavior under test.

Copilot uses AI. Check for mistakes.

try
{
await client.StartAsync();
Assert.Equal(ConnectionState.Connected, client.State);

var pong = await client.PingAsync("node-debug-stripped");
Assert.Equal("pong: node-debug-stripped", pong.Message);

await client.StopAsync();
}
finally
{
await client.ForceStopAsync();
}
}
}
2 changes: 1 addition & 1 deletion go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec
- `UseStdio` (bool): Use stdio transport instead of TCP (default: true)
- `LogLevel` (string): Log level (default: "info")
- `AutoStart` (\*bool): Auto-start server on first use (default: true). Use `Bool(false)` to disable.
- `Env` ([]string): Environment variables for CLI process (default: inherits from current process)
- `Env` ([]string): Full environment for the CLI process as `"KEY=VALUE"` strings (default: inherits from current process when nil). **Unlike the Node.js, Python, and .NET SDKs, the Go SDK uses full-replacement semantics**: when non-nil, this slice becomes the CLI process's complete environment. To add or override specific keys while preserving system variables, start from `os.Environ()`: `env := append(os.Environ(), "MY_KEY=value")`.
- `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 `CLIUrl`.
- `Telemetry` (\*TelemetryConfig): OpenTelemetry configuration for the CLI process. Providing this enables telemetry — no separate flag needed. See [Telemetry](#telemetry) below.
Expand Down
23 changes: 18 additions & 5 deletions go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,24 @@ type ClientOptions struct {
AutoStart *bool
// Deprecated: AutoRestart has no effect and will be removed in a future release.
AutoRestart *bool
// Env is the environment variables for the CLI process (default: inherits from current process).
// Each entry is of the form "key=value".
// If Env is nil, the new process uses the current process's environment.
// If Env contains duplicate environment keys, only the last value in the
// slice for each duplicate key is used.
// Env specifies the full environment for the CLI process. Each entry must be
// of the form "key=value".
//
// If Env is nil (the default), the CLI process inherits the current process's
// environment unchanged.
//
// If Env is non-nil, it becomes the COMPLETE environment of the CLI process —
// variables not listed here are NOT inherited. To add or override a few keys
// while preserving PATH and other system variables, start from os.Environ():
//
// env := append(os.Environ(), "COPILOT_API_URL=http://proxy:8080")
//
// Note: the Go SDK behaves differently from the Node.js, Python, and .NET SDKs,
// which automatically merge user-provided keys into the inherited environment.
// In Go, the []string format makes full replacement the natural choice; callers
// must explicitly include inherited variables when partial overrides are needed.
//
// If Env contains duplicate keys, only the last value for each key is used.
Env []string
// GitHubToken is the GitHub token to use for authentication.
// When provided, the token is passed to the CLI server via environment variable.
Expand Down
1 change: 1 addition & 0 deletions nodejs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ new CopilotClient(options?: CopilotClientOptions)
- `useStdio?: boolean` - Use stdio transport instead of TCP (default: true)
- `logLevel?: string` - Log level (default: "info")
- `autoStart?: boolean` - Auto-start server (default: true)
- `env?: Record<string, string>` - Extra environment variables for the CLI process. Specified keys are **merged into** `process.env` (they override or add to inherited variables; all other variables remain intact). When not set, the CLI process inherits `process.env` unchanged.
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

README documents env?: Record<string, string>, but the public type is env?: Record<string, string | undefined> (and undefined is meaningful for child_process.spawn env values). Please align the README type signature with CopilotClientOptions.env to avoid confusing consumers.

Suggested change
- `env?: Record<string, string>` - Extra environment variables for the CLI process. Specified keys are **merged into** `process.env` (they override or add to inherited variables; all other variables remain intact). When not set, the CLI process inherits `process.env` unchanged.
- `env?: Record<string, string | undefined>` - Extra environment variables for the CLI process. Specified keys are **merged into** `process.env` (they override or add to inherited variables; all other variables remain intact). When not set, the CLI process inherits `process.env` unchanged.

Copilot uses AI. Check for mistakes.
- `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 `cliUrl`.
- `telemetry?: TelemetryConfig` - OpenTelemetry configuration for the CLI process. Providing this object enables telemetry — no separate flag needed. See [Telemetry](#telemetry) below.
Expand Down
7 changes: 6 additions & 1 deletion nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,12 @@ export class CopilotClient {
this.onGetTraceContext = options.onGetTraceContext;
this.sessionFsConfig = options.sessionFs ?? null;

const effectiveEnv = options.env ?? process.env;
// Merge user-provided env overrides into the current process environment.
// Providing a partial dict (e.g. { COPILOT_API_URL: "..." }) adds/overrides
// those keys while keeping PATH, HOME, and all other inherited variables intact.
// Users who need a fully-isolated environment can pass { ...process.env } as a
// base themselves, but that case is rare and explicit.
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment suggests users can get a “fully-isolated environment” by passing { ...process.env } as a base, but that still inherits the full parent environment (just as a copy). With the new merge semantics there’s also no way to request full-replacement behavior via env. Please adjust the comment to avoid implying isolation/replacement is supported (or document an explicit way to opt into replacement if that’s intended).

Suggested change
// Users who need a fully-isolated environment can pass { ...process.env } as a
// base themselves, but that case is rare and explicit.
// This API does not currently support replacing the inherited environment
// wholesale via `env`; values in `options.env` are always merged with process.env.

Copilot uses AI. Check for mistakes.
const effectiveEnv = options.env ? { ...process.env, ...options.env } : process.env;
this.options = {
cliPath: options.cliUrl
? undefined
Expand Down
12 changes: 11 additions & 1 deletion nodejs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,17 @@ export interface CopilotClientOptions {
autoRestart?: boolean;

/**
* Environment variables to pass to the CLI process. If not set, inherits process.env.
* Extra environment variables to set for the CLI process.
*
* When provided, the specified keys are **merged into** (override or add to) the
* current `process.env`. All other inherited variables (PATH, HOME, etc.) remain
* intact. When not set, the CLI process inherits `process.env` unchanged.
*
* @example
* ```typescript
* // Add one override — PATH and everything else are still inherited
* const client = new CopilotClient({ env: { COPILOT_API_URL: "http://proxy:8080" } });
* ```
*/
env?: Record<string, string | undefined>;

Expand Down
Loading
Loading