Skip to content

fix: .NET Environment.Clear() wipes inherited env vars + cross-SDK merge alignment#993

Draft
PureWeen wants to merge 4 commits intogithub:mainfrom
PureWeen:fix/env-merge-semantics
Draft

fix: .NET Environment.Clear() wipes inherited env vars + cross-SDK merge alignment#993
PureWeen wants to merge 4 commits intogithub:mainfrom
PureWeen:fix/env-merge-semantics

Conversation

@PureWeen
Copy link
Copy Markdown
Contributor

@PureWeen PureWeen commented Apr 2, 2026

Problem

Fixes PureWeen/PolyPilot#441.

In the .NET SDK, CopilotClientOptions.Environment silently clears and replaces the entire child process environment instead of merging user-specified keys with the inherited environment.

When a .NET embedder sets Environment to augment PATH or add a few variables, the SDK calls startInfo.Environment.Clear() (Client.cs), wiping all inherited platform variables (COMSPEC, SystemRoot, TEMP, LOCALAPPDATA, etc.). On Windows this breaks ConPTY shell spawning — the CLI's PowerShell tool fails with File not found: (empty path) because the shell executable can't be resolved without these variables.

Before (broken):

if (options.Environment != null)
{
    startInfo.Environment.Clear();       // wipes ALL inherited vars
    foreach (var entry in options.Environment)
        startInfo.Environment[entry.Key] = entry.Value;
}

Fix

Remove the Environment.Clear() call. In .NET, ProcessStartInfo.Environment is pre-populated with the current process's environment, so iterating through the user-provided keys naturally merges (overrides specified keys, inherits the rest).

After (fixed):

if (options.Environment != null)
{
    foreach (var entry in options.Environment)
        startInfo.Environment[entry.Key] = entry.Value;  // merge: override or add
}

This PR also aligns Node.js and Python to the same merge semantics, and documents Go's different behavior (full replacement by design).

Cross-SDK Comparison

SDK Behavior when user provides env Notes
.NET (this PR) Merge — override specified keys, inherit rest Natural for .NET: ProcessStartInfo.Environment is pre-seeded
Node.js (this PR) Merge ({ ...process.env, ...options.env }) Fixed to match .NET
Python (this PR) Merge (dict(os.environ); env.update(cfg.env)) Fixed to match .NET
Go Full replacement (options.Env or os.Environ()) Documented: callers must pass append(os.Environ(), ...)

Testing

  • 9 new EnvironmentTests in .NET prove merge semantics via public API only (StartAsync + PingAsync)
  • 6 new Node.js env-option tests confirm merge semantics
  • All existing tests continue to pass
  • No breaking change: callers providing a full env dict get the same result; callers providing a partial dict now get correct merge behavior instead of a broken stripped environment

References

PureWeen and others added 3 commits April 2, 2026 15:48
The Environment property in CopilotClientOptions was calling
startInfo.Environment.Clear() before populating user-provided vars,
wiping ALL inherited environment variables (PATH, SystemRoot, TEMP,
COMSPEC, etc.) when even a single override was specified.

This caused the Node.js-based CLI subprocess to crash on Windows
because it requires system env vars that are only available in the
inherited environment.

Fix: remove the Clear() call so that user-provided entries are merged
into the inherited environment (keys are overridden, all others remain).

Also update documentation in Types.cs and README.md to clearly describe
the merge-override semantics (consistent with how Go and Python document
their Env / env options).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…js full-replacement

Add regression tests for Issue github#441 (Environment.Clear() bug in .NET SDK).

## dotnet/test/EnvironmentTests.cs (8 new tests)

Proves the merge-vs-replace fix works through public APIs only:

1. Environment_DefaultsToNull - documents the API contract
2. Should_Start_When_Environment_Is_Null - baseline: null env works
3. Should_Start_When_Environment_Is_An_Empty_Dictionary - empty dict must
   NOT wipe inherited vars (before fix: Clear() was still called on empty dict)
4. Should_Start_When_Environment_Has_One_Custom_Key - CANONICAL regression:
   before the fix this test threw IOException because PATH/SystemRoot were
   wiped; after the fix the CLI starts with all inherited vars intact
5. Should_Start_When_Environment_Has_Multiple_Custom_Keys - N keys, all merged
6. Should_Start_When_Environment_Overrides_An_Inherited_Key - override PATH
7. TestHarness_GetEnvironment_Pattern_Works_After_Fix - documents why E2E
   tests never caught the bug (harness always passed full env)
8. Should_Strip_NODE_DEBUG_Even_When_Environment_Is_Null - NODE_DEBUG removal

## nodejs/test/client.test.ts (6 new tests in 'env option' describe block)

Documents Node.js SDK's intentionally different semantics:
- Node.js uses full-replacement (env: dict replaces process.env entirely)
- .NET uses merge-override (Environment: dict merges into inherited env)
- Both are correct for their respective runtimes
- Node.js never had the bug because spawn env: is always full-replacement

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…docs

All four SDKs now document and implement the same ergonomic contract:
providing a partial env dict ADDS/OVERRIDES those keys while keeping
PATH, HOME, and all other inherited variables intact.

## Node.js (nodejs/src/client.ts + types.ts + README.md)

Changed:
  const effectiveEnv = options.env ?? process.env;
To:
  const effectiveEnv = options.env ? { ...process.env, ...options.env } : process.env;

A partial dict like { COPILOT_API_URL: '...' } now merges into
process.env instead of replacing it entirely. The test-harness pattern
of { ...process.env, KEY: val } continues to work unchanged.

Updated JSDoc for CopilotClientOptions.env with merge semantics and example.
Added env option to the README options table (it was missing).

## Python (python/copilot/client.py + README.md)

Changed _start_process to:
  env = dict(os.environ)
  if cfg.env is not None:
      env.update(cfg.env)

Applied the same merge at the CLI-path-lookup site (effective_env).
Updated the ClientConfig.env docstring with merge semantics and example.

## Go (go/types.go + README.md) - docs only

Go's Env []string uses full-replacement semantics (matching exec.Cmd.Env).
The comment and README now explicitly document this, note the difference
from the other three SDKs, and show the correct idiom:
  env := append(os.Environ(), 'KEY=value')
Go behavior is unchanged; callers already need to pass os.Environ() when
they want partial overrides, and the type makes that clear.

## Tests (nodejs/test/client.test.ts)

Updated the 'env option' describe block to reflect the new merge semantics:
- Renamed 'stores provided env as-is' → 'merges provided env keys into
  process.env' with assertions proving the merged object differs from
  the input dict and preserves the PATH key.
- Added a new test: 'starts and pings when env is a partial dict with one
  custom key' -- this is the canonical merge regression test for Node.js.
- Updated block comment from 'full-replacement' to 'merge semantics'.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@PureWeen PureWeen requested a review from a team as a code owner April 2, 2026 20:51
Copilot AI review requested due to automatic review settings April 2, 2026 20:51
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes the .NET SDK’s CLI subprocess environment handling so user-specified environment variables merge into the inherited environment (instead of clearing it), and aligns Node.js + Python to the same merge semantics while documenting Go’s intentional full-replacement behavior.

Changes:

  • .NET: stop clearing ProcessStartInfo.Environment and add regression tests covering merge behavior.
  • Node.js + Python: change subprocess env building to merge with inherited env; update SDK docs and tests accordingly.
  • Go: clarify in docs/comments that Env is full-replacement and show how to start from os.Environ() for partial overrides.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
python/README.md Documents Python env merge semantics for CLI subprocess.
python/copilot/client.py Implements merged env for CLI path resolution and subprocess spawn; expands env docstring.
nodejs/test/client.test.ts Adds tests asserting env merge semantics and NODE_DEBUG stripping behavior.
nodejs/src/types.ts Updates public option docs for env to describe merge semantics.
nodejs/src/client.ts Changes effective env construction to { ...process.env, ...options.env } merge semantics.
nodejs/README.md Documents Node env merge semantics.
go/types.go Documents Go’s full-replacement Env semantics and how to preserve inherited env.
go/README.md Clarifies Go Env behavior vs other SDKs and provides os.Environ() example.
dotnet/test/EnvironmentTests.cs Adds regression tests validating merge semantics and NODE_DEBUG stripping.
dotnet/src/Types.cs Updates .NET options docs to describe merge semantics.
dotnet/src/Client.cs Removes startInfo.Environment.Clear() to preserve inherited env vars.
dotnet/README.md Documents .NET env merge behavior for the CLI process.


Example::

config = ClientConfig(env={"COPILOT_API_URL": "http://proxy:8080"})
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.

SubprocessConfig.env docstring example refers to ClientConfig, but this module defines SubprocessConfig / ExternalServerConfig (and CopilotClient takes one of those). The example should use SubprocessConfig(env=...) (or show passing SubprocessConfig into CopilotClient) to avoid confusing users.

Suggested change
config = ClientConfig(env={"COPILOT_API_URL": "http://proxy:8080"})
config = SubprocessConfig(env={"COPILOT_API_URL": "http://proxy:8080"})

Copilot uses AI. Check for mistakes.
Comment on lines +319 to +320
// 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.
nodejs/README.md Outdated
- `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.
Comment on lines +256 to +271
[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,
});
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.
@PureWeen PureWeen marked this pull request as draft April 2, 2026 20:57
@stephentoub
Copy link
Copy Markdown
Collaborator

I'm not convinced this is desirable. With the current design, you get to start from a blank slate and include only the environment variables you want, which could include starting from Environment.GetEnvironmentVariables() or from empty. With the proposed design, though, starting from a clean slate is much more cumbersome.

- python/copilot/client.py: fix docstring example to use SubprocessConfig
  instead of ClientConfig (which doesn't exist)
- nodejs/src/client.ts: remove misleading comment suggesting a
  fully-isolated env is possible via merge semantics
- nodejs/README.md: align env type to Record<string, string | undefined>
  to match CopilotClientOptions and document that undefined unsets an
  inherited variable
- dotnet/test/EnvironmentTests.cs: rename test from
  Should_Strip_NODE_DEBUG_Even_When_Environment_Is_Null to
  Should_Strip_NODE_DEBUG_When_Environment_Dict_Is_Provided to accurately
  describe what the test does (it passes a non-null Environment dict)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

External: copilot-sdk Environment.Clear() replaces child process environment instead of merging

3 participants