fix: .NET Environment.Clear() wipes inherited env vars + cross-SDK merge alignment#993
fix: .NET Environment.Clear() wipes inherited env vars + cross-SDK merge alignment#993PureWeen wants to merge 4 commits intogithub:mainfrom
Conversation
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>
There was a problem hiding this comment.
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.Environmentand 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
Envis full-replacement and show how to start fromos.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. |
python/copilot/client.py
Outdated
|
|
||
| Example:: | ||
|
|
||
| config = ClientConfig(env={"COPILOT_API_URL": "http://proxy:8080"}) |
There was a problem hiding this comment.
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.
| config = ClientConfig(env={"COPILOT_API_URL": "http://proxy:8080"}) | |
| config = SubprocessConfig(env={"COPILOT_API_URL": "http://proxy:8080"}) |
nodejs/src/client.ts
Outdated
| // Users who need a fully-isolated environment can pass { ...process.env } as a | ||
| // base themselves, but that case is rare and explicit. |
There was a problem hiding this comment.
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).
| // 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. |
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. |
There was a problem hiding this comment.
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.
| - `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. |
| [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, | ||
| }); |
There was a problem hiding this comment.
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.
|
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>
Problem
Fixes PureWeen/PolyPilot#441.
In the .NET SDK,
CopilotClientOptions.Environmentsilently clears and replaces the entire child process environment instead of merging user-specified keys with the inherited environment.When a .NET embedder sets
Environmentto augmentPATHor add a few variables, the SDK callsstartInfo.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 withFile not found: (empty path)because the shell executable can't be resolved without these variables.Before (broken):
Fix
Remove the
Environment.Clear()call. In .NET,ProcessStartInfo.Environmentis 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):
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
ProcessStartInfo.Environmentis pre-seeded{ ...process.env, ...options.env })dict(os.environ); env.update(cfg.env))options.Envoros.Environ())append(os.Environ(), ...)Testing
EnvironmentTestsin .NET prove merge semantics via public API only (StartAsync+PingAsync)References