diff --git a/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs b/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs index d30ebed8..251469eb 100644 --- a/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs +++ b/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs @@ -50,4 +50,9 @@ public interface IProxyConfiguration bool ValidateSchemas { get; } IEnumerable WatchPids { get; set; } IEnumerable WatchProcessNames { get; set; } +} + +public interface IProcessTreeProxyConfiguration +{ + bool WatchProcessTree { get; set; } } \ No newline at end of file diff --git a/DevProxy.Integration.Tests/TestProxyConfiguration.cs b/DevProxy.Integration.Tests/TestProxyConfiguration.cs index 9733939f..4297df6e 100644 --- a/DevProxy.Integration.Tests/TestProxyConfiguration.cs +++ b/DevProxy.Integration.Tests/TestProxyConfiguration.cs @@ -11,9 +11,9 @@ namespace DevProxy.Integration.Tests; /// /// Minimal for the integration harness. Only the /// members the Kestrel engine reads at boot (Port, IPAddress, AsSystemProxy, -/// WatchPids/WatchProcessNames) actually matter; the rest carry inert defaults. +/// WatchPids/WatchProcessNames/WatchProcessTree) actually matter; the rest carry inert defaults. /// -internal sealed class TestProxyConfiguration : IProxyConfiguration +internal sealed class TestProxyConfiguration : IProxyConfiguration, IProcessTreeProxyConfiguration { public string ApiIpAddress { get; set; } = "127.0.0.1"; public int ApiPort { get; set; } @@ -35,4 +35,5 @@ internal sealed class TestProxyConfiguration : IProxyConfiguration public bool ValidateSchemas => false; public IEnumerable WatchPids { get; set; } = []; public IEnumerable WatchProcessNames { get; set; } = []; + public bool WatchProcessTree { get; set; } } diff --git a/DevProxy.Proxy.Kestrel.Tests/ProcessFilterTests.cs b/DevProxy.Proxy.Kestrel.Tests/ProcessFilterTests.cs index c4c54387..948282a5 100644 --- a/DevProxy.Proxy.Kestrel.Tests/ProcessFilterTests.cs +++ b/DevProxy.Proxy.Kestrel.Tests/ProcessFilterTests.cs @@ -13,9 +13,21 @@ public class ProcessFilterTests private static ProcessFilter Filter( IEnumerable? pids = null, IEnumerable? names = null, + bool watchProcessTree = false, Func? resolvePid = null, - Func? resolveName = null) => - new(pids ?? [], names ?? [], resolvePid, resolveName); + Func? resolveName = null, + Func? resolveStartTime = null, + Func>? resolveParentPids = null, + Func? utcNow = null) => + new( + pids ?? [], + names ?? [], + watchProcessTree, + resolvePid, + resolveName, + resolveStartTime, + resolveParentPids, + utcNow); [Fact] public void IsEmpty_True_WhenNoFilterConfigured() @@ -103,6 +115,218 @@ public void IsWatchedProcess_PidWins_WithoutResolvingName() resolveName: _ => throw new InvalidOperationException("name lookup not needed")); Assert.True(filter.IsWatchedProcess(54321)); } + + [Fact] + public void IsWatchedProcess_False_ForChild_WhenProcessTreeDisabled() + { + var filter = Filter( + pids: [100], + resolvePid: _ => 300, + resolveParentPids: () => new Dictionary { [300] = 200, [200] = 100 }); + + Assert.False(filter.IsWatchedProcess(54321)); + } + + [Fact] + public void IsWatchedProcess_True_WhenAncestorPidMatches() + { + var filter = Filter( + pids: [100], + watchProcessTree: true, + resolvePid: _ => 300, + resolveName: _ => null, + resolveStartTime: pid => DateTimeOffset.UnixEpoch.AddSeconds(pid), + resolveParentPids: () => new Dictionary { [300] = 200, [200] = 100 }); + + Assert.True(filter.IsWatchedProcess(54321)); + } + + [Fact] + public void IsWatchedProcess_True_WhenAncestorNameMatches() + { + var names = new Dictionary + { + [100] = "Visual Studio Code", + [200] = "extension-host", + [300] = "node" + }; + var filter = Filter( + names: ["Visual Studio Code"], + watchProcessTree: true, + resolvePid: _ => 300, + resolveName: pid => names[pid], + resolveStartTime: pid => DateTimeOffset.UnixEpoch.AddSeconds(pid), + resolveParentPids: () => new Dictionary { [300] = 200, [200] = 100 }); + + Assert.True(filter.IsWatchedProcess(54321)); + } + + [Fact] + public void IsWatchedProcess_False_WhenNoAncestorMatches() + { + var filter = Filter( + names: ["Visual Studio Code"], + watchProcessTree: true, + resolvePid: _ => 300, + resolveName: pid => pid == 100 ? "terminal" : "node", + resolveStartTime: pid => DateTimeOffset.UnixEpoch.AddSeconds(pid), + resolveParentPids: () => new Dictionary { [300] = 200, [200] = 100 }); + + Assert.False(filter.IsWatchedProcess(54321)); + } + + [Fact] + public void IsWatchedProcess_UsesCachedDecision_ForSameProcessInstance() + { + var parentResolutionCount = 0; + var filter = Filter( + pids: [100], + watchProcessTree: true, + resolvePid: _ => 300, + resolveName: _ => null, + resolveStartTime: pid => DateTimeOffset.UnixEpoch.AddSeconds(pid), + resolveParentPids: () => + { + parentResolutionCount++; + return new Dictionary { [300] = 200, [200] = 100 }; + }); + + Assert.True(filter.IsWatchedProcess(54321)); + Assert.True(filter.IsWatchedProcess(54322)); + Assert.Equal(1, parentResolutionCount); + } + + [Fact] + public void IsWatchedProcess_DoesNotReuseCache_WhenPidIsReused() + { + var startTime = DateTimeOffset.UnixEpoch; + var parents = new Dictionary { [300] = 100 }; + var filter = Filter( + pids: [100], + watchProcessTree: true, + resolvePid: _ => 300, + resolveName: _ => null, + resolveStartTime: _ => startTime, + resolveParentPids: () => parents); + + Assert.True(filter.IsWatchedProcess(54321)); + + startTime = startTime.AddMinutes(1); + parents = new Dictionary { [300] = 400 }; + + Assert.False(filter.IsWatchedProcess(54322)); + } + + [Fact] + public void IsWatchedProcess_False_WhenAncestorPidWasReused() + { + var processStartTimes = new Dictionary + { + [300] = DateTimeOffset.UnixEpoch.AddMinutes(1), + // PID 100 is listed as the creator of 300, but the process currently + // owning that PID started later and is therefore unrelated. + [100] = DateTimeOffset.UnixEpoch.AddMinutes(2) + }; + var filter = Filter( + names: ["Visual Studio Code"], + watchProcessTree: true, + resolvePid: _ => 300, + resolveName: pid => pid == 100 ? "Visual Studio Code" : "node", + resolveStartTime: pid => processStartTimes[pid], + resolveParentPids: () => new Dictionary { [300] = 100 }); + + Assert.False(filter.IsWatchedProcess(54321)); + } + + [Fact] + public void IsWatchedProcess_False_WhenAncestorStartTimeCannotBeResolved() + { + var filter = Filter( + pids: [100], + watchProcessTree: true, + resolvePid: _ => 300, + resolveName: _ => null, + resolveStartTime: pid => pid == 300 ? DateTimeOffset.UnixEpoch : null, + resolveParentPids: () => new Dictionary { [300] = 100 }); + + Assert.False(filter.IsWatchedProcess(54321)); + } + + [Fact] + public void IsWatchedProcess_RefreshesExpiredCacheEntry() + { + var now = DateTimeOffset.UnixEpoch; + var parentResolutionCount = 0; + var filter = Filter( + pids: [100], + watchProcessTree: true, + resolvePid: _ => 300, + resolveName: _ => null, + resolveStartTime: pid => DateTimeOffset.UnixEpoch.AddSeconds(pid), + resolveParentPids: () => + { + parentResolutionCount++; + return new Dictionary { [300] = 100 }; + }, + utcNow: () => now); + + Assert.True(filter.IsWatchedProcess(54321)); + + now = now.AddMinutes(2); + + Assert.True(filter.IsWatchedProcess(54322)); + Assert.Equal(2, parentResolutionCount); + } + + [Fact] + public void IsWatchedProcess_StopsWhenParentGraphContainsCycle() + { + var filter = Filter( + pids: [100], + watchProcessTree: true, + resolvePid: _ => 300, + resolveName: _ => null, + resolveStartTime: pid => DateTimeOffset.UnixEpoch.AddSeconds(pid), + resolveParentPids: () => new Dictionary { [300] = 200, [200] = 300 }); + + Assert.False(filter.IsWatchedProcess(54321)); + } +} + +public class ProcessTreeResolverTests +{ + [Fact] + public void ParsePsOutput_ReturnsParentRelationships() + { + const string output = """ + 1 0 + 100 1 + 200 100 + 300 200 + """; + + var parents = ProcessTreeResolver.ParsePsOutput(output); + + Assert.Equal(0, parents[1]); + Assert.Equal(1, parents[100]); + Assert.Equal(100, parents[200]); + Assert.Equal(200, parents[300]); + } + + [Fact] + public void ParsePsOutput_IgnoresMalformedLines() + { + const string output = """ + PID PPID + invalid + 200 100 + """; + + var parents = ProcessTreeResolver.ParsePsOutput(output); + + Assert.Single(parents); + Assert.Equal(100, parents[200]); + } } public class LsofParserTests diff --git a/DevProxy.Proxy.Kestrel/Internal/ProcessFilter.cs b/DevProxy.Proxy.Kestrel/Internal/ProcessFilter.cs index 3066c557..ee169333 100644 --- a/DevProxy.Proxy.Kestrel/Internal/ProcessFilter.cs +++ b/DevProxy.Proxy.Kestrel/Internal/ProcessFilter.cs @@ -2,9 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Collections.Concurrent; using System.Diagnostics; using System.Globalization; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; using System.Text.RegularExpressions; +using Microsoft.Win32.SafeHandles; namespace DevProxy.Proxy.Kestrel.Internal; @@ -22,31 +26,48 @@ namespace DevProxy.Proxy.Kestrel.Internal; /// /// /// -/// The PID resolver and name resolver are injectable so the decision logic can be -/// unit-tested without spawning real processes; the defaults shell out to -/// and . +/// The PID, process metadata, and process-tree resolvers are injectable so the decision +/// logic can be unit-tested without querying real processes; the defaults shell out to +/// / ps, use the Windows process snapshot +/// API, and read metadata through . /// /// internal sealed class ProcessFilter { + private static readonly TimeSpan CacheLifetime = TimeSpan.FromMinutes(1); + private const int MaxCacheEntries = 4096; + private readonly HashSet _pids; // Ordinal (case-sensitive) to match the Titanium engine's IEnumerable.Contains. private readonly HashSet _names; + private readonly bool _watchProcessTree; private readonly Func _resolvePid; private readonly Func _resolveName; + private readonly Func _resolveStartTime; + private readonly Func> _resolveParentPids; + private readonly Func _utcNow; + private readonly ConcurrentDictionary _cache = new(); public ProcessFilter( IEnumerable watchPids, IEnumerable watchProcessNames, + bool watchProcessTree = false, Func? resolvePid = null, - Func? resolveName = null) + Func? resolveName = null, + Func? resolveStartTime = null, + Func>? resolveParentPids = null, + Func? utcNow = null) { ArgumentNullException.ThrowIfNull(watchPids); ArgumentNullException.ThrowIfNull(watchProcessNames); _pids = [.. watchPids]; _names = new HashSet(watchProcessNames, StringComparer.Ordinal); + _watchProcessTree = watchProcessTree; _resolvePid = resolvePid ?? ConnectionProcessResolver.ResolveProcessId; _resolveName = resolveName ?? DefaultResolveName; + _resolveStartTime = resolveStartTime ?? DefaultResolveStartTime; + _resolveParentPids = resolveParentPids ?? ProcessTreeResolver.ResolveParentProcessIds; + _utcNow = utcNow ?? (() => DateTimeOffset.UtcNow); } /// True when no pid/name filter is configured — every process is watched. @@ -75,30 +96,294 @@ public bool IsWatchedProcess(int clientPort) return true; } - if (_names.Count > 0) + if (MatchesProcessName(pid.Value)) + { + return true; + } + + if (!_watchProcessTree) + { + return false; + } + + var cacheKeys = new List(); + var processKey = ResolveCacheKey(pid.Value); + if (processKey is null) + { + // Process start time is required to validate that each parent PID still + // identifies the process that originally created its child. + return false; + } + + if (TryGetCachedDecision(processKey.Value, out var cached)) + { + return cached; + } + cacheKeys.Add(processKey.Value); + + var parentPids = _resolveParentPids(); + if (parentPids.Count == 0) { - var name = _resolveName(pid.Value); - if (name is not null && _names.Contains(name)) + return false; + } + + var visited = new HashSet { pid.Value }; + var currentPid = pid.Value; + var currentKey = processKey.Value; + var isWatched = false; + while (parentPids.TryGetValue(currentPid, out var parentPid) && + parentPid > 0 && + visited.Add(parentPid)) + { + var parentKey = ResolveCacheKey(parentPid); + if (parentKey is null || + parentKey.Value.StartTimeUtcTicks > currentKey.StartTimeUtcTicks) + { + // PROCESSENTRY32 and similar process tables retain only the creator PID. + // If that PID was reused after the child started, it is no longer an + // ancestor. Missing start times cannot establish a safe relationship. + break; + } + + if (TryGetCachedDecision(parentKey.Value, out cached)) + { + isWatched = cached; + break; + } + cacheKeys.Add(parentKey.Value); + + if (_pids.Contains(parentPid) || MatchesProcessName(parentPid)) { + isWatched = true; + break; + } + + currentPid = parentPid; + currentKey = parentKey.Value; + } + + foreach (var key in cacheKeys) + { + CacheDecision(key, isWatched); + } + + return isWatched; + } + + private bool MatchesProcessName(int pid) + { + if (_names.Count == 0) + { + return false; + } + + var name = _resolveName(pid); + return name is not null && _names.Contains(name); + } + + private ProcessCacheKey? ResolveCacheKey(int pid) + { + var startTime = _resolveStartTime(pid); + return startTime is null ? null : new(pid, startTime.Value.UtcTicks); + } + + private bool TryGetCachedDecision(ProcessCacheKey key, out bool isWatched) + { + if (_cache.TryGetValue(key, out var entry)) + { + if (entry.ExpiresAt > _utcNow()) + { + isWatched = entry.IsWatched; return true; } + + _ = _cache.TryRemove(key, out _); } + isWatched = false; return false; } + private void CacheDecision(ProcessCacheKey key, bool isWatched) + { + var now = _utcNow(); + if (_cache.Count >= MaxCacheEntries) + { + foreach (var entry in _cache) + { + if (entry.Value.ExpiresAt <= now) + { + _ = _cache.TryRemove(entry.Key, out _); + } + } + + if (_cache.Count >= MaxCacheEntries) + { + _cache.Clear(); + } + } + + _cache[key] = new(isWatched, now.Add(CacheLifetime)); + } + private static string? DefaultResolveName(int pid) { try { - return Process.GetProcessById(pid).ProcessName; + using var process = Process.GetProcessById(pid); + return process.ProcessName; + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or System.ComponentModel.Win32Exception) + { + // Process exited or its metadata is unavailable. + return null; + } + } + + private static DateTimeOffset? DefaultResolveStartTime(int pid) + { + try + { + using var process = Process.GetProcessById(pid); + return process.StartTime.ToUniversalTime(); } - catch (ArgumentException) + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or System.ComponentModel.Win32Exception) { - // Process has already exited. return null; } } + + private readonly record struct ProcessCacheKey(int Pid, long StartTimeUtcTicks); + private readonly record struct CachedDecision(bool IsWatched, DateTimeOffset ExpiresAt); +} + +/// +/// Captures the current process parent relationships. A single snapshot is used for each +/// uncached process-tree decision so walking a deep hierarchy does not repeatedly query the OS. +/// +internal static class ProcessTreeResolver +{ + public static IReadOnlyDictionary ResolveParentProcessIds() + { + try + { + return OperatingSystem.IsWindows() + ? ResolveWindowsParentProcessIds() + : ResolveUnixParentProcessIds(); + } + catch (Exception ex) when (ex is InvalidOperationException or IOException or System.ComponentModel.Win32Exception) + { + return new Dictionary(); + } + } + + internal static Dictionary ParsePsOutput(string output) + { + ArgumentNullException.ThrowIfNull(output); + + var parentPids = new Dictionary(); + foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2 && + int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var pid) && + int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parentPid)) + { + parentPids[pid] = parentPid; + } + } + + return parentPids; + } + + private static Dictionary ResolveUnixParentProcessIds() + { + var startInfo = new ProcessStartInfo + { + FileName = "ps", + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add("-axo"); + startInfo.ArgumentList.Add("pid=,ppid="); + + using var process = Process.Start(startInfo); + if (process is null) + { + return new Dictionary(); + } + + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + return process.ExitCode == 0 + ? ParsePsOutput(output) + : new Dictionary(); + } + + [SupportedOSPlatform("windows")] + private static Dictionary ResolveWindowsParentProcessIds() + { + var parentPids = new Dictionary(); + using var snapshot = CreateToolhelp32Snapshot(Th32csSnapprocess, 0); + if (snapshot.IsInvalid) + { + return parentPids; + } + + var entry = new ProcessEntry32 + { + Size = (uint)Marshal.SizeOf() + }; + if (!Process32First(snapshot, ref entry)) + { + return parentPids; + } + + do + { + parentPids[(int)entry.ProcessId] = (int)entry.ParentProcessId; + entry.Size = (uint)Marshal.SizeOf(); + } + while (Process32Next(snapshot, ref entry)); + + return parentPids; + } + + private const uint Th32csSnapprocess = 0x00000002; + private const int MaxPath = 260; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct ProcessEntry32 + { + public uint Size; + public uint Usage; + public uint ProcessId; + public IntPtr DefaultHeapId; + public uint ModuleId; + public uint Threads; + public uint ParentProcessId; + public int PriorityClassBase; + public uint Flags; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = MaxPath)] + public string ExecutableFile; + } + +#pragma warning disable SYSLIB1054 + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [DllImport("kernel32.dll", SetLastError = true)] + private static extern SafeFileHandle CreateToolhelp32Snapshot(uint flags, uint processId); + + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [DllImport("kernel32.dll", EntryPoint = "Process32FirstW", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool Process32First(SafeFileHandle snapshot, ref ProcessEntry32 entry); + + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [DllImport("kernel32.dll", EntryPoint = "Process32NextW", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool Process32Next(SafeFileHandle snapshot, ref ProcessEntry32 entry); +#pragma warning restore SYSLIB1054 } /// diff --git a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs index 68b46437..20074f97 100644 --- a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs +++ b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs @@ -54,7 +54,11 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) using var httpClient = new HttpClient(httpHandler, disposeHandler: false); var forwarder = new UpstreamForwarder(httpClient); var watchList = HostWatchList.FromUrls(urlsToWatch); - var processFilter = new ProcessFilter(configuration.WatchPids, configuration.WatchProcessNames); + var processFilter = new ProcessFilter( + configuration.WatchPids, + configuration.WatchProcessNames, + configuration is IProcessTreeProxyConfiguration processTreeConfiguration && + processTreeConfiguration.WatchProcessTree); var pipeline = new PluginPipeline( plugins, urlsToWatch, diff --git a/DevProxy.Tests/Fakes.cs b/DevProxy.Tests/Fakes.cs index 68b85382..39447f29 100644 --- a/DevProxy.Tests/Fakes.cs +++ b/DevProxy.Tests/Fakes.cs @@ -68,7 +68,7 @@ internal sealed class RecordingConsole : ISystemConsole /// Minimal ; only Output/ApiIpAddress/ApiPort/Record /// are read by the interactive console, the rest carry inert defaults. /// -internal sealed class FakeProxyConfiguration : IProxyConfiguration +internal sealed class FakeProxyConfiguration : IProxyConfiguration, IProcessTreeProxyConfiguration { public string ApiIpAddress { get; set; } = "127.0.0.1"; public int ApiPort { get; set; } = 8897; @@ -90,4 +90,5 @@ internal sealed class FakeProxyConfiguration : IProxyConfiguration public bool ValidateSchemas => false; public IEnumerable WatchPids { get; set; } = []; public IEnumerable WatchProcessNames { get; set; } = []; + public bool WatchProcessTree { get; set; } } diff --git a/DevProxy/Commands/DevProxyCommand.cs b/DevProxy/Commands/DevProxyCommand.cs index 493cd3c2..7d482748 100644 --- a/DevProxy/Commands/DevProxyCommand.cs +++ b/DevProxy/Commands/DevProxyCommand.cs @@ -31,6 +31,7 @@ sealed class DevProxyCommand : RootCommand internal const string RecordOptionName = "--record"; internal const string WatchPidsOptionName = "--watch-pids"; internal const string WatchProcessNamesOptionName = "--watch-process-names"; + internal const string WatchProcessTreeOptionName = "--watch-process-tree"; internal const string ConfigFileOptionName = "--config-file"; internal static readonly Option ConfigFileOption = new(ConfigFileOptionName, "-c") { @@ -456,6 +457,11 @@ private void ConfigureCommand() HelpName = "process-names", }; + var watchProcessTreeOption = new Option(WatchProcessTreeOptionName) + { + Description = "Also watch descendants of processes selected by --watch-pids or --watch-process-names" + }; + var noFirstRunOption = new Option(NoFirstRunOptionName) { Description = "Skip the first run experience" @@ -609,7 +615,8 @@ private void ConfigureCommand() timeoutOption, urlsToWatchOption, watchPidsOption, - watchProcessNamesOption + watchProcessNamesOption, + watchProcessTreeOption }; options.AddRange(_plugins .SelectMany(p => p.GetOptions()) @@ -704,6 +711,12 @@ private void ConfigureFromOptions(ParseResult parseResult) { _proxyConfiguration.WatchProcessNames = watchProcessNames; } + var watchProcessTree = parseResult.GetValueOrDefault(WatchProcessTreeOptionName); + if (watchProcessTree is not null && + _proxyConfiguration is IProcessTreeProxyConfiguration processTreeConfiguration) + { + processTreeConfiguration.WatchProcessTree = watchProcessTree.Value; + } var noFirstRun = parseResult.GetValueOrDefault(NoFirstRunOptionName); if (noFirstRun is not null) { diff --git a/DevProxy/Proxy/ProxyConfiguration.cs b/DevProxy/Proxy/ProxyConfiguration.cs index 02196357..27b65c73 100755 --- a/DevProxy/Proxy/ProxyConfiguration.cs +++ b/DevProxy/Proxy/ProxyConfiguration.cs @@ -8,7 +8,7 @@ namespace DevProxy.Proxy; -sealed class ProxyConfiguration : IProxyConfiguration +sealed class ProxyConfiguration : IProxyConfiguration, IProcessTreeProxyConfiguration { private readonly IConfigurationRoot _configurationRoot; @@ -51,6 +51,7 @@ public string ConfigFile public bool ValidateSchemas { get; set; } = true; public IEnumerable WatchPids { get; set; } = []; public IEnumerable WatchProcessNames { get; set; } = []; + public bool WatchProcessTree { get; set; } public ProxyConfiguration(IConfigurationRoot configurationRoot) { diff --git a/schemas/v4.0.0/rc.schema.json b/schemas/v4.0.0/rc.schema.json index e2a4e5d0..0ffcf7cf 100644 --- a/schemas/v4.0.0/rc.schema.json +++ b/schemas/v4.0.0/rc.schema.json @@ -201,6 +201,10 @@ "type": "string" } }, + "watchProcessTree": { + "type": "boolean", + "description": "Whether to also watch descendants of processes selected by watchPids or watchProcessNames." + }, "showTimestamps": { "type": "boolean", "description": "Show timestamps in log output." diff --git a/skills/dev-proxy/references/configuration.md b/skills/dev-proxy/references/configuration.md index 31cf5678..049e0318 100644 --- a/skills/dev-proxy/references/configuration.md +++ b/skills/dev-proxy/references/configuration.md @@ -135,6 +135,7 @@ Plugin-specific override: | `record` | off | `--record` | Start in recording mode | | `watchPids` | — | `--watch-pids` | Only intercept from these PIDs | | `watchProcessNames` | — | `--watch-process-names` | Only intercept from these processes | +| `watchProcessTree` | — | `--watch-process-tree` | Also intercept descendants of watched processes | | `asSystemProxy` | `true` | `--as-system-proxy` | Register as system proxy | | — | off | `--detach` | Run in detached (background) mode | | — | `text` | `--output` | Output format: `text` or `json` | @@ -273,6 +274,7 @@ Limit interception to specific processes: ```bash devproxy --watch-process-names msedge node devproxy --watch-pids 1234 5678 +devproxy --watch-process-names code --watch-process-tree ``` Or filter by request headers: