From feb08dcead88cbfbf71b5677653f266ecc5bc138 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 9 Apr 2026 15:21:31 -0500 Subject: [PATCH 1/2] Add configurable custom CLI launcher support Let users choose a custom CLI executable and optional launcher arguments for embedded and persistent modes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../IProviderHostContext.cs | 3 +- PolyPilot.Tests/CliPathResolutionTests.cs | 22 +++ PolyPilot.Tests/ConnectionSettingsTests.cs | 32 ++++- .../ProtocolVersionMismatchTests.cs | 4 +- PolyPilot.Tests/ProviderPluginTests.cs | 15 ++ PolyPilot.Tests/ServerManagerTests.cs | 30 ++++ PolyPilot.Tests/ServerRecoveryTests.cs | 4 +- PolyPilot.Tests/SettingsRegistryTests.cs | 20 +++ PolyPilot.Tests/TestStubs.cs | 6 +- PolyPilot/Components/Pages/Settings.razor | 40 +++++- PolyPilot/Models/ConnectionSettings.cs | 87 +++++++++++- .../Services/CopilotService.Utilities.cs | 6 +- PolyPilot/Services/CopilotService.cs | 79 ++++++++--- PolyPilot/Services/IServerManager.cs | 4 +- PolyPilot/Services/ProviderHostContext.cs | 10 +- PolyPilot/Services/ServerManager.cs | 132 ++++++++---------- PolyPilot/Services/SettingsRegistry.cs | 37 ++++- 17 files changed, 420 insertions(+), 111 deletions(-) diff --git a/PolyPilot.Provider.Abstractions/IProviderHostContext.cs b/PolyPilot.Provider.Abstractions/IProviderHostContext.cs index 22cd4ed0d4..8023aa809f 100644 --- a/PolyPilot.Provider.Abstractions/IProviderHostContext.cs +++ b/PolyPilot.Provider.Abstractions/IProviderHostContext.cs @@ -47,5 +47,6 @@ public enum ProviderConnectionMode public enum ProviderCliSource { BuiltIn, - System + System, + Custom } diff --git a/PolyPilot.Tests/CliPathResolutionTests.cs b/PolyPilot.Tests/CliPathResolutionTests.cs index f2623d242b..8d3795e0d5 100644 --- a/PolyPilot.Tests/CliPathResolutionTests.cs +++ b/PolyPilot.Tests/CliPathResolutionTests.cs @@ -26,6 +26,12 @@ public void CliSourceMode_System_IsOne() Assert.Equal(1, (int)CliSourceMode.System); } + [Fact] + public void CliSourceMode_Custom_IsTwo() + { + Assert.Equal(2, (int)CliSourceMode.Custom); + } + [Fact] public void CopilotClientOptions_CliPath_AcceptsNonExistentPath() { @@ -410,6 +416,22 @@ public void ResolveCopilotCliPath_System_ReturnsNonNull() "(system install or bundled fallback)"); } + [Fact] + public void ResolveCopilotCliPath_Custom_ReturnsConfiguredPath() + { + var path = CopilotService.ResolveCopilotCliPath(CliSourceMode.Custom, "/tmp/custom-copilot"); + + Assert.Equal("/tmp/custom-copilot", path); + } + + [Fact] + public void ResolveCopilotCliPath_Custom_Blank_ReturnsNull() + { + var path = CopilotService.ResolveCopilotCliPath(CliSourceMode.Custom, " "); + + Assert.Null(path); + } + [Fact] public void GetCliSourceInfo_ReturnsBuiltInPath() { diff --git a/PolyPilot.Tests/ConnectionSettingsTests.cs b/PolyPilot.Tests/ConnectionSettingsTests.cs index 9cd8bb0cb1..dd3a0fe5a4 100644 --- a/PolyPilot.Tests/ConnectionSettingsTests.cs +++ b/PolyPilot.Tests/ConnectionSettingsTests.cs @@ -142,6 +142,8 @@ public void DefaultValues_NewFields_AreCorrect() Assert.Null(settings.ServerPassword); Assert.False(settings.DirectSharingEnabled); Assert.Equal(CliSourceMode.BuiltIn, settings.CliSource); + Assert.Null(settings.CustomCliPath); + Assert.Null(settings.CustomCliArguments); Assert.Null(settings.RepositoryStorageRoot); } @@ -155,7 +157,9 @@ public void Save_Load_RoundTrip_WithNewFields() Port = 4321, ServerPassword = "mypass", DirectSharingEnabled = true, - CliSource = CliSourceMode.System, + CliSource = CliSourceMode.Custom, + CustomCliPath = "/custom/copilot-wrapper", + CustomCliArguments = "copilot --profile work", RepositoryStorageRoot = "D:\\DevDrive\\PolyPilot" }; @@ -165,7 +169,9 @@ public void Save_Load_RoundTrip_WithNewFields() Assert.NotNull(loaded); Assert.Equal("mypass", loaded!.ServerPassword); Assert.True(loaded.DirectSharingEnabled); - Assert.Equal(CliSourceMode.System, loaded.CliSource); + Assert.Equal(CliSourceMode.Custom, loaded.CliSource); + Assert.Equal("/custom/copilot-wrapper", loaded.CustomCliPath); + Assert.Equal("copilot --profile work", loaded.CustomCliArguments); Assert.Equal("D:\\DevDrive\\PolyPilot", loaded.RepositoryStorageRoot); } @@ -181,6 +187,8 @@ public void BackwardCompatibility_OldJsonWithoutNewFields() Assert.Null(loaded.ServerPassword); Assert.False(loaded.DirectSharingEnabled); Assert.Equal(CliSourceMode.BuiltIn, loaded.CliSource); + Assert.Null(loaded.CustomCliPath); + Assert.Null(loaded.CustomCliArguments); Assert.Null(loaded.RepositoryStorageRoot); } @@ -196,6 +204,26 @@ public void NormalizeRepositoryStorageRoot_TrimmedPath_ReturnsTrimmed() Assert.Equal("C:\\Dev", ConnectionSettings.NormalizeRepositoryStorageRoot(" C:\\Dev ")); } + [Fact] + public void NormalizeCustomCliPath_Whitespace_ReturnsNull() + { + Assert.Null(ConnectionSettings.NormalizeCustomCliPath(" ")); + } + + [Fact] + public void NormalizeCustomCliArguments_Trimmed_ReturnsTrimmed() + { + Assert.Equal("--flag value", ConnectionSettings.NormalizeCustomCliArguments(" --flag value ")); + } + + [Fact] + public void SplitCommandLineArguments_HandlesQuotesAndSpaces() + { + var args = ConnectionSettings.SplitCommandLineArguments("wrapper subcommand --label \"two words\" 'three words'"); + + Assert.Equal(new[] { "wrapper", "subcommand", "--label", "two words", "three words" }, args); + } + [Fact] public void ServerPassword_NotInCliUrl() { diff --git a/PolyPilot.Tests/ProtocolVersionMismatchTests.cs b/PolyPilot.Tests/ProtocolVersionMismatchTests.cs index 3fb62a7f2b..67591d90e4 100644 --- a/PolyPilot.Tests/ProtocolVersionMismatchTests.cs +++ b/PolyPilot.Tests/ProtocolVersionMismatchTests.cs @@ -37,10 +37,10 @@ public void StubServerManager_StopServer_ClearsIsServerRunning() public async Task StubServerManager_StartServerAsync_ReturnsConfiguredResult() { _serverManager.StartServerResult = true; - Assert.True(await _serverManager.StartServerAsync(4321)); + Assert.True(await _serverManager.StartServerAsync(new ConnectionSettings { Port = 4321 })); _serverManager.StartServerResult = false; - Assert.False(await _serverManager.StartServerAsync(4321)); + Assert.False(await _serverManager.StartServerAsync(new ConnectionSettings { Port = 4321 })); } [Fact] diff --git a/PolyPilot.Tests/ProviderPluginTests.cs b/PolyPilot.Tests/ProviderPluginTests.cs index 5940f86ccc..00f91d794c 100644 --- a/PolyPilot.Tests/ProviderPluginTests.cs +++ b/PolyPilot.Tests/ProviderPluginTests.cs @@ -136,6 +136,21 @@ public void ProviderHostContext_Maps_Embedded() Assert.Equal(ProviderCliSource.BuiltIn, ctx.CliSource); } + [Fact] + public void ProviderHostContext_Maps_CustomCliSource() + { + var settings = new ConnectionSettings + { + Mode = ConnectionMode.Embedded, + CliSource = CliSourceMode.Custom, + CustomCliPath = "/custom/copilot-wrapper" + }; + var ctx = new ProviderHostContext(settings); + + Assert.Equal(ProviderConnectionMode.Embedded, ctx.ConnectionMode); + Assert.Equal(ProviderCliSource.Custom, ctx.CliSource); + } + [Fact] public void ProviderHostContext_Maps_Persistent() { diff --git a/PolyPilot.Tests/ServerManagerTests.cs b/PolyPilot.Tests/ServerManagerTests.cs index cc78fa1099..0b0e036ef8 100644 --- a/PolyPilot.Tests/ServerManagerTests.cs +++ b/PolyPilot.Tests/ServerManagerTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Sockets; +using PolyPilot.Models; using PolyPilot.Services; namespace PolyPilot.Tests; @@ -88,4 +89,33 @@ public void CheckServerRunning_DefaultPort_UsesServerPort() Assert.True(defaultResult || !defaultResult); // completed without throwing Assert.False(customResult); } + + [Fact] + public void BuildLaunchArguments_PrependsCustomArgs_BeforeHeadlessFlags() + { + var settings = new ConnectionSettings + { + CliSource = CliSourceMode.Custom, + CustomCliPath = "/custom/copilot-wrapper", + CustomCliArguments = "copilot --profile work" + }; + + var args = ServerManager.BuildLaunchArguments(settings, 4567); + + Assert.Equal("copilot", args[0]); + Assert.Equal("--profile", args[1]); + Assert.Equal("work", args[2]); + Assert.Contains("--headless", args); + Assert.Contains("4567", args); + } + + [Fact] + public void CreateStartInfo_ForwardsGitHubToken() + { + var settings = new ConnectionSettings(); + var psi = ServerManager.CreateStartInfo(settings, "/tmp/copilot", 4321, "token-123"); + + Assert.Equal("/tmp/copilot", psi.FileName); + Assert.Equal("token-123", psi.Environment["COPILOT_GITHUB_TOKEN"]); + } } diff --git a/PolyPilot.Tests/ServerRecoveryTests.cs b/PolyPilot.Tests/ServerRecoveryTests.cs index 2890989f10..79809f60bb 100644 --- a/PolyPilot.Tests/ServerRecoveryTests.cs +++ b/PolyPilot.Tests/ServerRecoveryTests.cs @@ -211,7 +211,7 @@ public void ServerManager_AcceptsGitHubToken_InStartServerAsync() // Verify the stub properly records the token parameter var mgr = new StubServerManager(); mgr.StartServerResult = true; - mgr.StartServerAsync(4321, "test-token-123").GetAwaiter().GetResult(); + mgr.StartServerAsync(new ConnectionSettings { Port = 4321 }, "test-token-123").GetAwaiter().GetResult(); Assert.Equal("test-token-123", mgr.LastGitHubToken); } @@ -220,7 +220,7 @@ public void ServerManager_AcceptsNullGitHubToken_InStartServerAsync() { var mgr = new StubServerManager(); mgr.StartServerResult = true; - mgr.StartServerAsync(4321).GetAwaiter().GetResult(); + mgr.StartServerAsync(new ConnectionSettings { Port = 4321 }).GetAwaiter().GetResult(); Assert.Null(mgr.LastGitHubToken); } diff --git a/PolyPilot.Tests/SettingsRegistryTests.cs b/PolyPilot.Tests/SettingsRegistryTests.cs index 769a5d57a7..8885749edc 100644 --- a/PolyPilot.Tests/SettingsRegistryTests.cs +++ b/PolyPilot.Tests/SettingsRegistryTests.cs @@ -51,6 +51,26 @@ public void ForCategory_RespectsVisibilityPredicate() Assert.DoesNotContain(cliSettings, s => s.Id == "cli.source"); } + [Fact] + public void CustomCliSettings_Hidden_WhenSourceIsNotCustom() + { + var ctx = CreateContext(new ConnectionSettings { CliSource = CliSourceMode.BuiltIn }); + var devSettings = SettingsRegistry.ForCategory("Developer", ctx).ToList(); + + Assert.DoesNotContain(devSettings, s => s.Id == "cli.customPath"); + Assert.DoesNotContain(devSettings, s => s.Id == "cli.customArguments"); + } + + [Fact] + public void CustomCliSettings_Visible_WhenSourceIsCustom() + { + var ctx = CreateContext(new ConnectionSettings { CliSource = CliSourceMode.Custom }); + var devSettings = SettingsRegistry.ForCategory("Developer", ctx).ToList(); + + Assert.Contains(devSettings, s => s.Id == "cli.customPath"); + Assert.Contains(devSettings, s => s.Id == "cli.customArguments"); + } + [Fact] public void Search_MatchesByLabel() { diff --git a/PolyPilot.Tests/TestStubs.cs b/PolyPilot.Tests/TestStubs.cs index 3122d75f11..94ef254718 100644 --- a/PolyPilot.Tests/TestStubs.cs +++ b/PolyPilot.Tests/TestStubs.cs @@ -46,13 +46,15 @@ internal class StubServerManager : IServerManager public bool CheckServerRunning(string host = "localhost", int? port = null) => IsServerRunning; - public Task StartServerAsync(int port, string? githubToken = null) + public Task StartServerAsync(ConnectionSettings settings, string? githubToken = null) { - ServerPort = port; + ServerPort = settings.Port; LastGitHubToken = githubToken; + LastStartSettings = settings; return Task.FromResult(StartServerResult); } public string? LastGitHubToken { get; private set; } + public ConnectionSettings? LastStartSettings { get; private set; } public void StopServer() { IsServerRunning = false; StopServerCallCount++; } public int StopServerCallCount { get; private set; } diff --git a/PolyPilot/Components/Pages/Settings.razor b/PolyPilot/Components/Pages/Settings.razor index 41bc363e9d..de3b30aadd 100644 --- a/PolyPilot/Components/Pages/Settings.razor +++ b/PolyPilot/Components/Pages/Settings.razor @@ -576,7 +576,7 @@ } @if (cliSourceChanged) { -
⟳ Restart the app to apply the change
+
⟳ Reconnect or restart the persistent server to apply the change
} } @@ -831,7 +831,7 @@ { "connection" => SectionVisible("transport mode embedded persistent remote server port start stop pid devtunnel share tunnel mobile qr url token connect save reconnect"), "ui" => SectionVisible("chat message layout default reversed both left theme font size text zoom"), - "developer" => SectionVisible("auto update main git watch relaunch rebuild cli source built-in system repository repo clone worktree storage root directory dev drive"), + "developer" => SectionVisible("auto update main git watch relaunch rebuild cli source built-in system custom executable path launcher wrapper repository repo clone worktree storage root directory dev drive"), "plugins" => SectionVisible("plugins provider extension dll assembly trust enable disable"), "diagnostics" => SectionVisible("logs diagnostics troubleshoot crash event console"), _ => true @@ -1121,7 +1121,7 @@ private void SetCliSource(CliSourceMode source) { // Only block selection if no CLI is available at all (both modes have fallback logic) - if (cliInfo.builtInPath == null && cliInfo.systemPath == null) return; + if (source != CliSourceMode.Custom && cliInfo.builtInPath == null && cliInfo.systemPath == null) return; settings.CliSource = source; cliSourceChanged = source != _initialCliSource; settings.Save(); @@ -1141,6 +1141,31 @@ private static bool IsStorageRootValid(string? path) => string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path.Trim()); + private bool ValidateCustomCliConfiguration() + { + settings.CustomCliPath = ConnectionSettings.NormalizeCustomCliPath(settings.CustomCliPath); + settings.CustomCliArguments = ConnectionSettings.NormalizeCustomCliArguments(settings.CustomCliArguments); + + if (settings.CliSource != CliSourceMode.Custom + || settings.Mode == ConnectionMode.Remote + || settings.Mode == ConnectionMode.Demo) + return true; + + if (string.IsNullOrWhiteSpace(settings.CustomCliPath)) + { + ShowStatus("Enter a custom CLI path or executable name first", "error", 5000); + return false; + } + + if (Path.IsPathRooted(settings.CustomCliPath) && !File.Exists(settings.CustomCliPath)) + { + ShowStatus($"Custom CLI path not found: {settings.CustomCliPath}", "error", 8000); + return false; + } + + return true; + } + private async Task BrowseRepositoryStorageRoot() { #if MACCATALYST || WINDOWS @@ -1293,10 +1318,14 @@ private async Task StartServer() { + if (!ValidateCustomCliConfiguration()) + return; + + settings.Save(); starting = true; StateHasChanged(); - var success = await ServerManager.StartServerAsync(settings.Port); + var success = await ServerManager.StartServerAsync(settings); serverAlive = success; starting = false; @@ -1657,6 +1686,9 @@ return; } + if (!ValidateCustomCliConfiguration()) + return; + if (settings.Mode == ConnectionMode.Persistent && !serverAlive) { ShowStatus("Start the persistent server first", "error", 5000); diff --git a/PolyPilot/Models/ConnectionSettings.cs b/PolyPilot/Models/ConnectionSettings.cs index d71bff9745..0a7ca3a6ca 100644 --- a/PolyPilot/Models/ConnectionSettings.cs +++ b/PolyPilot/Models/ConnectionSettings.cs @@ -1,3 +1,4 @@ +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -41,7 +42,8 @@ public enum UiTheme public enum CliSourceMode { BuiltIn, // Use the CLI bundled with the app - System // Use the CLI installed on the system (PATH, homebrew, npm) + System, // Use the CLI installed on the system (PATH, homebrew, npm) + Custom // Use a user-specified executable path (plus optional launcher args) } public enum VsCodeVariant @@ -116,6 +118,8 @@ public string? ServerPassword public UiTheme Theme { get; set; } = UiTheme.System; public bool AutoUpdateFromMain { get; set; } = false; public CliSourceMode CliSource { get; set; } = CliSourceMode.BuiltIn; + public string? CustomCliPath { get; set; } + public string? CustomCliArguments { get; set; } public VsCodeVariant Editor { get; set; } = VsCodeVariant.Stable; public string? RepositoryStorageRoot { get; set; } public List DisabledMcpServers { get; set; } = new(); @@ -269,6 +273,8 @@ public static ConnectionSettings Load() if (!PlatformHelper.AvailableModes.Contains(settings.Mode)) settings.Mode = PlatformHelper.DefaultMode; settings.RepositoryStorageRoot = NormalizeRepositoryStorageRoot(settings.RepositoryStorageRoot); + settings.CustomCliPath = NormalizeCustomCliPath(settings.CustomCliPath); + settings.CustomCliArguments = NormalizeCustomCliArguments(settings.CustomCliArguments); NormalizeEnumFields(settings); @@ -294,6 +300,85 @@ public static ConnectionSettings Load() return path.Trim(); } + public static string? NormalizeCustomCliPath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + return null; + return path.Trim(); + } + + public static string? NormalizeCustomCliArguments(string? args) + { + if (string.IsNullOrWhiteSpace(args)) + return null; + return args.Trim(); + } + + internal static IReadOnlyList SplitCommandLineArguments(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return Array.Empty(); + + var args = new List(); + var current = new StringBuilder(); + char? quote = null; + var escaping = false; + + foreach (var ch in raw) + { + if (escaping) + { + current.Append(ch); + escaping = false; + continue; + } + + if (ch == '\\' && quote != '\'') + { + escaping = true; + continue; + } + + if (quote is not null) + { + if (ch == quote.Value) + { + quote = null; + continue; + } + + current.Append(ch); + continue; + } + + if (ch == '"' || ch == '\'') + { + quote = ch; + continue; + } + + if (char.IsWhiteSpace(ch)) + { + if (current.Length > 0) + { + args.Add(current.ToString()); + current.Clear(); + } + continue; + } + + current.Append(ch); + } + + if (escaping) + current.Append('\\'); + + if (current.Length > 0) + args.Add(current.ToString()); + + return args; + } + /// Normalize invalid enum values to safe defaults. Testable separately from Load(). internal static void NormalizeEnumFields(ConnectionSettings settings) { diff --git a/PolyPilot/Services/CopilotService.Utilities.cs b/PolyPilot/Services/CopilotService.Utilities.cs index 749857377f..f76eadba53 100644 --- a/PolyPilot/Services/CopilotService.Utilities.cs +++ b/PolyPilot/Services/CopilotService.Utilities.cs @@ -918,8 +918,8 @@ private async Task> GetSdkAvailableModelsAsync() private async Task> GetDirectCliAvailableModelsAsync() { - var cliPath = ResolveCopilotCliPath(_currentSettings?.CliSource ?? CliSourceMode.BuiltIn); - if (string.IsNullOrWhiteSpace(cliPath) || !File.Exists(cliPath)) + var cliPath = ResolveCopilotCliPath(_currentSettings?.CliSource ?? CliSourceMode.BuiltIn, _currentSettings?.CustomCliPath); + if (string.IsNullOrWhiteSpace(cliPath) || (Path.IsPathRooted(cliPath) && !File.Exists(cliPath))) return Array.Empty(); var tempDir = Path.Combine(Path.GetTempPath(), "polypilot-model-probe"); @@ -936,6 +936,8 @@ private async Task> GetDirectCliAvailableModelsAsync() CreateNoWindow = true, WorkingDirectory = tempDir }; + foreach (var arg in GetCustomCliArgs(_currentSettings)) + process.StartInfo.ArgumentList.Add(arg); process.StartInfo.ArgumentList.Add("-p"); process.StartInfo.ArgumentList.Add("Return exactly the list of model IDs shown in the /model Available tab as a JSON array. Use the current CLI runtime model availability. Do not inspect files or use tools. Return only JSON."); process.StartInfo.ArgumentList.Add("--add-dir"); diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 5e8d14e54e..c7bbf10b61 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -357,8 +357,7 @@ public void ClearAuthNotice() /// Returns the full `copilot login` command using the resolved CLI path. public string GetLoginCommand() { - var cliPath = ResolveCopilotCliPath(_currentSettings?.CliSource ?? CliSourceMode.BuiltIn); - return string.IsNullOrEmpty(cliPath) ? "copilot login" : $"\"{cliPath}\" login"; + return BuildCliCommand(_currentSettings, "login"); } /// @@ -1119,7 +1118,7 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default) if (!_serverManager.CheckServerRunning("127.0.0.1", settings.Port)) { Debug($"Persistent server not running, auto-starting on port {settings.Port}..."); - var started = await _serverManager.StartServerAsync(settings.Port, _resolvedGitHubToken); + var started = await _serverManager.StartServerAsync(settings, _resolvedGitHubToken); if (!started) { Debug("Failed to auto-start server, falling back to Embedded mode"); @@ -1179,7 +1178,7 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default) await Task.Delay(250, cancellationToken); } - var restarted = await _serverManager.StartServerAsync(settings.Port, _resolvedGitHubToken); + var restarted = await _serverManager.StartServerAsync(settings, _resolvedGitHubToken); if (restarted) { Debug("Server restarted, retrying connection..."); @@ -1511,7 +1510,7 @@ internal async Task TryRecoverPersistentServerAsync() // authenticate on its own via its native credential store. var tokenToForward = _resolvedGitHubToken; - var started = await _serverManager.StartServerAsync(settings.Port, tokenToForward); + var started = await _serverManager.StartServerAsync(settings, tokenToForward); if (!started) { Debug("[SERVER-RECOVERY] Failed to restart persistent server"); @@ -1610,7 +1609,7 @@ public async Task RestartServerAsync(CancellationToken cancellationToken = defau } // 5. Start fresh server (will extract current native modules) - var started = await _serverManager.StartServerAsync(restartSettings.Port, _resolvedGitHubToken); + var started = await _serverManager.StartServerAsync(restartSettings, _resolvedGitHubToken); if (!started) { Debug("[SERVER-RESTART] Failed to restart server"); @@ -1678,16 +1677,15 @@ private CopilotClient CreateClient(ConnectionSettings settings) else { // Embedded mode: spawn copilot as a child process via stdio - var cliPath = ResolveCopilotCliPath(settings.CliSource); + var cliPath = ResolveCopilotCliPath(settings.CliSource, settings.CustomCliPath); + if (settings.CliSource == CliSourceMode.Custom && string.IsNullOrWhiteSpace(cliPath)) + throw new InvalidOperationException("Custom CLI source is selected, but no executable path is configured."); if (cliPath != null) options.CliPath = cliPath; - // Pass additional MCP server configs via CLI args. - // The CLI auto-reads ~/.copilot/mcp-config.json, but mcp-servers.json - // uses a different format that needs to be passed explicitly. - var mcpArgs = GetMcpCliArgs(); - if (mcpArgs.Length > 0) - options.CliArgs = mcpArgs; + var cliArgs = GetConfiguredCliArgs(settings); + if (cliArgs.Length > 0) + options.CliArgs = cliArgs; } return new CopilotClient(options); @@ -1698,9 +1696,13 @@ private CopilotClient CreateClient(ConnectionSettings settings) /// Resolves the copilot CLI path based on user preference. /// BuiltIn: bundled binary first, then system fallback. /// System: system-installed binary first, then bundled fallback. + /// Custom: caller-provided executable path, with no implicit fallback. /// - internal static string? ResolveCopilotCliPath(CliSourceMode source = CliSourceMode.BuiltIn) + internal static string? ResolveCopilotCliPath(CliSourceMode source = CliSourceMode.BuiltIn, string? customPath = null) { + if (source == CliSourceMode.Custom) + return ConnectionSettings.NormalizeCustomCliPath(customPath); + if (source == CliSourceMode.System) { // Prefer system CLI, fall back to built-in @@ -1864,6 +1866,49 @@ public static (string? builtInPath, string? builtInVersion, string? systemPath, ); } + internal static string[] GetCustomCliArgs(ConnectionSettings? settings) + { + if (settings?.CliSource != CliSourceMode.Custom) + return Array.Empty(); + + return ConnectionSettings.SplitCommandLineArguments(settings.CustomCliArguments).ToArray(); + } + + internal static string[] GetConfiguredCliArgs(ConnectionSettings settings) + { + var args = new List(); + args.AddRange(GetCustomCliArgs(settings)); + args.AddRange(GetMcpCliArgs()); + return args.ToArray(); + } + + internal static string BuildCliCommand(ConnectionSettings? settings, params string[] tailArgs) + { + var source = settings?.CliSource ?? CliSourceMode.BuiltIn; + var cliPath = ResolveCopilotCliPath(source, settings?.CustomCliPath); + if (source == CliSourceMode.Custom && string.IsNullOrWhiteSpace(cliPath)) + return "Set a Custom CLI Path in Settings first"; + + var args = new List(); + args.AddRange(GetCustomCliArgs(settings)); + args.AddRange(tailArgs); + + var parts = new List { QuoteCliCommandArg(cliPath ?? "copilot") }; + parts.AddRange(args.Select(QuoteCliCommandArg)); + return string.Join(" ", parts); + } + + private static string QuoteCliCommandArg(string arg) + { + if (string.IsNullOrEmpty(arg)) + return "\"\""; + + if (arg.IndexOfAny([' ', '\t', '"']) == -1) + return arg; + + return $"\"{arg.Replace("\\", "\\\\").Replace("\"", "\\\"")}\""; + } + /// /// Build CLI args to pass additional MCP server configs. /// Also writes a merged mcp-config.json that the CLI auto-reads at startup, @@ -2850,7 +2895,7 @@ ALWAYS run the relaunch script as the final step after making changes to this pr if (!_serverManager.CheckServerRunning("127.0.0.1", settings.Port)) { Debug("Persistent server not running, restarting..."); - var started = await _serverManager.StartServerAsync(settings.Port, _resolvedGitHubToken); + var started = await _serverManager.StartServerAsync(settings, _resolvedGitHubToken); if (!started) { Debug("Failed to restart persistent server"); @@ -3596,7 +3641,7 @@ public async Task SendPromptAsync(string sessionName, string prompt, Lis if (CurrentMode == ConnectionMode.Persistent && !_serverManager.CheckServerRunning("127.0.0.1", reinitSettings.Port)) { - await _serverManager.StartServerAsync(reinitSettings.Port, _resolvedGitHubToken); + await _serverManager.StartServerAsync(reinitSettings, _resolvedGitHubToken); } _client = CreateClient(reinitSettings); await _client.StartAsync(cancellationToken); @@ -3637,7 +3682,7 @@ public async Task SendPromptAsync(string sessionName, string prompt, Lis !_serverManager.CheckServerRunning("127.0.0.1", connSettings.Port)) { Debug("Persistent server not running, restarting..."); - var started = await _serverManager.StartServerAsync(connSettings.Port, _resolvedGitHubToken); + var started = await _serverManager.StartServerAsync(connSettings, _resolvedGitHubToken); if (!started) { Debug("Failed to restart persistent server"); diff --git a/PolyPilot/Services/IServerManager.cs b/PolyPilot/Services/IServerManager.cs index e22469a818..ee05e5f06f 100644 --- a/PolyPilot/Services/IServerManager.cs +++ b/PolyPilot/Services/IServerManager.cs @@ -1,3 +1,5 @@ +using PolyPilot.Models; + namespace PolyPilot.Services; /// @@ -13,7 +15,7 @@ public interface IServerManager event Action? OnStatusChanged; bool CheckServerRunning(string host = "127.0.0.1", int? port = null); - Task StartServerAsync(int port, string? githubToken = null); + Task StartServerAsync(ConnectionSettings settings, string? githubToken = null); void StopServer(); bool DetectExistingServer(); } diff --git a/PolyPilot/Services/ProviderHostContext.cs b/PolyPilot/Services/ProviderHostContext.cs index 832c7d2a63..16eca6fb19 100644 --- a/PolyPilot/Services/ProviderHostContext.cs +++ b/PolyPilot/Services/ProviderHostContext.cs @@ -27,9 +27,12 @@ public CopilotClientOptions CreateCopilotClientOptions(string? workingDirectory options.UseStdio = true; options.AutoStart = true; options.AutoRestart = true; - options.CliPath = _settings.CliSource == CliSourceMode.BuiltIn - ? CopilotService.ResolveBundledCliPath() - : null; + options.CliPath = CopilotService.ResolveCopilotCliPath(_settings.CliSource, _settings.CustomCliPath); + if (_settings.CliSource == CliSourceMode.Custom && string.IsNullOrWhiteSpace(options.CliPath)) + throw new InvalidOperationException("Custom CLI source is selected, but no executable path is configured."); + var cliArgs = CopilotService.GetConfiguredCliArgs(_settings); + if (cliArgs.Length > 0) + options.CliArgs = cliArgs; break; case Models.ConnectionMode.Persistent: @@ -115,6 +118,7 @@ public CopilotClientOptions CreateCopilotClientOptions(string? workingDirectory { CliSourceMode.BuiltIn => ProviderCliSource.BuiltIn, CliSourceMode.System => ProviderCliSource.System, + CliSourceMode.Custom => ProviderCliSource.Custom, _ => ProviderCliSource.BuiltIn }; diff --git a/PolyPilot/Services/ServerManager.cs b/PolyPilot/Services/ServerManager.cs index 70fd3c9c1b..acb2f52cc8 100644 --- a/PolyPilot/Services/ServerManager.cs +++ b/PolyPilot/Services/ServerManager.cs @@ -1,6 +1,5 @@ using System.Diagnostics; using System.Net.Sockets; -using GitHub.Copilot.SDK; using PolyPilot.Models; namespace PolyPilot.Services; @@ -64,8 +63,9 @@ public bool CheckServerRunning(string host = "127.0.0.1", int? port = null) /// /// Start copilot in headless server mode, detached from app lifecycle /// - public async Task StartServerAsync(int port = 4321, string? githubToken = null) + public async Task StartServerAsync(ConnectionSettings settings, string? githubToken = null) { + var port = settings.Port; ServerPort = port; LastError = null; @@ -78,39 +78,18 @@ public async Task StartServerAsync(int port = 4321, string? githubToken = try { - // Use the native binary directly for better detachment - var copilotPath = FindCopilotBinary(); - var psi = new ProcessStartInfo + // Use the configured binary directly for better detachment. + var copilotPath = FindCopilotBinary(settings); + if (string.IsNullOrWhiteSpace(copilotPath)) { - FileName = copilotPath, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - RedirectStandardInput = false - }; - - // Forward the GitHub token via environment variable so the headless server - // can authenticate even when the macOS Keychain is inaccessible (e.g., the - // Keychain entry was created in a terminal session and the ACL dialog can't - // be shown for a background process). - if (!string.IsNullOrEmpty(githubToken)) - { - psi.Environment["COPILOT_GITHUB_TOKEN"] = githubToken; - Console.WriteLine("[ServerManager] Passing COPILOT_GITHUB_TOKEN to headless server"); + LastError = settings.CliSource == CliSourceMode.Custom + ? "Custom CLI source is selected, but no executable path is configured." + : "Failed to locate a Copilot CLI executable."; + Console.WriteLine($"[ServerManager] {LastError}"); + return false; } - // Use ArgumentList for proper escaping (especially MCP JSON) - psi.ArgumentList.Add("--headless"); - psi.ArgumentList.Add("--no-auto-update"); - psi.ArgumentList.Add("--log-level"); - psi.ArgumentList.Add("info"); - psi.ArgumentList.Add("--port"); - psi.ArgumentList.Add(port.ToString()); - - // Pass additional MCP server configs so tools are available - foreach (var arg in CopilotService.GetMcpCliArgs()) - psi.ArgumentList.Add(arg); + var psi = CreateStartInfo(settings, copilotPath, port, githubToken); var process = Process.Start(psi); if (process == null) @@ -169,6 +148,46 @@ public async Task StartServerAsync(int port = 4321, string? githubToken = } } + internal static ProcessStartInfo CreateStartInfo(ConnectionSettings settings, string cliPath, int port, string? githubToken = null) + { + var psi = new ProcessStartInfo + { + FileName = cliPath, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = false + }; + + // Forward the GitHub token via environment variable so the headless server + // can authenticate without the app trying to read platform credential stores. + if (!string.IsNullOrEmpty(githubToken)) + { + psi.Environment["COPILOT_GITHUB_TOKEN"] = githubToken; + Console.WriteLine("[ServerManager] Passing COPILOT_GITHUB_TOKEN to headless server"); + } + + foreach (var arg in BuildLaunchArguments(settings, port)) + psi.ArgumentList.Add(arg); + + return psi; + } + + internal static string[] BuildLaunchArguments(ConnectionSettings settings, int port) + { + var args = new List(); + args.AddRange(CopilotService.GetCustomCliArgs(settings)); + args.Add("--headless"); + args.Add("--no-auto-update"); + args.Add("--log-level"); + args.Add("info"); + args.Add("--port"); + args.Add(port.ToString()); + args.AddRange(CopilotService.GetMcpCliArgs()); + return args.ToArray(); + } + /// /// Stop the persistent server /// @@ -248,51 +267,20 @@ private void DeletePidFile() try { File.Delete(PidFilePath); } catch { } } - private static string FindCopilotBinary() + private static string? FindCopilotBinary(ConnectionSettings settings) { - // Prefer the SDK-bundled binary — it's guaranteed to match the SDK's protocol version. - // System-installed CLIs may have been updated independently and could have a mismatched protocol. - var bundledPath = CopilotService.ResolveBundledCliPath(); - if (bundledPath != null) - return bundledPath; - - Console.WriteLine($"[ServerManager] Bundled copilot binary not found. " + - $"Assembly.Location='{typeof(CopilotClient).Assembly.Location}', " + - $"AppContext.BaseDirectory='{AppContext.BaseDirectory}'"); - - // Fall back to platform-specific native binaries (system-installed) - var nativePaths = new List(); - - if (OperatingSystem.IsWindows()) + var resolvedPath = CopilotService.ResolveCopilotCliPath(settings.CliSource, settings.CustomCliPath); + if (!string.IsNullOrWhiteSpace(resolvedPath)) { - var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); - var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - nativePaths.AddRange(new[] - { - Path.Combine(appData, "npm", "node_modules", "@github", "copilot", "node_modules", "@github", "copilot-win-x64", "copilot.exe"), - Path.Combine(localAppData, "npm", "node_modules", "@github", "copilot", "node_modules", "@github", "copilot-win-x64", "copilot.exe"), - Path.Combine(appData, "npm", "copilot.cmd"), - }); - } - else - { - nativePaths.AddRange(new[] - { - "/opt/homebrew/lib/node_modules/@github/copilot/node_modules/@github/copilot-darwin-arm64/copilot", - "/usr/local/lib/node_modules/@github/copilot/node_modules/@github/copilot-darwin-arm64/copilot", - }); + if (settings.CliSource == CliSourceMode.Custom) + Console.WriteLine($"[ServerManager] Using configured custom CLI: {resolvedPath}"); + return resolvedPath; } - foreach (var path in nativePaths) - { - if (File.Exists(path)) - { - Console.WriteLine($"[ServerManager] Using system copilot binary: {path}"); - return path; - } - } + if (settings.CliSource == CliSourceMode.Custom) + return null; - // Fallback to node wrapper (works if copilot is on PATH) + // Final PATH lookup fallback. Console.WriteLine("[ServerManager] WARNING: No copilot binary found at any known path, falling back to PATH lookup"); return OperatingSystem.IsWindows() ? "copilot.cmd" : "copilot"; } diff --git a/PolyPilot/Services/SettingsRegistry.cs b/PolyPilot/Services/SettingsRegistry.cs index 64e02fd54e..e103f0f19f 100644 --- a/PolyPilot/Services/SettingsRegistry.cs +++ b/PolyPilot/Services/SettingsRegistry.cs @@ -358,15 +358,16 @@ private static List Build() { Id = "cli.source", Label = "CLI Source", - Description = "Use the CLI bundled with the app or one installed on your system.", + Description = "Use the CLI bundled with the app, one installed on your system, or a custom executable.", Category = "Developer", Type = SettingType.CardEnum, Order = 5, - SearchKeywords = "cli source built-in system version binary copilot", + SearchKeywords = "cli source built-in system custom executable path launcher wrapper version binary copilot", Options = new[] { new SettingOption("BuiltIn", "📦 Built-in"), new SettingOption("System", "💻 System"), + new SettingOption("Custom", "🛠️ Custom"), }, GetValue = ctx => ctx.Settings.CliSource.ToString(), SetValue = (ctx, v) => @@ -378,6 +379,38 @@ private static List Build() && ctx.Settings.Mode != ConnectionMode.Demo }); + list.Add(new SettingDescriptor + { + Id = "cli.customPath", + Label = "Custom CLI Path", + Description = "Path or executable name for a custom CLI launcher. Required when CLI Source is set to Custom.", + Category = "Developer", + Type = SettingType.String, + Order = 6, + SearchKeywords = "custom cli path executable binary launcher wrapper headless", + GetValue = ctx => ctx.Settings.CustomCliPath, + SetValue = (ctx, v) => ctx.Settings.CustomCliPath = ConnectionSettings.NormalizeCustomCliPath(v as string), + IsVisible = ctx => ctx.Settings.CliSource == CliSourceMode.Custom + && ctx.Settings.Mode != ConnectionMode.Remote + && ctx.Settings.Mode != ConnectionMode.Demo + }); + + list.Add(new SettingDescriptor + { + Id = "cli.customArguments", + Label = "Custom CLI Arguments", + Description = "Optional arguments inserted before PolyPilot's normal CLI flags. Useful when the launcher needs a subcommand or extra switches.", + Category = "Developer", + Type = SettingType.String, + Order = 7, + SearchKeywords = "custom cli arguments args wrapper subcommand launch flags headless", + GetValue = ctx => ctx.Settings.CustomCliArguments, + SetValue = (ctx, v) => ctx.Settings.CustomCliArguments = ConnectionSettings.NormalizeCustomCliArguments(v as string), + IsVisible = ctx => ctx.Settings.CliSource == CliSourceMode.Custom + && ctx.Settings.Mode != ConnectionMode.Remote + && ctx.Settings.Mode != ConnectionMode.Demo + }); + list.Add(new SettingDescriptor { Id = "developer.autoUpdate", From e3f4c8bd98d658f72461c7b023c58af229e32a79 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 9 Apr 2026 16:48:39 -0500 Subject: [PATCH 2/2] Fix custom CLI arg path parsing Preserve literal backslashes in custom launcher arguments and add regression tests for quoted and unquoted path cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/ConnectionSettingsTests.cs | 32 ++++++++++++++++++++++ PolyPilot/Models/ConnectionSettings.cs | 20 ++++++++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/PolyPilot.Tests/ConnectionSettingsTests.cs b/PolyPilot.Tests/ConnectionSettingsTests.cs index dd3a0fe5a4..28ff8f0c5c 100644 --- a/PolyPilot.Tests/ConnectionSettingsTests.cs +++ b/PolyPilot.Tests/ConnectionSettingsTests.cs @@ -224,6 +224,38 @@ public void SplitCommandLineArguments_HandlesQuotesAndSpaces() Assert.Equal(new[] { "wrapper", "subcommand", "--label", "two words", "three words" }, args); } + [Fact] + public void SplitCommandLineArguments_PreservesBackslashesInUnquotedPaths() + { + var args = ConnectionSettings.SplitCommandLineArguments("--launcher C:\\Tools\\wrapper.exe --flag"); + + Assert.Equal(new[] { "--launcher", "C:\\Tools\\wrapper.exe", "--flag" }, args); + } + + [Fact] + public void SplitCommandLineArguments_PreservesBackslashesInDoubleQuotedPaths() + { + var args = ConnectionSettings.SplitCommandLineArguments("--config \"C:\\Users\\me\\.copilot\\config.json\""); + + Assert.Equal(new[] { "--config", "C:\\Users\\me\\.copilot\\config.json" }, args); + } + + [Fact] + public void SplitCommandLineArguments_PreservesBackslashesInQuotedUncPaths() + { + var args = ConnectionSettings.SplitCommandLineArguments("--config \"\\\\server\\share\\tool.json\""); + + Assert.Equal(new[] { "--config", "\\\\server\\share\\tool.json" }, args); + } + + [Fact] + public void SplitCommandLineArguments_StillSupportsEscapedWhitespace() + { + var args = ConnectionSettings.SplitCommandLineArguments("one\\ two three"); + + Assert.Equal(new[] { "one two", "three" }, args); + } + [Fact] public void ServerPassword_NotInCliUrl() { diff --git a/PolyPilot/Models/ConnectionSettings.cs b/PolyPilot/Models/ConnectionSettings.cs index 0a7ca3a6ca..1ddc974094 100644 --- a/PolyPilot/Models/ConnectionSettings.cs +++ b/PolyPilot/Models/ConnectionSettings.cs @@ -324,8 +324,9 @@ internal static IReadOnlyList SplitCommandLineArguments(string? raw) char? quote = null; var escaping = false; - foreach (var ch in raw) + for (var i = 0; i < raw.Length; i++) { + var ch = raw[i]; if (escaping) { current.Append(ch); @@ -333,9 +334,22 @@ internal static IReadOnlyList SplitCommandLineArguments(string? raw) continue; } - if (ch == '\\' && quote != '\'') + if (ch == '\\') { - escaping = true; + var next = i + 1 < raw.Length ? raw[i + 1] : '\0'; + // This settings field is tokenized directly into ProcessStartInfo.ArgumentList, + // so preserve literal backslashes (paths, UNC shares, regexes) and only treat + // backslash as an escape for whitespace or quotes the user typed explicitly. + var shouldEscapeNext = next != '\0' + && (next == '"' || next == '\'' || char.IsWhiteSpace(next)); + + if (quote != '\'' && shouldEscapeNext) + { + escaping = true; + continue; + } + + current.Append(ch); continue; }