using System.Diagnostics; using System.Text; using System.Text.RegularExpressions; using GitHub.Copilot.SDK; using Aryx.AgentHost.Contracts; namespace Aryx.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 GetCliVersionDiagnosticsAsync( CopilotCliContext cliContext, CancellationToken cancellationToken) { try { (string executablePath, string[] arguments) = CreateCliCommand(cliContext, "version"); CommandResult result = await RunProcessAsync( executablePath: executablePath, arguments: arguments, 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 (string ExecutablePath, string[] Arguments) CreateCliCommand( CopilotCliContext cliContext, params string[] arguments) { ArgumentNullException.ThrowIfNull(cliContext); ArgumentNullException.ThrowIfNull(arguments); return ( cliContext.LaunchPath, [.. cliContext.LaunchArgs, .. arguments]); } 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 TryGetAuthStatusAsync( CopilotClient client, CancellationToken cancellationToken) { try { return await client.GetAuthStatusAsync(cancellationToken).ConfigureAwait(false); } catch (Exception exception) { Console.Error.WriteLine($"[aryx sidecar] Failed to inspect Copilot auth status: {exception.Message}"); return null; } } internal static async Task CreateAccountDiagnosticsAsync( GetAuthStatusResponse? authStatus, IReadOnlyDictionary environment, CancellationToken cancellationToken) { if (authStatus is null) { return null; } string? normalizedHost = NormalizeHost(authStatus.Host); IReadOnlyList? 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 ParseOrganizationsOutput(string? output) { return (output ?? string.Empty) .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); } private static async Task?> TryListOrganizationsAsync( string login, string host, IReadOnlyDictionary 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 TryRunGhCommandAsync( IReadOnlyList arguments, IReadOnlyDictionary 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 RunProcessAsync( string executablePath, IReadOnlyList arguments, IReadOnlyDictionary 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}'."); } process.StandardInput.Close(); Task standardOutputTask = process.StandardOutput.ReadToEndAsync(timeoutSource.Token); Task 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 arguments, IReadOnlyDictionary environment) { ProcessStartInfo startInfo = new() { RedirectStandardOutput = true, RedirectStandardError = true, RedirectStandardInput = 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 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 arguments) { IEnumerable 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(?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z\.-]+)?)\b", RegexOptions.Compiled)] private static partial Regex SemanticVersionPattern(); }