Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/spec/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.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:

Expand Down
24 changes: 24 additions & 0 deletions openspec/specs/netclaw-model-providers/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand All @@ -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 <name> github-copilot --auth oauth-device --github-host <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
Expand Down
78 changes: 78 additions & 0 deletions src/Netclaw.Cli.Tests/Provider/ProviderCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
7 changes: 6 additions & 1 deletion src/Netclaw.Cli/Config/ProviderCredentialWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ internal static class ProviderCredentialWriter
/// <param name="apiKey">API key if auth method is ApiKey, null otherwise.</param>
/// <param name="registry">Provider registry for looking up default endpoints.</param>
/// <param name="protector">Secrets protector override. When null, creates one from paths.</param>
/// <param name="vendorOptions">Provider-owned non-secret options for netclaw.json.</param>
internal static void WriteProvider(
NetclawPaths paths,
string providerName,
Expand All @@ -47,7 +48,8 @@ internal static void WriteProvider(
OAuthDeviceFlowResult? oauthResult,
string? apiKey,
ProviderDescriptorRegistry? registry = null,
ISecretsProtector? protector = null)
ISecretsProtector? protector = null,
IReadOnlyDictionary<string, object?>? vendorOptions = null)
{
paths.EnsureDirectoriesExist();

Expand All @@ -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");
Expand Down
103 changes: 98 additions & 5 deletions src/Netclaw.Cli/Provider/ProviderCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
// </copyright>
// -----------------------------------------------------------------------
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;

Expand Down Expand Up @@ -114,6 +116,8 @@ private static async Task<int> 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++)
{
Expand All @@ -134,6 +138,18 @@ private static async Task<int> RunAddAsync(string[] args, NetclawPaths paths, Pr
authFlag = args[++i];
continue;
}

if (args[i] is "--github-host" && i + 1 < args.Length)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new CLI options for adding github enterprise auth via a single-shot command rather than the TUI

{
gitHubHost = args[++i];
continue;
}

if (args[i] is "--github-api-base" && i + 1 < args.Length)
{
gitHubApiBase = args[++i];
continue;
}
}

AuthMethod? requestedAuthMethod = null;
Expand All @@ -156,9 +172,20 @@ private static async Task<int> 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)
Expand All @@ -169,7 +196,15 @@ private static async Task<int> 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))
Expand Down Expand Up @@ -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<string, object?>? 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<int> RunOAuthDeviceFlowAsync(
string name, string type, string? endpoint,
IProviderDescriptor descriptor, NetclawPaths paths, TextWriter writer)
IProviderDescriptor descriptor,
NetclawPaths paths,
TextWriter writer,
IReadOnlyDictionary<string, object?>? 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.");
Expand Down Expand Up @@ -300,7 +382,9 @@ private static async Task<int> 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;
Expand Down Expand Up @@ -392,6 +476,12 @@ internal static Dictionary<string, ProviderEntry> LoadProviders(NetclawPaths pat
{
var entry = JsonSerializer.Deserialize<ProviderEntry>(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;
}
}
Expand Down Expand Up @@ -517,13 +607,16 @@ private static int WriteHelp(ProviderDescriptorRegistry registry, TextWriter wri
writer.WriteLine(" --api-key <key> API key (or prompted interactively)");
writer.WriteLine(" --endpoint <url> Custom endpoint URL");
writer.WriteLine(" --auth <method> Auth method: api-key, oauth-device");
writer.WriteLine(" --github-host <url> GitHub Enterprise auth host for github-copilot");
writer.WriteLine(" --github-api-base <url> GitHub Enterprise API base for github-copilot");
writer.WriteLine();
writer.WriteLine("Provider types: " + string.Join(", ", registry.KnownTypeKeys));
writer.WriteLine();
writer.WriteLine("Examples:");
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;
Expand Down
Loading
Loading