From 9d60c58054572e83cddb53a4a8824447ec6d225a Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 25 Jun 2026 16:03:17 -0500 Subject: [PATCH] feat(providers): support GitHub Enterprise Copilot auth Resolve GitHub OAuth and Copilot token exchange endpoints from persisted provider vendor options while keeping public GitHub Copilot entries on public endpoints by default. Refs #1456 Co-authored-by: Proxicon <14370617+Proxicon@users.noreply.github.com> --- docs/spec/configuration.md | 1 + .../references/providers.md | 14 + .../specs/netclaw-model-providers/spec.md | 24 ++ .../Provider/ProviderCommandTests.cs | 78 ++++++ .../Config/ProviderCredentialWriter.cs | 7 +- src/Netclaw.Cli/Provider/ProviderCommand.cs | 103 ++++++- src/Netclaw.Cli/Tui/OAuthFlowCoordinator.cs | 50 +++- .../Tui/ProviderManagerViewModel.cs | 70 ++++- .../Tui/Wizard/Steps/ProviderStepViewModel.cs | 86 +++++- .../Tui/Wizard/WizardConfigBuilder.cs | 4 + .../ProviderOAuthRefreshingProbeTests.cs | 104 +++++++ src/Netclaw.Configuration/ProviderEntry.cs | 3 + .../CopilotTokenExchangerTests.cs | 27 ++ .../GitHubCopilotAuthResolverTests.cs | 89 ++++++ .../GitHubCopilot/CopilotTokenExchanger.cs | 29 +- .../GitHubCopilot/GitHubCopilotAuthOptions.cs | 259 ++++++++++++++++++ .../GitHubCopilot/GitHubCopilotDescriptor.cs | 45 +-- .../GitHubCopilotProviderPlugin.cs | 3 +- .../OAuth/ProviderOAuthRefreshingProbe.cs | 8 +- 19 files changed, 951 insertions(+), 53 deletions(-) create mode 100644 src/Netclaw.Daemon.Tests/Providers/GitHubCopilot/GitHubCopilotAuthResolverTests.cs create mode 100644 src/Netclaw.Providers/GitHubCopilot/GitHubCopilotAuthOptions.cs diff --git a/docs/spec/configuration.md b/docs/spec/configuration.md index 7267ed971..94dd1a787 100644 --- a/docs/spec/configuration.md +++ b/docs/spec/configuration.md @@ -99,6 +99,7 @@ keys used by model references. | `Type` | string | `"ollama"` | Provider SDK to use. Supported: `ollama`, `openai-compatible`, `openrouter`, `openai`, `anthropic`, `github-copilot`, `veniceai`. | | `Endpoint` | string | `"http://localhost:11434"` | Base URL for the provider API. | | `ApiKey` | string? | `null` | API key. Should go in `secrets.json` or an environment variable. | +| `VendorOptions` | object? | `null` | Provider-owned non-secret options. For `github-copilot`, `GitHubHost` and `GitHubApiBase` select the GitHub Enterprise host used for OAuth and Copilot token exchange; the Copilot API base remains `Endpoint`. | ### Models diff --git a/feeds/skills/.system/files/netclaw-operations/references/providers.md b/feeds/skills/.system/files/netclaw-operations/references/providers.md index ffc28d6ca..62e50755b 100644 --- a/feeds/skills/.system/files/netclaw-operations/references/providers.md +++ b/feeds/skills/.system/files/netclaw-operations/references/providers.md @@ -68,6 +68,20 @@ Netclaw GitHub App. On success, the long-lived GitHub OAuth token is persisted to `secrets.json`. A short-lived (~30 min) Copilot API token is minted lazily on each chat request and never written to disk. +For GitHub Enterprise-backed Copilot, pass the enterprise GitHub host during +setup: + +```bash +netclaw provider add my-copilot github-copilot --auth oauth-device --github-host https://github.example.com +``` + +If the API base cannot be derived from the host, also pass +`--github-api-base`. Netclaw stores the resolved non-secret values as +`Providers..VendorOptions.GitHubHost` and `.GitHubApiBase`. Runtime +uses those persisted values only; ambient `GH_HOST`, `GITHUB_API_URL`, and +related GitHub environment variables are setup conveniences, not runtime +fallbacks. The Copilot chat/model API base remains the provider `Endpoint`. + If a Copilot probe or chat call returns "GitHub Copilot authorization expired", the stored OAuth token has been revoked. The remediation is: diff --git a/openspec/specs/netclaw-model-providers/spec.md b/openspec/specs/netclaw-model-providers/spec.md index 40a7b7249..137995337 100644 --- a/openspec/specs/netclaw-model-providers/spec.md +++ b/openspec/specs/netclaw-model-providers/spec.md @@ -232,6 +232,18 @@ memory only and never persisted to disk. The long-lived GitHub OAuth token SHALL be persisted via the existing `ProviderEntry.OAuthAccessToken` field in the secrets store. +For GitHub Enterprise-backed Copilot entries, the provider MAY persist +non-secret host settings under `ProviderEntry.VendorOptions` as +`GitHubHost` and `GitHubApiBase`. When present, `GitHubHost` SHALL be used +to derive the device authorization endpoint at `/login/device/code` and the +OAuth token endpoint at `/login/oauth/access_token`; `GitHubApiBase` SHALL +be used to derive `/copilot_internal/v2/token`. Runtime provider resolution +SHALL use only the persisted provider entry, not ambient `GH_HOST`, +`GITHUB_API_URL`, or related GitHub environment variables, so existing public +GitHub Copilot entries keep the public endpoints unless explicitly +reconfigured. Chat completion and model discovery requests SHALL continue to +use `ProviderEntry.Endpoint`, defaulting to `https://api.githubcopilot.com`. + Each request to `api.githubcopilot.com` SHALL carry these headers in addition to the standard `Content-Type` and `Accept`: @@ -258,6 +270,18 @@ selectable" rather than implicitly non-chat. - **AND** on successful authorization the GitHub OAuth token is persisted to the secrets store under the operator-chosen provider name +#### Scenario: Operator configures GitHub Enterprise Copilot + +- **GIVEN** the operator runs `netclaw provider add github-copilot --auth oauth-device --github-host ` +- **WHEN** OAuth authorization succeeds +- **THEN** the provider entry SHALL persist `VendorOptions.GitHubHost` and + `VendorOptions.GitHubApiBase` when those resolved values are not the public + GitHub defaults +- **AND** the device flow and OAuth token exchange SHALL use the resolved + GitHub Enterprise host settings +- **AND** Copilot chat/model requests SHALL use the provider entry's + `Endpoint` value + #### Scenario: Chat completion against Copilot - **GIVEN** a `github-copilot` provider entry is configured with a valid diff --git a/src/Netclaw.Cli.Tests/Provider/ProviderCommandTests.cs b/src/Netclaw.Cli.Tests/Provider/ProviderCommandTests.cs index 1e7d4cb7d..72d08bc8c 100644 --- a/src/Netclaw.Cli.Tests/Provider/ProviderCommandTests.cs +++ b/src/Netclaw.Cli.Tests/Provider/ProviderCommandTests.cs @@ -197,6 +197,84 @@ public async Task Add_OAuthOnlyProvider_WithoutAuthFlag_RefusesAndGuides() && providers.TryGetProperty("my-copilot", out _)); } + [Fact] + public void BuildGitHubCopilotVendorOptions_ExplicitEnterpriseHost_ReturnsMinimalOptions() + { + var writer = new StringWriter(); + + var ok = ProviderCommand.TryBuildGitHubCopilotVendorOptions( + "github-copilot", + "https://example.ghe.com", + "https://api.example.ghe.com", + includeAmbientEnvironment: false, + writer, + out var vendorOptions, + out var authOptions); + + Assert.True(ok, writer.ToString()); + Assert.NotNull(vendorOptions); + Assert.Equal("https://example.ghe.com", vendorOptions!["GitHubHost"]); + Assert.Equal("https://api.example.ghe.com", vendorOptions["GitHubApiBase"]); + Assert.DoesNotContain("CopilotApiBase", vendorOptions.Keys); + Assert.DoesNotContain("CopilotTokenExchangePath", vendorOptions.Keys); + Assert.Equal(new Uri("https://example.ghe.com"), authOptions!.GitHubHost); + } + + [Fact] + public void BuildGitHubCopilotVendorOptions_RejectsGitHubOptionsForOtherProviders() + { + var writer = new StringWriter(); + + var ok = ProviderCommand.TryBuildGitHubCopilotVendorOptions( + "openrouter", + "https://example.ghe.com", + null, + includeAmbientEnvironment: false, + writer, + out _, + out _); + + Assert.False(ok); + Assert.Contains("github-copilot", writer.ToString()); + } + + [Fact] + public void BuildGitHubCopilotVendorOptions_AmbientEnvironmentRequiresOptIn() + { + var previous = Environment.GetEnvironmentVariable("COPILOT_GH_HOST"); + try + { + Environment.SetEnvironmentVariable("COPILOT_GH_HOST", "example.ghe.com"); + var writer = new StringWriter(); + + var noAmbient = ProviderCommand.TryBuildGitHubCopilotVendorOptions( + "github-copilot", + null, + null, + includeAmbientEnvironment: false, + writer, + out var noAmbientVendorOptions, + out _); + var ambient = ProviderCommand.TryBuildGitHubCopilotVendorOptions( + "github-copilot", + null, + null, + includeAmbientEnvironment: true, + writer, + out var ambientVendorOptions, + out _); + + Assert.True(noAmbient, writer.ToString()); + Assert.Null(noAmbientVendorOptions); + Assert.True(ambient, writer.ToString()); + Assert.Equal("https://example.ghe.com", ambientVendorOptions!["GitHubHost"]); + } + finally + { + Environment.SetEnvironmentVariable("COPILOT_GH_HOST", previous); + } + } + [Fact] public async Task Remove_UnreferencedProvider_Succeeds() { diff --git a/src/Netclaw.Cli/Config/ProviderCredentialWriter.cs b/src/Netclaw.Cli/Config/ProviderCredentialWriter.cs index 06d47f8bf..50338a0c2 100644 --- a/src/Netclaw.Cli/Config/ProviderCredentialWriter.cs +++ b/src/Netclaw.Cli/Config/ProviderCredentialWriter.cs @@ -38,6 +38,7 @@ internal static class ProviderCredentialWriter /// API key if auth method is ApiKey, null otherwise. /// Provider registry for looking up default endpoints. /// Secrets protector override. When null, creates one from paths. + /// Provider-owned non-secret options for netclaw.json. internal static void WriteProvider( NetclawPaths paths, string providerName, @@ -47,7 +48,8 @@ internal static void WriteProvider( OAuthDeviceFlowResult? oauthResult, string? apiKey, ProviderDescriptorRegistry? registry = null, - ISecretsProtector? protector = null) + ISecretsProtector? protector = null, + IReadOnlyDictionary? vendorOptions = null) { paths.EnsureDirectoriesExist(); @@ -71,6 +73,9 @@ internal static void WriteProvider( if (!string.IsNullOrWhiteSpace(endpoint)) providerEntry["Endpoint"] = endpoint; + if (vendorOptions is not null && vendorOptions.Count > 0) + providerEntry["VendorOptions"] = vendorOptions; + // OAuthTokenExpiry goes in netclaw.json (NOT secrets) — see class remarks. if (oauthResult?.ExpiresAt is { } expiresAt) providerEntry["OAuthTokenExpiry"] = expiresAt.ToString("o"); diff --git a/src/Netclaw.Cli/Provider/ProviderCommand.cs b/src/Netclaw.Cli/Provider/ProviderCommand.cs index 67e9bcc8b..08be43d31 100644 --- a/src/Netclaw.Cli/Provider/ProviderCommand.cs +++ b/src/Netclaw.Cli/Provider/ProviderCommand.cs @@ -4,10 +4,12 @@ // // ----------------------------------------------------------------------- using System.Text.Json; +using System.Text.Json.Nodes; using Netclaw.Cli.Config; using Netclaw.Cli.Json; using Netclaw.Configuration; using Netclaw.Providers; +using Netclaw.Providers.GitHubCopilot; using Netclaw.Providers.OAuth; using Netclaw.Configuration.Secrets; @@ -114,6 +116,8 @@ private static async Task RunAddAsync(string[] args, NetclawPaths paths, Pr string? apiKey = null; string? endpoint = null; string? authFlag = null; + string? gitHubHost = null; + string? gitHubApiBase = null; for (var i = 4; i < args.Length; i++) { @@ -134,6 +138,18 @@ private static async Task RunAddAsync(string[] args, NetclawPaths paths, Pr authFlag = args[++i]; continue; } + + if (args[i] is "--github-host" && i + 1 < args.Length) + { + gitHubHost = args[++i]; + continue; + } + + if (args[i] is "--github-api-base" && i + 1 < args.Length) + { + gitHubApiBase = args[++i]; + continue; + } } AuthMethod? requestedAuthMethod = null; @@ -156,9 +172,20 @@ private static async Task RunAddAsync(string[] args, NetclawPaths paths, Pr } var supportedAuth = descriptor.Auth.SupportedAuthMethods; + if (!TryBuildGitHubCopilotVendorOptions( + type, + gitHubHost, + gitHubApiBase, + includeAmbientEnvironment: requestedAuthMethod == AuthMethod.OAuthDevice, + writer, + out var vendorOptions, + out var copilotAuthOptions)) + { + return 1; + } if (ShouldDefaultToOAuthDevice(type, apiKey, requestedAuthMethod, supportedAuth)) - return await RunOAuthDeviceFlowAsync(name, type, endpoint, descriptor, paths, writer); + return await RunOAuthDeviceFlowAsync(name, type, endpoint, descriptor, paths, writer, null, null); // Handle --auth oauth-device explicitly if (requestedAuthMethod == AuthMethod.OAuthDevice) @@ -169,7 +196,15 @@ private static async Task RunAddAsync(string[] args, NetclawPaths paths, Pr return 1; } - return await RunOAuthDeviceFlowAsync(name, type, endpoint, descriptor, paths, writer); + return await RunOAuthDeviceFlowAsync( + name, + type, + endpoint, + descriptor, + paths, + writer, + vendorOptions, + copilotAuthOptions); } if (requestedAuthMethod == AuthMethod.ApiKey && !supportedAuth.Contains(AuthMethod.ApiKey)) @@ -246,13 +281,60 @@ internal static bool ShouldDefaultToOAuthDevice( && string.Equals(providerType, "openai", StringComparison.OrdinalIgnoreCase) && supportedAuth.Contains(AuthMethod.OAuthDevice); + internal static bool TryBuildGitHubCopilotVendorOptions( + string providerType, + string? gitHubHost, + string? gitHubApiBase, + bool includeAmbientEnvironment, + TextWriter writer, + out IReadOnlyDictionary? vendorOptions, + out GitHubCopilotAuthOptions? authOptions) + { + vendorOptions = null; + authOptions = null; + var hasGitHubCopilotOptions = gitHubHost is not null || gitHubApiBase is not null; + var isGitHubCopilot = string.Equals(providerType, "github-copilot", StringComparison.OrdinalIgnoreCase); + + if (!isGitHubCopilot) + { + if (hasGitHubCopilotOptions) + { + writer.WriteLine("Error: GitHub enterprise host options can only be used with provider type 'github-copilot'."); + return false; + } + + return true; + } + + if (!GitHubCopilotAuthResolver.TryResolveSetupOptions( + gitHubHost, + gitHubApiBase, + includeAmbientEnvironment, + out var resolvedOptions, + out var error)) + { + writer.WriteLine($"Error: {error}"); + return false; + } + + authOptions = resolvedOptions; + vendorOptions = GitHubCopilotAuthResolver.ToVendorOptions(resolvedOptions); + return true; + } + private static async Task RunOAuthDeviceFlowAsync( string name, string type, string? endpoint, - IProviderDescriptor descriptor, NetclawPaths paths, TextWriter writer) + IProviderDescriptor descriptor, + NetclawPaths paths, + TextWriter writer, + IReadOnlyDictionary? vendorOptions, + GitHubCopilotAuthOptions? copilotAuthOptions) { endpoint ??= descriptor.DefaultEndpoint; - var oauth = descriptor.Auth.GetOAuthConfig(); + var oauth = string.Equals(type, "github-copilot", StringComparison.OrdinalIgnoreCase) + ? GitHubCopilotDescriptor.CreateOAuthAuth(copilotAuthOptions ?? new GitHubCopilotAuthOptions()) + : descriptor.Auth.GetOAuthConfig(); if (oauth is null) { writer.WriteLine($"Error: Provider '{type}' does not support OAuth."); @@ -300,7 +382,9 @@ private static async Task RunOAuthDeviceFlowAsync( ProviderCredentialWriter.WriteProvider( paths, name, type, AuthMethod.OAuthDevice, endpoint, - oauthResult: result, apiKey: null); + oauthResult: result, + apiKey: null, + vendorOptions: vendorOptions); writer.WriteLine($"Added provider '{name}' ({type}) with OAuth authentication."); return 0; @@ -392,6 +476,12 @@ internal static Dictionary LoadProviders(NetclawPaths pat { var entry = JsonSerializer.Deserialize(prop.Value.GetRawText(), JsonDefaults.EnumAware) ?? new ProviderEntry(); + if (prop.Value.TryGetProperty(nameof(ProviderEntry.VendorOptions), out var vendorOptions) + && vendorOptions.ValueKind == JsonValueKind.Object) + { + entry.SetVendorOptions(JsonNode.Parse(vendorOptions.GetRawText())?.AsObject()); + } + result[prop.Name] = entry; } } @@ -517,6 +607,8 @@ private static int WriteHelp(ProviderDescriptorRegistry registry, TextWriter wri writer.WriteLine(" --api-key API key (or prompted interactively)"); writer.WriteLine(" --endpoint Custom endpoint URL"); writer.WriteLine(" --auth Auth method: api-key, oauth-device"); + writer.WriteLine(" --github-host GitHub Enterprise auth host for github-copilot"); + writer.WriteLine(" --github-api-base GitHub Enterprise API base for github-copilot"); writer.WriteLine(); writer.WriteLine("Provider types: " + string.Join(", ", registry.KnownTypeKeys)); writer.WriteLine(); @@ -524,6 +616,7 @@ private static int WriteHelp(ProviderDescriptorRegistry registry, TextWriter wri writer.WriteLine(" netclaw provider add my-ollama ollama --endpoint http://my-gpu-server:11434"); writer.WriteLine(" netclaw provider add my-anthropic anthropic --api-key sk-ant-..."); writer.WriteLine(" netclaw provider add my-openai openai --auth oauth-device"); + writer.WriteLine(" netclaw provider add copilot-ghe github-copilot --auth oauth-device --github-host https://example.ghe.com --github-api-base https://api.example.ghe.com"); writer.WriteLine(" netclaw provider rename my-ollama lab-a100"); writer.WriteLine(" netclaw provider remove my-ollama"); return 0; diff --git a/src/Netclaw.Cli/Tui/OAuthFlowCoordinator.cs b/src/Netclaw.Cli/Tui/OAuthFlowCoordinator.cs index 5036ca5ba..8c64b32b1 100644 --- a/src/Netclaw.Cli/Tui/OAuthFlowCoordinator.cs +++ b/src/Netclaw.Cli/Tui/OAuthFlowCoordinator.cs @@ -8,6 +8,7 @@ using Netclaw.Cli.Daemon; using Netclaw.Configuration; using Netclaw.Providers; +using Netclaw.Providers.GitHubCopilot; using Netclaw.Providers.OAuth; using Netclaw.Tools; using R3; @@ -104,11 +105,13 @@ public CancellationToken StartMcpBrowserFlow( /// Returns a that fires when the flow ends. /// public CancellationToken StartDeviceFlow( - string providerType, Action? onSuccess = null) + string providerType, + Action? onSuccess = null, + ProviderEntry? entry = null) { Cancel(); _cts = new CancellationTokenSource(); - Completion = RunDeviceFlowAsync(providerType, onSuccess, _cts.Token); + Completion = RunDeviceFlowAsync(providerType, onSuccess, entry, _cts.Token); return _cts.Token; } @@ -359,7 +362,10 @@ private async Task RunBrowserFlowCoreAsync( // ── Device authorization flow (RFC 8628) ───────────────────────── private async Task RunDeviceFlowAsync( - string providerType, Action? onSuccess, CancellationToken ct) + string providerType, + Action? onSuccess, + ProviderEntry? entry, + CancellationToken ct) { if (_deviceFlowFactory is null) { @@ -370,7 +376,19 @@ private async Task RunDeviceFlowAsync( } var descriptor = _registry.Get(providerType); - var oauth = descriptor.Auth.GetOAuthConfig(); + OAuthAuth? oauth; + try + { + oauth = ResolveOAuthConfig(providerType, descriptor, entry); + } + catch (InvalidOperationException ex) + { + ErrorMessage = ex.Message; + FlowState.Value = DeviceFlowState.Error; + _requestRedraw(); + return; + } + if (oauth is null || oauth.DeviceEndpoint is null) { ErrorMessage = "Provider does not support OAuth device flow."; @@ -442,4 +460,28 @@ private async Task RunDeviceFlowAsync( Cancel(); } } + + private static OAuthAuth? ResolveOAuthConfig( + string providerType, + IProviderDescriptor descriptor, + ProviderEntry? entry) + { + if (!string.Equals(providerType, "github-copilot", StringComparison.OrdinalIgnoreCase)) + return descriptor.Auth.GetOAuthConfig(); + + if (entry is not null) + return GitHubCopilotDescriptor.CreateOAuthAuth(entry); + + if (!GitHubCopilotAuthResolver.TryResolveSetupOptions( + gitHubHost: null, + gitHubApiBase: null, + includeAmbientEnvironment: true, + out var setupOptions, + out var error)) + { + throw new InvalidOperationException(error); + } + + return GitHubCopilotDescriptor.CreateOAuthAuth(setupOptions); + } } diff --git a/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs b/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs index f1ade3a0c..93ba32008 100644 --- a/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs +++ b/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs @@ -4,10 +4,14 @@ // // ----------------------------------------------------------------------- using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Nodes; using Netclaw.Cli.Config; using Netclaw.Cli.Daemon; +using Netclaw.Cli.Json; using Netclaw.Configuration; using Netclaw.Providers; +using Netclaw.Providers.GitHubCopilot; using Netclaw.Providers.OAuth; using Netclaw.Configuration.Secrets; using R3; @@ -122,6 +126,7 @@ public sealed class ProviderManagerViewModel : ReactiveViewModel public AuthMethod NewAuthMethod { get; set; } = AuthMethod.None; public string? NewApiKey { get; set; } public string? NewEndpoint { get; set; } + public IReadOnlyDictionary? NewVendorOptions { get; set; } private bool _newProviderPersisted; // ── OAuth flow (shared coordinator) ── @@ -447,6 +452,10 @@ public void StartOAuthReAuth() NewProviderType = type; NewProviderName = DetailProvider.ConfiguredName; NewEndpoint = DetailProvider.Entry?.Endpoint; + NewVendorOptions = string.Equals(type, "github-copilot", StringComparison.OrdinalIgnoreCase) + && DetailProvider.Entry is not null + ? GitHubCopilotAuthResolver.ToVendorOptions(DetailProvider.Entry) + : null; IsFixFlow = true; var oauthMethod = descriptor.Auth.SupportedAuthMethods @@ -464,6 +473,13 @@ public void SelectAuthMethod(AuthMethod method) if (method == AuthMethod.OAuthDevice) { + if (!TryBuildOAuthFlowEntry(out var oauthEntry, out var error)) + { + StatusMessage.Value = error; + RequestRedraw(); + return; + } + CurrentState.Value = ProviderManagerState.AddOAuthDeviceFlow; NotifyStateChanged(); ProbeElapsedSeconds.Value = 0; @@ -473,7 +489,7 @@ public void SelectAuthMethod(AuthMethod method) CurrentState.Value = ProviderManagerState.AddValidating; NotifyStateChanged(); StartProbe(); - }); + }, oauthEntry); _ = RunProbeTimerAsync(ct); return; } @@ -499,6 +515,40 @@ public void SelectAuthMethod(AuthMethod method) NotifyStateChanged(); } + private bool TryBuildOAuthFlowEntry(out ProviderEntry? entry, out string error) + { + entry = null; + error = string.Empty; + if (!string.Equals(NewProviderType, "github-copilot", StringComparison.OrdinalIgnoreCase)) + return true; + + if (IsFixFlow && DetailProvider?.Entry is { } existing) + { + entry = existing; + return true; + } + + if (!GitHubCopilotAuthResolver.TryResolveSetupOptions( + gitHubHost: null, + gitHubApiBase: null, + includeAmbientEnvironment: true, + out var setupOptions, + out var setupError)) + { + error = setupError ?? "GitHub Copilot enterprise host settings are invalid."; + return false; + } + + NewVendorOptions = GitHubCopilotAuthResolver.ToVendorOptions(setupOptions); + entry = new ProviderEntry + { + Type = "github-copilot", + AuthMethod = AuthMethod.OAuthDevice, + }; + entry.SetVendorOptions(ToJsonObject(NewVendorOptions)); + return true; + } + /// /// Submit credentials and start validation probe. /// @@ -541,6 +591,10 @@ public void SubmitFixCredentials() NewApiKey = FixApiKey ?? DetailProvider.Entry?.ApiKey?.Value ?? DetailProvider.Entry?.OAuthAccessToken?.Value; + NewVendorOptions = string.Equals(type, "github-copilot", StringComparison.OrdinalIgnoreCase) + && DetailProvider.Entry is not null + ? GitHubCopilotAuthResolver.ToVendorOptions(DetailProvider.Entry) + : null; IsFixFlow = true; CurrentState.Value = ProviderManagerState.AddValidating; @@ -1074,6 +1128,8 @@ private ProviderEntry BuildNewProviderProbeEntry(string providerType) entry.ApiKey = new SensitiveString(NewApiKey); } + entry.SetVendorOptions(ToJsonObject(NewVendorOptions)); + return entry; } @@ -1090,7 +1146,8 @@ private void WriteProviderConfig() NewEndpoint, OAuth.Result, NewApiKey, - _registry); + _registry, + vendorOptions: NewVendorOptions); } // ── Helpers ── @@ -1127,12 +1184,21 @@ private void ClearAddState() NewAuthMethod = AuthMethod.None; NewApiKey = null; NewEndpoint = null; + NewVendorOptions = null; ProbeResult.Value = null; ProbeElapsedSeconds.Value = 0; IsFixFlow = false; _newProviderPersisted = false; } + private static JsonObject? ToJsonObject(IReadOnlyDictionary? vendorOptions) + { + if (vendorOptions is null || vendorOptions.Count == 0) + return null; + + return JsonNode.Parse(JsonSerializer.Serialize(vendorOptions, JsonDefaults.ConfigFile))?.AsObject(); + } + private void NotifyStateChanged() { StateVersion.Value++; diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs index a3e2bb34c..ad994151e 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs @@ -4,14 +4,18 @@ // // ----------------------------------------------------------------------- using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Nodes; using Microsoft.Extensions.DependencyInjection; using Netclaw.Cli.Config; using Netclaw.Cli.Daemon; +using Netclaw.Cli.Json; using Netclaw.Cli.Tui; using Netclaw.Cli.Tui.Sections; using Netclaw.Configuration; using Netclaw.Configuration.Secrets; using Netclaw.Providers; +using Netclaw.Providers.GitHubCopilot; using Netclaw.Providers.OAuth; using R3; @@ -59,6 +63,7 @@ public ProviderStepViewModel( public AuthMethod SelectedAuthMethod { get; set; } = AuthMethod.None; public string? ApiKeyInput { get; set; } public string? EndpointInput { get; set; } + public IReadOnlyDictionary? VendorOptions { get; set; } public string? SelectedModelId { get; set; } public bool HasStoredCredential { get; private set; } public List DiscoveredModels { get; } = []; @@ -271,6 +276,8 @@ private ProviderEntry BuildProbeEntry(string providerType) entry.ApiKey = new SensitiveString(ApiKeyInput); } + entry.SetVendorOptions(ToJsonObject(VendorOptions)); + return entry; } @@ -279,15 +286,49 @@ private ProviderEntry BuildProbeEntry(string providerType) public void StartOAuthFlow() { if (SelectedProviderType is null) return; + if (!TryBuildOAuthFlowEntry(out var oauthEntry, out var error)) + { + ProbeResult.Value = new ProviderProbeResult(false, error, []); + return; + } + ProbeElapsedSeconds.Value = 0; var ct = OAuth.StartDeviceFlow(SelectedProviderType, result => { ApiKeyInput = result.AccessToken.Value; StartProbe(); - }); + }, oauthEntry); _ = RunProbeTimerAsync(ct); } + private bool TryBuildOAuthFlowEntry(out ProviderEntry? entry, out string error) + { + entry = null; + error = string.Empty; + if (!string.Equals(SelectedProviderType, "github-copilot", StringComparison.OrdinalIgnoreCase)) + return true; + + if (!GitHubCopilotAuthResolver.TryResolveSetupOptions( + gitHubHost: null, + gitHubApiBase: null, + includeAmbientEnvironment: true, + out var setupOptions, + out var setupError)) + { + error = setupError ?? "GitHub Copilot enterprise host settings are invalid."; + return false; + } + + VendorOptions = GitHubCopilotAuthResolver.ToVendorOptions(setupOptions); + entry = new ProviderEntry + { + Type = "github-copilot", + AuthMethod = AuthMethod.OAuthDevice, + }; + entry.SetVendorOptions(ToJsonObject(VendorOptions)); + return true; + } + public void StartBrowserOAuthFlow() { if (SelectedProviderType is null) return; @@ -310,6 +351,7 @@ internal void ClearFromProvider() SelectedAuthMethod = AuthMethod.None; ApiKeyInput = null; EndpointInput = null; + VendorOptions = null; ProbeResult.Value = null; ProbeElapsedSeconds.Value = 0; SelectedModelId = null; @@ -332,7 +374,8 @@ public void ContributeConfig(WizardConfigBuilder builder) ? EndpointInput : _registry.TryGet(providerName, out var desc) && desc.Auth is EndpointOnlyAuth ? desc.DefaultEndpoint - : null + : null, + VendorOptions = VendorOptions, }; var selectedModel = DiscoveredModels.FirstOrDefault(model => @@ -374,9 +417,10 @@ public void WriteProviderCredentials(NetclawPaths paths) EndpointInput, OAuth.Result, ApiKeyInput, - _registry, + registry: _registry, // Protector for this config's keys directory, not the process-wide static service locator. - SecretsProtection.CreateProtector(paths)); + protector: SecretsProtection.CreateProtector(paths), + vendorOptions: VendorOptions); } public Task ContributeHealthChecksAsync(HealthCheckRunner runner, CancellationToken ct) @@ -466,6 +510,9 @@ private void PrefillFromExistingConfig(WizardContext context) EndpointInput ??= endpointText; } + if (TryReadExistingVendorOptions(context, providerType, out var vendorOptions)) + VendorOptions ??= vendorOptions; + if (ConfigFileHelper.TryGetPathValue(context.ExistingConfig, $"Providers.{providerType}.AuthMethod", out var authMethod) && authMethod is string authMethodText && Enum.TryParse(authMethodText, ignoreCase: true, out var parsed)) @@ -535,9 +582,40 @@ private Dictionary BuildProviderEntry(ProviderStepViewModel vm, if (!string.IsNullOrWhiteSpace(endpoint)) entry["Endpoint"] = endpoint; + if (vm.VendorOptions is not null && vm.VendorOptions.Count > 0) + entry["VendorOptions"] = vm.VendorOptions; + return entry; } + private static bool TryReadExistingVendorOptions( + WizardContext context, + string providerType, + out IReadOnlyDictionary? vendorOptions) + { + vendorOptions = null; + if (context.ExistingConfig is null + || !ConfigFileHelper.TryGetPathValue(context.ExistingConfig, $"Providers.{providerType}.VendorOptions", out var raw) + || raw is null) + { + return false; + } + + var json = raw is JsonElement element + ? element.GetRawText() + : JsonSerializer.Serialize(raw, JsonDefaults.ConfigFile); + vendorOptions = JsonSerializer.Deserialize>(json, JsonDefaults.ConfigRead); + return vendorOptions is not null; + } + + private static JsonObject? ToJsonObject(IReadOnlyDictionary? vendorOptions) + { + if (vendorOptions is null || vendorOptions.Count == 0) + return null; + + return JsonNode.Parse(JsonSerializer.Serialize(vendorOptions, JsonDefaults.ConfigFile))?.AsObject(); + } + public void Dispose() { CancelProbe(); diff --git a/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs b/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs index b62227b86..f8bc4bf18 100644 --- a/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs +++ b/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs @@ -125,6 +125,9 @@ internal Dictionary BuildConfigDictionary() if (!string.IsNullOrWhiteSpace(Provider.Endpoint)) providerEntry["Endpoint"] = Provider.Endpoint; + if (Provider.VendorOptions is not null && Provider.VendorOptions.Count > 0) + providerEntry["VendorOptions"] = Provider.VendorOptions; + providers[Provider.TypeKey] = providerEntry; } @@ -560,6 +563,7 @@ public sealed class ProviderConfigSection public required string TypeKey { get; init; } public AuthMethod AuthMethod { get; init; } = AuthMethod.None; public string? Endpoint { get; init; } + public IReadOnlyDictionary? VendorOptions { get; init; } } public sealed class ModelConfigSection diff --git a/src/Netclaw.Configuration.Tests/Providers/OAuth/ProviderOAuthRefreshingProbeTests.cs b/src/Netclaw.Configuration.Tests/Providers/OAuth/ProviderOAuthRefreshingProbeTests.cs index 4fefb508f..acf74b144 100644 --- a/src/Netclaw.Configuration.Tests/Providers/OAuth/ProviderOAuthRefreshingProbeTests.cs +++ b/src/Netclaw.Configuration.Tests/Providers/OAuth/ProviderOAuthRefreshingProbeTests.cs @@ -4,10 +4,13 @@ // // ----------------------------------------------------------------------- using System.Net; +using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using Microsoft.Extensions.Time.Testing; using Netclaw.Configuration; using Netclaw.Providers; +using Netclaw.Providers.GitHubCopilot; using Netclaw.Providers.OAuth; using Netclaw.Tests.Utilities; using Xunit; @@ -93,6 +96,69 @@ public async Task ProbeConfiguredAsync_InvalidRefreshToken_ReturnsFailureBeforeD Assert.Null(descriptor.ProbedAccessToken); } + [Fact] + public async Task ProbeConfiguredAsync_GitHubCopilot_RefreshesAgainstConfiguredEnterpriseHost() + { + using var dir = new DisposableTempDir(); + var paths = new NetclawPaths(dir.Path); + WriteGitHubCopilotProviderConfig(paths, "copilot-ghe"); + + var now = new DateTimeOffset(2026, 6, 23, 12, 0, 0, TimeSpan.Zero); + var time = new FakeTimeProvider(now); + var requestUris = new List(); + var handler = new FakeHttpMessageHandler(request => + { + requestUris.Add(request.RequestUri!.ToString()); + return request.RequestUri!.ToString() switch + { + "https://ghe.example.com/login/oauth/access_token" => FakeHttpMessageHandler.JsonResponse(new + { + access_token = "gho_ghe_new", + refresh_token = "refresh-ghe-new", + expires_in = 3600, + }), + "https://ghe.example.com/api/v3/copilot_internal/v2/token" => FakeHttpMessageHandler.JsonResponse(new + { + token = "copilot-ghe", + expires_at = now.AddMinutes(30).ToUnixTimeSeconds(), + }), + "https://api.githubcopilot.com/models" => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + """ + { "data": [ { "id": "gpt-4o", "capabilities": { "type": "chat" } } ] } + """, + Encoding.UTF8, + "application/json"), + }, + _ => new HttpResponseMessage(HttpStatusCode.NotFound), + }; + }); + var httpClient = new HttpClient(handler); + var refreshService = new ProviderOAuthTokenRefreshService( + paths, + new DeviceFlowServiceFactory( + new OAuthDeviceFlowService(httpClient, time), + new OpenAiDeviceFlowService(httpClient, time)), + timeProvider: time); + var exchanger = new CopilotTokenExchanger(httpClient, time, refreshService); + var descriptor = new GitHubCopilotDescriptor(httpClient, exchanger); + var probe = new ProviderOAuthRefreshingProbe(new ProviderDescriptorRegistry([descriptor]), refreshService); + var entry = ExpiredGitHubCopilotEntry(now); + + var result = await probe.ProbeConfiguredAsync( + "copilot-ghe", + entry, + TestContext.Current.CancellationToken); + + Assert.True(result.Success, result.ErrorMessage); + Assert.Equal([ + "https://ghe.example.com/login/oauth/access_token", + "https://ghe.example.com/api/v3/copilot_internal/v2/token", + "https://api.githubcopilot.com/models", + ], requestUris); + } + private static ProviderOAuthRefreshingProbe CreateProbe( NetclawPaths paths, FakeTimeProvider time, @@ -132,6 +198,44 @@ private static void WriteProviderConfig(NetclawPaths paths, string providerName) """); } + private static ProviderEntry ExpiredGitHubCopilotEntry(DateTimeOffset now) + { + var entry = new ProviderEntry + { + Type = "github-copilot", + AuthMethod = AuthMethod.OAuthDevice, + OAuthAccessToken = new SensitiveString("gho_ghe_old"), + OAuthRefreshToken = new SensitiveString("refresh-ghe-old"), + OAuthTokenExpiry = now.AddMinutes(-1), + }; + entry.SetVendorOptions(new JsonObject + { + ["GitHubHost"] = "https://ghe.example.com", + ["GitHubApiBase"] = "https://ghe.example.com/api/v3", + }); + return entry; + } + + private static void WriteGitHubCopilotProviderConfig(NetclawPaths paths, string providerName) + { + paths.EnsureDirectoriesExist(); + File.WriteAllText(paths.NetclawConfigPath, $$""" + { + "configVersion": 1, + "Providers": { + "{{providerName}}": { + "Type": "github-copilot", + "AuthMethod": "OAuthDevice", + "VendorOptions": { + "GitHubHost": "https://ghe.example.com", + "GitHubApiBase": "https://ghe.example.com/api/v3" + } + } + } + } + """); + } + private sealed class TestOAuthDescriptor : IProviderDescriptor { public string? ProbedAccessToken { get; private set; } diff --git a/src/Netclaw.Configuration/ProviderEntry.cs b/src/Netclaw.Configuration/ProviderEntry.cs index 7b9ffd337..7725eb388 100644 --- a/src/Netclaw.Configuration/ProviderEntry.cs +++ b/src/Netclaw.Configuration/ProviderEntry.cs @@ -38,4 +38,7 @@ public sealed class ProviderEntry /// . /// public JsonObject? VendorOptions { get; internal set; } + + public void SetVendorOptions(JsonObject? vendorOptions) => + VendorOptions = vendorOptions; } diff --git a/src/Netclaw.Daemon.Tests/Providers/GitHubCopilot/CopilotTokenExchangerTests.cs b/src/Netclaw.Daemon.Tests/Providers/GitHubCopilot/CopilotTokenExchangerTests.cs index 0ff42b511..56bea6c59 100644 --- a/src/Netclaw.Daemon.Tests/Providers/GitHubCopilot/CopilotTokenExchangerTests.cs +++ b/src/Netclaw.Daemon.Tests/Providers/GitHubCopilot/CopilotTokenExchangerTests.cs @@ -6,6 +6,7 @@ using System.Net; using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using Microsoft.Extensions.Time.Testing; using Netclaw.Configuration; using Netclaw.Providers; @@ -308,6 +309,32 @@ await exchanger.GetTokenAsync(EntryWithOAuth("ghu_oauth"), Assert.Equal("2022-11-28", captured.Headers.GetValues("X-GitHub-Api-Version").Single()); } + [Fact] + public async Task GetToken_ConfiguredGitHubApiBase_ExchangesAgainstEnterpriseEndpoint() + { + HttpRequestMessage? captured = null; + var handler = new FakeHttpMessageHandler(request => + { + captured = request; + return TokenResponse("copilot-ghe", + DateTimeOffset.UtcNow.AddMinutes(30).ToUnixTimeSeconds()); + }); + var exchanger = new CopilotTokenExchanger(new HttpClient(handler)); + var entry = EntryWithOAuth("gho_ghe"); + entry.SetVendorOptions(new JsonObject + { + ["GitHubHost"] = "https://ghe.example.com", + ["GitHubApiBase"] = "https://ghe.example.com/api/v3", + }); + + var token = await exchanger.GetTokenAsync(entry, TestContext.Current.CancellationToken); + + Assert.Equal("copilot-ghe", token); + Assert.Equal("https://ghe.example.com/api/v3/copilot_internal/v2/token", + captured!.RequestUri!.ToString()); + Assert.Equal("Bearer gho_ghe", captured.Headers.Authorization!.ToString()); + } + private static ProviderOAuthTokenRefreshService CreateRefreshService( NetclawPaths paths, FakeTimeProvider time, diff --git a/src/Netclaw.Daemon.Tests/Providers/GitHubCopilot/GitHubCopilotAuthResolverTests.cs b/src/Netclaw.Daemon.Tests/Providers/GitHubCopilot/GitHubCopilotAuthResolverTests.cs new file mode 100644 index 000000000..6b6bf5457 --- /dev/null +++ b/src/Netclaw.Daemon.Tests/Providers/GitHubCopilot/GitHubCopilotAuthResolverTests.cs @@ -0,0 +1,89 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text.Json.Nodes; +using Netclaw.Configuration; +using Netclaw.Providers.GitHubCopilot; +using Xunit; + +namespace Netclaw.Daemon.Tests.Providers.GitHubCopilot; + +public sealed class GitHubCopilotAuthResolverTests +{ + [Fact] + public void Resolve_DefaultEntry_IgnoresAmbientGitHubHostEnvironment() + { + var previous = Environment.GetEnvironmentVariable("GH_HOST"); + try + { + Environment.SetEnvironmentVariable("GH_HOST", "enterprise.example.com"); + var entry = new ProviderEntry { Type = "github-copilot", AuthMethod = AuthMethod.OAuthDevice }; + + var resolved = GitHubCopilotAuthResolver.Resolve(entry); + + Assert.Equal(new Uri("https://github.com/login/device/code"), resolved.DeviceEndpoint); + Assert.Equal(new Uri("https://github.com/login/oauth/access_token"), resolved.OAuthTokenEndpoint); + Assert.Equal(new Uri("https://api.github.com/copilot_internal/v2/token"), resolved.CopilotTokenExchangeEndpoint); + } + finally + { + Environment.SetEnvironmentVariable("GH_HOST", previous); + } + } + + [Fact] + public void Resolve_GheComHost_DerivesApiSubdomainForSetup() + { + var ok = GitHubCopilotAuthResolver.TryResolveSetupOptions( + gitHubHost: "my-company.ghe.com", + gitHubApiBase: null, + includeAmbientEnvironment: false, + out var options, + out var error); + + Assert.True(ok, error); + var resolved = GitHubCopilotAuthResolver.Resolve(options); + Assert.Equal(new Uri("https://my-company.ghe.com/login/device/code"), resolved.DeviceEndpoint); + Assert.Equal(new Uri("https://api.my-company.ghe.com/copilot_internal/v2/token"), resolved.CopilotTokenExchangeEndpoint); + } + + [Fact] + public void Resolve_GhesApiBase_PreservesApiV3Path() + { + var entry = new ProviderEntry { Type = "github-copilot", AuthMethod = AuthMethod.OAuthDevice }; + entry.SetVendorOptions(new JsonObject + { + ["GitHubHost"] = "https://ghe.example.com", + ["GitHubApiBase"] = "https://ghe.example.com/api/v3", + }); + + var resolved = GitHubCopilotAuthResolver.Resolve(entry); + + Assert.Equal(new Uri("https://ghe.example.com/api/v3/copilot_internal/v2/token"), + resolved.CopilotTokenExchangeEndpoint); + } + + [Fact] + public void TryResolveSetupOptions_RejectsHttpHost() + { + var ok = GitHubCopilotAuthResolver.TryResolveSetupOptions( + gitHubHost: "http://ghe.example.com", + gitHubApiBase: null, + includeAmbientEnvironment: false, + out _, + out var error); + + Assert.False(ok); + Assert.Contains("HTTPS", error); + } + + [Fact] + public void ToVendorOptions_DefaultOptions_ReturnsNull() + { + var vendorOptions = GitHubCopilotAuthResolver.ToVendorOptions(new GitHubCopilotAuthOptions()); + + Assert.Null(vendorOptions); + } +} diff --git a/src/Netclaw.Providers/GitHubCopilot/CopilotTokenExchanger.cs b/src/Netclaw.Providers/GitHubCopilot/CopilotTokenExchanger.cs index 527462ac1..53f3752f8 100644 --- a/src/Netclaw.Providers/GitHubCopilot/CopilotTokenExchanger.cs +++ b/src/Netclaw.Providers/GitHubCopilot/CopilotTokenExchanger.cs @@ -33,9 +33,6 @@ public sealed class CopilotTokenExchanger( TimeProvider? timeProvider = null, ProviderOAuthTokenRefreshService? tokenRefreshService = null) { - private static readonly Uri TokenEndpoint = - new("https://api.github.com/copilot_internal/v2/token"); - private const string ComponentName = "copilot-token"; // Refresh slightly before the server-reported expiry so chat calls in @@ -64,7 +61,7 @@ public async Task GetTokenAsync( var oauthToken = entry.OAuthAccessToken.RequireValid( "GitHub OAuth access token (re-run 'netclaw provider add github-copilot --auth oauth-device')"); - return await GetTokenAsync(oauthToken, ct); + return await GetTokenAsync(oauthToken, GitHubCopilotAuthResolver.Resolve(entry).CopilotTokenExchangeEndpoint, ct); } /// @@ -87,12 +84,12 @@ public async Task GetTokenAsync( oauth, ct); - return await GetTokenAsync(oauthToken, ct); + return await GetTokenAsync(oauthToken, GitHubCopilotAuthResolver.Resolve(entry).CopilotTokenExchangeEndpoint, ct); } - private async Task GetTokenAsync(SensitiveString oauthToken, CancellationToken ct) + private async Task GetTokenAsync(SensitiveString oauthToken, Uri tokenEndpoint, CancellationToken ct) { - var slot = slots.GetOrAdd(HashKey(oauthToken.Value), _ => new CacheSlot()); + var slot = slots.GetOrAdd(HashKey(oauthToken.Value, tokenEndpoint), _ => new CacheSlot()); if (IsFresh(slot.Token)) return slot.Token!.Token; @@ -105,7 +102,7 @@ private async Task GetTokenAsync(SensitiveString oauthToken, Cancellatio if (IsFresh(slot.Token)) return slot.Token!.Token; - var fresh = await ExchangeAsync(oauthToken.Value, ct); + var fresh = await ExchangeAsync(oauthToken.Value, tokenEndpoint, ct); slot.Token = fresh; return fresh.Token; } @@ -118,9 +115,9 @@ private async Task GetTokenAsync(SensitiveString oauthToken, Cancellatio private bool IsFresh(CachedToken? cached) => cached is { } c && c.ExpiresAt - RefreshBuffer > time.GetUtcNow(); - private async Task ExchangeAsync(string oauthToken, CancellationToken ct) + private async Task ExchangeAsync(string oauthToken, Uri tokenEndpoint, CancellationToken ct) { - using var request = new HttpRequestMessage(HttpMethod.Get, TokenEndpoint); + using var request = new HttpRequestMessage(HttpMethod.Get, tokenEndpoint); // The exchange endpoint requires the full editor-integration header // contract — Copilot-Integration-Id is what tells GitHub's gateway @@ -155,13 +152,13 @@ private async Task ExchangeAsync(string oauthToken, CancellationTok if (!response.IsSuccessStatusCode) { throw new InvalidOperationException( - $"GitHub Copilot token exchange failed at {TokenEndpoint} with " + $"GitHub Copilot token exchange failed at {tokenEndpoint} with " + $"HTTP {(int)response.StatusCode}: {Truncate(body)}"); } var parsed = JsonSerializer.Deserialize(body) ?? throw new InvalidOperationException( - $"Empty token response from {TokenEndpoint}."); + $"Empty token response from {tokenEndpoint}."); // System.Text.Json doesn't enforce required-ness on positional record // parameters by default, so a {} response would deserialize to @@ -178,14 +175,14 @@ private async Task ExchangeAsync(string oauthToken, CancellationTok if (string.IsNullOrWhiteSpace(parsed.Token)) { throw new InvalidOperationException( - $"GitHub Copilot token exchange at {TokenEndpoint} returned a " + $"GitHub Copilot token exchange at {tokenEndpoint} returned a " + "payload with no 'token' field."); } if (parsed.ExpiresAt <= 0) { throw new InvalidOperationException( - $"GitHub Copilot token exchange at {TokenEndpoint} returned an " + $"GitHub Copilot token exchange at {tokenEndpoint} returned an " + $"invalid 'expires_at' value ({parsed.ExpiresAt})."); } @@ -197,9 +194,9 @@ private async Task ExchangeAsync(string oauthToken, CancellationTok private static string Truncate(string body) => body.Length > 512 ? body[..512] + "…" : body; - private static string HashKey(string token) + private static string HashKey(string token, Uri tokenEndpoint) { - var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{tokenEndpoint}\n{token}")); return Convert.ToHexString(bytes); } diff --git a/src/Netclaw.Providers/GitHubCopilot/GitHubCopilotAuthOptions.cs b/src/Netclaw.Providers/GitHubCopilot/GitHubCopilotAuthOptions.cs new file mode 100644 index 000000000..f3512061e --- /dev/null +++ b/src/Netclaw.Providers/GitHubCopilot/GitHubCopilotAuthOptions.cs @@ -0,0 +1,259 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Configuration; +using Netclaw.Configuration.Providers; + +namespace Netclaw.Providers.GitHubCopilot; + +/// +/// GitHub host settings used by the GitHub Copilot provider. +/// +public sealed class GitHubCopilotAuthOptions : IVendorOptions +{ + public Uri GitHubHost { get; init; } = GitHubCopilotAuthResolver.PublicGitHubHost; + public Uri GitHubApiBase { get; init; } = GitHubCopilotAuthResolver.PublicGitHubApiBase; +} + +public sealed record GitHubCopilotResolvedAuthOptions( + Uri GitHubHost, + Uri GitHubApiBase, + Uri DeviceEndpoint, + Uri OAuthTokenEndpoint, + Uri CopilotTokenExchangeEndpoint) +{ + public GitHubCopilotAuthOptions ToOptions() => new() + { + GitHubHost = GitHubHost, + GitHubApiBase = GitHubApiBase, + }; +} + +public static class GitHubCopilotAuthResolver +{ + public static readonly Uri PublicGitHubHost = new("https://github.com"); + public static readonly Uri PublicGitHubApiBase = new("https://api.github.com"); + + private static readonly string[] GitHubHostEnvironmentVariables = + [ + "COPILOT_GH_HOST", + "GHE_HOST", + "GH_HOST", + "GITHUB_SERVER_URL", + ]; + + public static GitHubCopilotResolvedAuthOptions Resolve(ProviderEntry entry) + { + var options = entry.GetVendorOptions() ?? new GitHubCopilotAuthOptions(); + return Resolve(options); + } + + public static GitHubCopilotResolvedAuthOptions Resolve(GitHubCopilotAuthOptions? options) + { + options ??= new GitHubCopilotAuthOptions(); + var gitHubHost = NormalizeGitHubHost(options.GitHubHost, nameof(options.GitHubHost)); + var gitHubApiBase = NormalizeGitHubApiBase(options.GitHubApiBase, nameof(options.GitHubApiBase)); + + return new GitHubCopilotResolvedAuthOptions( + gitHubHost, + gitHubApiBase, + AppendPath(gitHubHost, "login/device/code"), + AppendPath(gitHubHost, "login/oauth/access_token"), + AppendPath(gitHubApiBase, "copilot_internal/v2/token")); + } + + public static bool TryResolveSetupOptions( + string? gitHubHost, + string? gitHubApiBase, + bool includeAmbientEnvironment, + out GitHubCopilotAuthOptions options, + out string? error) + { + options = new GitHubCopilotAuthOptions(); + error = null; + + var hostValue = FirstNonEmpty(gitHubHost, + includeAmbientEnvironment ? ReadFirstEnvironment(GitHubHostEnvironmentVariables) : null); + var apiBaseValue = FirstNonEmpty(gitHubApiBase, + includeAmbientEnvironment ? Environment.GetEnvironmentVariable("GITHUB_API_URL") : null); + + if (hostValue is null && apiBaseValue is null) + return true; + + if (hostValue is null) + { + if (TryParseUri(apiBaseValue!, assumeHttps: true, out var apiOnly) + && UriEquals(NormalizeGitHubApiBase(apiOnly, "GITHUB_API_URL"), PublicGitHubApiBase)) + { + return true; + } + + error = "GitHub Copilot enterprise API base requires a GitHub enterprise host."; + return false; + } + + Uri normalizedHost; + try + { + if (!TryParseUri(hostValue, assumeHttps: true, out var parsedHost)) + { + error = $"GitHub Copilot enterprise host must be an absolute HTTPS URI or hostname, got '{hostValue}'."; + return false; + } + + normalizedHost = NormalizeGitHubHost(parsedHost, "GitHubHost"); + } + catch (InvalidOperationException ex) + { + error = ex.Message; + return false; + } + + Uri normalizedApiBase; + try + { + if (apiBaseValue is null) + { + normalizedApiBase = DeriveGitHubApiBase(normalizedHost); + } + else + { + if (!TryParseUri(apiBaseValue, assumeHttps: true, out var parsedApiBase)) + { + error = $"GitHub Copilot enterprise API base must be an absolute HTTPS URI or hostname, got '{apiBaseValue}'."; + return false; + } + + normalizedApiBase = NormalizeGitHubApiBase(parsedApiBase, "GitHubApiBase"); + } + } + catch (InvalidOperationException ex) + { + error = ex.Message; + return false; + } + + options = new GitHubCopilotAuthOptions + { + GitHubHost = normalizedHost, + GitHubApiBase = normalizedApiBase, + }; + return true; + } + + public static IReadOnlyDictionary? ToVendorOptions(GitHubCopilotAuthOptions options) + { + var resolved = Resolve(options); + if (UriEquals(resolved.GitHubHost, PublicGitHubHost) + && UriEquals(resolved.GitHubApiBase, PublicGitHubApiBase)) + { + return null; + } + + return new Dictionary + { + [nameof(GitHubCopilotAuthOptions.GitHubHost)] = resolved.GitHubHost.ToString().TrimEnd('/'), + [nameof(GitHubCopilotAuthOptions.GitHubApiBase)] = resolved.GitHubApiBase.ToString().TrimEnd('/'), + }; + } + + public static IReadOnlyDictionary? ToVendorOptions(ProviderEntry entry) => + ToVendorOptions(Resolve(entry).ToOptions()); + + private static Uri DeriveGitHubApiBase(Uri gitHubHost) + { + if (UriEquals(gitHubHost, PublicGitHubHost)) + return PublicGitHubApiBase; + + if (gitHubHost.Host.EndsWith(".ghe.com", StringComparison.OrdinalIgnoreCase)) + { + return NormalizeGitHubApiBase(new UriBuilder(gitHubHost) + { + Host = $"api.{gitHubHost.Host}", + Path = string.Empty, + Query = string.Empty, + Fragment = string.Empty, + }.Uri, "GitHubApiBase"); + } + + return NormalizeGitHubApiBase(AppendPath(gitHubHost, "api/v3"), "GitHubApiBase"); + } + + private static Uri NormalizeGitHubHost(Uri uri, string name) + { + RequireSafeHttpsUri(uri, name); + if (uri.AbsolutePath is not ("" or "/")) + throw new InvalidOperationException($"{name} must be a host origin, not a URL with a path."); + + return new UriBuilder(uri) + { + Path = string.Empty, + Query = string.Empty, + Fragment = string.Empty, + }.Uri; + } + + private static Uri NormalizeGitHubApiBase(Uri uri, string name) + { + RequireSafeHttpsUri(uri, name); + var path = uri.AbsolutePath.TrimEnd('/'); + return new UriBuilder(uri) + { + Path = path == "/" ? string.Empty : path, + Query = string.Empty, + Fragment = string.Empty, + }.Uri; + } + + private static void RequireSafeHttpsUri(Uri uri, string name) + { + if (!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"{name} must use HTTPS."); + if (!string.IsNullOrEmpty(uri.UserInfo)) + throw new InvalidOperationException($"{name} must not include user information."); + if (!string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment)) + throw new InvalidOperationException($"{name} must not include a query string or fragment."); + } + + private static Uri AppendPath(Uri baseUri, string relativePath) + { + var basePath = baseUri.AbsolutePath.TrimEnd('/'); + if (basePath == "/") + basePath = string.Empty; + + return new UriBuilder(baseUri) + { + Path = $"{basePath}/{relativePath.TrimStart('/')}", + Query = string.Empty, + Fragment = string.Empty, + }.Uri; + } + + private static bool TryParseUri(string value, bool assumeHttps, out Uri uri) + { + var normalized = assumeHttps && !value.Contains("://", StringComparison.Ordinal) + ? $"https://{value.Trim()}" + : value.Trim(); + return Uri.TryCreate(normalized, UriKind.Absolute, out uri!); + } + + private static string? FirstNonEmpty(params string?[] values) => + values.FirstOrDefault(static value => !string.IsNullOrWhiteSpace(value))?.Trim(); + + private static string? ReadFirstEnvironment(IEnumerable names) + { + foreach (var name in names) + { + var value = Environment.GetEnvironmentVariable(name); + if (!string.IsNullOrWhiteSpace(value)) + return value.Trim(); + } + + return null; + } + + private static bool UriEquals(Uri left, Uri right) => + string.Equals(left.ToString().TrimEnd('/'), right.ToString().TrimEnd('/'), StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Netclaw.Providers/GitHubCopilot/GitHubCopilotDescriptor.cs b/src/Netclaw.Providers/GitHubCopilot/GitHubCopilotDescriptor.cs index 86faf4a4d..a1ed74ba3 100644 --- a/src/Netclaw.Providers/GitHubCopilot/GitHubCopilotDescriptor.cs +++ b/src/Netclaw.Providers/GitHubCopilot/GitHubCopilotDescriptor.cs @@ -24,25 +24,34 @@ public sealed class GitHubCopilotDescriptor( public string DefaultEndpoint => "https://api.githubcopilot.com"; public string ModelListingPath => "/models"; - public IProviderAuth Auth { get; } = new OAuthAuth + public IProviderAuth Auth { get; } = CreateOAuthAuth(new GitHubCopilotAuthOptions()); + + public static OAuthAuth CreateOAuthAuth(GitHubCopilotAuthOptions options) { - SupportedAuthMethods = [AuthMethod.OAuthDevice], - TokenEndpoint = new Uri("https://github.com/login/oauth/access_token"), - DeviceEndpoint = new Uri("https://github.com/login/device/code"), - - // OAuth App client_id borrowed from the Neovim Copilot plugin. The - // /copilot_internal/v2/token exchange endpoint is gated to a small - // allowlist of editor-integration OAuth Apps (VS Code, Neovim, - // JetBrains, gh CLI); a Netclaw-owned GitHub App was rejected with - // HTTP 403 "Resource not accessible by integration" regardless of - // configured permissions. Every community Copilot client (avante.nvim, - // copilot.lua, CodeAlta) takes the same posture. Replace if/when - // Netclaw gets its own OAuth App allowlisted by GitHub, or when we - // migrate to the documented Copilot SDK pathway. - ClientId = "Iv1.b507a08c87ecfe98", - Scope = "read:user", - UseProprietaryDeviceFlow = false, - }; + var resolved = GitHubCopilotAuthResolver.Resolve(options); + return new OAuthAuth + { + SupportedAuthMethods = [AuthMethod.OAuthDevice], + TokenEndpoint = resolved.OAuthTokenEndpoint, + DeviceEndpoint = resolved.DeviceEndpoint, + + // OAuth App client_id borrowed from the Neovim Copilot plugin. The + // /copilot_internal/v2/token exchange endpoint is gated to a small + // allowlist of editor-integration OAuth Apps (VS Code, Neovim, + // JetBrains, gh CLI); a Netclaw-owned GitHub App was rejected with + // HTTP 403 "Resource not accessible by integration" regardless of + // configured permissions. Every community Copilot client (avante.nvim, + // copilot.lua, CodeAlta) takes the same posture. Replace if/when + // Netclaw gets its own OAuth App allowlisted by GitHub, or when we + // migrate to the documented Copilot SDK pathway. + ClientId = "Iv1.b507a08c87ecfe98", + Scope = "read:user", + UseProprietaryDeviceFlow = false, + }; + } + + public static OAuthAuth CreateOAuthAuth(ProviderEntry entry) => + CreateOAuthAuth(GitHubCopilotAuthResolver.Resolve(entry).ToOptions()); // Fallback model set used only when /models is unreachable so the // operator never sees an empty list on a transient failure. diff --git a/src/Netclaw.Providers/GitHubCopilot/GitHubCopilotProviderPlugin.cs b/src/Netclaw.Providers/GitHubCopilot/GitHubCopilotProviderPlugin.cs index d90f7c0fa..23458180e 100644 --- a/src/Netclaw.Providers/GitHubCopilot/GitHubCopilotProviderPlugin.cs +++ b/src/Netclaw.Providers/GitHubCopilot/GitHubCopilotProviderPlugin.cs @@ -50,8 +50,7 @@ public override IChatClient CreateChatClient(ProviderEntry entry, ModelReference // token) on every call. The "placeholder" is overwritten before the // first request goes out. var credential = new ApiKeyCredential("placeholder"); - var oauth = Descriptor.Auth.GetOAuthConfig() - ?? throw new InvalidOperationException("GitHub Copilot OAuth configuration is missing."); + var oauth = GitHubCopilotDescriptor.CreateOAuthAuth(entry); options.AddPolicy( new CopilotRequestPolicy(tokenExchanger, entry, credential, model.Provider, oauth), PipelinePosition.PerCall); diff --git a/src/Netclaw.Providers/OAuth/ProviderOAuthRefreshingProbe.cs b/src/Netclaw.Providers/OAuth/ProviderOAuthRefreshingProbe.cs index 9dec3f988..dc23bb11a 100644 --- a/src/Netclaw.Providers/OAuth/ProviderOAuthRefreshingProbe.cs +++ b/src/Netclaw.Providers/OAuth/ProviderOAuthRefreshingProbe.cs @@ -4,6 +4,7 @@ // // ----------------------------------------------------------------------- using Netclaw.Configuration; +using Netclaw.Providers.GitHubCopilot; namespace Netclaw.Providers.OAuth; @@ -49,7 +50,7 @@ public async Task ProbeConfiguredAsync( if (entry.AuthMethod is AuthMethod.OAuthDevice or AuthMethod.OAuthPkce && entry.OAuthTokenExpiry is not null - && descriptor.Auth.GetOAuthConfig() is { } oauth) + && ResolveOAuthConfig(entry, descriptor) is { } oauth) { var refreshResult = await TryRefreshAsync(providerName, entry, oauth, ct); if (refreshResult is not null) @@ -59,6 +60,11 @@ public async Task ProbeConfiguredAsync( return await descriptor.ProbeAsync(entry, ct); } + private static OAuthAuth? ResolveOAuthConfig(ProviderEntry entry, IProviderDescriptor descriptor) + => string.Equals(entry.Type, "github-copilot", StringComparison.OrdinalIgnoreCase) + ? GitHubCopilotDescriptor.CreateOAuthAuth(entry) + : descriptor.Auth.GetOAuthConfig(); + private async Task TryRefreshAsync( string providerName, ProviderEntry entry,