From 56c81fe053e065e6b90db3a103ee3a556ce86161 Mon Sep 17 00:00:00 2001 From: Copilot CLI Date: Sat, 21 Mar 2026 10:44:00 +0100 Subject: [PATCH] fix: shell-launch copilot on windows Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/CopilotCliPathResolver.cs | 66 ++++++++++++++++- .../CopilotCliPathResolverTests.cs | 71 +++++++++++++++++++ src/main/sidecar/sidecarEnvironment.ts | 4 +- tests/main/sidecarEnvironment.test.ts | 6 ++ 4 files changed, 144 insertions(+), 3 deletions(-) diff --git a/sidecar/src/Kopaya.AgentHost/Services/CopilotCliPathResolver.cs b/sidecar/src/Kopaya.AgentHost/Services/CopilotCliPathResolver.cs index 9e1f921..18d5b06 100644 --- a/sidecar/src/Kopaya.AgentHost/Services/CopilotCliPathResolver.cs +++ b/sidecar/src/Kopaya.AgentHost/Services/CopilotCliPathResolver.cs @@ -1,3 +1,4 @@ +using System.Collections; using GitHub.Copilot.SDK; namespace Kopaya.AgentHost.Services; @@ -6,6 +7,7 @@ internal static class CopilotCliPathResolver { private const string CopilotCommandName = "copilot"; private const string DefaultWindowsPathExtensions = ".COM;.EXE;.BAT;.CMD"; + private static readonly string[] BlockedCliEnvironmentPrefixes = ["BUN_", "COPILOT_", "ELECTRON_", "NODE_", "NPM_"]; public static CopilotClientOptions CreateClientOptions() { @@ -21,9 +23,16 @@ internal static class CopilotCliPathResolver "Kopaya requires the system-installed 'copilot' command on PATH. Install the GitHub Copilot CLI and ensure it is available in the current environment."); } + CopilotCliLaunch launch = ResolveCliLaunch( + cliPath, + OperatingSystem.IsWindows(), + Environment.GetEnvironmentVariable("ComSpec")); + return new CopilotClientOptions { - CliPath = cliPath, + CliPath = launch.Path, + CliArgs = launch.Args, + Environment = ResolveCliEnvironment(GetCurrentEnvironmentVariables()), }; } @@ -37,6 +46,50 @@ internal static class CopilotCliPathResolver return ResolveCliPath(pathValue, pathExtValue, isWindows, fileExists); } + internal static IReadOnlyDictionary ResolveCliEnvironment( + IEnumerable> environmentVariables) + { + ArgumentNullException.ThrowIfNull(environmentVariables); + + Dictionary sanitizedEnvironment = new(StringComparer.OrdinalIgnoreCase); + + foreach (KeyValuePair entry in environmentVariables) + { + if (string.IsNullOrWhiteSpace(entry.Key) || entry.Value is null) + { + continue; + } + + string normalizedKey = entry.Key.ToUpperInvariant(); + if (BlockedCliEnvironmentPrefixes.Any(prefix => normalizedKey.StartsWith(prefix, StringComparison.Ordinal))) + { + continue; + } + + sanitizedEnvironment[entry.Key] = entry.Value; + } + + return sanitizedEnvironment; + } + + internal static CopilotCliLaunch ResolveCliLaunch(string cliPath, bool isWindows, string? commandProcessorPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(cliPath); + + if (!isWindows) + { + return new CopilotCliLaunch(cliPath, []); + } + + string launchPath = string.IsNullOrWhiteSpace(commandProcessorPath) + ? "cmd.exe" + : commandProcessorPath; + + return new CopilotCliLaunch( + launchPath, + ["/d", "/s", "/c", CopilotCommandName]); + } + private static string? ResolveCliPath( string? pathValue, string? pathExtValue, @@ -98,4 +151,15 @@ internal static class CopilotCliPathResolver } } } + + private static IEnumerable> GetCurrentEnvironmentVariables() + { + return Environment.GetEnvironmentVariables() + .Cast() + .Select(entry => new KeyValuePair( + entry.Key?.ToString() ?? string.Empty, + entry.Value?.ToString())); + } } + +internal sealed record CopilotCliLaunch(string Path, string[] Args); diff --git a/sidecar/tests/Kopaya.AgentHost.Tests/CopilotCliPathResolverTests.cs b/sidecar/tests/Kopaya.AgentHost.Tests/CopilotCliPathResolverTests.cs index de20d35..7e2927a 100644 --- a/sidecar/tests/Kopaya.AgentHost.Tests/CopilotCliPathResolverTests.cs +++ b/sidecar/tests/Kopaya.AgentHost.Tests/CopilotCliPathResolverTests.cs @@ -51,4 +51,75 @@ public sealed class CopilotCliPathResolverTests Assert.Null(cliPath); } + + [Fact] + public void ResolveCliEnvironment_RemovesRuntimeSpecificPrefixes() + { + IReadOnlyDictionary environment = CopilotCliPathResolver.ResolveCliEnvironment( + [ + new KeyValuePair("PATH", @"C:\tools"), + new KeyValuePair("APPDATA", @"C:\Users\mail\AppData\Roaming"), + new KeyValuePair("COPILOT_CLI", "1"), + new KeyValuePair("NODE_OPTIONS", "--no-warnings"), + new KeyValuePair("electron_run_as_node", "1"), + new KeyValuePair("BUN_FAKE_FLAG", "1"), + new KeyValuePair("npm_config_user_agent", "bun/1.3.6"), + ]); + + Assert.Equal( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["PATH"] = @"C:\tools", + ["APPDATA"] = @"C:\Users\mail\AppData\Roaming", + }, + environment); + } + + [Fact] + public void ResolveCliEnvironment_PreservesUnrelatedVariables() + { + IReadOnlyDictionary environment = CopilotCliPathResolver.ResolveCliEnvironment( + [ + new KeyValuePair("PATH", @"C:\tools"), + new KeyValuePair("HOME", @"C:\Users\mail"), + new KeyValuePair("HTTPS_PROXY", "http://proxy.local:8080"), + new KeyValuePair("FORCE_COLOR", "1"), + ]); + + Assert.Equal( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["PATH"] = @"C:\tools", + ["HOME"] = @"C:\Users\mail", + ["HTTPS_PROXY"] = "http://proxy.local:8080", + ["FORCE_COLOR"] = "1", + }, + environment); + } + + [Fact] + public void ResolveCliLaunch_UsesCommandProcessorWrapperOnWindows() + { + CopilotCliLaunch launch = CopilotCliPathResolver.ResolveCliLaunch( + cliPath: @"C:\Tools With Spaces\copilot.exe", + isWindows: true, + commandProcessorPath: @"C:\Windows\System32\cmd.exe"); + + Assert.Equal(@"C:\Windows\System32\cmd.exe", launch.Path); + Assert.Equal( + ["/d", "/s", "/c", "copilot"], + launch.Args); + } + + [Fact] + public void ResolveCliLaunch_UsesCliDirectlyOutsideWindows() + { + CopilotCliLaunch launch = CopilotCliPathResolver.ResolveCliLaunch( + cliPath: "/usr/local/bin/copilot", + isWindows: false, + commandProcessorPath: null); + + Assert.Equal("/usr/local/bin/copilot", launch.Path); + Assert.Empty(launch.Args); + } } diff --git a/src/main/sidecar/sidecarEnvironment.ts b/src/main/sidecar/sidecarEnvironment.ts index 12f61d7..d13b0a7 100644 --- a/src/main/sidecar/sidecarEnvironment.ts +++ b/src/main/sidecar/sidecarEnvironment.ts @@ -1,4 +1,4 @@ -const blockedEnvironmentKeys = new Set(['ELECTRON_RUN_AS_NODE', 'NODE_OPTIONS']); +const blockedEnvironmentPrefixes = ['BUN_', 'COPILOT_', 'ELECTRON_', 'NODE_', 'NPM_']; export function createSidecarEnvironment(baseEnvironment: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const sanitizedEnvironment: NodeJS.ProcessEnv = {}; @@ -6,7 +6,7 @@ export function createSidecarEnvironment(baseEnvironment: NodeJS.ProcessEnv): No for (const [name, value] of Object.entries(baseEnvironment)) { const normalizedName = name.toUpperCase(); - if (normalizedName.startsWith('COPILOT_') || blockedEnvironmentKeys.has(normalizedName)) { + if (blockedEnvironmentPrefixes.some((prefix) => normalizedName.startsWith(prefix))) { continue; } diff --git a/tests/main/sidecarEnvironment.test.ts b/tests/main/sidecarEnvironment.test.ts index a50f6c1..33d60a1 100644 --- a/tests/main/sidecarEnvironment.test.ts +++ b/tests/main/sidecarEnvironment.test.ts @@ -7,13 +7,17 @@ describe('createSidecarEnvironment', () => { expect( createSidecarEnvironment({ PATH: 'C:\\tools', + APPDATA: 'C:\\Users\\mail\\AppData\\Roaming', COPILOT_CLI: '1', COPILOT_LOADER_PID: '1234', copilot_run_app: '1', + bun_install: 'C:\\Users\\mail\\.bun', NODE_OPTIONS: '--no-warnings', electron_run_as_node: '1', + npm_config_user_agent: 'bun/1.3.6', }), ).toEqual({ + APPDATA: 'C:\\Users\\mail\\AppData\\Roaming', PATH: 'C:\\tools', }); }); @@ -24,11 +28,13 @@ describe('createSidecarEnvironment', () => { PATH: 'C:\\tools', HOME: 'C:\\Users\\mail', FORCE_COLOR: '1', + HTTPS_PROXY: 'http://proxy.local:8080', }), ).toEqual({ PATH: 'C:\\tools', HOME: 'C:\\Users\\mail', FORCE_COLOR: '1', + HTTPS_PROXY: 'http://proxy.local:8080', }); }); });