12. Environment Inheritance, Overrides and Removal
A typed environment becomes a dictionary, then a process configuration. At those boundaries the difference between an absent entry, a null entry and an empty string is operationally significant.
There are two environments to distinguish: the one used to acquire help and the one attached to an application's command. They share declarations, but have different execution paths.
Runtime precedence
The process normally inherits its host environment. BinaryWrapper then applies binding defaults overridden by the command's serialized environment.
CommandExecutor.BuildProcessSpec copies binding entries first, then command entries. It uses case-insensitive name comparison on Windows and ordinal comparison elsewhere. The inputs are not mutated and each execution receives an independent dictionary.
Three dictionary meanings
A null value in a process dictionary is an explicit removal instruction. An omitted key leaves inheritance untouched. An empty string is an explicit value.
| Dictionary state | Process effect |
|---|---|
| Key absent | Preserve earlier binding or inherited process value |
| Key mapped to null | Remove from ProcessStartInfo.Environment |
Key mapped to "" |
Keep key with an empty value |
| Key mapped to text | Override with that text |
A typed nullable string assigned null normally emits no entry. It therefore does not mean removal. A presence boolean assigned false deliberately emits a null entry, while NonEmptyEnvironmentSwitch.Disabled emits an empty entry.
This distinction belongs to conversion and generation, not to a generic “null means disabled” convention.
An execution-boundary example
The following complete offline example captures the environment delivered to a fake transport. It then applies that dictionary to a process configuration object without starting the process.
using System.Diagnostics;
using FrenchExDev.Net.BinaryWrapper;
using FrenchExDev.Net.BinaryWrapper.Lib;
using FrenchExDev.Net.BinaryWrapper.Testing;
using FrenchExDev.Net.Vagrant;
var version = SemanticVersion.Parse("2.4.9");
var binding = new BinaryBinding
{
Identifier = new BinaryIdentifier("vagrant"),
ExecutablePath = "unused",
DetectedVersion = version,
EnvironmentVariables = new Dictionary<string, string?>
{
["VAGRANT_HOME"] = "binding-home",
["VAGRANT_NO_PLUGINS"] = "1",
["VAGRANT_BOX_UPDATE_CHECK_DISABLE"] = "1",
},
};
var command = new VagrantUpCommand(version)
{
Environment = new VagrantEnvironment
{
Home = "command-home",
NoPlugins = false,
BoxUpdateCheckDisable = NonEmptyEnvironmentSwitch.Disabled,
},
};
var transport = new FakeProcessRunner();
var executor = new CommandExecutor(
new DictionaryBinaryResolver(binding), transport);
await executor.ExecuteAsync(binding.Identifier, command);
var configured = new ProcessStartInfo("unused");
configured.Environment["VAGRANT_NO_PLUGINS"] = "inherited";
ProcessEnvironment.ApplyTo(configured, transport.LastSpec!.EnvironmentVariables);
Console.WriteLine(configured.Environment["VAGRANT_HOME"]);
Console.WriteLine(configured.Environment.ContainsKey("VAGRANT_NO_PLUGINS"));
Console.WriteLine(configured.Environment["VAGRANT_BOX_UPDATE_CHECK_DISABLE"] == "");using System.Diagnostics;
using FrenchExDev.Net.BinaryWrapper;
using FrenchExDev.Net.BinaryWrapper.Lib;
using FrenchExDev.Net.BinaryWrapper.Testing;
using FrenchExDev.Net.Vagrant;
var version = SemanticVersion.Parse("2.4.9");
var binding = new BinaryBinding
{
Identifier = new BinaryIdentifier("vagrant"),
ExecutablePath = "unused",
DetectedVersion = version,
EnvironmentVariables = new Dictionary<string, string?>
{
["VAGRANT_HOME"] = "binding-home",
["VAGRANT_NO_PLUGINS"] = "1",
["VAGRANT_BOX_UPDATE_CHECK_DISABLE"] = "1",
},
};
var command = new VagrantUpCommand(version)
{
Environment = new VagrantEnvironment
{
Home = "command-home",
NoPlugins = false,
BoxUpdateCheckDisable = NonEmptyEnvironmentSwitch.Disabled,
},
};
var transport = new FakeProcessRunner();
var executor = new CommandExecutor(
new DictionaryBinaryResolver(binding), transport);
await executor.ExecuteAsync(binding.Identifier, command);
var configured = new ProcessStartInfo("unused");
configured.Environment["VAGRANT_NO_PLUGINS"] = "inherited";
ProcessEnvironment.ApplyTo(configured, transport.LastSpec!.EnvironmentVariables);
Console.WriteLine(configured.Environment["VAGRANT_HOME"]);
Console.WriteLine(configured.Environment.ContainsKey("VAGRANT_NO_PLUGINS"));
Console.WriteLine(configured.Environment["VAGRANT_BOX_UPDATE_CHECK_DISABLE"] == "");The configured Home is command-home. NoPlugins is removed. The non-empty switch is present with an empty value. The fake run proves the executor's merge boundary; the configuration-object check proves the final application of null and empty values.
Validation before applying overrides
ProcessEnvironment.ApplyTo validates all names and values before modifying the configuration. Names cannot be empty or contain equals or NUL; values cannot contain NUL.
The helper sets UseShellExecute = false and updates ProcessStartInfo.Environment. It does not serialize variables into command-line arguments. Passing a configured variable through ToArguments() would mix distinct protocols and expose the wrong invocation shape.
Only the specified entries are modified. The model is an override snapshot, not a complete isolated process environment unless a host deliberately constructs one.
Design-time environment acquisition
Consumer catalogues read only their declared process variables and preserve explicitly empty strings. Those dictionaries configure help acquisition and, where applicable, installed-version detection and option probes.
The generic container stage calls DesignEnvironment.Snapshot. Container variable names use ordinal comparison even when the host is Windows, because their semantics belong to the container environment.
DesignEnvironment.HelpArguments transports overrides with container exec arguments. Its handling preserves explicit values and removal semantics at the container boundary. Custom help runners must use this mechanism too; Git and Dotnet have specialized paths where omitting it would change collected help.
A source build's dependency environment and the environment of the binary queried for help need not be the same. Keep compiler/bootstrap configuration distinct from variables that change CLI behavior.
DotEnv has a limited role
FrenchExDev.Net.DotEnv provides DotEnvLoader for loading dotenv values. It is not the home of the generated environment record hierarchy or runtime environment semantics.
Loading a dotenv file does not by itself prove every key reached a container or child process. Follow the returned values or process environment into the consumer catalogue, runner and final process specification.
For Compose, three notions can otherwise be confused: the wrapper process's environment, Compose's interpolation configuration and the environment assigned to service containers. Setting the first can influence the second; it is not equivalent to declaring all container environment entries.
Version and mutability concerns
Typed command serialization uses TargetVersion for compatibility. Output parser selection can fall back to the binding version, but that fallback does not repair an environment object with a missing command target.
Dictionary snapshots protect executions from mutation of binding or command dictionaries after merging. Record with copies are shallow, so changing a referenced list before serialization can still change the produced values.
If an operation needs stable configuration, construct its values before execution and treat referenced collections as immutable. Environment generation explains which assignment history survives copies.
What the setting actually guarantees
An override proves the configured process received a value, subject to the operating system and transport. It does not guarantee the tool's final behavior when another flag, context, configuration file or variable has precedence inside that tool.
Docker's Context/Host interaction is one example. Effective behavior belongs in a consumer's documentation and tests. The Environment API makes that configuration explicit; it does not replace upstream configuration rules.
Source snapshot
Source inspection: 2 October 2026. These implementation paths are relative to Net/FrenchExDev in the local ecosystem checkout. The v2 changes are largely uncommitted; these paths identify inspected code, without claiming that public branch links contain it.
BinaryWrapper/src/FrenchExDev.Net.BinaryWrapper/ProcessEnvironment.csBinaryWrapper/src/FrenchExDev.Net.BinaryWrapper/Code.csBinaryWrapper/src/FrenchExDev.Net.BinaryWrapper.Design.Lib/DesignEnvironment.csBinaryWrapper/test/FrenchExDev.Net.BinaryWrapper.Tests/ProcessEnvironmentTests.csBinaryWrapper/test/FrenchExDev.Net.BinaryWrapper.Tests/CommandExecutorEnvironmentTests.csDotEnv/src/FrenchExDev.Net.DotEnv/DotEnvLoader.cs
Previous · Series index · Next