// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Aspire.Cli.Processes;
using Aspire.Cli.Bundles;
using Aspire.Cli.Layout;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace Aspire.Cli.DotNet;
/// <summary>
/// Creates process executions backed by real OS processes.
/// </summary>
internal sealed class ProcessExecutionFactory : IProcessExecutionFactory
{
internal static IReadOnlyList<string> InvocationScopedEnvVarNames { get; } = [KnownConfigNames.CliAppHostSelectionOrigin];
private readonly IEnvironment _environment;
private readonly ILogger<ProcessExecutionFactory> _logger;
private readonly ILayoutDiscovery? _layoutDiscovery;
private readonly IBundleService? _bundleService;
private readonly CliExecutionContext? _executionContext;
public ProcessExecutionFactory(IEnvironment environment, ILogger<ProcessExecutionFactory> logger)
: this(environment, logger, layoutDiscovery: null, bundleService: null, executionContext: null)
{
}
public ProcessExecutionFactory(
IEnvironment environment,
ILogger<ProcessExecutionFactory> logger,
ILayoutDiscovery? layoutDiscovery,
IBundleService? bundleService,
CliExecutionContext? executionContext)
{
_environment = environment;
_logger = logger;
_layoutDiscovery = layoutDiscovery;
_bundleService = bundleService;
_executionContext = executionContext;
}
public IProcessExecution CreateExecution(string fileName, string[] args, IDictionary<string, string>? env, DirectoryInfo workingDirectory, ProcessInvocationOptions options)
{
var effectiveLogger = options.SuppressLogging ? (ILogger)NullLogger.Instance : _logger;
// `dotnet run --project AppHost.csproj -- <appHostArgs>` reaches this factory with the
// forwarded AppHost arguments still attached, so redact past the separator before logging.
// Direct AppHost launches have no separator at all and instead declare the boundary through
// ProcessInvocationOptions.AppHostArgumentStartIndex.
var loggableArgs = options.AppHostArgumentStartIndex is { } appHostArgumentStartIndex
? AppHostArgumentRedactor.RedactFromToString(args, appHostArgumentStartIndex)
: AppHostArgumentRedactor.RedactToString(args);
effectiveLogger.LogDebug("Running {FileName} in {WorkingDirectory} with args: {Args}", fileName, workingDirectory.FullName, loggableArgs);
if (env is not null)
{
foreach (var envKvp in env)
{
effectiveLogger.LogDebug("{FileName} env: {EnvKey}={EnvValue}", fileName, envKvp.Key, envKvp.Value);
}
}
var startInfo = new IsolatedProcessStartInfo
{
FileName = fileName,
WorkingDirectory = workingDirectory.FullName,
IsolateConsole = options.IsolateConsole,
KillOnParentExit = options.KillOnParentExit,
Detached = options.Detached,
DetachedUnixLauncherPath = options.DetachedUnixLauncherPathOverride,
};
foreach (var a in args)
{
startInfo.ArgumentList.Add(a);
}
// Touching Environment here snapshots the parent env on first access. Strip invocation-scoped
// values before overlaying explicit values so the detached child CLI can deliberately receive
// the marker while AppHost and build children do not inherit it.
StripIdentityEnvVars(startInfo);
StripInvocationScopedEnvVars(startInfo);
ApplyEnvironmentVariableFilter(startInfo, options.EnvironmentVariableFilter);
if (env is not null)
{
foreach (var envKvp in env)
{
startInfo.Environment[envKvp.Key] = envKvp.Value;
}
}
return Build(startInfo, fileName, effectiveLogger, options, _environment, _layoutDiscovery, _bundleService, _executionContext);
}
public IProcessExecution CreateExecution(System.Diagnostics.ProcessStartInfo startInfo, ProcessInvocationOptions options)
{
var effectiveLogger = options.SuppressLogging ? (ILogger)NullLogger.Instance : _logger;
// Same redaction boundary as the ArgumentList-building overload: anything after the first
// "--" is application input forwarded to the AppHost and must not be logged verbatim.
effectiveLogger.LogDebug("Running {FileName} in {WorkingDirectory} with args: {Args}", startInfo.FileName, startInfo.WorkingDirectory, AppHostArgumentRedactor.RedactToString(startInfo.ArgumentList));
var isolatedStartInfo = new IsolatedProcessStartInfo
{
FileName = startInfo.FileName,
WorkingDirectory = startInfo.WorkingDirectory,
IsolateConsole = options.IsolateConsole,
KillOnParentExit = options.KillOnParentExit,
Detached = options.Detached,
DetachedUnixLauncherPath = options.DetachedUnixLauncherPathOverride,
};
foreach (var arg in startInfo.ArgumentList)
{
isolatedStartInfo.ArgumentList.Add(arg);
}
// Replace (not overlay) the env block so callers that did startInfo.Environment.Remove(key)
// see that removal honored — e.g. PrebuiltAppHostServer.CreateStartInfo explicitly removes
// KnownConfigNames.IntegrationLibsPath / IntegrationProbeManifestPath when they aren't
// configured, to suppress any value the parent CLI happens to have set in its own env.
// ProcessStartInfo.Environment is eagerly snapshotted from the parent, so iterating it gives
// the authoritative "what the child should see" view; a missing key really means "do not pass
// this to the child". Start from an empty block (UseEmptyEnvironment skips the parent snapshot
// we would otherwise allocate and immediately discard) so HasCustomEnvironment is set and the
// spawn uses our explicit block rather than re-inheriting the parent.
var childEnvironment = isolatedStartInfo.UseEmptyEnvironment();
foreach (var (key, value) in startInfo.Environment)
{
// Match ProcessStartInfo.Environment semantics: a null value means "do not set this
// variable in the child" — we get there by simply not adding it.
if (value is not null)
{
childEnvironment[key] = value;
}
}
// Strip after the copy so an ASPIRE_CLI_* var the parent happens to hold is not re-introduced
// into the child via startInfo.Environment (which is parent-seeded). Same rationale as the
// other overload — see StripIdentityEnvVars.
StripIdentityEnvVars(isolatedStartInfo);
StripInvocationScopedEnvVars(isolatedStartInfo);
ApplyEnvironmentVariableFilter(isolatedStartInfo, options.EnvironmentVariableFilter);
return Build(isolatedStartInfo, startInfo.FileName, effectiveLogger, options, _environment, _layoutDiscovery, _bundleService, _executionContext);
}
// Strip ASPIRE_CLI_* identity overrides from every spawned process — both the isolated AppHost
// run path and every non-isolated subprocess. These env vars are an in-process, parent-only test
// affordance: a developer or test bench uses them to coerce the *current* CLI into pretending it
// is a different channel/version/commit or to retarget its emitted nuget.config at a local proxy.
// Letting them leak into child processes (apphost, dotnet, restore, peer probes) means any nested
// `aspire` invocation inherits the parent's lie about its identity, which silently corrupts
// `aspire doctor`, breaks peer probing, and undermines the "what is this binary actually" answer
// we want callers to see on disk. We strip before merging caller env so a caller can still re-add
// an ASPIRE_CLI_* var deliberately if a future test needs to. See docs/specs/cli-identity-sidecar.md.
private static void StripIdentityEnvVars(IsolatedProcessStartInfo startInfo)
{
foreach (var envVarName in Acquisition.IdentityResolver.IdentityEnvVarNames)
{
startInfo.Environment.Remove(envVarName);
}
}
private static void StripInvocationScopedEnvVars(IsolatedProcessStartInfo startInfo)
{
foreach (var envVarName in InvocationScopedEnvVarNames)
{
startInfo.Environment.Remove(envVarName);
}
}
private static void ApplyEnvironmentVariableFilter(IsolatedProcessStartInfo startInfo, Func<string, bool>? environmentVariableFilter)
{
if (environmentVariableFilter is null)
{
return;
}
var keysToRemove = new List<string>();
foreach (var key in startInfo.Environment.Keys)
{
if (environmentVariableFilter(key))
{
keysToRemove.Add(key);
}
}
foreach (var key in keysToRemove)
{
startInfo.Environment.Remove(key);
}
}
private static IProcessExecution Build(
IsolatedProcessStartInfo startInfo,
string fileName,
ILogger logger,
ProcessInvocationOptions options,
IEnvironment environment,
ILayoutDiscovery? layoutDiscovery,
IBundleService? bundleService,
CliExecutionContext? executionContext)
{
// Snapshot args + env now so the IProcessExecution surfaces them before StartAsync() spawns the
// child. The extension-host launch path reads Arguments / EnvironmentVariables and returns
// without ever calling StartAsync (DotNetCliRunner), so these must be valid pre-spawn.
var argsSnapshot = startInfo.ArgumentList.ToArray();
var envSnapshot = new Dictionary<string, string?>(startInfo.Environment, StringComparer.OrdinalIgnoreCase);
return new ProcessExecution(startInfo, fileName, argsSnapshot, envSnapshot, logger, options, environment, layoutDiscovery, bundleService, executionContext);
}
}