feat: add Copilot version and account diagnostics

- report Copilot CLI version freshness as latest, outdated, or unknown
- expose active Copilot account metadata from the SDK auth status
- add best-effort GitHub organization context when gh is available
- document the frontend follow-up in HANDOVER.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-22 15:10:10 +01:00
co-authored by Copilot
parent 5e0167cf1d
commit bf5aa7ce57
7 changed files with 702 additions and 164 deletions
@@ -62,9 +62,29 @@ public sealed class SidecarConnectionDiagnosticsDto
public string Summary { get; init; } = string.Empty;
public string? Detail { get; init; }
public string? CopilotCliPath { get; init; }
public SidecarCopilotCliVersionDiagnosticsDto? CopilotCliVersion { get; init; }
public SidecarCopilotAccountDiagnosticsDto? Account { get; init; }
public string CheckedAt { get; init; } = string.Empty;
}
public sealed class SidecarCopilotCliVersionDiagnosticsDto
{
public string Status { get; init; } = "unknown";
public string? InstalledVersion { get; init; }
public string? LatestVersion { get; init; }
public string? Detail { get; init; }
}
public sealed class SidecarCopilotAccountDiagnosticsDto
{
public bool Authenticated { get; init; }
public string? Login { get; init; }
public string? Host { get; init; }
public string? AuthType { get; init; }
public string? StatusMessage { get; init; }
public IReadOnlyList<string>? Organizations { get; init; }
}
public sealed class SidecarCapabilitiesDto
{
public string Runtime { get; init; } = "dotnet-maf";
@@ -0,0 +1,393 @@
using System.Diagnostics;
using System.Text;
using System.Text.RegularExpressions;
using GitHub.Copilot.SDK;
using Kopaya.AgentHost.Contracts;
namespace Kopaya.AgentHost.Services;
internal static partial class CopilotConnectionMetadataResolver
{
private static readonly string[] LatestVersionIndicators =
[
"running the latest version",
"already up to date",
"is up to date",
];
private static readonly string[] OutdatedVersionIndicators =
[
"newer version",
"new version available",
"update available",
"download link",
];
private static readonly TimeSpan CopilotVersionTimeout = TimeSpan.FromSeconds(5);
private static readonly TimeSpan GitHubContextTimeout = TimeSpan.FromSeconds(4);
internal static async Task<SidecarCopilotCliVersionDiagnosticsDto> GetCliVersionDiagnosticsAsync(
CopilotCliContext cliContext,
CancellationToken cancellationToken)
{
try
{
CommandResult result = await RunProcessAsync(
executablePath: cliContext.CliPath,
arguments: ["version"],
environment: cliContext.Environment,
timeout: CopilotVersionTimeout,
cancellationToken).ConfigureAwait(false);
return ParseCliVersionOutput(result.StandardOutput, result.StandardError, result.ExitCode);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return new SidecarCopilotCliVersionDiagnosticsDto
{
Status = "unknown",
Detail = "Timed out while checking the installed GitHub Copilot CLI version.",
};
}
catch (Exception exception)
{
return new SidecarCopilotCliVersionDiagnosticsDto
{
Status = "unknown",
Detail = $"Failed to check the installed GitHub Copilot CLI version: {exception.Message}",
};
}
}
internal static SidecarCopilotCliVersionDiagnosticsDto ParseCliVersionOutput(
string? standardOutput,
string? standardError = null,
int exitCode = 0)
{
string output = NormalizeOutput(standardOutput, standardError);
string? installedVersion = ExtractInstalledVersion(output);
string status = ClassifyCliVersionStatus(output, exitCode);
string? latestVersion = status switch
{
"latest" => installedVersion,
"outdated" => ExtractLatestVersion(output, installedVersion),
_ => null,
};
string? detail = string.IsNullOrWhiteSpace(output)
? null
: output;
if (detail is null && status == "unknown")
{
detail = exitCode == 0
? "GitHub Copilot CLI version could not be determined."
: $"GitHub Copilot CLI version check exited with code {exitCode}.";
}
return new SidecarCopilotCliVersionDiagnosticsDto
{
Status = status,
InstalledVersion = installedVersion,
LatestVersion = latestVersion,
Detail = detail,
};
}
internal static string ClassifyCliVersionStatus(string output, int exitCode = 0)
{
if (string.IsNullOrWhiteSpace(output))
{
return "unknown";
}
if (LatestVersionIndicators.Any(indicator =>
output.Contains(indicator, StringComparison.OrdinalIgnoreCase)))
{
return "latest";
}
if (OutdatedVersionIndicators.Any(indicator =>
output.Contains(indicator, StringComparison.OrdinalIgnoreCase)))
{
return "outdated";
}
return exitCode == 0 && ExtractInstalledVersion(output) is not null
? "unknown"
: "unknown";
}
internal static string? ExtractInstalledVersion(string output)
{
Match match = SemanticVersionPattern().Match(output);
return match.Success ? match.Groups["version"].Value : null;
}
internal static string? ExtractLatestVersion(string output, string? installedVersion)
{
return SemanticVersionPattern()
.Matches(output)
.Select(match => match.Groups["version"].Value)
.FirstOrDefault(version =>
!string.Equals(version, installedVersion, StringComparison.OrdinalIgnoreCase));
}
internal static async Task<GetAuthStatusResponse?> TryGetAuthStatusAsync(
CopilotClient client,
CancellationToken cancellationToken)
{
try
{
return await client.GetAuthStatusAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception exception)
{
Console.Error.WriteLine($"[kopaya sidecar] Failed to inspect Copilot auth status: {exception.Message}");
return null;
}
}
internal static async Task<SidecarCopilotAccountDiagnosticsDto?> CreateAccountDiagnosticsAsync(
GetAuthStatusResponse? authStatus,
IReadOnlyDictionary<string, string> environment,
CancellationToken cancellationToken)
{
if (authStatus is null)
{
return null;
}
string? normalizedHost = NormalizeHost(authStatus.Host);
IReadOnlyList<string>? organizations = null;
if (authStatus.IsAuthenticated
&& !string.IsNullOrWhiteSpace(authStatus.Login)
&& !string.IsNullOrWhiteSpace(normalizedHost))
{
organizations = await TryListOrganizationsAsync(
authStatus.Login,
normalizedHost,
environment,
cancellationToken).ConfigureAwait(false);
}
return new SidecarCopilotAccountDiagnosticsDto
{
Authenticated = authStatus.IsAuthenticated,
Login = authStatus.Login,
Host = normalizedHost,
AuthType = authStatus.AuthType,
StatusMessage = authStatus.StatusMessage,
Organizations = organizations,
};
}
internal static string? NormalizeHost(string? host)
{
if (string.IsNullOrWhiteSpace(host))
{
return null;
}
string trimmed = host.Trim();
if (Uri.TryCreate(trimmed, UriKind.Absolute, out Uri? uri))
{
return uri.Host;
}
return trimmed
.Replace("https://", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace("http://", string.Empty, StringComparison.OrdinalIgnoreCase)
.TrimEnd('/');
}
internal static IReadOnlyList<string> ParseOrganizationsOutput(string? output)
{
return (output ?? string.Empty)
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static async Task<IReadOnlyList<string>?> TryListOrganizationsAsync(
string login,
string host,
IReadOnlyDictionary<string, string> environment,
CancellationToken cancellationToken)
{
CommandResult? loginResult = await TryRunGhCommandAsync(
["api", "--hostname", host, "user", "--jq", ".login"],
environment,
cancellationToken).ConfigureAwait(false);
string? resolvedLogin = loginResult is { ExitCode: 0 }
? loginResult.StandardOutput.Trim()
: null;
if (string.IsNullOrWhiteSpace(resolvedLogin)
|| !string.Equals(resolvedLogin, login, StringComparison.OrdinalIgnoreCase))
{
return null;
}
CommandResult? organizationsResult = await TryRunGhCommandAsync(
["api", "--hostname", host, "user/orgs", "--jq", ".[].login"],
environment,
cancellationToken).ConfigureAwait(false);
return organizationsResult is { ExitCode: 0 }
? ParseOrganizationsOutput(organizationsResult.StandardOutput)
: null;
}
private static async Task<CommandResult?> TryRunGhCommandAsync(
IReadOnlyList<string> arguments,
IReadOnlyDictionary<string, string> environment,
CancellationToken cancellationToken)
{
try
{
return await RunProcessAsync(
executablePath: OperatingSystem.IsWindows() ? "gh.exe" : "gh",
arguments,
environment,
GitHubContextTimeout,
cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return null;
}
catch
{
return null;
}
}
private static async Task<CommandResult> RunProcessAsync(
string executablePath,
IReadOnlyList<string> arguments,
IReadOnlyDictionary<string, string> environment,
TimeSpan timeout,
CancellationToken cancellationToken)
{
using CancellationTokenSource timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(timeout);
using Process process = new()
{
StartInfo = CreateProcessStartInfo(executablePath, arguments, environment),
};
if (!process.Start())
{
throw new InvalidOperationException($"Failed to start command '{executablePath}'.");
}
Task<string> standardOutputTask = process.StandardOutput.ReadToEndAsync(timeoutSource.Token);
Task<string> standardErrorTask = process.StandardError.ReadToEndAsync(timeoutSource.Token);
await process.WaitForExitAsync(timeoutSource.Token).ConfigureAwait(false);
return new CommandResult(
process.ExitCode,
await standardOutputTask.ConfigureAwait(false),
await standardErrorTask.ConfigureAwait(false));
}
private static ProcessStartInfo CreateProcessStartInfo(
string executablePath,
IReadOnlyList<string> arguments,
IReadOnlyDictionary<string, string> environment)
{
ProcessStartInfo startInfo = new()
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
if (OperatingSystem.IsWindows() && RequiresWindowsShell(executablePath))
{
startInfo.FileName = string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ComSpec"))
? "cmd.exe"
: Environment.GetEnvironmentVariable("ComSpec");
startInfo.ArgumentList.Add("/d");
startInfo.ArgumentList.Add("/s");
startInfo.ArgumentList.Add("/c");
startInfo.ArgumentList.Add(BuildWindowsShellCommand(executablePath, arguments));
}
else
{
startInfo.FileName = executablePath;
foreach (string argument in arguments)
{
startInfo.ArgumentList.Add(argument);
}
}
startInfo.Environment.Clear();
foreach (KeyValuePair<string, string> entry in environment)
{
startInfo.Environment[entry.Key] = entry.Value;
}
return startInfo;
}
private static bool RequiresWindowsShell(string executablePath)
{
if (!OperatingSystem.IsWindows())
{
return false;
}
string extension = Path.GetExtension(executablePath);
return extension.Equals(".cmd", StringComparison.OrdinalIgnoreCase)
|| extension.Equals(".bat", StringComparison.OrdinalIgnoreCase);
}
private static string BuildWindowsShellCommand(string executablePath, IReadOnlyList<string> arguments)
{
IEnumerable<string> tokens = [executablePath, .. arguments];
return string.Join(" ", tokens.Select(QuoteWindowsShellToken));
}
private static string QuoteWindowsShellToken(string value)
{
if (string.IsNullOrEmpty(value))
{
return "\"\"";
}
bool needsQuotes = value.Any(character =>
char.IsWhiteSpace(character)
|| character is '&' or '(' or ')' or '[' or ']' or '{' or '}' or '^' or '=' or ';' or '!' or '+' or ',' or '`' or '~');
if (!needsQuotes)
{
return value;
}
return "\"" + value.Replace("\"", "\"\"", StringComparison.Ordinal) + "\"";
}
private static string NormalizeOutput(string? standardOutput, string? standardError)
{
return string.Join(
Environment.NewLine,
new[] { standardOutput, standardError }
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value!.Trim()));
}
private sealed record CommandResult(int ExitCode, string StandardOutput, string StandardError);
[GeneratedRegex(@"\b(?<version>\d+\.\d+\.\d+(?:[-+][0-9A-Za-z\.-]+)?)\b", RegexOptions.Compiled)]
private static partial Regex SemanticVersionPattern();
}
@@ -179,6 +179,8 @@ public sealed class SidecarProtocolHost
IReadOnlyList<SidecarModelCapabilityDto> models = [];
CopilotCliContext cliContext;
SidecarConnectionDiagnosticsDto connection;
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null;
SidecarCopilotAccountDiagnosticsDto? account = null;
try
{
@@ -197,14 +199,31 @@ public sealed class SidecarProtocolHost
};
}
Task<SidecarCopilotCliVersionDiagnosticsDto> cliVersionTask =
CopilotConnectionMetadataResolver.GetCliVersionDiagnosticsAsync(cliContext, cancellationToken);
try
{
models = await ListAvailableModelsAsync(cliContext, cancellationToken).ConfigureAwait(false);
connection = CreateReadyConnectionDiagnostics(cliContext.CliPath, models.Count);
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(cliContext);
await using CopilotClient client = new(clientOptions);
await client.StartAsync(cancellationToken).ConfigureAwait(false);
GetAuthStatusResponse? authStatus =
await CopilotConnectionMetadataResolver.TryGetAuthStatusAsync(client, cancellationToken).ConfigureAwait(false);
account = await CopilotConnectionMetadataResolver.CreateAccountDiagnosticsAsync(
authStatus,
cliContext.Environment,
cancellationToken).ConfigureAwait(false);
models = await ListAvailableModelsAsync(client, cancellationToken).ConfigureAwait(false);
cliVersion = await cliVersionTask.ConfigureAwait(false);
connection = CreateReadyConnectionDiagnostics(cliContext.CliPath, models.Count, cliVersion, account);
}
catch (Exception exception)
{
connection = CreateFailureConnectionDiagnostics(cliContext.CliPath, exception);
cliVersion = await cliVersionTask.ConfigureAwait(false);
connection = CreateFailureConnectionDiagnostics(cliContext.CliPath, exception, cliVersion, account);
Console.Error.WriteLine($"[kopaya sidecar] Failed to list available Copilot models: {exception.Message}");
}
@@ -234,14 +253,9 @@ public sealed class SidecarProtocolHost
}
private static async Task<IReadOnlyList<SidecarModelCapabilityDto>> ListAvailableModelsAsync(
CopilotCliContext cliContext,
CopilotClient client,
CancellationToken cancellationToken)
{
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(cliContext);
await using CopilotClient client = new(clientOptions);
await client.StartAsync(cancellationToken).ConfigureAwait(false);
List<ModelInfo> models = await client.ListModelsAsync(cancellationToken).ConfigureAwait(false);
return models
.Select(model => new SidecarModelCapabilityDto
@@ -278,7 +292,9 @@ public sealed class SidecarProtocolHost
internal static SidecarConnectionDiagnosticsDto CreateReadyConnectionDiagnostics(
string cliPath,
int modelCount)
int modelCount,
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null,
SidecarCopilotAccountDiagnosticsDto? account = null)
{
string summary = modelCount switch
{
@@ -293,13 +309,17 @@ public sealed class SidecarProtocolHost
Summary = summary,
Detail = $"Using Copilot CLI at {cliPath}.",
CopilotCliPath = cliPath,
CopilotCliVersion = cliVersion,
Account = account,
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
};
}
internal static SidecarConnectionDiagnosticsDto CreateFailureConnectionDiagnostics(
string? cliPath,
Exception exception)
Exception exception,
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null,
SidecarCopilotAccountDiagnosticsDto? account = null)
{
string status = ClassifyConnectionStatus(exception);
string summary = status == "copilot-auth-required"
@@ -312,6 +332,8 @@ public sealed class SidecarProtocolHost
Summary = summary,
Detail = exception.Message,
CopilotCliPath = cliPath,
CopilotCliVersion = cliVersion,
Account = account,
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
};
}