From ed018cbf07bb5696a3ae8760c20a84df4cbbfdb5 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 27 Mar 2026 06:37:10 -0500 Subject: [PATCH 1/7] feat: surface auth errors with actionable guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Check auth status via SDK GetAuthStatusAsync after initialization - Show dismissible πŸ”‘ banner when CLI server is not authenticated - Humanize 'not created with authentication info' error to tell users to run 'copilot login' then reconnect - Add error pattern to IsAuthError() for proper classification - Clear auth notice on reconnect Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Components/Pages/Dashboard.razor | 15 ++++++++++ PolyPilot/Models/ErrorMessageHelper.cs | 5 ++++ .../Services/CopilotService.Utilities.cs | 29 +++++++++++++++++++ PolyPilot/Services/CopilotService.cs | 8 +++++ 4 files changed, 57 insertions(+) diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index 9d9b815a..b9ffed79 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -54,6 +54,15 @@ } + @if (!string.IsNullOrEmpty(CopilotService.AuthNotice)) + { +
+ πŸ”‘ +

@CopilotService.AuthNotice

+ + +
+ } @if (PlatformHelper.IsMobile && !CopilotService.IsInitialized && !_initializationComplete) {
@@ -753,6 +762,12 @@ StateHasChanged(); } + private void DismissAuthNotice() + { + CopilotService.ClearAuthNotice(); + StateHasChanged(); + } + private async Task DashboardScanQr() { var result = await QrScanner.ScanAsync(); diff --git a/PolyPilot/Models/ErrorMessageHelper.cs b/PolyPilot/Models/ErrorMessageHelper.cs index e2516dc1..befd1140 100644 --- a/PolyPilot/Models/ErrorMessageHelper.cs +++ b/PolyPilot/Models/ErrorMessageHelper.cs @@ -66,6 +66,11 @@ public static string HumanizeMessage(string message) if (message.Contains("Host is down", StringComparison.OrdinalIgnoreCase)) return "The server appears to be down. Try again later."; + // Authentication errors from the CLI SDK + if (message.Contains("not created with authentication info", StringComparison.OrdinalIgnoreCase) + || message.Contains("not created with authentication", StringComparison.OrdinalIgnoreCase)) + return "Not authenticated β€” run `copilot login` in your terminal, then reconnect in Settings."; + // Catch-all for any other net_webstatus_ codes we haven't mapped if (message.Contains("net_webstatus_", StringComparison.OrdinalIgnoreCase)) return "A network error occurred. Check your connection and try again."; diff --git a/PolyPilot/Services/CopilotService.Utilities.cs b/PolyPilot/Services/CopilotService.Utilities.cs index b03ab5bc..42469228 100644 --- a/PolyPilot/Services/CopilotService.Utilities.cs +++ b/PolyPilot/Services/CopilotService.Utilities.cs @@ -515,6 +515,7 @@ internal static bool IsAuthError(Exception ex) || msg.Contains("not authenticated", StringComparison.OrdinalIgnoreCase) || msg.Contains("authentication failed", StringComparison.OrdinalIgnoreCase) || msg.Contains("authentication required", StringComparison.OrdinalIgnoreCase) + || msg.Contains("not created with authentication info", StringComparison.OrdinalIgnoreCase) || msg.Contains("token expired", StringComparison.OrdinalIgnoreCase) || msg.Contains("token is invalid", StringComparison.OrdinalIgnoreCase) || msg.Contains("invalid token", StringComparison.OrdinalIgnoreCase) @@ -834,4 +835,32 @@ private async Task FetchGitHubUserInfoAsync() Debug($"Failed to fetch GitHub user info: {ex.Message}"); } } + + /// + /// Checks the CLI server's authentication status via the SDK and surfaces a + /// dismissible banner if the server is not authenticated. + /// + private async Task CheckAuthStatusAsync() + { + if (IsDemoMode || IsRemoteMode || _client == null) return; + try + { + var status = await _client.GetAuthStatusAsync(); + if (status.IsAuthenticated) + { + AuthNotice = null; + Debug($"[AUTH] Authenticated as {status.Login} via {status.AuthType}"); + } + else + { + AuthNotice = "Not authenticated β€” run `copilot login` in your terminal, then click Reconnect."; + Debug($"[AUTH] Not authenticated: {status.StatusMessage}"); + } + InvokeOnUI(() => OnStateChanged?.Invoke()); + } + catch (Exception ex) + { + Debug($"[AUTH] Failed to check auth status: {ex.Message}"); + } + } } diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 7dc69185..6be72d6c 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -292,6 +292,10 @@ internal CopilotService(IChatDatabase chatDb, IServerManager serverManager, IWsB public void ClearServerHealthNotice() => ServerHealthNotice = null; public void SetServerHealthNotice(string notice) => ServerHealthNotice = notice; + // Auth notice β€” shown when the CLI server is not authenticated + public string? AuthNotice { get; private set; } + public void ClearAuthNotice() => AuthNotice = null; + // GitHub user info public string? GitHubAvatarUrl { get; private set; } public string? GitHubLogin { get; private set; } @@ -987,6 +991,9 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default) // Fetch GitHub user info for avatar _ = FetchGitHubUserInfoAsync(); + // Check auth status β€” surface a banner if not authenticated + _ = CheckAuthStatusAsync(); + // Load organization state FIRST (groups, pinning, sorting) so reconcile during restore doesn't wipe it LoadOrganization(); @@ -1115,6 +1122,7 @@ public async Task ReconnectAsync(ConnectionSettings settings, CancellationToken IsRemoteMode = false; IsDemoMode = false; FallbackNotice = null; // Clear any previous fallback notice + AuthNotice = null; // Clear any previous auth notice CurrentMode = settings.Mode; CodespacesEnabled = settings.CodespacesEnabled && settings.Mode == ConnectionMode.Embedded; OnStateChanged?.Invoke(); From d079e6107b61bb28ed534fc3cee7cafcc092643b Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 27 Mar 2026 08:30:36 -0500 Subject: [PATCH 2/7] feat: enhanced auth banner with re-authenticate, copy command, and auto-polling - Replace 'Reconnect' with 'Re-authenticate' that force-restarts the headless server via TryRecoverPersistentServerAsync() to pick up fresh credentials (running server caches stale tokens in memory) - Add 'Copy Command' button showing the resolved copilot login path - Add background auth polling (10s interval) that auto-detects when user completes 'copilot login' and triggers server restart + clears banner - Wire SessionErrorEvent to set AuthNotice reactively for mid-session auth failures - Re-verify auth after server restart; update banner if still failing - Clean up polling on dismiss, reconnect, and dispose Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Components/Pages/Dashboard.razor | 38 +++++++++++- .../Components/Pages/Dashboard.razor.css | 44 ++++++++++++++ PolyPilot/Services/CopilotService.Events.cs | 6 ++ .../Services/CopilotService.Utilities.cs | 59 ++++++++++++++++++- PolyPilot/Services/CopilotService.cs | 47 ++++++++++++++- 5 files changed, 189 insertions(+), 5 deletions(-) diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index b9ffed79..e0ec85e9 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -58,9 +58,19 @@ {
πŸ”‘ -

@CopilotService.AuthNotice

- - +
+

@CopilotService.AuthNotice

+
+ @CopilotService.GetLoginCommand() + +
+
+
+ + +
} @if (PlatformHelper.IsMobile && !CopilotService.IsInitialized && !_initializationComplete) @@ -768,6 +778,28 @@ StateHasChanged(); } + private bool _isReauthenticating; + private async Task ReauthenticateAsync() + { + if (_isReauthenticating) return; + _isReauthenticating = true; + StateHasChanged(); + try + { + await CopilotService.ReauthenticateAsync(); + } + finally + { + _isReauthenticating = false; + StateHasChanged(); + } + } + + private async Task CopyLoginCommand() + { + await CopyToClipboard(CopilotService.GetLoginCommand()); + } + private async Task DashboardScanQr() { var result = await QrScanner.ScanAsync(); diff --git a/PolyPilot/Components/Pages/Dashboard.razor.css b/PolyPilot/Components/Pages/Dashboard.razor.css index 240b47dc..3f94ec49 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor.css +++ b/PolyPilot/Components/Pages/Dashboard.razor.css @@ -333,6 +333,50 @@ .fallback-notice .init-error-icon { font-size: var(--type-large-title); } .fallback-notice .init-error-text { color: var(--text-secondary); } +.auth-notice { + max-width: 400px; + align-items: flex-start; +} +.auth-notice-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.4rem; +} +.auth-command-row { + display: flex; + align-items: center; + gap: 0.4rem; +} +.auth-command { + font-family: var(--font-mono, 'SF Mono', monospace); + font-size: var(--type-callout); + background: rgba(255,255,255,0.06); + padding: 0.25rem 0.5rem; + border-radius: 6px; + color: var(--text-primary); + user-select: all; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 280px; +} +.copy-cmd-btn { + background: transparent; + border: none; + cursor: pointer; + font-size: var(--type-body); + padding: 0.15rem 0.3rem; + border-radius: 4px; + opacity: 0.7; +} +.copy-cmd-btn:hover { opacity: 1; background: var(--hover-bg); } +.auth-notice-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.25rem; +} + .session-grid { display: grid; grid-template-columns: repeat(3, 1fr); /* overridden by inline style from _gridColumns */ diff --git a/PolyPilot/Services/CopilotService.Events.cs b/PolyPilot/Services/CopilotService.Events.cs index 9e8246f4..3ff9ea9c 100644 --- a/PolyPilot/Services/CopilotService.Events.cs +++ b/PolyPilot/Services/CopilotService.Events.cs @@ -789,6 +789,12 @@ await notifService.SendNotificationAsync( { if (state.IsOrphaned) return; OnError?.Invoke(sessionName, errMsg); + // Surface auth errors as a dismissible banner + if (IsAuthError(new Exception(err.Data?.Message ?? ""))) + { + AuthNotice = "Not authenticated β€” run the login command below, then click Re-authenticate."; + StartAuthPolling(); + } // Flush any accumulated partial response before clearing the accumulator FlushCurrentResponse(state); state.FlushedResponse.Clear(); diff --git a/PolyPilot/Services/CopilotService.Utilities.cs b/PolyPilot/Services/CopilotService.Utilities.cs index 42469228..6ea837e3 100644 --- a/PolyPilot/Services/CopilotService.Utilities.cs +++ b/PolyPilot/Services/CopilotService.Utilities.cs @@ -849,12 +849,14 @@ private async Task CheckAuthStatusAsync() if (status.IsAuthenticated) { AuthNotice = null; + StopAuthPolling(); Debug($"[AUTH] Authenticated as {status.Login} via {status.AuthType}"); } else { - AuthNotice = "Not authenticated β€” run `copilot login` in your terminal, then click Reconnect."; + AuthNotice = "Not authenticated β€” run the login command below, then click Re-authenticate."; Debug($"[AUTH] Not authenticated: {status.StatusMessage}"); + StartAuthPolling(); } InvokeOnUI(() => OnStateChanged?.Invoke()); } @@ -863,4 +865,59 @@ private async Task CheckAuthStatusAsync() Debug($"[AUTH] Failed to check auth status: {ex.Message}"); } } + + /// + /// Starts background polling of auth status every 10s. When auth is detected + /// (user completed `copilot login`), automatically restarts the server and clears the banner. + /// + private void StartAuthPolling() + { + if (_authPollCts != null) return; // already polling + var cts = new CancellationTokenSource(); + _authPollCts = cts; + _ = Task.Run(async () => + { + Debug("[AUTH-POLL] Started polling for re-authentication"); + while (!cts.Token.IsCancellationRequested) + { + try + { + await Task.Delay(10_000, cts.Token); + if (_client == null) continue; + var status = await _client.GetAuthStatusAsync(cts.Token); + if (status.IsAuthenticated) + { + Debug($"[AUTH-POLL] Auth detected ({status.Login}) β€” triggering server restart"); + var recovered = await TryRecoverPersistentServerAsync(); + if (recovered) + { + InvokeOnUI(() => + { + AuthNotice = null; + _ = FetchGitHubUserInfoAsync(); + OnStateChanged?.Invoke(); + }); + } + break; + } + } + catch (OperationCanceledException) { break; } + catch (Exception ex) + { + Debug($"[AUTH-POLL] Error: {ex.Message}"); + } + } + Debug("[AUTH-POLL] Stopped polling"); + }, cts.Token); + } + + private void StopAuthPolling() + { + if (_authPollCts != null) + { + _authPollCts.Cancel(); + _authPollCts.Dispose(); + _authPollCts = null; + } + } } diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 6be72d6c..4b92034d 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -80,6 +80,7 @@ internal void SetTurnEndGuardForTesting(string sessionName, bool active) private readonly ConcurrentDictionary _tunnelHandles = new(); // Codespace health-check background task private CancellationTokenSource? _codespaceHealthCts; + private CancellationTokenSource? _authPollCts; private Task? _codespaceHealthTask; // Cached dotfiles status β€” checked once when first SetupRequired state is encountered private CodespaceService.DotfilesStatus? _dotfilesStatus; @@ -294,7 +295,49 @@ internal CopilotService(IChatDatabase chatDb, IServerManager serverManager, IWsB // Auth notice β€” shown when the CLI server is not authenticated public string? AuthNotice { get; private set; } - public void ClearAuthNotice() => AuthNotice = null; + public void ClearAuthNotice() + { + AuthNotice = null; + StopAuthPolling(); + } + + /// 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"; + } + + /// + /// Force-restarts the headless server to pick up fresh credentials, then re-checks auth. + /// Called from the Dashboard "Re-authenticate" button after the user runs `copilot login`. + /// + public async Task ReauthenticateAsync() + { + StopAuthPolling(); + Debug("[AUTH] Re-authenticate requested β€” forcing server restart to pick up new credentials"); + var recovered = await TryRecoverPersistentServerAsync(); + if (recovered) + { + await CheckAuthStatusAsync(); + if (AuthNotice == null) + { + Debug("[AUTH] Re-authentication successful"); + _ = FetchGitHubUserInfoAsync(); + } + else + { + Debug("[AUTH] Server restarted but still not authenticated"); + AuthNotice = "Server restarted but still not authenticated. Please ensure `copilot login` completed successfully and try again."; + StartAuthPolling(); + } + } + else + { + AuthNotice = "Server restart failed β€” please try running `copilot login` again."; + } + InvokeOnUI(() => OnStateChanged?.Invoke()); + } // GitHub user info public string? GitHubAvatarUrl { get; private set; } @@ -1123,6 +1166,7 @@ public async Task ReconnectAsync(ConnectionSettings settings, CancellationToken IsDemoMode = false; FallbackNotice = null; // Clear any previous fallback notice AuthNotice = null; // Clear any previous auth notice + StopAuthPolling(); CurrentMode = settings.Mode; CodespacesEnabled = settings.CodespacesEnabled && settings.Mode == ConnectionMode.Embedded; OnStateChanged?.Invoke(); @@ -4534,6 +4578,7 @@ public async ValueTask DisposeAsync() StopKeepalivePing(); await StopCodespaceHealthCheckAsync(); StopExternalSessionScanner(); + StopAuthPolling(); // Flush any pending debounced writes immediately FlushSaveActiveSessionsToDisk(); From 545111125234603c91251e1f9cbb6a8fd8f7f36a Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 27 Mar 2026 10:33:36 -0500 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?thread=20safety,=20resource=20leak,=20and=20test=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1: Add _authPollLock to prevent TOCTOU race in StartAuthPolling/ StopAuthPolling (lock wraps both methods) C2: Call StopAuthPolling() before returning from polling success path to prevent CTS leak and permanent guard stuck C3: Wrap all AuthNotice mutations in InvokeOnUI() to match service convention (CheckAuthStatusAsync, ReauthenticateAsync) M1: Add 11 tests β€” IsAuthError string overload, GetLoginCommand fallback, ClearAuthNotice cleanup, ReauthenticateAsync failure M2: Remove redundant StartAuthPolling() in ReauthenticateAsync (CheckAuthStatusAsync already starts it if needed) M3: Treat thrown exception in CheckAuthStatusAsync as possibly unauthenticated β€” start polling for retry instead of silently swallowing m1: Remove dead code substring check in ErrorMessageHelper m2: Add IsAuthError(string) overload to avoid throwaway Exception allocation in SessionErrorEvent handler m3: ClearAuthNotice() now calls InvokeOnUI(OnStateChanged) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/ServerRecoveryTests.cs | 59 ++++++++++ PolyPilot/Models/ErrorMessageHelper.cs | 3 +- PolyPilot/Services/CopilotService.Events.cs | 2 +- .../Services/CopilotService.Utilities.cs | 107 +++++++++++------- PolyPilot/Services/CopilotService.cs | 16 ++- 5 files changed, 137 insertions(+), 50 deletions(-) diff --git a/PolyPilot.Tests/ServerRecoveryTests.cs b/PolyPilot.Tests/ServerRecoveryTests.cs index abe03e87..5c0b11fa 100644 --- a/PolyPilot.Tests/ServerRecoveryTests.cs +++ b/PolyPilot.Tests/ServerRecoveryTests.cs @@ -89,6 +89,65 @@ public void IsAuthError_ReturnsFalseForEmptyAggregate() Assert.False(CopilotService.IsAuthError(agg)); } + // ===== IsAuthError string overload ===== + + [Theory] + [InlineData("Unauthorized")] + [InlineData("Not authenticated")] + [InlineData("not created with authentication info")] + [InlineData("Token expired")] + [InlineData("HTTP 401")] + public void IsAuthError_StringOverload_DetectsAuthMessages(string message) + { + Assert.True(CopilotService.IsAuthError(message)); + } + + [Theory] + [InlineData("Session not found")] + [InlineData("Connection refused")] + [InlineData("")] + public void IsAuthError_StringOverload_ReturnsFalseForNonAuth(string message) + { + Assert.False(CopilotService.IsAuthError(message)); + } + + // ===== GetLoginCommand ===== + + [Fact] + public void GetLoginCommand_ReturnsFallback_WhenNoSettings() + { + var svc = CreateService(); + var cmd = svc.GetLoginCommand(); + // Without settings or resolved path, returns the generic fallback + Assert.Contains("login", cmd); + } + + // ===== ClearAuthNotice ===== + + [Fact] + public async Task ClearAuthNotice_ClearsNoticeAndStopsPolling() + { + var svc = CreateService(); + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + // ClearAuthNotice should not throw even when no notice is set + svc.ClearAuthNotice(); + Assert.Null(svc.AuthNotice); + } + + // ===== ReauthenticateAsync ===== + + [Fact] + public async Task ReauthenticateAsync_NonPersistentMode_SetsFailureNotice() + { + var svc = CreateService(); + // Initialize in Demo mode β€” TryRecoverPersistentServerAsync returns false + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + await svc.ReauthenticateAsync(); + // Should set a failure notice since recovery isn't available in demo mode + Assert.NotNull(svc.AuthNotice); + Assert.Contains("restart failed", svc.AuthNotice!, StringComparison.OrdinalIgnoreCase); + } + // ===== IsConnectionError now catches auth errors ===== [Theory] diff --git a/PolyPilot/Models/ErrorMessageHelper.cs b/PolyPilot/Models/ErrorMessageHelper.cs index befd1140..320f978d 100644 --- a/PolyPilot/Models/ErrorMessageHelper.cs +++ b/PolyPilot/Models/ErrorMessageHelper.cs @@ -67,8 +67,7 @@ public static string HumanizeMessage(string message) return "The server appears to be down. Try again later."; // Authentication errors from the CLI SDK - if (message.Contains("not created with authentication info", StringComparison.OrdinalIgnoreCase) - || message.Contains("not created with authentication", StringComparison.OrdinalIgnoreCase)) + if (message.Contains("not created with authentication info", StringComparison.OrdinalIgnoreCase)) return "Not authenticated β€” run `copilot login` in your terminal, then reconnect in Settings."; // Catch-all for any other net_webstatus_ codes we haven't mapped diff --git a/PolyPilot/Services/CopilotService.Events.cs b/PolyPilot/Services/CopilotService.Events.cs index 3ff9ea9c..8c5e510c 100644 --- a/PolyPilot/Services/CopilotService.Events.cs +++ b/PolyPilot/Services/CopilotService.Events.cs @@ -790,7 +790,7 @@ await notifService.SendNotificationAsync( if (state.IsOrphaned) return; OnError?.Invoke(sessionName, errMsg); // Surface auth errors as a dismissible banner - if (IsAuthError(new Exception(err.Data?.Message ?? ""))) + if (IsAuthError(err.Data?.Message ?? "")) { AuthNotice = "Not authenticated β€” run the login command below, then click Re-authenticate."; StartAuthPolling(); diff --git a/PolyPilot/Services/CopilotService.Utilities.cs b/PolyPilot/Services/CopilotService.Utilities.cs index 6ea837e3..f5fb7cfe 100644 --- a/PolyPilot/Services/CopilotService.Utilities.cs +++ b/PolyPilot/Services/CopilotService.Utilities.cs @@ -510,8 +510,16 @@ internal static bool IsProcessError(Exception ex) /// internal static bool IsAuthError(Exception ex) { - var msg = ex.Message; - if (msg.Contains("unauthorized", StringComparison.OrdinalIgnoreCase) + if (IsAuthError(ex.Message)) + return true; + if (ex is AggregateException agg) + return agg.InnerExceptions.Any(IsAuthError); + return ex.InnerException != null && IsAuthError(ex.InnerException); + } + + internal static bool IsAuthError(string msg) + { + return msg.Contains("unauthorized", StringComparison.OrdinalIgnoreCase) || msg.Contains("not authenticated", StringComparison.OrdinalIgnoreCase) || msg.Contains("authentication failed", StringComparison.OrdinalIgnoreCase) || msg.Contains("authentication required", StringComparison.OrdinalIgnoreCase) @@ -524,11 +532,7 @@ internal static bool IsAuthError(Exception ex) || msg.Contains("HTTP 401", StringComparison.OrdinalIgnoreCase) || msg.Contains("not authorized", StringComparison.OrdinalIgnoreCase) || msg.Contains("bad credentials", StringComparison.OrdinalIgnoreCase) - || msg.Contains("login required", StringComparison.OrdinalIgnoreCase)) - return true; - if (ex is AggregateException agg) - return agg.InnerExceptions.Any(IsAuthError); - return ex.InnerException != null && IsAuthError(ex.InnerException); + || msg.Contains("login required", StringComparison.OrdinalIgnoreCase); } /// @@ -848,21 +852,31 @@ private async Task CheckAuthStatusAsync() var status = await _client.GetAuthStatusAsync(); if (status.IsAuthenticated) { - AuthNotice = null; StopAuthPolling(); + InvokeOnUI(() => + { + AuthNotice = null; + OnStateChanged?.Invoke(); + }); Debug($"[AUTH] Authenticated as {status.Login} via {status.AuthType}"); } else { - AuthNotice = "Not authenticated β€” run the login command below, then click Re-authenticate."; + InvokeOnUI(() => + { + AuthNotice = "Not authenticated β€” run the login command below, then click Re-authenticate."; + OnStateChanged?.Invoke(); + }); Debug($"[AUTH] Not authenticated: {status.StatusMessage}"); StartAuthPolling(); } - InvokeOnUI(() => OnStateChanged?.Invoke()); } catch (Exception ex) { - Debug($"[AUTH] Failed to check auth status: {ex.Message}"); + Debug($"[AUTH] Failed to check auth status: {ex.Message} β€” scheduling retry"); + // Treat a thrown exception (server not ready, transient error) as possibly unauthenticated + // and start polling so the banner can appear once the server is reachable. + StartAuthPolling(); } } @@ -872,52 +886,59 @@ private async Task CheckAuthStatusAsync() /// private void StartAuthPolling() { - if (_authPollCts != null) return; // already polling - var cts = new CancellationTokenSource(); - _authPollCts = cts; - _ = Task.Run(async () => + lock (_authPollLock) { - Debug("[AUTH-POLL] Started polling for re-authentication"); - while (!cts.Token.IsCancellationRequested) + if (_authPollCts != null) return; // already polling + var cts = new CancellationTokenSource(); + _authPollCts = cts; + _ = Task.Run(async () => { - try + Debug("[AUTH-POLL] Started polling for re-authentication"); + while (!cts.Token.IsCancellationRequested) { - await Task.Delay(10_000, cts.Token); - if (_client == null) continue; - var status = await _client.GetAuthStatusAsync(cts.Token); - if (status.IsAuthenticated) + try { - Debug($"[AUTH-POLL] Auth detected ({status.Login}) β€” triggering server restart"); - var recovered = await TryRecoverPersistentServerAsync(); - if (recovered) + await Task.Delay(10_000, cts.Token); + if (_client == null) continue; + var status = await _client.GetAuthStatusAsync(cts.Token); + if (status.IsAuthenticated) { - InvokeOnUI(() => + Debug($"[AUTH-POLL] Auth detected ({status.Login}) β€” triggering server restart"); + StopAuthPolling(); + var recovered = await TryRecoverPersistentServerAsync(); + if (recovered) { - AuthNotice = null; - _ = FetchGitHubUserInfoAsync(); - OnStateChanged?.Invoke(); - }); + InvokeOnUI(() => + { + AuthNotice = null; + _ = FetchGitHubUserInfoAsync(); + OnStateChanged?.Invoke(); + }); + } + return; } - break; + } + catch (OperationCanceledException) { break; } + catch (Exception ex) + { + Debug($"[AUTH-POLL] Error: {ex.Message}"); } } - catch (OperationCanceledException) { break; } - catch (Exception ex) - { - Debug($"[AUTH-POLL] Error: {ex.Message}"); - } - } - Debug("[AUTH-POLL] Stopped polling"); - }, cts.Token); + Debug("[AUTH-POLL] Stopped polling"); + }, cts.Token); + } } private void StopAuthPolling() { - if (_authPollCts != null) + lock (_authPollLock) { - _authPollCts.Cancel(); - _authPollCts.Dispose(); - _authPollCts = null; + if (_authPollCts != null) + { + _authPollCts.Cancel(); + _authPollCts.Dispose(); + _authPollCts = null; + } } } } diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 4b92034d..2a5a1524 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -81,6 +81,7 @@ internal void SetTurnEndGuardForTesting(string sessionName, bool active) // Codespace health-check background task private CancellationTokenSource? _codespaceHealthCts; private CancellationTokenSource? _authPollCts; + private readonly object _authPollLock = new(); private Task? _codespaceHealthTask; // Cached dotfiles status β€” checked once when first SetupRequired state is encountered private CodespaceService.DotfilesStatus? _dotfilesStatus; @@ -299,6 +300,7 @@ public void ClearAuthNotice() { AuthNotice = null; StopAuthPolling(); + InvokeOnUI(() => OnStateChanged?.Invoke()); } /// Returns the full `copilot login` command using the resolved CLI path. @@ -328,15 +330,21 @@ public async Task ReauthenticateAsync() else { Debug("[AUTH] Server restarted but still not authenticated"); - AuthNotice = "Server restarted but still not authenticated. Please ensure `copilot login` completed successfully and try again."; - StartAuthPolling(); + InvokeOnUI(() => + { + AuthNotice = "Server restarted but still not authenticated. Please ensure `copilot login` completed successfully and try again."; + OnStateChanged?.Invoke(); + }); } } else { - AuthNotice = "Server restart failed β€” please try running `copilot login` again."; + InvokeOnUI(() => + { + AuthNotice = "Server restart failed β€” please try running `copilot login` again."; + OnStateChanged?.Invoke(); + }); } - InvokeOnUI(() => OnStateChanged?.Invoke()); } // GitHub user info From a6a943da19721e89965d3902de14455019290f5a Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Sat, 28 Mar 2026 14:33:09 -0500 Subject: [PATCH 4/7] fix: forward GitHub token to headless server for Keychain-inaccessible environments Root cause: when copilot login stores credentials in macOS Keychain, the headless server spawned by PolyPilot may not have Keychain access (ACL dialog can't be shown for background processes). The server starts without auth, causing 'session was not created with authentication info'. Fix: ResolveGitHubTokenForServer() checks COPILOT_GITHUB_TOKEN, GH_TOKEN, GITHUB_TOKEN env vars, then falls back to 'gh auth token' CLI. The resolved token is passed to StartServerAsync() which sets GITHUB_TOKEN on the headless server process environment. - Add githubToken parameter to IServerManager.StartServerAsync - Resolve token once at init, cache in _resolvedGitHubToken - Re-resolve on ReauthenticateAsync (user may have just logged in) - Forward to all 7 StartServerAsync call sites - Add 3 tests for token resolution and parameter forwarding Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/ServerRecoveryTests.cs | 32 +++++++++++ PolyPilot.Tests/TestStubs.cs | 4 +- PolyPilot/Models/ErrorMessageHelper.cs | 2 +- .../Services/CopilotService.Utilities.cs | 55 +++++++++++++++++++ PolyPilot/Services/CopilotService.cs | 22 +++++--- PolyPilot/Services/IServerManager.cs | 2 +- PolyPilot/Services/ServerManager.cs | 12 +++- 7 files changed, 118 insertions(+), 11 deletions(-) diff --git a/PolyPilot.Tests/ServerRecoveryTests.cs b/PolyPilot.Tests/ServerRecoveryTests.cs index 5c0b11fa..43eb09e8 100644 --- a/PolyPilot.Tests/ServerRecoveryTests.cs +++ b/PolyPilot.Tests/ServerRecoveryTests.cs @@ -148,6 +148,38 @@ public async Task ReauthenticateAsync_NonPersistentMode_SetsFailureNotice() Assert.Contains("restart failed", svc.AuthNotice!, StringComparison.OrdinalIgnoreCase); } + // ===== ResolveGitHubTokenForServer ===== + + [Fact] + public void ResolveGitHubTokenForServer_ReturnsNull_WhenNoTokenAvailable() + { + // In test environment, no env vars should be set and gh CLI may not be available. + // The method should return null gracefully without throwing. + var token = CopilotService.ResolveGitHubTokenForServer(); + // We can't assert null because the test runner might have GH_TOKEN set. + // Just verify it doesn't throw and returns a string or null. + Assert.True(token == null || token.Length > 0); + } + + [Fact] + 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(); + Assert.Equal("test-token-123", mgr.LastGitHubToken); + } + + [Fact] + public void ServerManager_AcceptsNullGitHubToken_InStartServerAsync() + { + var mgr = new StubServerManager(); + mgr.StartServerResult = true; + mgr.StartServerAsync(4321).GetAwaiter().GetResult(); + Assert.Null(mgr.LastGitHubToken); + } + // ===== IsConnectionError now catches auth errors ===== [Theory] diff --git a/PolyPilot.Tests/TestStubs.cs b/PolyPilot.Tests/TestStubs.cs index 9c4b9176..cf7c665b 100644 --- a/PolyPilot.Tests/TestStubs.cs +++ b/PolyPilot.Tests/TestStubs.cs @@ -46,11 +46,13 @@ internal class StubServerManager : IServerManager public bool CheckServerRunning(string host = "localhost", int? port = null) => IsServerRunning; - public Task StartServerAsync(int port) + public Task StartServerAsync(int port, string? githubToken = null) { ServerPort = port; + LastGitHubToken = githubToken; return Task.FromResult(StartServerResult); } + public string? LastGitHubToken { get; private set; } public void StopServer() { IsServerRunning = false; StopServerCallCount++; } public int StopServerCallCount { get; private set; } diff --git a/PolyPilot/Models/ErrorMessageHelper.cs b/PolyPilot/Models/ErrorMessageHelper.cs index 320f978d..df8b0904 100644 --- a/PolyPilot/Models/ErrorMessageHelper.cs +++ b/PolyPilot/Models/ErrorMessageHelper.cs @@ -68,7 +68,7 @@ public static string HumanizeMessage(string message) // Authentication errors from the CLI SDK if (message.Contains("not created with authentication info", StringComparison.OrdinalIgnoreCase)) - return "Not authenticated β€” run `copilot login` in your terminal, then reconnect in Settings."; + return "Not authenticated β€” run `copilot login` (or `gh auth login`) in your terminal, then click Re-authenticate."; // Catch-all for any other net_webstatus_ codes we haven't mapped if (message.Contains("net_webstatus_", StringComparison.OrdinalIgnoreCase)) diff --git a/PolyPilot/Services/CopilotService.Utilities.cs b/PolyPilot/Services/CopilotService.Utilities.cs index f5fb7cfe..1832ed6c 100644 --- a/PolyPilot/Services/CopilotService.Utilities.cs +++ b/PolyPilot/Services/CopilotService.Utilities.cs @@ -941,4 +941,59 @@ private void StopAuthPolling() } } } + + /// + /// Attempts to resolve a GitHub token that can be forwarded to the headless server + /// via the GITHUB_TOKEN env var. This helps when the server can't access the macOS + /// Keychain (e.g., Keychain entry ACL blocks headless processes). + /// Checks, in order: COPILOT_GITHUB_TOKEN, GH_TOKEN, GITHUB_TOKEN env vars, + /// then falls back to `gh auth token` if the gh CLI is available. + /// + internal static string? ResolveGitHubTokenForServer() + { + // Check environment variables (same precedence as copilot CLI) + foreach (var envVar in new[] { "COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN" }) + { + var val = Environment.GetEnvironmentVariable(envVar); + if (!string.IsNullOrEmpty(val)) + { + Console.WriteLine($"[AUTH] Resolved token from ${envVar}"); + return val; + } + } + + // Try the gh CLI as a fallback + try + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "gh", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + psi.ArgumentList.Add("auth"); + psi.ArgumentList.Add("token"); + + using var proc = System.Diagnostics.Process.Start(psi); + if (proc != null) + { + var token = proc.StandardOutput.ReadToEnd().Trim(); + proc.WaitForExit(5000); + if (proc.ExitCode == 0 && !string.IsNullOrEmpty(token)) + { + Console.WriteLine("[AUTH] Resolved token from `gh auth token`"); + return token; + } + } + } + catch + { + // gh CLI not available β€” that's fine + } + + Console.WriteLine("[AUTH] No GitHub token could be resolved for server forwarding"); + return null; + } } diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 2a5a1524..886450a5 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -82,6 +82,7 @@ internal void SetTurnEndGuardForTesting(string sessionName, bool active) private CancellationTokenSource? _codespaceHealthCts; private CancellationTokenSource? _authPollCts; private readonly object _authPollLock = new(); + private string? _resolvedGitHubToken; private Task? _codespaceHealthTask; // Cached dotfiles status β€” checked once when first SetupRequired state is encountered private CodespaceService.DotfilesStatus? _dotfilesStatus; @@ -318,6 +319,8 @@ public async Task ReauthenticateAsync() { StopAuthPolling(); Debug("[AUTH] Re-authenticate requested β€” forcing server restart to pick up new credentials"); + // Re-resolve the token in case the user just ran `copilot login` or `gh auth login` + _resolvedGitHubToken = ResolveGitHubTokenForServer(); var recovered = await TryRecoverPersistentServerAsync(); if (recovered) { @@ -917,10 +920,15 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default) // In Persistent mode, auto-start the server if not already running if (settings.Mode == ConnectionMode.Persistent) { + // Resolve a GitHub token that can be forwarded to the headless server. + // This handles the case where the Keychain entry created by `copilot login` + // is inaccessible to a headless process (macOS Keychain ACL restriction). + _resolvedGitHubToken ??= ResolveGitHubTokenForServer(); + 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); + var started = await _serverManager.StartServerAsync(settings.Port, _resolvedGitHubToken); if (!started) { Debug("Failed to auto-start server, falling back to Embedded mode"); @@ -967,7 +975,7 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default) await Task.Delay(250, cancellationToken); } - var restarted = await _serverManager.StartServerAsync(settings.Port); + var restarted = await _serverManager.StartServerAsync(settings.Port, _resolvedGitHubToken); if (restarted) { Debug("Server restarted, retrying connection..."); @@ -1284,7 +1292,7 @@ internal async Task TryRecoverPersistentServerAsync() } // Start a fresh server β€” this forces the CLI to re-authenticate with GitHub - var started = await _serverManager.StartServerAsync(settings.Port); + var started = await _serverManager.StartServerAsync(settings.Port, _resolvedGitHubToken); if (!started) { Debug("[SERVER-RECOVERY] Failed to restart persistent server"); @@ -1381,7 +1389,7 @@ public async Task RestartServerAsync(CancellationToken cancellationToken = defau } // 5. Start fresh server (will extract current native modules) - var started = await _serverManager.StartServerAsync(restartSettings.Port); + var started = await _serverManager.StartServerAsync(restartSettings.Port, _resolvedGitHubToken); if (!started) { Debug("[SERVER-RESTART] Failed to restart server"); @@ -2496,7 +2504,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); + var started = await _serverManager.StartServerAsync(settings.Port, _resolvedGitHubToken); if (!started) { Debug("Failed to restart persistent server"); @@ -3242,7 +3250,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); + await _serverManager.StartServerAsync(reinitSettings.Port, _resolvedGitHubToken); } _client = CreateClient(reinitSettings); await _client.StartAsync(cancellationToken); @@ -3283,7 +3291,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); + var started = await _serverManager.StartServerAsync(connSettings.Port, _resolvedGitHubToken); if (!started) { Debug("Failed to restart persistent server"); diff --git a/PolyPilot/Services/IServerManager.cs b/PolyPilot/Services/IServerManager.cs index 826256c9..e22469a8 100644 --- a/PolyPilot/Services/IServerManager.cs +++ b/PolyPilot/Services/IServerManager.cs @@ -13,7 +13,7 @@ public interface IServerManager event Action? OnStatusChanged; bool CheckServerRunning(string host = "127.0.0.1", int? port = null); - Task StartServerAsync(int port); + Task StartServerAsync(int port, string? githubToken = null); void StopServer(); bool DetectExistingServer(); } diff --git a/PolyPilot/Services/ServerManager.cs b/PolyPilot/Services/ServerManager.cs index f87f9fdf..22650879 100644 --- a/PolyPilot/Services/ServerManager.cs +++ b/PolyPilot/Services/ServerManager.cs @@ -50,7 +50,7 @@ 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) + public async Task StartServerAsync(int port = 4321, string? githubToken = null) { ServerPort = port; LastError = null; @@ -76,6 +76,16 @@ public async Task StartServerAsync(int port = 4321) 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["GITHUB_TOKEN"] = githubToken; + Console.WriteLine("[ServerManager] Passing GITHUB_TOKEN to headless server"); + } + // Use ArgumentList for proper escaping (especially MCP JSON) psi.ArgumentList.Add("--headless"); psi.ArgumentList.Add("--no-auto-update"); From f77d61155db757e873e9ed167d75e4fc778ff4cc Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Sat, 28 Mar 2026 15:10:02 -0500 Subject: [PATCH 5/7] Fix Keychain auth: add "copilot-cli" service name for token lookup The copilot CLI stores its OAuth token in macOS Keychain under service name "copilot-cli" (via keytar.node), not "github-copilot" or "GitHub Copilot" as previously assumed. Users who completed `copilot login` but do not have `gh` CLI would get "Session was not created with authentication info" because the Keychain lookup missed the correct service name. Add "copilot-cli" as the first (most common) service name to try in TryReadCopilotKeychainToken(). Keep the other names as fallbacks for older CLI versions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/ServerRecoveryTests.cs | 25 +++++++ .../Services/CopilotService.Utilities.cs | 69 +++++++++++++++++-- 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/PolyPilot.Tests/ServerRecoveryTests.cs b/PolyPilot.Tests/ServerRecoveryTests.cs index 43eb09e8..eef291a2 100644 --- a/PolyPilot.Tests/ServerRecoveryTests.cs +++ b/PolyPilot.Tests/ServerRecoveryTests.cs @@ -161,6 +161,31 @@ public void ResolveGitHubTokenForServer_ReturnsNull_WhenNoTokenAvailable() Assert.True(token == null || token.Length > 0); } + // ===== TryReadCopilotKeychainToken ===== + + [Fact] + public void TryReadCopilotKeychainToken_DoesNotThrow() + { + // Should silently return null (or a token) β€” never throw β€” even if the entry + // is absent, the `security` binary is missing, or it times out. + var result = CopilotService.TryReadCopilotKeychainToken(); + Assert.True(result == null || result.Length > 0); + } + + [Fact] + public void TryReadCopilotKeychainToken_ReturnsNonEmptyToken_WhenCopilotLoginDone() + { + // Only meaningful on macOS where `copilot login` writes to the login Keychain. + // On non-macOS the method always returns null β€” that's fine, verified by the + // DoesNotThrow test above. + if (!OperatingSystem.IsMacOS() && !OperatingSystem.IsMacCatalyst()) + return; + + var result = CopilotService.TryReadCopilotKeychainToken(); + // May be null if the user hasn't run `copilot login`, but must never be empty string. + Assert.True(result == null || result.Length > 0); + } + [Fact] public void ServerManager_AcceptsGitHubToken_InStartServerAsync() { diff --git a/PolyPilot/Services/CopilotService.Utilities.cs b/PolyPilot/Services/CopilotService.Utilities.cs index 1832ed6c..bc3db2d9 100644 --- a/PolyPilot/Services/CopilotService.Utilities.cs +++ b/PolyPilot/Services/CopilotService.Utilities.cs @@ -946,12 +946,14 @@ private void StopAuthPolling() /// Attempts to resolve a GitHub token that can be forwarded to the headless server /// via the GITHUB_TOKEN env var. This helps when the server can't access the macOS /// Keychain (e.g., Keychain entry ACL blocks headless processes). - /// Checks, in order: COPILOT_GITHUB_TOKEN, GH_TOKEN, GITHUB_TOKEN env vars, - /// then falls back to `gh auth token` if the gh CLI is available. + /// Checks, in order: + /// 1. COPILOT_GITHUB_TOKEN, GH_TOKEN, GITHUB_TOKEN env vars + /// 2. macOS Keychain entry written by `copilot login` (service "copilot-cli" / "github-copilot") + /// 3. `gh auth token` if the gh CLI is installed and authenticated /// internal static string? ResolveGitHubTokenForServer() { - // Check environment variables (same precedence as copilot CLI) + // 1. Environment variables (same precedence as copilot CLI) foreach (var envVar in new[] { "COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN" }) { var val = Environment.GetEnvironmentVariable(envVar); @@ -962,7 +964,20 @@ private void StopAuthPolling() } } - // Try the gh CLI as a fallback + // 2. macOS Keychain β€” `copilot login` stores the OAuth token here via keytar.node. + // The headless server process may not inherit the ACL for this entry, so we read + // it from the UI process (which has full login-keychain access) and forward it. + if (OperatingSystem.IsMacOS() || OperatingSystem.IsMacCatalyst()) + { + var keychainToken = TryReadCopilotKeychainToken(); + if (keychainToken != null) + { + Console.WriteLine("[AUTH] Resolved token from macOS Keychain (copilot login)"); + return keychainToken; + } + } + + // 3. gh CLI fallback β€” works when the user authenticated via `gh auth login` try { var psi = new System.Diagnostics.ProcessStartInfo @@ -996,4 +1011,50 @@ private void StopAuthPolling() Console.WriteLine("[AUTH] No GitHub token could be resolved for server forwarding"); return null; } + + /// + /// Reads the GitHub OAuth token stored by copilot login from the macOS login Keychain. + /// Uses the security CLI (built into macOS) so no extra entitlements are needed. + /// Returns null silently on any failure (missing entry, no access, etc.). + /// + internal static string? TryReadCopilotKeychainToken() + { + // `copilot login` stores the token via keytar.node under a service name that + // has changed across CLI versions. We try all known names (most common first). + foreach (var serviceName in new[] { "copilot-cli", "github-copilot", "GitHub Copilot" }) + { + try + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "security", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + // find-generic-password -s -w β†’ prints just the password + // Omitting -a (account) matches the first entry for this service, + // which is correct for the common single-account case. + psi.ArgumentList.Add("find-generic-password"); + psi.ArgumentList.Add("-s"); + psi.ArgumentList.Add(serviceName); + psi.ArgumentList.Add("-w"); + + using var proc = System.Diagnostics.Process.Start(psi); + if (proc == null) continue; + + var token = proc.StandardOutput.ReadToEnd().Trim(); + proc.WaitForExit(3000); + + if (proc.ExitCode == 0 && !string.IsNullOrEmpty(token)) + return token; + } + catch + { + // security CLI error or timeout β€” try next service name + } + } + return null; + } } From 37aadc067dbc1011e04af623374595193294b310 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Sat, 28 Mar 2026 15:21:56 -0500 Subject: [PATCH 6/7] fix: use COPILOT_GITHUB_TOKEN and add Keychain fallback for copilot-login-only users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The copilot CLI stores its OAuth token in macOS Keychain under service name 'copilot-cli' (via keytar.node). The headless server spawned by PolyPilot may not inherit the Keychain ACL (different binary path than the terminal copilot), causing 'not created with authentication info'. Token resolution order (ResolveGitHubTokenForServer): 1. Env vars: COPILOT_GITHUB_TOKEN > GH_TOKEN > GITHUB_TOKEN 2. macOS Keychain: service names 'copilot-cli', 'github-copilot', 'GitHub Copilot' (covers CLI version variations) 3. gh CLI: 'gh auth token' fallback Use COPILOT_GITHUB_TOKEN (not GITHUB_TOKEN) when forwarding to the headless server β€” it has highest precedence per copilot docs and won't conflict with other tools' env vars. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Services/ServerManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PolyPilot/Services/ServerManager.cs b/PolyPilot/Services/ServerManager.cs index 22650879..ad09cd63 100644 --- a/PolyPilot/Services/ServerManager.cs +++ b/PolyPilot/Services/ServerManager.cs @@ -82,8 +82,8 @@ public async Task StartServerAsync(int port = 4321, string? githubToken = // be shown for a background process). if (!string.IsNullOrEmpty(githubToken)) { - psi.Environment["GITHUB_TOKEN"] = githubToken; - Console.WriteLine("[ServerManager] Passing GITHUB_TOKEN to headless server"); + psi.Environment["COPILOT_GITHUB_TOKEN"] = githubToken; + Console.WriteLine("[ServerManager] Passing COPILOT_GITHUB_TOKEN to headless server"); } // Use ArgumentList for proper escaping (especially MCP JSON) From d679f9a8b6097bd3c0711f39b761184c57cd28e8 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Sat, 28 Mar 2026 19:05:58 -0500 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20address=20R3=20review=20=E2=80=94=20?= =?UTF-8?q?race=20condition,=20subprocess=20safety,=20dead=20state=20recov?= =?UTF-8?q?ery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - R2-M1: CheckAuthStatusAsync returns bool; ReauthenticateAsync uses return value instead of reading stale AuthNotice (InvokeOnUI race) - R2-M2: Polling recovery failure restarts polling instead of dead state - M3/M4: Extract RunProcessWithTimeout helper with WaitForExit check and proc.Kill on timeout β€” prevents zombies and ReadToEnd blocks - M5: Clear resolvedGitHubToken in ReconnectAsync to force re-resolve - R2-m1: ClearAuthNotice wraps AuthNotice=null inside InvokeOnUI - Add 4 tests for RunProcessWithTimeout (success, non-zero exit, missing binary, timeout) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/ServerRecoveryTests.cs | 34 +++++ .../Services/CopilotService.Utilities.cs | 122 +++++++++--------- PolyPilot/Services/CopilotService.cs | 19 +-- 3 files changed, 108 insertions(+), 67 deletions(-) diff --git a/PolyPilot.Tests/ServerRecoveryTests.cs b/PolyPilot.Tests/ServerRecoveryTests.cs index eef291a2..02a3a2f8 100644 --- a/PolyPilot.Tests/ServerRecoveryTests.cs +++ b/PolyPilot.Tests/ServerRecoveryTests.cs @@ -205,6 +205,40 @@ public void ServerManager_AcceptsNullGitHubToken_InStartServerAsync() Assert.Null(mgr.LastGitHubToken); } + // ===== RunProcessWithTimeout ===== + + [Fact] + public void RunProcessWithTimeout_ReturnsOutput_OnSuccess() + { + // `echo` is universally available β€” should return the text + var result = CopilotService.RunProcessWithTimeout("echo", new[] { "hello" }, 3000); + Assert.Equal("hello", result); + } + + [Fact] + public void RunProcessWithTimeout_ReturnsNull_OnNonZeroExit() + { + // `false` exits with code 1 + var result = CopilotService.RunProcessWithTimeout("false", Array.Empty(), 3000); + Assert.Null(result); + } + + [Fact] + public void RunProcessWithTimeout_ReturnsNull_OnMissingBinary() + { + var result = CopilotService.RunProcessWithTimeout("nonexistent-binary-12345", + Array.Empty(), 3000); + Assert.Null(result); + } + + [Fact] + public void RunProcessWithTimeout_ReturnsNull_WhenTimeoutExceeded() + { + // `sleep 30` with a 100ms timeout should be killed + var result = CopilotService.RunProcessWithTimeout("sleep", new[] { "30" }, 100); + Assert.Null(result); + } + // ===== IsConnectionError now catches auth errors ===== [Theory] diff --git a/PolyPilot/Services/CopilotService.Utilities.cs b/PolyPilot/Services/CopilotService.Utilities.cs index bc3db2d9..1fafa57a 100644 --- a/PolyPilot/Services/CopilotService.Utilities.cs +++ b/PolyPilot/Services/CopilotService.Utilities.cs @@ -843,10 +843,11 @@ private async Task FetchGitHubUserInfoAsync() /// /// Checks the CLI server's authentication status via the SDK and surfaces a /// dismissible banner if the server is not authenticated. + /// Returns true if authenticated, false otherwise. /// - private async Task CheckAuthStatusAsync() + private async Task CheckAuthStatusAsync() { - if (IsDemoMode || IsRemoteMode || _client == null) return; + if (IsDemoMode || IsRemoteMode || _client == null) return false; try { var status = await _client.GetAuthStatusAsync(); @@ -859,6 +860,7 @@ private async Task CheckAuthStatusAsync() OnStateChanged?.Invoke(); }); Debug($"[AUTH] Authenticated as {status.Login} via {status.AuthType}"); + return true; } else { @@ -869,6 +871,7 @@ private async Task CheckAuthStatusAsync() }); Debug($"[AUTH] Not authenticated: {status.StatusMessage}"); StartAuthPolling(); + return false; } } catch (Exception ex) @@ -877,6 +880,7 @@ private async Task CheckAuthStatusAsync() // Treat a thrown exception (server not ready, transient error) as possibly unauthenticated // and start polling so the banner can appear once the server is reachable. StartAuthPolling(); + return false; } } @@ -904,6 +908,8 @@ private void StartAuthPolling() if (status.IsAuthenticated) { Debug($"[AUTH-POLL] Auth detected ({status.Login}) β€” triggering server restart"); + // Re-resolve token in case it changed + _resolvedGitHubToken = ResolveGitHubTokenForServer(); StopAuthPolling(); var recovered = await TryRecoverPersistentServerAsync(); if (recovered) @@ -915,6 +921,16 @@ private void StartAuthPolling() OnStateChanged?.Invoke(); }); } + else + { + Debug("[AUTH-POLL] Server recovery failed β€” restarting polling"); + InvokeOnUI(() => + { + AuthNotice = "Authentication detected but server restart failed. Will retry..."; + OnStateChanged?.Invoke(); + }); + StartAuthPolling(); + } return; } } @@ -978,34 +994,11 @@ private void StopAuthPolling() } // 3. gh CLI fallback β€” works when the user authenticated via `gh auth login` - try - { - var psi = new System.Diagnostics.ProcessStartInfo - { - FileName = "gh", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - psi.ArgumentList.Add("auth"); - psi.ArgumentList.Add("token"); - - using var proc = System.Diagnostics.Process.Start(psi); - if (proc != null) - { - var token = proc.StandardOutput.ReadToEnd().Trim(); - proc.WaitForExit(5000); - if (proc.ExitCode == 0 && !string.IsNullOrEmpty(token)) - { - Console.WriteLine("[AUTH] Resolved token from `gh auth token`"); - return token; - } - } - } - catch + var ghToken = RunProcessWithTimeout("gh", new[] { "auth", "token" }, 5000); + if (ghToken != null) { - // gh CLI not available β€” that's fine + Console.WriteLine("[AUTH] Resolved token from `gh auth token`"); + return ghToken; } Console.WriteLine("[AUTH] No GitHub token could be resolved for server forwarding"); @@ -1023,38 +1016,51 @@ private void StopAuthPolling() // has changed across CLI versions. We try all known names (most common first). foreach (var serviceName in new[] { "copilot-cli", "github-copilot", "GitHub Copilot" }) { - try + var token = RunProcessWithTimeout("security", + new[] { "find-generic-password", "-s", serviceName, "-w" }, 3000); + if (token != null) + return token; + } + return null; + } + + /// + /// Runs a process with a timeout, returning trimmed stdout on success or null on failure/timeout. + /// Kills the process if it exceeds the timeout to prevent zombies. + /// + internal static string? RunProcessWithTimeout(string fileName, string[] args, int timeoutMs) + { + try + { + var psi = new System.Diagnostics.ProcessStartInfo { - var psi = new System.Diagnostics.ProcessStartInfo - { - FileName = "security", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - // find-generic-password -s -w β†’ prints just the password - // Omitting -a (account) matches the first entry for this service, - // which is correct for the common single-account case. - psi.ArgumentList.Add("find-generic-password"); - psi.ArgumentList.Add("-s"); - psi.ArgumentList.Add(serviceName); - psi.ArgumentList.Add("-w"); - - using var proc = System.Diagnostics.Process.Start(psi); - if (proc == null) continue; - - var token = proc.StandardOutput.ReadToEnd().Trim(); - proc.WaitForExit(3000); - - if (proc.ExitCode == 0 && !string.IsNullOrEmpty(token)) - return token; - } - catch + FileName = fileName, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + foreach (var arg in args) + psi.ArgumentList.Add(arg); + + using var proc = System.Diagnostics.Process.Start(psi); + if (proc == null) return null; + + // Read output asynchronously with timeout to prevent blocking on + // ACL dialogs (security) or network hangs (gh) + var readTask = proc.StandardOutput.ReadToEndAsync(); + if (!proc.WaitForExit(timeoutMs)) { - // security CLI error or timeout β€” try next service name + try { proc.Kill(); } catch { } + return null; } + // Process exited within timeout β€” ReadToEndAsync should complete quickly + var output = readTask.GetAwaiter().GetResult().Trim(); + return proc.ExitCode == 0 && !string.IsNullOrEmpty(output) ? output : null; + } + catch + { + return null; } - return null; } } diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 886450a5..a6d865b9 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -299,9 +299,12 @@ internal CopilotService(IChatDatabase chatDb, IServerManager serverManager, IWsB public string? AuthNotice { get; private set; } public void ClearAuthNotice() { - AuthNotice = null; StopAuthPolling(); - InvokeOnUI(() => OnStateChanged?.Invoke()); + InvokeOnUI(() => + { + AuthNotice = null; + OnStateChanged?.Invoke(); + }); } /// Returns the full `copilot login` command using the resolved CLI path. @@ -324,8 +327,8 @@ public async Task ReauthenticateAsync() var recovered = await TryRecoverPersistentServerAsync(); if (recovered) { - await CheckAuthStatusAsync(); - if (AuthNotice == null) + var isAuthenticated = await CheckAuthStatusAsync(); + if (isAuthenticated) { Debug("[AUTH] Re-authentication successful"); _ = FetchGitHubUserInfoAsync(); @@ -333,11 +336,7 @@ public async Task ReauthenticateAsync() else { Debug("[AUTH] Server restarted but still not authenticated"); - InvokeOnUI(() => - { - AuthNotice = "Server restarted but still not authenticated. Please ensure `copilot login` completed successfully and try again."; - OnStateChanged?.Invoke(); - }); + // CheckAuthStatusAsync already set AuthNotice and started polling } } else @@ -345,6 +344,7 @@ public async Task ReauthenticateAsync() InvokeOnUI(() => { AuthNotice = "Server restart failed β€” please try running `copilot login` again."; + StartAuthPolling(); OnStateChanged?.Invoke(); }); } @@ -1182,6 +1182,7 @@ public async Task ReconnectAsync(ConnectionSettings settings, CancellationToken IsDemoMode = false; FallbackNotice = null; // Clear any previous fallback notice AuthNotice = null; // Clear any previous auth notice + _resolvedGitHubToken = null; // Force re-resolve on next server start StopAuthPolling(); CurrentMode = settings.Mode; CodespacesEnabled = settings.CodespacesEnabled && settings.Mode == ConnectionMode.Embedded;