Skip to content

Commit 35cb1c0

Browse files
Add copyEnvironmentFrom for GUI app debugging via desktop process environment inheritance (#111)
* Add copyEnvironmentFrom feature for GUI app debugging support - Added copyEnvironmentFrom property to SecureShellRemoteLaunchProfile.xaml - Added QueryCopyEnvironmentFrom() to ConfigurationAggregator - Added QueryProcessEnvironmentAsync() to ISecureShellRemoteOperationsService and implementation - Modified AdapterLaunchConfiguration to fetch and merge environment variables from target process - Environment variables explicitly set in profile take precedence over copied ones * Add documentation for copyEnvironmentFrom feature * Address code review feedback: security and performance improvements * Add input validation to prevent command injection in process name * Optimize environment variable override logic to avoid O(n²) complexity * Use backward iteration with RemoveAt for efficient removal - Add support for "processName|var1;var2;var3" syntax - processName alone copies all variables (backward compatible) - processName|var1;var2 copies only specified variables - Update ConfigurationAggregator with TryParseCopyEnvironmentFrom method - Update QueryProcessEnvironmentAsync to accept optional variable filter list - Update documentation with new syntax and examples --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: MichaelKoster70 <[email protected]>
1 parent e54d7bb commit 35cb1c0

File tree

7 files changed

+255
-8
lines changed

7 files changed

+255
-8
lines changed

docs/LaunchProfile.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ The following launch profile properties are independant of the application type:
1111
"environmentVariables": {
1212
"answer": "42"
1313
},
14+
"copyEnvironmentFrom": "gnome-shell",
1415
"hostName": "192.168.1.106",
1516
"hostPort": "22",
1617
"userName": "pi",
@@ -39,6 +40,7 @@ The following launch profile properties are independant of the application type:
3940
| dotNetInstallFolderPath | no | string | Linux path | The .NET install path |
4041
| debuggerInstallFolderPath | no | string | Linux path | The vsdbg install path |
4142
| appFolderPath | no | string | Linux path | The path where the app gets deployed to |
43+
| copyEnvironmentFrom | no | string | Process name | Name of a process to copy environment variables from (e.g., gnome-shell, plasmashell) |
4244
| additionalFiles | no | string | Multiple entries separated by ';'. Each entry: 'SourcePath|TargetPath' | Additional files to deploy |
4345
| additionalDirectories | no | string | Multiple entries separated by ';'. Each entry: 'SourcePath|TargetPath' | Additional directories to deploy |
4446
| publishMode | no | enum | SelfContained/FrameworkDependant | Publish mode |
@@ -76,3 +78,74 @@ The additional files and directories to deploy can be specified in the launch pr
7678
* All relative source paths for Windows are relative to the project folder.
7779
* All relative target paths (do not begin with /) for Linux are relative to the configured `appFolderPath`.
7880
* The entries in the additionalFiles are assumed to be files, the entries in additionalDirectories are assumed to be directories.
81+
82+
## GUI Applications and Desktop Environment
83+
When debugging GUI applications that need to run inside a desktop environment, you typically need specific environment variables set. This is because an SSH session doesn't have access to the desktop environment's configuration.
84+
85+
### Copy Environment From Process
86+
The `copyEnvironmentFrom` property allows you to copy environment variables from a running process that has the correct desktop environment setup. This is particularly useful for GUI applications that need variables like:
87+
- `DISPLAY`
88+
- `WAYLAND_DISPLAY`
89+
- `DBUS_SESSION_BUS_ADDRESS`
90+
- `XDG_RUNTIME_DIR`
91+
- `XDG_CURRENT_DESKTOP`
92+
- `XAUTHORITY`
93+
94+
**Syntax:**
95+
The `copyEnvironmentFrom` property supports two formats:
96+
1. **Copy all variables**: `processName`
97+
- Example: `"gnome-shell"` - copies all environment variables from the gnome-shell process
98+
2. **Copy specific variables**: `processName|var1;var2;var3`
99+
- Example: `"gnome-shell|DISPLAY;XAUTHORITY;DBUS_SESSION_BUS_ADDRESS"` - copies only the specified variables
100+
101+
**Usage:**
102+
1. Log into the target machine through a different connection having a desktop session (e.g., remote desktop via RDP/VNC or the virtual display of a VM)
103+
2. In your launch profile, set `copyEnvironmentFrom` to the name of a desktop process, such as:
104+
- `gnome-shell` (GNOME desktop)
105+
- `plasmashell` or `ksmserver` (KDE Plasma)
106+
- `xfce4-session` (XFCE)
107+
- `sway` (Sway compositor)
108+
- `kwin_wayland` or `kwin_x11` (KWin window manager)
109+
- `Xwayland` or `Xorg` (X server)
110+
111+
**Examples:**
112+
113+
Copy all environment variables:
114+
```json
115+
{
116+
"profiles": {
117+
"SSH Remote GUI": {
118+
"commandName": "SshRemoteLaunch",
119+
"hostName": "192.168.1.106",
120+
"copyEnvironmentFrom": "gnome-shell",
121+
"environmentVariables": {
122+
"MY_CUSTOM_VAR": "value"
123+
}
124+
}
125+
}
126+
}
127+
```
128+
129+
Copy only specific environment variables:
130+
```json
131+
{
132+
"profiles": {
133+
"SSH Remote GUI": {
134+
"commandName": "SshRemoteLaunch",
135+
"hostName": "192.168.1.106",
136+
"copyEnvironmentFrom": "gnome-shell|DISPLAY;WAYLAND_DISPLAY;XAUTHORITY;DBUS_SESSION_BUS_ADDRESS",
137+
"environmentVariables": {
138+
"MY_CUSTOM_VAR": "value"
139+
}
140+
}
141+
}
142+
}
143+
```
144+
145+
**Behavior:**
146+
- The extension finds a running process with the specified name that is owned by the same user as the SSH connection
147+
- It reads all environment variables from that process (via `/proc/{pid}/environ`)
148+
- These variables are added to the debugger launch configuration
149+
- Any environment variables explicitly specified in the `environmentVariables` property will override the copied ones
150+
151+
**Note:** If the specified process is not found, the feature is silently skipped and debugging proceeds with only the explicitly configured environment variables.

src/Extension/RemoteDebuggerLauncher/ProjectSystem/Debugger/AdapterLaunchConfiguration.cs

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
using System;
99
using System.Collections.Generic;
10+
using System.Linq;
1011
using System.Threading.Tasks;
1112
using Microsoft.VisualStudio.ProjectSystem;
1213
using Microsoft.VisualStudio.ProjectSystem.Debug;
@@ -141,7 +142,7 @@ public static async Task<string> CreateFrameworkDependantAsync(ConfigurationAggr
141142
config.Args.Add($"{assemblyFileDirectory}/{assemblyFileName}");
142143
config.CurrentWorkingDirectory = workingDirectory;
143144
config.AppendCommandLineArguments(configurationAggregator);
144-
config.AppendEnvironmentVariables(configurationAggregator);
145+
await config.AppendEnvironmentVariablesAsync(configurationAggregator, remoteOperations);
145146

146147
var launchConfigurationJson = JsonConvert.SerializeObject(config);
147148

@@ -194,7 +195,7 @@ public static async Task<string> CreateSelfContainedAsync(ConfigurationAggregato
194195
config.Args.Add($"./{assemblyFileName}");
195196
config.CurrentWorkingDirectory = workingDirectory;
196197
config.AppendCommandLineArguments(configurationAggregator);
197-
config.AppendEnvironmentVariables(configurationAggregator);
198+
await config.AppendEnvironmentVariablesAsync(configurationAggregator, remoteOperations);
198199

199200
var launchConfigurationJson = JsonConvert.SerializeObject(config);
200201

@@ -260,12 +261,43 @@ private static void AppendCommandLineArguments(this LaunchConfiguration config,
260261
}
261262
}
262263

263-
private static void AppendEnvironmentVariables(this LaunchConfiguration config, ConfigurationAggregator configurationAggregator)
264+
private static async Task AppendEnvironmentVariablesAsync(this LaunchConfiguration config, ConfigurationAggregator configurationAggregator, ISecureShellRemoteOperationsService remoteOperations)
264265
{
265-
var environment = configurationAggregator.QueryEnvironmentVariables();
266-
foreach(var ev in environment)
266+
// First, check if we need to copy environment from a process
267+
var (processName, variablesToCopy) = configurationAggregator.QueryCopyEnvironmentFrom();
268+
if (!string.IsNullOrEmpty(processName))
267269
{
268-
config.Environment.Add(new EnvironmentEntry { Name = ev.Key, Value = ev.Value });
270+
var processEnv = await remoteOperations.QueryProcessEnvironmentAsync(processName, variablesToCopy);
271+
272+
// Add all environment variables from the process (filtered if specified)
273+
foreach (var kvp in processEnv)
274+
{
275+
config.Environment.Add(new EnvironmentEntry { Name = kvp.Key, Value = kvp.Value });
276+
}
277+
}
278+
279+
// Then, add/override with explicitly configured environment variables
280+
// These take precedence over copied variables
281+
var explicitEnv = configurationAggregator.QueryEnvironmentVariables();
282+
if (explicitEnv.Count > 0)
283+
{
284+
// Create a HashSet of explicit keys for efficient lookup
285+
var explicitKeys = new HashSet<string>(explicitEnv.Keys);
286+
287+
// Remove any existing entries that will be overridden
288+
for (int i = config.Environment.Count - 1; i >= 0; i--)
289+
{
290+
if (explicitKeys.Contains(config.Environment[i].Name))
291+
{
292+
config.Environment.RemoveAt(i);
293+
}
294+
}
295+
296+
// Add the explicit environment variables
297+
foreach (var ev in explicitEnv)
298+
{
299+
config.Environment.Add(new EnvironmentEntry { Name = ev.Key, Value = ev.Value });
300+
}
269301
}
270302
}
271303
}

src/Extension/RemoteDebuggerLauncher/ProjectSystem/Debugger/ConfigurationAggregator.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
// ----------------------------------------------------------------------------
77

88
using System;
9+
using System.Collections.Generic;
910
using System.Collections.Immutable;
11+
using System.Linq;
1012
using System.Net;
1113
using Microsoft.VisualStudio.ProjectSystem.Debug;
1214
using RemoteDebuggerLauncher.Shared;
@@ -341,6 +343,46 @@ public string QueryCommandLineArguments()
341343
/// </remarks>
342344
public IImmutableDictionary<string, string> QueryEnvironmentVariables() => launchProfile.EnvironmentVariables;
343345

346+
/// <summary>
347+
/// Queries and parses the copyEnvironmentFrom configuration value.
348+
/// </summary>
349+
/// <returns>A tuple containing the process name and list of specific variables to copy. Returns (string.Empty, empty list) if not configured.</returns>
350+
/// <remarks>
351+
/// The syntax is: "processName|var1;var2;var3"
352+
/// - If no pipe character is present, all variables are copied (returns empty list)
353+
/// - If pipe is present with variables, only those variables are copied
354+
/// The following configuration providers are queried, first match wins:
355+
/// - selected launch profile
356+
/// </remarks>
357+
public (string ProcessName, IReadOnlyList<string> VariablesToCopy) QueryCopyEnvironmentFrom()
358+
{
359+
var copyEnvFrom = GetOtherSetting<string>("copyEnvironmentFrom") ?? string.Empty;
360+
361+
if (!string.IsNullOrWhiteSpace(copyEnvFrom))
362+
{
363+
// Split the value into process name and variable list
364+
var parts = copyEnvFrom.Split(new[] { '|' }, 2);
365+
var processName = parts[0].Trim();
366+
367+
if (string.IsNullOrWhiteSpace(processName))
368+
{
369+
return (string.Empty, Array.Empty<string>());
370+
}
371+
372+
IReadOnlyList<string> variablesToCopy = parts.Length == 2 && !string.IsNullOrWhiteSpace(parts[1]) ?
373+
parts[1].Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries)
374+
.Select(v => v.Trim())
375+
.Where(v => !string.IsNullOrEmpty(v))
376+
.ToList()
377+
: (IReadOnlyList<string>)Array.Empty<string>();
378+
379+
return (processName, variablesToCopy);
380+
}
381+
382+
// No args configured
383+
return (string.Empty, Array.Empty<string>());
384+
}
385+
344386
/// <summary>
345387
/// Queries the flag whether to install the VS code debugger when start debugging.
346388
/// </summary>

src/Extension/RemoteDebuggerLauncher/ProjectSystem/Debugger/SecureShellRemoteLaunchProfile.xaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@
7373
</StringProperty.ValueEditors>
7474
</StringProperty>
7575

76+
<StringProperty Name="copyEnvironmentFrom"
77+
DisplayName="Copy environment from"
78+
Description="Name of a process to copy environment variables from (e.g., gnome-shell, plasmashell, Xwayland). Environment variables explicitly set above will not be overwritten."
79+
Subcategory="Remote Devices" />
80+
7681
<BoolProperty Name="launchBrowser"
7782
DisplayName="LaunchBrowser"
7883
Description="indicates that the web browser should automatically launch when debugging this project." />

src/Extension/RemoteDebuggerLauncher/RemoteOperations/ISecureShellRemoteOperationsService.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// ----------------------------------------------------------------------------
77

88
using System;
9+
using System.Collections.Generic;
910
using System.Threading.Tasks;
1011
using RemoteDebuggerLauncher.Shared;
1112

@@ -148,5 +149,13 @@ internal interface ISecureShellRemoteOperationsService
148149
/// </summary>
149150
/// <returns>The <see cref="string"/> holding the runtime ID.</returns>
150151
Task<string> GetRuntimeIdAsync();
152+
153+
/// <summary>
154+
/// Queries environment variables from a running process owned by the current user.
155+
/// </summary>
156+
/// <param name="processName">The name of the process to query.</param>
157+
/// <param name="variablesToCopy">Optional list of specific variables to copy. If null or empty, all variables are copied.</param>
158+
/// <returns>A <see cref="Task{IDictionary}"/> representing the asynchronous operation: a dictionary of environment variables, or an empty dictionary if the process is not found.</returns>
159+
Task<IDictionary<string, string>> QueryProcessEnvironmentAsync(string processName, IReadOnlyList<string> variablesToCopy = null);
151160
}
152161
}

src/Extension/RemoteDebuggerLauncher/RemoteOperations/SecureShellKeySetupService.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,12 @@ private async Task<bool> RegisterServerFingerprintWithConnectionAsync(SecureShel
188188

189189
var outputPaneWriter = OutputPaneWriterServiceAsync.Create(OutputPaneWriter);
190190

191-
outputPaneWriter.WriteLineAsync(Resources.RemoteCommandSetupSshScanProgressFingerprintSsh1, settings.UserName, settings.HostName, settings.HostPort);
192-
outputPaneWriter.WriteLineAsync(Resources.RemoteCommandSetupSshScanProgressFingerprintSsh2, arguments);
191+
await outputPaneWriter.WriteLineAsync(Resources.RemoteCommandSetupSshScanProgressFingerprintSsh1, settings.UserName, settings.HostName, settings.HostPort);
192+
await
193+
194+
195+
196+
outputPaneWriter.WriteLineAsync(Resources.RemoteCommandSetupSshScanProgressFingerprintSsh2, arguments);
193197

194198
using (var process = PseudoConsoleProcess.Start(startInfo))
195199
{

src/Extension/RemoteDebuggerLauncher/RemoteOperations/SecureShellRemoteOperationsService.cs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
// ----------------------------------------------------------------------------
77

88
using System;
9+
using System.Collections.Generic;
910
using System.IO;
1011
using System.Linq;
1112
using System.Management.Automation;
1213
using System.Management.Automation.Runspaces;
1314
using System.Net.Http;
15+
using System.Text.RegularExpressions;
1416
using System.Threading.Tasks;
1517
using Microsoft.Build.Tasks;
1618
using Microsoft.Extensions.Logging;
@@ -809,6 +811,86 @@ private async Task InstallDotnetAsync(string filePath)
809811
outputPaneWriter.WriteLine(Resources.RemoteCommandCommonSuccess);
810812
}
811813
}
814+
815+
/// <inheritdoc />
816+
public async Task<IDictionary<string, string>> QueryProcessEnvironmentAsync(string processName, IReadOnlyList<string> variablesToCopy = null)
817+
{
818+
var result = new Dictionary<string, string>();
819+
820+
if (string.IsNullOrWhiteSpace(processName))
821+
{
822+
logger.LogDebug("QueryProcessEnvironmentAsync: No processName specified, exiting");
823+
return result;
824+
}
825+
826+
try
827+
{
828+
logger.LogDebug("QueryProcessEnvironmentAsync: Querying environment from process '{ProcessName}'", processName);
829+
830+
// Escape the process name to prevent command injection
831+
// Only allow alphanumeric characters, hyphens, and underscores
832+
if (!Regex.IsMatch(processName, @"^[a-zA-Z0-9_-]+$"))
833+
{
834+
logger.LogWarning("QueryProcessEnvironmentAsync: Invalid process name '{ProcessName}' - only alphanumeric, hyphens, and underscores are allowed", processName);
835+
return result;
836+
}
837+
838+
// Find the process ID of a process with the given name owned by the current user
839+
var userName = session.Settings.UserName;
840+
var findPidCommand = $"pgrep -u {userName} -x \"{processName}\" | head -n 1";
841+
842+
var pidResult = await session.ExecuteSingleCommandAsync(findPidCommand);
843+
var pid = pidResult.Trim();
844+
845+
if (string.IsNullOrWhiteSpace(pid) || !int.TryParse(pid, out _))
846+
{
847+
logger.LogDebug("QueryProcessEnvironmentAsync: Process '{ProcessName}' not found or not owned by user '{UserName}'", processName, userName);
848+
return result;
849+
}
850+
851+
logger.LogDebug("QueryProcessEnvironmentAsync: Found process '{ProcessName}' with PID {Pid}", processName, pid);
852+
853+
// Read the environment variables from /proc/{pid}/environ
854+
var environCommand = $"cat /proc/{pid}/environ";
855+
var environResult = await session.ExecuteSingleCommandAsync(environCommand);
856+
857+
// The environ file contains null-terminated strings
858+
var envVars = environResult.Split(new char[] { '\0' }, StringSplitOptions.RemoveEmptyEntries);
859+
860+
// Create a HashSet for efficient lookup if filtering is needed
861+
HashSet<string> filterSet = null;
862+
if (variablesToCopy != null && variablesToCopy.Count > 0)
863+
{
864+
filterSet = new HashSet<string>(variablesToCopy, StringComparer.Ordinal);
865+
logger.LogDebug("QueryProcessEnvironmentAsync: Filtering to {Count} specific variables", filterSet.Count);
866+
}
867+
868+
foreach (var envVar in envVars)
869+
{
870+
var separatorIndex = envVar.IndexOf('=');
871+
if (separatorIndex > 0)
872+
{
873+
var key = envVar.Substring(0, separatorIndex);
874+
var value = envVar.Substring(separatorIndex + 1);
875+
876+
// Only add if no filter, or if the key is in the filter set
877+
if (filterSet == null || filterSet.Contains(key))
878+
{
879+
result[key] = value;
880+
}
881+
}
882+
}
883+
884+
logger.LogDebug("QueryProcessEnvironmentAsync: Successfully retrieved {Count} environment variables from process '{ProcessName}'", result.Count, processName);
885+
}
886+
catch (Exception ex)
887+
{
888+
logger.LogWarning(ex, "QueryProcessEnvironmentAsync: Failed to query environment from process '{ProcessName}'", processName);
889+
// Return empty dictionary on error - this is not a critical failure
890+
}
891+
892+
return result;
893+
}
812894
#endregion
813895
}
814896
}

0 commit comments

Comments
 (0)