Skip to content

fix(dotnet): merge env vars instead of replacing inherited environment#1

Closed
PureWeen wants to merge 5 commits into
mainfrom
COPILOTSDK-orchestrator-f371
Closed

fix(dotnet): merge env vars instead of replacing inherited environment#1
PureWeen wants to merge 5 commits into
mainfrom
COPILOTSDK-orchestrator-f371

Conversation

@PureWeen

@PureWeen PureWeen commented Mar 31, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes the bug reported in PureWeen/PolyPilot#441 (the PureWeen/PolyPilot issue tracker, not github/copilot-sdk).

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.

Problem

When a .NET embedder sets Environment to augment PATH or add a few variables, the SDK calls startInfo.Environment.Clear() (Client.cs:1151), 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
}

Cross-SDK Comparison

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

Testing

  • All 157 .NET tests pass (6 skipped for unrelated reasons)
  • 9 new EnvironmentTests prove merge semantics via public API only (StartAsync + PingAsync)
  • 6 new Node.js env-option tests confirm merge semantics
  • Existing E2E test harness already copies full environment (E2ETestContext.GetEnvironment()), so behavior is unchanged for existing callers
  • 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 5 commits March 30, 2026 17:26
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>
…kground tasks (PolyPilot github#299)

Before this fix all four SDKs' sendAndWait/SendAndWaitAsync/send_and_wait/SendAndWait
resolved immediately on ANY session.idle event, even when backgroundTasks.agents[]
or backgroundTasks.shells[] was non-empty — indicating background agents/shells are
still running and the session is not truly idle.

Fix: only resolve when backgroundTasks is absent or both arrays are empty.

Changed files:
- nodejs/src/session.ts: check bgTasks.agents.length / bgTasks.shells.length
- dotnet/src/Session.cs: check bgTasks.Agents.Length / bgTasks.Shells.Length
- python/copilot/session.py: check len(bg_tasks.agents) / len(bg_tasks.shells)
- go/session.go: check len(bgTasks.Agents) / len(bgTasks.Shells)

Tests: added 4 Node.js unit tests in test/client.test.ts that reproduce the bug
(tests 2+3 fail before fix, pass after):
  - resolves immediately when session.idle has no backgroundTasks (baseline)
  - does NOT resolve when session.idle reports active background agents [bug repro]
  - does NOT resolve when session.idle reports active background shells
  - resolves when session.idle has empty backgroundTasks arrays

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

PureWeen commented Apr 2, 2026

Copy link
Copy Markdown
Owner Author

Closing in favor of a clean PR from fix/env-merge-semantics that contains only the env-fix changes (no session.idle contamination).

@PureWeen PureWeen closed this Apr 2, 2026
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.

1 participant