Skip to content

Commit a6a943d

Browse files
PureWeenCopilot
andcommitted
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>
1 parent 5451111 commit a6a943d

7 files changed

Lines changed: 118 additions & 11 deletions

File tree

PolyPilot.Tests/ServerRecoveryTests.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,38 @@ public async Task ReauthenticateAsync_NonPersistentMode_SetsFailureNotice()
148148
Assert.Contains("restart failed", svc.AuthNotice!, StringComparison.OrdinalIgnoreCase);
149149
}
150150

151+
// ===== ResolveGitHubTokenForServer =====
152+
153+
[Fact]
154+
public void ResolveGitHubTokenForServer_ReturnsNull_WhenNoTokenAvailable()
155+
{
156+
// In test environment, no env vars should be set and gh CLI may not be available.
157+
// The method should return null gracefully without throwing.
158+
var token = CopilotService.ResolveGitHubTokenForServer();
159+
// We can't assert null because the test runner might have GH_TOKEN set.
160+
// Just verify it doesn't throw and returns a string or null.
161+
Assert.True(token == null || token.Length > 0);
162+
}
163+
164+
[Fact]
165+
public void ServerManager_AcceptsGitHubToken_InStartServerAsync()
166+
{
167+
// Verify the stub properly records the token parameter
168+
var mgr = new StubServerManager();
169+
mgr.StartServerResult = true;
170+
mgr.StartServerAsync(4321, "test-token-123").GetAwaiter().GetResult();
171+
Assert.Equal("test-token-123", mgr.LastGitHubToken);
172+
}
173+
174+
[Fact]
175+
public void ServerManager_AcceptsNullGitHubToken_InStartServerAsync()
176+
{
177+
var mgr = new StubServerManager();
178+
mgr.StartServerResult = true;
179+
mgr.StartServerAsync(4321).GetAwaiter().GetResult();
180+
Assert.Null(mgr.LastGitHubToken);
181+
}
182+
151183
// ===== IsConnectionError now catches auth errors =====
152184

153185
[Theory]

PolyPilot.Tests/TestStubs.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,13 @@ internal class StubServerManager : IServerManager
4646

4747
public bool CheckServerRunning(string host = "localhost", int? port = null) => IsServerRunning;
4848

49-
public Task<bool> StartServerAsync(int port)
49+
public Task<bool> StartServerAsync(int port, string? githubToken = null)
5050
{
5151
ServerPort = port;
52+
LastGitHubToken = githubToken;
5253
return Task.FromResult(StartServerResult);
5354
}
55+
public string? LastGitHubToken { get; private set; }
5456

5557
public void StopServer() { IsServerRunning = false; StopServerCallCount++; }
5658
public int StopServerCallCount { get; private set; }

PolyPilot/Models/ErrorMessageHelper.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ public static string HumanizeMessage(string message)
6868

6969
// Authentication errors from the CLI SDK
7070
if (message.Contains("not created with authentication info", StringComparison.OrdinalIgnoreCase))
71-
return "Not authenticated — run `copilot login` in your terminal, then reconnect in Settings.";
71+
return "Not authenticated — run `copilot login` (or `gh auth login`) in your terminal, then click Re-authenticate.";
7272

7373
// Catch-all for any other net_webstatus_ codes we haven't mapped
7474
if (message.Contains("net_webstatus_", StringComparison.OrdinalIgnoreCase))

PolyPilot/Services/CopilotService.Utilities.cs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -941,4 +941,59 @@ private void StopAuthPolling()
941941
}
942942
}
943943
}
944+
945+
/// <summary>
946+
/// Attempts to resolve a GitHub token that can be forwarded to the headless server
947+
/// via the GITHUB_TOKEN env var. This helps when the server can't access the macOS
948+
/// Keychain (e.g., Keychain entry ACL blocks headless processes).
949+
/// Checks, in order: COPILOT_GITHUB_TOKEN, GH_TOKEN, GITHUB_TOKEN env vars,
950+
/// then falls back to `gh auth token` if the gh CLI is available.
951+
/// </summary>
952+
internal static string? ResolveGitHubTokenForServer()
953+
{
954+
// Check environment variables (same precedence as copilot CLI)
955+
foreach (var envVar in new[] { "COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN" })
956+
{
957+
var val = Environment.GetEnvironmentVariable(envVar);
958+
if (!string.IsNullOrEmpty(val))
959+
{
960+
Console.WriteLine($"[AUTH] Resolved token from ${envVar}");
961+
return val;
962+
}
963+
}
964+
965+
// Try the gh CLI as a fallback
966+
try
967+
{
968+
var psi = new System.Diagnostics.ProcessStartInfo
969+
{
970+
FileName = "gh",
971+
UseShellExecute = false,
972+
RedirectStandardOutput = true,
973+
RedirectStandardError = true,
974+
CreateNoWindow = true
975+
};
976+
psi.ArgumentList.Add("auth");
977+
psi.ArgumentList.Add("token");
978+
979+
using var proc = System.Diagnostics.Process.Start(psi);
980+
if (proc != null)
981+
{
982+
var token = proc.StandardOutput.ReadToEnd().Trim();
983+
proc.WaitForExit(5000);
984+
if (proc.ExitCode == 0 && !string.IsNullOrEmpty(token))
985+
{
986+
Console.WriteLine("[AUTH] Resolved token from `gh auth token`");
987+
return token;
988+
}
989+
}
990+
}
991+
catch
992+
{
993+
// gh CLI not available — that's fine
994+
}
995+
996+
Console.WriteLine("[AUTH] No GitHub token could be resolved for server forwarding");
997+
return null;
998+
}
944999
}

PolyPilot/Services/CopilotService.cs

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ internal void SetTurnEndGuardForTesting(string sessionName, bool active)
8282
private CancellationTokenSource? _codespaceHealthCts;
8383
private CancellationTokenSource? _authPollCts;
8484
private readonly object _authPollLock = new();
85+
private string? _resolvedGitHubToken;
8586
private Task? _codespaceHealthTask;
8687
// Cached dotfiles status — checked once when first SetupRequired state is encountered
8788
private CodespaceService.DotfilesStatus? _dotfilesStatus;
@@ -318,6 +319,8 @@ public async Task ReauthenticateAsync()
318319
{
319320
StopAuthPolling();
320321
Debug("[AUTH] Re-authenticate requested — forcing server restart to pick up new credentials");
322+
// Re-resolve the token in case the user just ran `copilot login` or `gh auth login`
323+
_resolvedGitHubToken = ResolveGitHubTokenForServer();
321324
var recovered = await TryRecoverPersistentServerAsync();
322325
if (recovered)
323326
{
@@ -917,10 +920,15 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default)
917920
// In Persistent mode, auto-start the server if not already running
918921
if (settings.Mode == ConnectionMode.Persistent)
919922
{
923+
// Resolve a GitHub token that can be forwarded to the headless server.
924+
// This handles the case where the Keychain entry created by `copilot login`
925+
// is inaccessible to a headless process (macOS Keychain ACL restriction).
926+
_resolvedGitHubToken ??= ResolveGitHubTokenForServer();
927+
920928
if (!_serverManager.CheckServerRunning("127.0.0.1", settings.Port))
921929
{
922930
Debug($"Persistent server not running, auto-starting on port {settings.Port}...");
923-
var started = await _serverManager.StartServerAsync(settings.Port);
931+
var started = await _serverManager.StartServerAsync(settings.Port, _resolvedGitHubToken);
924932
if (!started)
925933
{
926934
Debug("Failed to auto-start server, falling back to Embedded mode");
@@ -967,7 +975,7 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default)
967975
await Task.Delay(250, cancellationToken);
968976
}
969977

970-
var restarted = await _serverManager.StartServerAsync(settings.Port);
978+
var restarted = await _serverManager.StartServerAsync(settings.Port, _resolvedGitHubToken);
971979
if (restarted)
972980
{
973981
Debug("Server restarted, retrying connection...");
@@ -1284,7 +1292,7 @@ internal async Task<bool> TryRecoverPersistentServerAsync()
12841292
}
12851293

12861294
// Start a fresh server — this forces the CLI to re-authenticate with GitHub
1287-
var started = await _serverManager.StartServerAsync(settings.Port);
1295+
var started = await _serverManager.StartServerAsync(settings.Port, _resolvedGitHubToken);
12881296
if (!started)
12891297
{
12901298
Debug("[SERVER-RECOVERY] Failed to restart persistent server");
@@ -1381,7 +1389,7 @@ public async Task RestartServerAsync(CancellationToken cancellationToken = defau
13811389
}
13821390

13831391
// 5. Start fresh server (will extract current native modules)
1384-
var started = await _serverManager.StartServerAsync(restartSettings.Port);
1392+
var started = await _serverManager.StartServerAsync(restartSettings.Port, _resolvedGitHubToken);
13851393
if (!started)
13861394
{
13871395
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
24962504
if (!_serverManager.CheckServerRunning("127.0.0.1", settings.Port))
24972505
{
24982506
Debug("Persistent server not running, restarting...");
2499-
var started = await _serverManager.StartServerAsync(settings.Port);
2507+
var started = await _serverManager.StartServerAsync(settings.Port, _resolvedGitHubToken);
25002508
if (!started)
25012509
{
25022510
Debug("Failed to restart persistent server");
@@ -3242,7 +3250,7 @@ public async Task<string> SendPromptAsync(string sessionName, string prompt, Lis
32423250
if (CurrentMode == ConnectionMode.Persistent &&
32433251
!_serverManager.CheckServerRunning("127.0.0.1", reinitSettings.Port))
32443252
{
3245-
await _serverManager.StartServerAsync(reinitSettings.Port);
3253+
await _serverManager.StartServerAsync(reinitSettings.Port, _resolvedGitHubToken);
32463254
}
32473255
_client = CreateClient(reinitSettings);
32483256
await _client.StartAsync(cancellationToken);
@@ -3283,7 +3291,7 @@ public async Task<string> SendPromptAsync(string sessionName, string prompt, Lis
32833291
!_serverManager.CheckServerRunning("127.0.0.1", connSettings.Port))
32843292
{
32853293
Debug("Persistent server not running, restarting...");
3286-
var started = await _serverManager.StartServerAsync(connSettings.Port);
3294+
var started = await _serverManager.StartServerAsync(connSettings.Port, _resolvedGitHubToken);
32873295
if (!started)
32883296
{
32893297
Debug("Failed to restart persistent server");

PolyPilot/Services/IServerManager.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ public interface IServerManager
1313
event Action? OnStatusChanged;
1414

1515
bool CheckServerRunning(string host = "127.0.0.1", int? port = null);
16-
Task<bool> StartServerAsync(int port);
16+
Task<bool> StartServerAsync(int port, string? githubToken = null);
1717
void StopServer();
1818
bool DetectExistingServer();
1919
}

PolyPilot/Services/ServerManager.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public bool CheckServerRunning(string host = "127.0.0.1", int? port = null)
5050
/// <summary>
5151
/// Start copilot in headless server mode, detached from app lifecycle
5252
/// </summary>
53-
public async Task<bool> StartServerAsync(int port = 4321)
53+
public async Task<bool> StartServerAsync(int port = 4321, string? githubToken = null)
5454
{
5555
ServerPort = port;
5656
LastError = null;
@@ -76,6 +76,16 @@ public async Task<bool> StartServerAsync(int port = 4321)
7676
RedirectStandardInput = false
7777
};
7878

79+
// Forward the GitHub token via environment variable so the headless server
80+
// can authenticate even when the macOS Keychain is inaccessible (e.g., the
81+
// Keychain entry was created in a terminal session and the ACL dialog can't
82+
// be shown for a background process).
83+
if (!string.IsNullOrEmpty(githubToken))
84+
{
85+
psi.Environment["GITHUB_TOKEN"] = githubToken;
86+
Console.WriteLine("[ServerManager] Passing GITHUB_TOKEN to headless server");
87+
}
88+
7989
// Use ArgumentList for proper escaping (especially MCP JSON)
8090
psi.ArgumentList.Add("--headless");
8191
psi.ArgumentList.Add("--no-auto-update");

0 commit comments

Comments
 (0)