From d88d00df0d34df982a0a98f992837c470aac6785 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Wed, 25 Mar 2026 23:44:49 +0100 Subject: [PATCH] fix: make copilot CLI path resolution cross-platform Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/CopilotCliPathResolver.cs | 40 ++++++++++++++++++- .../CopilotCliPathResolverTests.cs | 28 +++++++++++-- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/sidecar/src/Eryx.AgentHost/Services/CopilotCliPathResolver.cs b/sidecar/src/Eryx.AgentHost/Services/CopilotCliPathResolver.cs index 4ede267..a4f7a84 100644 --- a/sidecar/src/Eryx.AgentHost/Services/CopilotCliPathResolver.cs +++ b/sidecar/src/Eryx.AgentHost/Services/CopilotCliPathResolver.cs @@ -8,6 +8,10 @@ internal static class CopilotCliPathResolver private const string CopilotCommandName = "copilot"; private const string DefaultWindowsCommandProcessor = "cmd.exe"; private const string DefaultWindowsPathExtensions = ".COM;.EXE;.BAT;.CMD"; + private const char WindowsSearchPathSeparator = ';'; + private const char UnixSearchPathSeparator = ':'; + private const char WindowsDirectorySeparator = '\\'; + private const char UnixDirectorySeparator = '/'; private static readonly string[] BlockedCliEnvironmentPrefixes = ["BUN_", "COPILOT_", "ELECTRON_", "NODE_", "NPM_"]; @@ -125,7 +129,7 @@ internal static class CopilotCliPathResolver { foreach (string candidateName in GetCandidateFileNames(pathExtValue, isWindows)) { - string candidatePath = Path.Combine(directory, candidateName); + string candidatePath = CombineSearchPath(directory, candidateName, isWindows); if (fileExists(candidatePath)) { return candidatePath; @@ -145,7 +149,7 @@ internal static class CopilotCliPathResolver StringComparer comparer = isWindows ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; foreach (string directory in pathValue - .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Split(GetSearchPathSeparator(isWindows), StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(segment => segment.Trim('"')) .Where(segment => !string.IsNullOrWhiteSpace(segment)) .Distinct(comparer)) @@ -184,6 +188,38 @@ internal static class CopilotCliPathResolver } } + private static char GetSearchPathSeparator(bool isWindows) + { + return isWindows ? WindowsSearchPathSeparator : UnixSearchPathSeparator; + } + + private static string CombineSearchPath(string directory, string fileName, bool isWindows) + { + if (string.IsNullOrEmpty(directory)) + { + return fileName; + } + + if (EndsWithDirectorySeparator(directory, isWindows)) + { + return directory + fileName; + } + + return directory + GetDirectorySeparator(isWindows) + fileName; + } + + private static bool EndsWithDirectorySeparator(string path, bool isWindows) + { + char lastCharacter = path[^1]; + return lastCharacter == GetDirectorySeparator(isWindows) + || (isWindows && lastCharacter == UnixDirectorySeparator); + } + + private static char GetDirectorySeparator(bool isWindows) + { + return isWindows ? WindowsDirectorySeparator : UnixDirectorySeparator; + } + private static IEnumerable> GetCurrentEnvironmentVariables() { return Environment.GetEnvironmentVariables() diff --git a/sidecar/tests/Eryx.AgentHost.Tests/CopilotCliPathResolverTests.cs b/sidecar/tests/Eryx.AgentHost.Tests/CopilotCliPathResolverTests.cs index c717681..d8cf95e 100644 --- a/sidecar/tests/Eryx.AgentHost.Tests/CopilotCliPathResolverTests.cs +++ b/sidecar/tests/Eryx.AgentHost.Tests/CopilotCliPathResolverTests.cs @@ -8,9 +8,10 @@ public sealed class CopilotCliPathResolverTests public void Resolve_UsesCopilotFromPath() { string copilotDirectory = @"C:\tools\copilot"; + string expectedCliPath = @"C:\tools\copilot\copilot.exe"; HashSet existingFiles = new(StringComparer.OrdinalIgnoreCase) { - Path.Combine(copilotDirectory, "copilot.exe"), + expectedCliPath, }; string? cliPath = CopilotCliPathResolver.Resolve( @@ -19,16 +20,17 @@ public sealed class CopilotCliPathResolverTests isWindows: true, fileExists: existingFiles.Contains); - Assert.Equal(Path.Combine(copilotDirectory, "copilot.exe"), cliPath, ignoreCase: true); + Assert.Equal(expectedCliPath, cliPath, ignoreCase: true); } [Fact] public void Resolve_UsesDefaultWindowsExtensionsWhenPathExtMissing() { string copilotDirectory = @"C:\tools\copilot"; + string expectedCliPath = @"C:\tools\copilot\copilot.cmd"; HashSet existingFiles = new(StringComparer.OrdinalIgnoreCase) { - Path.Combine(copilotDirectory, "copilot.cmd"), + expectedCliPath, }; string? cliPath = CopilotCliPathResolver.Resolve( @@ -37,7 +39,25 @@ public sealed class CopilotCliPathResolverTests isWindows: true, fileExists: existingFiles.Contains); - Assert.Equal(Path.Combine(copilotDirectory, "copilot.cmd"), cliPath, ignoreCase: true); + Assert.Equal(expectedCliPath, cliPath, ignoreCase: true); + } + + [Fact] + public void Resolve_UsesCopilotFromPathOutsideWindows() + { + const string expectedCliPath = "/usr/local/bin/copilot"; + HashSet existingFiles = new(StringComparer.Ordinal) + { + expectedCliPath, + }; + + string? cliPath = CopilotCliPathResolver.Resolve( + pathValue: "/usr/bin:/usr/local/bin", + pathExtValue: null, + isWindows: false, + fileExists: existingFiles.Contains); + + Assert.Equal(expectedCliPath, cliPath); } [Fact]