Skip to content

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

Open
PureWeen wants to merge 4 commits intomainfrom
fix/env-merge-semantics
Open

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

Conversation

@PureWeen
Copy link
Copy Markdown
Owner

@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 4 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>
- 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

1 participant