diff --git a/PolyPilot.Tests/ServerRecoveryTests.cs b/PolyPilot.Tests/ServerRecoveryTests.cs index abe03e87..02a3a2f8 100644 --- a/PolyPilot.Tests/ServerRecoveryTests.cs +++ b/PolyPilot.Tests/ServerRecoveryTests.cs @@ -89,6 +89,156 @@ 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); + } + + // ===== 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); + } + + // ===== 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() + { + // 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); + } + + // ===== 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.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/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index 9d9b815a..e0ec85e9 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -54,6 +54,25 @@ } + @if (!string.IsNullOrEmpty(CopilotService.AuthNotice)) + { +
+ 🔑 +
+

@CopilotService.AuthNotice

+
+ @CopilotService.GetLoginCommand() + +
+
+
+ + +
+
+ } @if (PlatformHelper.IsMobile && !CopilotService.IsInitialized && !_initializationComplete) {
@@ -753,6 +772,34 @@ StateHasChanged(); } + private void DismissAuthNotice() + { + CopilotService.ClearAuthNotice(); + 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/Models/ErrorMessageHelper.cs b/PolyPilot/Models/ErrorMessageHelper.cs index e2516dc1..df8b0904 100644 --- a/PolyPilot/Models/ErrorMessageHelper.cs +++ b/PolyPilot/Models/ErrorMessageHelper.cs @@ -66,6 +66,10 @@ 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)) + 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)) return "A network error occurred. Check your connection and try again."; diff --git a/PolyPilot/Services/CopilotService.Events.cs b/PolyPilot/Services/CopilotService.Events.cs index 9e8246f4..8c5e510c 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(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 b03ab5bc..1fafa57a 100644 --- a/PolyPilot/Services/CopilotService.Utilities.cs +++ b/PolyPilot/Services/CopilotService.Utilities.cs @@ -510,11 +510,20 @@ 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) + || 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) @@ -523,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); } /// @@ -834,4 +839,228 @@ 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. + /// Returns true if authenticated, false otherwise. + /// + private async Task CheckAuthStatusAsync() + { + if (IsDemoMode || IsRemoteMode || _client == null) return false; + try + { + var status = await _client.GetAuthStatusAsync(); + if (status.IsAuthenticated) + { + StopAuthPolling(); + InvokeOnUI(() => + { + AuthNotice = null; + OnStateChanged?.Invoke(); + }); + Debug($"[AUTH] Authenticated as {status.Login} via {status.AuthType}"); + return true; + } + else + { + InvokeOnUI(() => + { + AuthNotice = "Not authenticated — run the login command below, then click Re-authenticate."; + OnStateChanged?.Invoke(); + }); + Debug($"[AUTH] Not authenticated: {status.StatusMessage}"); + StartAuthPolling(); + return false; + } + } + catch (Exception ex) + { + 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(); + return false; + } + } + + /// + /// 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() + { + lock (_authPollLock) + { + 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"); + // Re-resolve token in case it changed + _resolvedGitHubToken = ResolveGitHubTokenForServer(); + StopAuthPolling(); + var recovered = await TryRecoverPersistentServerAsync(); + if (recovered) + { + InvokeOnUI(() => + { + AuthNotice = null; + _ = FetchGitHubUserInfoAsync(); + 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; + } + } + catch (OperationCanceledException) { break; } + catch (Exception ex) + { + Debug($"[AUTH-POLL] Error: {ex.Message}"); + } + } + Debug("[AUTH-POLL] Stopped polling"); + }, cts.Token); + } + } + + private void StopAuthPolling() + { + lock (_authPollLock) + { + if (_authPollCts != null) + { + _authPollCts.Cancel(); + _authPollCts.Dispose(); + _authPollCts = null; + } + } + } + + /// + /// 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: + /// 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() + { + // 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); + if (!string.IsNullOrEmpty(val)) + { + Console.WriteLine($"[AUTH] Resolved token from ${envVar}"); + return val; + } + } + + // 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` + var ghToken = RunProcessWithTimeout("gh", new[] { "auth", "token" }, 5000); + if (ghToken != null) + { + Console.WriteLine("[AUTH] Resolved token from `gh auth token`"); + return ghToken; + } + + 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" }) + { + 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 + { + 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)) + { + 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; + } + } } diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 7dc69185..a6d865b9 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -80,6 +80,9 @@ internal void SetTurnEndGuardForTesting(string sessionName, bool active) private readonly ConcurrentDictionary _tunnelHandles = new(); // Codespace health-check background task 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; @@ -292,6 +295,61 @@ 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() + { + StopAuthPolling(); + InvokeOnUI(() => + { + AuthNotice = null; + OnStateChanged?.Invoke(); + }); + } + + /// 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"); + // Re-resolve the token in case the user just ran `copilot login` or `gh auth login` + _resolvedGitHubToken = ResolveGitHubTokenForServer(); + var recovered = await TryRecoverPersistentServerAsync(); + if (recovered) + { + var isAuthenticated = await CheckAuthStatusAsync(); + if (isAuthenticated) + { + Debug("[AUTH] Re-authentication successful"); + _ = FetchGitHubUserInfoAsync(); + } + else + { + Debug("[AUTH] Server restarted but still not authenticated"); + // CheckAuthStatusAsync already set AuthNotice and started polling + } + } + else + { + InvokeOnUI(() => + { + AuthNotice = "Server restart failed — please try running `copilot login` again."; + StartAuthPolling(); + OnStateChanged?.Invoke(); + }); + } + } + // GitHub user info public string? GitHubAvatarUrl { get; private set; } public string? GitHubLogin { get; private set; } @@ -862,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"); @@ -912,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..."); @@ -987,6 +1050,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 +1181,9 @@ 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 + _resolvedGitHubToken = null; // Force re-resolve on next server start + StopAuthPolling(); CurrentMode = settings.Mode; CodespacesEnabled = settings.CodespacesEnabled && settings.Mode == ConnectionMode.Embedded; OnStateChanged?.Invoke(); @@ -1224,7 +1293,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"); @@ -1321,7 +1390,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"); @@ -2436,7 +2505,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"); @@ -3182,7 +3251,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); @@ -3223,7 +3292,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"); @@ -4526,6 +4595,7 @@ public async ValueTask DisposeAsync() StopKeepalivePing(); await StopCodespaceHealthCheckAsync(); StopExternalSessionScanner(); + StopAuthPolling(); // Flush any pending debounced writes immediately FlushSaveActiveSessionsToDisk(); 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..ad09cd63 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["COPILOT_GITHUB_TOKEN"] = githubToken; + Console.WriteLine("[ServerManager] Passing COPILOT_GITHUB_TOKEN to headless server"); + } + // Use ArgumentList for proper escaping (especially MCP JSON) psi.ArgumentList.Add("--headless"); psi.ArgumentList.Add("--no-auto-update");