From 0307deacfac7e42a5ea448d17eee244192bfc599 Mon Sep 17 00:00:00 2001 From: Tobias Oort Date: Thu, 9 Jul 2026 03:59:41 +0200 Subject: [PATCH] feat(github-copilot): support GitHub Enterprise data-residency Copilot auth (#99221) Adds GitHub Enterprise data-residency support to the existing bundled GitHub Copilot provider. Maintainer proof: - GitHub CI green on head 54010a6538f1543a7fcf161f0c46169bf059213b - `check-lint`, `check-additional-extension-bundled`, and `check-shrinkwrap` passed in CI - local `pnpm lint:extensions:bundled`, `pnpm lint`, and focused GitHub Copilot Vitest passed - AWS Crabbox proof passed - live microsoft.ghe.com device-flow/token-exchange/model-catalog proof passed Co-authored-by: Tobias Oort Co-authored-by: Gio Della-Libera <235387111+giodl73-repo@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/docs_map.md | 1 + docs/providers/github-copilot.md | 69 +++ extensions/copilot-proxy/openclaw.plugin.json | 3 +- extensions/github-copilot/domain.test.ts | 38 ++ extensions/github-copilot/domain.ts | 36 ++ extensions/github-copilot/embeddings.ts | 9 + extensions/github-copilot/index.test.ts | 393 ++++++++++++++++++ extensions/github-copilot/index.ts | 250 +++++++++-- extensions/github-copilot/login.test.ts | 84 ++++ extensions/github-copilot/login.ts | 114 ++++- extensions/github-copilot/models.test.ts | 25 ++ .../github-copilot/openclaw.plugin.json | 14 +- extensions/github-copilot/usage.ts | 9 +- scripts/plugin-sdk-surface-report.mjs | 4 +- src/agents/simple-completion-runtime.test.ts | 6 +- src/agents/simple-completion-runtime.ts | 1 + src/media-understanding/image.test.ts | 6 +- src/media-understanding/image.ts | 1 + src/plugin-sdk/provider-auth.test.ts | 270 ++++++++++++ src/plugin-sdk/provider-auth.ts | 171 +++++++- .../provider-discovery-contract.ts | 6 + .../contracts/registry.contract.test.ts | 14 +- 22 files changed, 1458 insertions(+), 66 deletions(-) create mode 100644 extensions/github-copilot/domain.test.ts create mode 100644 extensions/github-copilot/domain.ts diff --git a/docs/docs_map.md b/docs/docs_map.md index 8008eae59a3..55f65713bbd 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -7485,6 +7485,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Route: /providers/github-copilot - Headings: - H2: Three ways to use Copilot in OpenClaw + - H2: GitHub Enterprise (data residency) - H2: Optional flags - H2: Non-interactive onboarding - H2: Memory search embeddings diff --git a/docs/providers/github-copilot.md b/docs/providers/github-copilot.md index aad1c421249..db6f966843f 100644 --- a/docs/providers/github-copilot.md +++ b/docs/providers/github-copilot.md @@ -101,6 +101,75 @@ provider or agent runtime in three different ways. +## GitHub Enterprise (data residency) + +If your organization uses a data-residency GitHub Enterprise tenant (a +`*.ghe.com` host such as `your-org.ghe.com`), Copilot lives on tenant-local +endpoints rather than public `github.com`. OpenClaw exposes this as a +first-class auth choice so you do not have to hand-edit URLs. + + + + In onboarding or `openclaw models auth`, choose + **GitHub Copilot (Enterprise / data residency)**. You will be prompted for + your Enterprise domain (for example `your-org.ghe.com`), then the device + login runs against that tenant. + + Enter the tenant root only (`your-org.ghe.com`). Derived service hosts such + as `api.your-org.ghe.com` or `copilot-api.your-org.ghe.com` are not accepted; + OpenClaw derives those endpoints from the tenant root automatically. + + ```bash + openclaw models auth login --provider github-copilot --method device-enterprise + ``` + + + + The chosen host is stored under the provider params so later token refreshes + and completions target the tenant automatically: + + ```json5 + { + models: { + providers: { + "github-copilot": { params: { githubDomain: "your-org.ghe.com" } }, + }, + }, + } + ``` + + + + +The device flow, token exchange, and completions resolve to +`https://your-org.ghe.com/login/device/code`, +`https://api.your-org.ghe.com/copilot_internal/v2/token`, and +`https://copilot-api.your-org.ghe.com` respectively. Data-residency tokens carry +a tenant stamp and no proxy hint, so the completions base URL falls back to the +tenant Copilot host instead of the public endpoint. + + +Switching domains always re-runs the device login. If you already have a stored +Copilot token and pick a different domain (public `github.com` ↔ a `*.ghe.com` +tenant, or one tenant to another), OpenClaw will not reuse the existing token — +it forces a fresh login so the token is scoped to the domain being written to +config. Re-running login for the *same* domain still offers to reuse the current +token. Switching back to public `github.com` clears the persisted +`githubDomain` so config returns to the default. + + + +The `COPILOT_GITHUB_DOMAIN` environment variable overrides the resolved domain +for every Copilot path that resolves it — the Enterprise device login +(`--method device-enterprise`), the standalone +`openclaw models auth login-github-copilot` shortcut, token refresh, embeddings, +and completions. Set it to your `*.ghe.com` host for fully headless or CI +setups. Leave it unset (and the config param absent) to use public `github.com`. +Logins persist the domain they minted the token for (and clear it when logging +in against public `github.com`), so routing stays correct even after the +environment variable is unset. + + ## Optional flags | Command | Flag | Description | diff --git a/extensions/copilot-proxy/openclaw.plugin.json b/extensions/copilot-proxy/openclaw.plugin.json index a7f397572d3..9a9f34cef0b 100644 --- a/extensions/copilot-proxy/openclaw.plugin.json +++ b/extensions/copilot-proxy/openclaw.plugin.json @@ -14,9 +14,10 @@ "choiceId": "copilot-proxy", "choiceLabel": "Copilot Proxy", "choiceHint": "Configure base URL + model ids", + "assistantPriority": 3, "groupId": "copilot", "groupLabel": "Copilot", - "groupHint": "GitHub + local proxy" + "groupHint": "GitHub, GitHub Enterprise + Local Proxy" } ], "configSchema": { diff --git a/extensions/github-copilot/domain.test.ts b/extensions/github-copilot/domain.test.ts new file mode 100644 index 00000000000..4fd14bdfd61 --- /dev/null +++ b/extensions/github-copilot/domain.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { PUBLIC_GITHUB_COPILOT_DOMAIN, resolveGithubCopilotDomain } from "./domain.js"; + +describe("github-copilot domain resolution", () => { + const withDomain = (githubDomain: string) => + ({ + models: { providers: { "github-copilot": { params: { githubDomain } } } }, + }) as never; + + it("defaults to the public github.com host", () => { + expect(PUBLIC_GITHUB_COPILOT_DOMAIN).toBe("github.com"); + expect(resolveGithubCopilotDomain({ env: {} })).toBe("github.com"); + }); + + it("resolves domain by precedence env > config > default", () => { + expect(resolveGithubCopilotDomain({ env: {}, config: withDomain("cfg.ghe.com") })).toBe( + "cfg.ghe.com", + ); + expect( + resolveGithubCopilotDomain({ + env: { COPILOT_GITHUB_DOMAIN: "env.ghe.com" }, + config: withDomain("cfg.ghe.com"), + }), + ).toBe("env.ghe.com"); + }); + + it("fails closed to github.com for unsafe or non-tenant hosts", () => { + expect(resolveGithubCopilotDomain({ env: {}, config: withDomain("acme.ghe.co") })).toBe( + "github.com", + ); + expect(resolveGithubCopilotDomain({ env: {}, config: withDomain("api.acme.ghe.com") })).toBe( + "github.com", + ); + expect(resolveGithubCopilotDomain({ env: { COPILOT_GITHUB_DOMAIN: "evil.com" } })).toBe( + "github.com", + ); + }); +}); diff --git a/extensions/github-copilot/domain.ts b/extensions/github-copilot/domain.ts new file mode 100644 index 00000000000..4591ab78f67 --- /dev/null +++ b/extensions/github-copilot/domain.ts @@ -0,0 +1,36 @@ +// GitHub Copilot data-residency domain resolution. +// +// Lives inside the provider so the shared plugin SDK only needs to export the +// security-critical host allowlist (`normalizeGithubCopilotDomain`). The +// env/config precedence below is GitHub Copilot provider policy, not a +// plugin-SDK contract, so it is intentionally not part of the SDK surface. +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { normalizeGithubCopilotDomain } from "openclaw/plugin-sdk/provider-auth"; + +/** Public GitHub Copilot host used when no data-residency domain is configured. */ +export const PUBLIC_GITHUB_COPILOT_DOMAIN = "github.com"; + +function readConfiguredGithubCopilotDomain(config?: OpenClawConfig): string | undefined { + const params = config?.models?.providers?.["github-copilot"]?.params; + const value = params && typeof params === "object" ? params.githubDomain : undefined; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +/** + * Resolve the GitHub Copilot host for this provider from (in priority order) the + * `COPILOT_GITHUB_DOMAIN` env override, the persisted + * `models.providers.github-copilot.params.githubDomain` config, then public + * `github.com`. The result always passes through the SDK allowlist + * (`normalizeGithubCopilotDomain`) so an unsafe value fails closed. + */ +export function resolveGithubCopilotDomain(params?: { + env?: NodeJS.ProcessEnv; + config?: OpenClawConfig; +}): string { + const env = params?.env ?? process.env; + const fromEnv = env.COPILOT_GITHUB_DOMAIN?.trim(); + if (fromEnv) { + return normalizeGithubCopilotDomain(fromEnv); + } + return normalizeGithubCopilotDomain(readConfiguredGithubCopilotDomain(params?.config)); +} diff --git a/extensions/github-copilot/embeddings.ts b/extensions/github-copilot/embeddings.ts index bd5cb091d4c..2da4ae1ab16 100644 --- a/extensions/github-copilot/embeddings.ts +++ b/extensions/github-copilot/embeddings.ts @@ -14,6 +14,7 @@ import { import { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime"; import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; import { resolveFirstGithubToken } from "./auth.js"; +import { resolveGithubCopilotDomain } from "./domain.js"; import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken } from "./token.js"; const COPILOT_EMBEDDING_PROVIDER_ID = "github-copilot"; @@ -58,6 +59,7 @@ type GitHubCopilotEmbeddingClient = { headers?: Record; env?: NodeJS.ProcessEnv; fetchImpl?: typeof fetch; + githubDomain?: string; }; function isCopilotSetupError(err: unknown): boolean { @@ -206,6 +208,7 @@ async function resolveGitHubCopilotEmbeddingSession(client: GitHubCopilotEmbeddi githubToken: client.githubToken, env: client.env, fetchImpl: client.fetchImpl, + githubDomain: client.githubDomain, }); const baseUrl = client.baseUrl?.trim() || token.baseUrl || DEFAULT_COPILOT_API_BASE_URL; return { @@ -295,9 +298,14 @@ export const githubCopilotMemoryEmbeddingProviderAdapter: MemoryEmbeddingProvide throw new Error("No GitHub token available for Copilot embedding provider"); } + const githubDomain = resolveGithubCopilotDomain({ + env: process.env, + config: options.config, + }); const { token: copilotToken, baseUrl: resolvedBaseUrl } = await resolveCopilotApiToken({ githubToken, env: process.env, + githubDomain, }); const baseUrl = options.remote?.baseUrl?.trim() || resolvedBaseUrl || DEFAULT_COPILOT_API_BASE_URL; @@ -321,6 +329,7 @@ export const githubCopilotMemoryEmbeddingProviderAdapter: MemoryEmbeddingProvide env: process.env, fetchImpl: fetch, githubToken, + githubDomain, headers: options.remote?.headers, model, }); diff --git a/extensions/github-copilot/index.test.ts b/extensions/github-copilot/index.test.ts index b67be7d7966..bd707940dcc 100644 --- a/extensions/github-copilot/index.test.ts +++ b/extensions/github-copilot/index.test.ts @@ -331,6 +331,7 @@ describe("github-copilot plugin", () => { expect(mocks.resolveCopilotApiToken).toHaveBeenCalledWith({ githubToken: "gh_test_token", env: { GH_TOKEN: "gh_test_token" }, + githubDomain: "github.com", }); expect(result).toEqual({ provider: { @@ -374,6 +375,7 @@ describe("github-copilot plugin", () => { expect(mocks.resolveCopilotApiToken).toHaveBeenCalledWith({ githubToken: "gh_test_token", env: { GH_TOKEN: "gh_test_token" }, + githubDomain: "github.com", }); expect(result).toEqual([]); }); @@ -512,6 +514,332 @@ describe("github-copilot plugin", () => { } }); + function buildDeviceFlowFetchMock(domain: string, accessToken: string) { + return vi.fn(async (input: unknown, _init?: RequestInit) => { + const target = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input instanceof Request + ? input.url + : String(input); + if (target === `https://${domain}/login/device/code`) { + return new Response( + JSON.stringify({ + device_code: "device-code-stub", + user_code: "ABCD-1234", + verification_uri: `https://${domain}/login/device`, + expires_in: 900, + interval: 0, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (target === `https://${domain}/login/oauth/access_token`) { + return new Response(JSON.stringify({ access_token: accessToken, token_type: "bearer" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`unexpected fetch in github-copilot device flow test: ${target}`); + }); + } + + async function withTty(fn: () => Promise): Promise { + const isTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true }); + try { + return await fn(); + } finally { + vi.unstubAllGlobals(); + if (isTtyDescriptor) { + Object.defineProperty(process.stdin, "isTTY", isTtyDescriptor); + } else { + delete (process.stdin as { isTTY?: boolean }).isTTY; + } + } + } + + it("forces re-login and clears the domain when switching from a tenant back to github.com", async () => { + const provider = registerProviderWithPluginConfig({}); + const method = provider.auth[0]; + const agentDir = await createAgentDir(); + writeExistingCopilotTokenProfile(agentDir); + const fetchMock = buildDeviceFlowFetchMock("github.com", "public-fresh-token"); + vi.stubGlobal("fetch", fetchMock); + setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({ + response: await fetchMock(params.url, params.init), + finalUrl: params.url, + release: async () => {}, + })); + const prompter = { + confirm: vi.fn(async () => false), + text: vi.fn(async () => ""), + note: vi.fn(), + }; + + const result = await withTty( + async () => + await method.run({ + config: { + models: { + providers: { "github-copilot": { params: { githubDomain: "acme.ghe.com" } } }, + }, + }, + env: {}, + agentDir, + workspaceDir: "/tmp/workspace", + prompter, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + opts: {}, + secretInputMode: "plaintext", + allowSecretRefPrompt: false, + isRemote: false, + openUrl: vi.fn(), + oauth: { createVpsAwareHandlers: vi.fn() }, + } as never), + ); + if (!result) { + throw new Error("Expected GitHub Copilot auth result"); + } + + // Domain switch must not offer to reuse the tenant-scoped token. + expect(prompter.confirm).not.toHaveBeenCalled(); + expect(prompter.note).toHaveBeenCalled(); + expect(result.profiles[0]?.credential).toEqual({ + type: "token", + provider: "github-copilot", + token: "public-fresh-token", + }); + const params = ( + result.configPatch as { + models?: { providers?: Record }> }; + } + )?.models?.providers?.["github-copilot"]?.params; + expect(params && Object.hasOwn(params, "githubDomain")).toBe(true); + expect(params?.githubDomain).toBeUndefined(); + }); + + it("forces re-login when switching from github.com to a tenant domain", async () => { + const provider = registerProviderWithPluginConfig({}); + const method = provider.auth[1]; + const agentDir = await createAgentDir(); + writeExistingCopilotTokenProfile(agentDir); + const fetchMock = buildDeviceFlowFetchMock("acme.ghe.com", "tenant-fresh-token"); + vi.stubGlobal("fetch", fetchMock); + setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({ + response: await fetchMock(params.url, params.init), + finalUrl: params.url, + release: async () => {}, + })); + const prompter = { + confirm: vi.fn(async () => false), + text: vi.fn(async () => "acme.ghe.com"), + note: vi.fn(), + }; + + const result = await withTty( + async () => + await method.run({ + config: {}, + env: {}, + agentDir, + workspaceDir: "/tmp/workspace", + prompter, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + opts: {}, + secretInputMode: "plaintext", + allowSecretRefPrompt: false, + isRemote: false, + openUrl: vi.fn(), + oauth: { createVpsAwareHandlers: vi.fn() }, + } as never), + ); + if (!result) { + throw new Error("Expected GitHub Copilot auth result"); + } + + // Existing public token must not be reused for the tenant endpoint. + expect(prompter.confirm).not.toHaveBeenCalled(); + expect(result.profiles[0]?.credential).toEqual({ + type: "token", + provider: "github-copilot", + token: "tenant-fresh-token", + }); + const params = ( + result.configPatch as { + models?: { providers?: Record }> }; + } + )?.models?.providers?.["github-copilot"]?.params; + expect(params?.githubDomain).toBe("acme.ghe.com"); + }); + + it("forces re-login when an existing public profile meets an env-only tenant domain", async () => { + const provider = registerProviderWithPluginConfig({}); + const method = provider.auth[1]; + const agentDir = await createAgentDir(); + // Stored profile was minted for public github.com; config has no tenant. + writeExistingCopilotTokenProfile(agentDir); + // Tenant comes ONLY from the env override. The reuse gate must derive the + // previous domain from persisted config (github.com), not from the env, so + // the domain change is detected and the public token is not reused. + const fetchMock = buildDeviceFlowFetchMock("acme.ghe.com", "tenant-fresh-token"); + vi.stubGlobal("fetch", fetchMock); + setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({ + response: await fetchMock(params.url, params.init), + finalUrl: params.url, + release: async () => {}, + })); + const prompter = { + confirm: vi.fn(async () => false), + text: vi.fn(async () => "acme.ghe.com"), + note: vi.fn(), + }; + + const result = await withTty( + async () => + await method.run({ + config: {}, + env: { COPILOT_GITHUB_DOMAIN: "acme.ghe.com" }, + agentDir, + workspaceDir: "/tmp/workspace", + prompter, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + opts: {}, + secretInputMode: "plaintext", + allowSecretRefPrompt: false, + isRemote: false, + openUrl: vi.fn(), + oauth: { createVpsAwareHandlers: vi.fn() }, + } as never), + ); + if (!result) { + throw new Error("Expected GitHub Copilot auth result"); + } + + // Env is authoritative for the chosen domain, so the interactive prompt is + // skipped; the stale public token must NOT be reused for the tenant. + expect(prompter.text).not.toHaveBeenCalled(); + expect(prompter.confirm).not.toHaveBeenCalled(); + expect(result.profiles[0]?.credential).toEqual({ + type: "token", + provider: "github-copilot", + token: "tenant-fresh-token", + }); + const params = ( + result.configPatch as { + models?: { providers?: Record }> }; + } + )?.models?.providers?.["github-copilot"]?.params; + expect(params?.githubDomain).toBe("acme.ghe.com"); + }); + + it("still offers to reuse the token when re-running enterprise login for the same tenant", async () => { + const provider = registerProviderWithPluginConfig({}); + const method = provider.auth[1]; + const agentDir = await createAgentDir(); + writeExistingCopilotTokenProfile(agentDir); + const prompter = { + confirm: vi.fn(async () => false), + text: vi.fn(async () => "acme.ghe.com"), + note: vi.fn(), + }; + + const result = await method.run({ + config: { + models: { providers: { "github-copilot": { params: { githubDomain: "acme.ghe.com" } } } }, + }, + env: {}, + agentDir, + workspaceDir: "/tmp/workspace", + prompter, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + opts: {}, + secretInputMode: "plaintext", + allowSecretRefPrompt: false, + isRemote: false, + openUrl: vi.fn(), + oauth: { createVpsAwareHandlers: vi.fn() }, + } as never); + if (!result) { + throw new Error("Expected GitHub Copilot auth result"); + } + + expect(prompter.confirm).toHaveBeenCalledWith({ + message: "GitHub Copilot auth already exists. Re-run login?", + initialValue: false, + }); + expect(mocks.githubCopilotLoginCommand).not.toHaveBeenCalled(); + expect(result.profiles[0]?.credential).toEqual({ + type: "token", + provider: "github-copilot", + token: "existing-token", + }); + const params = ( + result.configPatch as { + models?: { providers?: Record }> }; + } + )?.models?.providers?.["github-copilot"]?.params; + expect(params?.githubDomain).toBe("acme.ghe.com"); + }); + + it("honors COPILOT_GITHUB_DOMAIN over a divergent prompt value during enterprise login", async () => { + const provider = registerProviderWithPluginConfig({}); + const method = provider.auth[1]; + const agentDir = await createAgentDir(); + // Device flow is mocked for the env tenant only; if login used the typed + // prompt value instead, the fetch mock would throw on an unexpected host. + const fetchMock = buildDeviceFlowFetchMock("env-tenant.ghe.com", "env-tenant-token"); + vi.stubGlobal("fetch", fetchMock); + setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({ + response: await fetchMock(params.url, params.init), + finalUrl: params.url, + release: async () => {}, + })); + const prompter = { + confirm: vi.fn(async () => false), + text: vi.fn(async () => "typed-tenant.ghe.com"), + note: vi.fn(), + }; + + const result = await withTty( + async () => + await method.run({ + config: {}, + env: { COPILOT_GITHUB_DOMAIN: "env-tenant.ghe.com" }, + agentDir, + workspaceDir: "/tmp/workspace", + prompter, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + opts: {}, + secretInputMode: "plaintext", + allowSecretRefPrompt: false, + isRemote: false, + openUrl: vi.fn(), + oauth: { createVpsAwareHandlers: vi.fn() }, + } as never), + ); + if (!result) { + throw new Error("Expected GitHub Copilot auth result"); + } + + // The interactive domain prompt must be skipped entirely when the env + // override is set, so a typed value can never diverge from it. + expect(prompter.text).not.toHaveBeenCalled(); + expect(result.profiles[0]?.credential).toEqual({ + type: "token", + provider: "github-copilot", + token: "env-tenant-token", + }); + const params = ( + result.configPatch as { + models?: { providers?: Record }> }; + } + )?.models?.providers?.["github-copilot"]?.params; + expect(params?.githubDomain).toBe("env-tenant.ghe.com"); + }); + it("rejects unsafe GitHub device code lifetimes before polling", async () => { const release = vi.fn(async () => {}); setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => ({ @@ -642,6 +970,71 @@ describe("github-copilot plugin", () => { }); }); + it("persists COPILOT_GITHUB_DOMAIN during non-interactive onboarding", async () => { + vi.stubEnv("COPILOT_GITHUB_DOMAIN", "acme.ghe.com"); + const provider = registerProviderWithPluginConfig({}); + const method = provider.auth[0]; + const agentDir = await createAgentDir(); + const runtime = { error: vi.fn(), exit: vi.fn() }; + + const result = await method.runNonInteractive({ + authChoice: "github-copilot", + config: {}, + baseConfig: {}, + opts: { githubCopilotToken: "ghu_test123" }, + runtime, + agentDir, + resolveApiKey: vi.fn(async () => ({ + key: "ghu_test123", + source: "flag" as const, + })), + toApiKeyCredential: vi.fn(), + }); + + expect(runtime.error).not.toHaveBeenCalled(); + expect(result?.models?.providers?.["github-copilot"]?.params?.githubDomain).toBe( + "acme.ghe.com", + ); + expect(result?.auth?.profiles?.["github-copilot:github"]).toEqual({ + provider: "github-copilot", + mode: "token", + }); + + const profile = ensureAuthProfileStore(agentDir).profiles["github-copilot:github"]; + expect(profile).toEqual({ + type: "token", + provider: "github-copilot", + token: "ghu_test123", + }); + }); + + it("clears a persisted enterprise domain during public non-interactive onboarding", async () => { + vi.stubEnv("COPILOT_GITHUB_DOMAIN", "github.com"); + const provider = registerProviderWithPluginConfig({}); + const method = provider.auth[0]; + const agentDir = await createAgentDir(); + const runtime = { error: vi.fn(), exit: vi.fn() }; + + const result = await method.runNonInteractive({ + authChoice: "github-copilot", + config: { + models: { providers: { "github-copilot": { params: { githubDomain: "acme.ghe.com" } } } }, + }, + baseConfig: {}, + opts: { githubCopilotToken: "ghu_public" }, + runtime, + agentDir, + resolveApiKey: vi.fn(async () => ({ + key: "ghu_public", + source: "flag" as const, + })), + toApiKeyCredential: vi.fn(), + }); + + expect(runtime.error).not.toHaveBeenCalled(); + expect(result?.models?.providers?.["github-copilot"]?.params?.githubDomain).toBeUndefined(); + }); + it("stores env-backed token refs for non-interactive onboarding ref mode", async () => { const provider = registerProviderWithPluginConfig({}); const method = provider.auth[0]; diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index 6ec07987f20..733b4d546a6 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -16,12 +16,14 @@ import { coerceSecretRef, ensureAuthProfileStore, listProfilesForProvider, + normalizeGithubCopilotDomain, normalizeOptionalSecretInput, resolveDefaultSecretProviderAlias, upsertAuthProfileWithLock, } from "openclaw/plugin-sdk/provider-auth"; import { getCachedLiveCatalogValue } from "openclaw/plugin-sdk/provider-catalog-shared"; import { resolveFirstGithubToken } from "./auth.js"; +import { PUBLIC_GITHUB_COPILOT_DOMAIN, resolveGithubCopilotDomain } from "./domain.js"; import { githubCopilotMemoryEmbeddingProviderAdapter } from "./embeddings.js"; import { resolveCopilotExtendedThinkingLevels } from "./model-metadata.js"; import { @@ -122,6 +124,70 @@ function resolveExistingCopilotAuthResult(agentDir?: string): ProviderAuthResult }; } +// Persists the chosen enterprise Copilot host under the provider's free-form +// params bag. The completions base URL is derived at runtime (token proxy hint +// or tenant fallback), so only the host is stored here. Mirror of +// clearGithubCopilotDomainConfigPatch; both are provider-owned and live with the +// plugin rather than the shared SDK. +function buildGithubCopilotDomainConfigPatch(domain: string): Partial { + const normalized = normalizeGithubCopilotDomain(domain); + return { + models: { + providers: { + [PROVIDER_ID]: { params: { githubDomain: normalized } }, + }, + }, + } as unknown as Partial; +} + +// Removes a previously persisted enterprise domain so config falls back to the +// "no config == github.com" default. Undefined leaves are deleted on merge. +function clearGithubCopilotDomainConfigPatch(): Partial { + return { + models: { + providers: { + [PROVIDER_ID]: { params: { githubDomain: undefined } }, + }, + }, + } as unknown as Partial; +} + +function applyGithubCopilotDomainToConfig( + config: OpenClawConfig, + domain: string, + previousDomain: string, +): OpenClawConfig { + const isEnterprise = domain !== PUBLIC_GITHUB_COPILOT_DOMAIN; + const shouldClear = !isEnterprise && previousDomain !== PUBLIC_GITHUB_COPILOT_DOMAIN; + if (!isEnterprise && !shouldClear) { + return config; + } + + const models = config.models ?? {}; + const providers = models.providers ?? {}; + const provider = providers[PROVIDER_ID] ?? {}; + const params = { ...provider.params } as Record; + if (isEnterprise) { + params.githubDomain = domain; + } else { + delete params.githubDomain; + } + + return { + ...config, + models: { + ...models, + providers: { + ...providers, + [PROVIDER_ID]: { + ...provider, + params, + }, + }, + }, + }; +} + async function resolveCopilotNonInteractiveToken( ctx: ProviderAuthMethodNonInteractiveContext, flagValue: string | undefined, @@ -243,8 +309,16 @@ async function runGitHubCopilotNonInteractiveAuth( profileId = existingProfileId; } + const resolvedDomain = resolveGithubCopilotDomain({ config: ctx.config }); + const previousDomain = resolveGithubCopilotDomain({ env: {}, config: ctx.config }); + const configWithDomain = applyGithubCopilotDomainToConfig( + ctx.config, + resolvedDomain, + previousDomain, + ); + return applyCopilotDefaultModel( - applyAuthProfileConfig(ctx.config, { + applyAuthProfileConfig(configWithDomain, { profileId, provider: PROVIDER_ID, mode: "token", @@ -292,6 +366,7 @@ export default definePluginEntry({ const token = await resolveCopilotApiToken({ githubToken, env: ctx.env, + githubDomain: resolveGithubCopilotDomain({ env: ctx.env, config: ctx.config }), }); baseUrl = token.baseUrl; copilotApiToken = token.token; @@ -348,21 +423,100 @@ export default definePluginEntry({ }); } - async function runGitHubCopilotAuth(ctx: ProviderAuthContext) { + async function promptForEnterpriseDomain(ctx: ProviderAuthContext): Promise { + // COPILOT_GITHUB_DOMAIN is authoritative for every runtime routing path + // (token refresh, usage, completions). Honor it here too when it is set so + // the persisted config and freshly minted token can never diverge from the + // host the runtime actually calls; a typed prompt value could otherwise + // silently disagree with the env override. + const envDomain = ctx.env?.COPILOT_GITHUB_DOMAIN?.trim(); + if (envDomain) { + const normalizedEnv = normalizeGithubCopilotDomain(envDomain); + await ctx.prompter.note( + `Using the GitHub Enterprise domain from COPILOT_GITHUB_DOMAIN (${normalizedEnv}). Unset it to enter a different domain interactively.`, + "GitHub Copilot", + ); + return normalizedEnv; + } + const current = resolveGithubCopilotDomain({ env: ctx.env, config: ctx.config }); + const value = await ctx.prompter.text({ + message: "GitHub Enterprise domain (data residency)", + placeholder: "your-org.ghe.com", + initialValue: current === PUBLIC_GITHUB_COPILOT_DOMAIN ? "" : current, + validate: (raw) => { + const trimmed = raw.trim(); + if (!trimmed) { + return "Enter your GitHub Enterprise domain (for example your-org.ghe.com)."; + } + if ( + normalizeGithubCopilotDomain(trimmed) === PUBLIC_GITHUB_COPILOT_DOMAIN && + trimmed.toLowerCase() !== PUBLIC_GITHUB_COPILOT_DOMAIN + ) { + // GitHub's GHE docs list derived service hosts (api..ghe.com, + // copilot-api..ghe.com) that users are likely to paste; point + // them at the tenant root instead of the generic hostname message. + if (trimmed.toLowerCase().endsWith(".ghe.com")) { + return "Enter your tenant root (for example your-org.ghe.com), not a service host like api.your-org.ghe.com — service endpoints are derived automatically."; + } + return "Enter a github.com or *.ghe.com hostname without scheme or path (for example your-org.ghe.com)."; + } + return undefined; + }, + }); + const domain = normalizeGithubCopilotDomain(value); + return domain; + } + + async function runGitHubCopilotDeviceAuth( + ctx: ProviderAuthContext, + domain: string, + ): Promise { + const normalizedDomain = normalizeGithubCopilotDomain(domain); + const isEnterprise = normalizedDomain !== PUBLIC_GITHUB_COPILOT_DOMAIN; + // Domain the currently stored profile was actually minted under. This must + // come from PERSISTED CONFIG ONLY (never COPILOT_GITHUB_DOMAIN): a + // successful login writes its tenant to config (enterprise) or leaves it + // absent (github.com), so config reflects the stored token's true tenant. + // Reading env here would let an env-selected tenant masquerade as the + // previous domain, making domainChanged=false and offering to reuse a + // public-minted token instead of forcing a fresh tenant device login. + const previousDomain = resolveGithubCopilotDomain({ env: {}, config: ctx.config }); + const domainChanged = previousDomain !== normalizedDomain; + // Enterprise logins persist the tenant domain. Switching back to github.com + // clears any persisted tenant so the default (no config == github.com) is + // restored; github.com stays absent otherwise to avoid redundant noise. + const configPatch = isEnterprise + ? buildGithubCopilotDomainConfigPatch(normalizedDomain) + : previousDomain !== PUBLIC_GITHUB_COPILOT_DOMAIN + ? clearGithubCopilotDomainConfigPatch() + : undefined; + const existing = resolveExistingCopilotAuthResult(ctx.agentDir); - if (existing) { + // Only offer to reuse the stored token when it was minted for the same + // domain. A domain switch (either direction) must re-run the device flow so + // the token is tenant-scoped to the domain being written to config. + if (existing && !domainChanged) { const runLogin = await ctx.prompter.confirm({ message: "GitHub Copilot auth already exists. Re-run login?", initialValue: false, }); if (!runLogin) { - return existing; + return { ...existing, ...(configPatch ? { configPatch } : {}) }; } + } else if (existing && domainChanged) { + await ctx.prompter.note( + isEnterprise + ? `Switching to ${normalizedDomain} requires a new tenant login to authorize Copilot for that domain.` + : "Switching back to github.com requires a new login to authorize Copilot for the public domain.", + "GitHub Copilot", + ); } await ctx.prompter.note( [ - "This will open a GitHub device login to authorize Copilot.", + isEnterprise + ? `This will open a GitHub Enterprise device login (${normalizedDomain}) to authorize Copilot.` + : "This will open a GitHub device login to authorize Copilot.", "Requires an active GitHub Copilot subscription.", ].join("\n"), "GitHub Copilot", @@ -370,25 +524,28 @@ export default definePluginEntry({ const { runGitHubCopilotDeviceFlow } = await import("./login.js"); - const result = await runGitHubCopilotDeviceFlow({ - showCode: async ({ verificationUrl, userCode, expiresInMs }) => { - const expiresInMinutes = Math.max(1, Math.round(expiresInMs / 60_000)); - await ctx.prompter.note( - [ - "Open this URL in your browser and enter the code below.", - `URL: ${verificationUrl}`, - `Code: ${userCode}`, - `Code expires in ${expiresInMinutes} minutes. Never share it.`, - "", - "If a browser does not open automatically after you continue, copy the URL manually.", - ].join("\n"), - "Authorize GitHub Copilot", - ); + const result = await runGitHubCopilotDeviceFlow( + { + showCode: async ({ verificationUrl, userCode, expiresInMs }) => { + const expiresInMinutes = Math.max(1, Math.round(expiresInMs / 60_000)); + await ctx.prompter.note( + [ + "Open this URL in your browser and enter the code below.", + `URL: ${verificationUrl}`, + `Code: ${userCode}`, + `Code expires in ${expiresInMinutes} minutes. Never share it.`, + "", + "If a browser does not open automatically after you continue, copy the URL manually.", + ].join("\n"), + "Authorize GitHub Copilot", + ); + }, + openUrl: async (url) => { + await ctx.openUrl(url); + }, }, - openUrl: async (url) => { - await ctx.openUrl(url); - }, - }); + normalizedDomain, + ); if (result.status === "access_denied") { await ctx.prompter.note("GitHub Copilot login was cancelled.", "GitHub Copilot"); @@ -415,9 +572,30 @@ export default definePluginEntry({ }, ], defaultModel: DEFAULT_COPILOT_MODEL, + ...(configPatch ? { configPatch } : {}), }; } + async function runGitHubCopilotAuth(ctx: ProviderAuthContext) { + return await runGitHubCopilotDeviceAuth(ctx, PUBLIC_GITHUB_COPILOT_DOMAIN); + } + + async function runGitHubCopilotEnterpriseAuth(ctx: ProviderAuthContext) { + const domain = await promptForEnterpriseDomain(ctx); + if (!domain) { + await ctx.prompter.note("Enterprise login cancelled.", "GitHub Copilot"); + return { profiles: [] }; + } + if (domain === PUBLIC_GITHUB_COPILOT_DOMAIN) { + await ctx.prompter.note( + "github.com is the default — use the standard GitHub Copilot login instead of the enterprise (data residency) option.", + "GitHub Copilot", + ); + return { profiles: [] }; + } + return await runGitHubCopilotDeviceAuth(ctx, domain); + } + api.registerMemoryEmbeddingProvider(githubCopilotMemoryEmbeddingProviderAdapter); api.registerProvider({ @@ -435,6 +613,23 @@ export default definePluginEntry({ run: async (ctx) => await runGitHubCopilotAuth(ctx), runNonInteractive: async (ctx) => await runGitHubCopilotNonInteractiveAuth(ctx), }, + { + id: "device-enterprise", + label: "GitHub Enterprise device login (data residency)", + hint: "Device-code flow against your *.ghe.com tenant", + kind: "device_code", + run: async (ctx) => await runGitHubCopilotEnterpriseAuth(ctx), + wizard: { + choiceId: "github-copilot-enterprise", + choiceLabel: "GitHub Copilot (Enterprise / data residency)", + choiceHint: "Device login against your GitHub Enterprise (*.ghe.com) tenant", + methodId: "device-enterprise", + assistantPriority: 2, + modelSelection: { + promptWhenAuthChoiceProvided: true, + }, + }, + }, ], wizard: { setup: { @@ -442,6 +637,7 @@ export default definePluginEntry({ choiceLabel: "GitHub Copilot", choiceHint: "Device login with your GitHub account", methodId: "device", + assistantPriority: 1, modelSelection: { promptWhenAuthChoiceProvided: true, }, @@ -473,6 +669,7 @@ export default definePluginEntry({ const token = await resolveCopilotApiToken({ githubToken: ctx.apiKey, env: ctx.env, + githubDomain: resolveGithubCopilotDomain({ env: ctx.env, config: ctx.config }), }); return { apiKey: token.token, @@ -483,7 +680,12 @@ export default definePluginEntry({ resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(), fetchUsageSnapshot: async (ctx) => { const { fetchCopilotUsage } = await loadGithubCopilotRuntime(); - return await fetchCopilotUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn); + return await fetchCopilotUsage( + ctx.token, + ctx.timeoutMs, + ctx.fetchFn, + resolveGithubCopilotDomain({ env: ctx.env, config: ctx.config }), + ); }, }); api.registerModelCatalogProvider({ diff --git a/extensions/github-copilot/login.test.ts b/extensions/github-copilot/login.test.ts index 255a7ee9205..acd50cca359 100644 --- a/extensions/github-copilot/login.test.ts +++ b/extensions/github-copilot/login.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { runGitHubCopilotDeviceFlow, setGitHubCopilotDeviceFlowFetchGuardForTesting, + withGithubCopilotDomainConfig, } from "./login.js"; const DEVICE_CODE_URL = "https://github.com/login/device/code"; @@ -203,3 +204,86 @@ describe("postGitHubDeviceFlowForm — response size bound", () => { expect(canceled).toBe(true); }); }); + +describe("runGitHubCopilotDeviceFlow — data-residency GitHub Enterprise", () => { + const GHE_DOMAIN = "acme.ghe.com"; + + it("targets the enterprise device-flow endpoints when a domain is provided", async () => { + const gheDeviceCodeUrl = `https://${GHE_DOMAIN}/login/device/code`; + const gheAccessTokenUrl = `https://${GHE_DOMAIN}/login/oauth/access_token`; + + const urls: string[] = []; + let callIdx = 0; + setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => { + urls.push(params.url); + callIdx += 1; + if (callIdx === 1) { + expect(params.policy).toEqual({ hostnameAllowlist: [GHE_DOMAIN] }); + return guardResponse( + { ...VALID_DEVICE_CODE_BODY, verification_uri: `https://${GHE_DOMAIN}/login/device` }, + 200, + gheDeviceCodeUrl, + ); + } + return guardResponse( + { access_token: "ghu_ghe_tok", token_type: "bearer" }, + 200, + gheAccessTokenUrl, + ); + }); + + const showCode = vi.fn(async () => {}); + const result = await runGitHubCopilotDeviceFlow({ showCode }, GHE_DOMAIN); + + expect(result).toEqual({ status: "authorized", accessToken: "ghu_ghe_tok" }); + expect(urls).toEqual([gheDeviceCodeUrl, gheAccessTokenUrl]); + expect(showCode).toHaveBeenCalledWith({ + verificationUrl: `https://${GHE_DOMAIN}/login/device`, + userCode: "ABCD-1234", + expiresInMs: expect.any(Number), + }); + }); + + it("rejects a verification URL whose host does not match the configured domain", async () => { + setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => + guardResponse( + { ...VALID_DEVICE_CODE_BODY, verification_uri: "https://github.com/login/device" }, + 200, + `https://${GHE_DOMAIN}/login/device/code`, + ), + ); + + await expect( + runGitHubCopilotDeviceFlow({ showCode: vi.fn(async () => {}) }, GHE_DOMAIN), + ).rejects.toThrow("unexpected verification URL"); + }); +}); + +describe("withGithubCopilotDomainConfig — shortcut login domain persistence", () => { + const tenantConfig = { + models: { + providers: { "github-copilot": { params: { githubDomain: "acme.ghe.com" } } }, + }, + } as never; + + it("persists the tenant domain when the shortcut minted a tenant token", () => { + const next = withGithubCopilotDomainConfig({} as never, "acme.ghe.com"); + expect( + (next as { models?: { providers?: Record }> } }) + .models?.providers?.["github-copilot"]?.params?.githubDomain, + ).toBe("acme.ghe.com"); + }); + + it("clears a stale tenant domain when the shortcut logged in against github.com", () => { + const next = withGithubCopilotDomainConfig(tenantConfig, "github.com"); + const params = ( + next as { models?: { providers?: Record }> } } + ).models?.providers?.["github-copilot"]?.params; + expect(params && "githubDomain" in params).toBe(false); + }); + + it("leaves config untouched for a public login with no persisted domain", () => { + const cfg = {} as never; + expect(withGithubCopilotDomainConfig(cfg, "github.com")).toBe(cfg); + }); +}); diff --git a/extensions/github-copilot/login.ts b/extensions/github-copilot/login.ts index 24deecaf89d..34d30f93899 100644 --- a/extensions/github-copilot/login.ts +++ b/extensions/github-copilot/login.ts @@ -1,6 +1,7 @@ // Github Copilot plugin module implements login behavior. import { intro, note, outro, spinner } from "@clack/prompts"; import { stylePromptTitle } from "openclaw/plugin-sdk/cli-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { logConfigUpdated, updateConfig } from "openclaw/plugin-sdk/config-mutation"; import { resolveExpiresAtMsFromDurationMs, @@ -11,17 +12,25 @@ import { import { applyAuthProfileConfig, ensureAuthProfileStore, + normalizeGithubCopilotDomain, upsertAuthProfileWithLock, } from "openclaw/plugin-sdk/provider-auth"; import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; +import { PUBLIC_GITHUB_COPILOT_DOMAIN, resolveGithubCopilotDomain } from "./domain.js"; const CLIENT_ID = "Iv1.b507a08c87ecfe98"; -const DEVICE_CODE_URL = "https://github.com/login/device/code"; -const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"; -const GITHUB_DEVICE_VERIFICATION_URL = "https://github.com/login/device"; -const GITHUB_AUTH_SSRF_POLICY: SsrFPolicy = { hostnameAllowlist: ["github.com"] }; +// Data-residency GitHub Enterprise support: the device flow, token exchange, and +// completions endpoints all live under the tenant host (e.g. "acme.ghe.com") +// instead of github.com. The host is threaded in from the selected auth flow so +// the SSRF allowlist and every request target stay consistent for one login. +const deviceCodeUrl = (domain: string) => `https://${domain}/login/device/code`; +const accessTokenUrl = (domain: string) => `https://${domain}/login/oauth/access_token`; +const deviceVerificationUrl = (domain: string) => `https://${domain}/login/device`; +const githubAuthSsrfPolicy = (domain: string): SsrFPolicy => ({ + hostnameAllowlist: [domain], +}); type DeviceCodeResponse = { deviceCode: string; @@ -129,6 +138,7 @@ async function postGitHubDeviceFlowForm(params: { url: string; body: URLSearchParams; failureLabel: string; + domain: string; }): Promise> { const { response, release } = await githubDeviceFlowFetchGuard({ url: params.url, @@ -141,7 +151,7 @@ async function postGitHubDeviceFlowForm(params: { body: params.body, }, requireHttps: true, - policy: GITHUB_AUTH_SSRF_POLICY, + policy: githubAuthSsrfPolicy(params.domain), auditContext: "github-copilot-device-flow", }); try { @@ -156,16 +166,20 @@ async function postGitHubDeviceFlowForm(params: { } } -async function requestDeviceCode(params: { scope: string }): Promise { +async function requestDeviceCode(params: { + scope: string; + domain: string; +}): Promise { const body = new URLSearchParams({ client_id: CLIENT_ID, scope: params.scope, }); const json = await postGitHubDeviceFlowForm({ - url: DEVICE_CODE_URL, + url: deviceCodeUrl(params.domain), body, failureLabel: "GitHub device code failed", + domain: params.domain, }); // Anchor expiry to when GitHub issued the code, before UI prompts or browser launch. return parseDeviceCodeResponse(json, Date.now()); @@ -175,6 +189,7 @@ async function pollForAccessToken(params: { deviceCode: string; intervalMs: number; expiresAt: number; + domain: string; }): Promise { const bodyBase = new URLSearchParams({ client_id: CLIENT_ID, @@ -184,9 +199,10 @@ async function pollForAccessToken(params: { while (Date.now() < params.expiresAt) { const json = (await postGitHubDeviceFlowForm({ - url: ACCESS_TOKEN_URL, + url: accessTokenUrl(params.domain), body: bodyBase, failureLabel: "GitHub device token failed", + domain: params.domain, })) as DeviceTokenResponse; if ("access_token" in json && typeof json.access_token === "string") { return json.access_token; @@ -231,7 +247,7 @@ async function sleepGitHubDevicePollDelay(delayMs: number, expiresAt: number): P } } -function normalizeGitHubDeviceVerificationUrl(raw: string): string { +function normalizeGitHubDeviceVerificationUrl(raw: string, domain: string): string { let parsed: URL; try { parsed = new URL(raw); @@ -241,7 +257,7 @@ function normalizeGitHubDeviceVerificationUrl(raw: string): string { if ( parsed.protocol !== "https:" || - parsed.hostname !== "github.com" || + parsed.hostname !== domain || parsed.pathname !== "/login/device" || parsed.username || parsed.password @@ -249,7 +265,7 @@ function normalizeGitHubDeviceVerificationUrl(raw: string): string { throw new Error("GitHub device flow returned an unexpected verification URL"); } - return GITHUB_DEVICE_VERIFICATION_URL; + return deviceVerificationUrl(domain); } function normalizeGitHubDeviceUserCode(raw: string): string { @@ -272,9 +288,11 @@ type GitHubCopilotDeviceFlowIO = { export async function runGitHubCopilotDeviceFlow( io: GitHubCopilotDeviceFlowIO, + domain: string = PUBLIC_GITHUB_COPILOT_DOMAIN, ): Promise { - const device = await requestDeviceCode({ scope: "read:user" }); - const verificationUrl = normalizeGitHubDeviceVerificationUrl(device.verificationUri); + const host = normalizeGithubCopilotDomain(domain); + const device = await requestDeviceCode({ scope: "read:user", domain: host }); + const verificationUrl = normalizeGitHubDeviceVerificationUrl(device.verificationUri, host); const userCode = normalizeGitHubDeviceUserCode(device.userCode); await io.showCode({ verificationUrl, @@ -293,6 +311,7 @@ export async function runGitHubCopilotDeviceFlow( deviceCode: device.deviceCode, intervalMs: Math.max(1000, device.intervalMs), expiresAt: device.expiresAt, + domain: host, }); return { status: "authorized", accessToken }; } catch (err) { @@ -306,6 +325,41 @@ export async function runGitHubCopilotDeviceFlow( } } +// The shortcut login mints its token against the resolved domain, so the same +// domain must land in persisted config: a tenant token with no stored +// githubDomain would silently route to github.com (and 401) once +// COPILOT_GITHUB_DOMAIN is unset. Mirrors the enterprise auth method's +// persist-on-tenant / clear-on-public behavior. +export function withGithubCopilotDomainConfig(cfg: OpenClawConfig, domain: string): OpenClawConfig { + // Normalize the optional layers to concrete objects before spreading: + // spreading a possibly-undefined object widens every optional property to + // `T | undefined`, which exactOptionalPropertyTypes rejects. + const models: NonNullable = cfg.models ?? {}; + const providers: NonNullable = models.providers ?? {}; + const provider: NonNullable<(typeof providers)[string]> = providers["github-copilot"] ?? {}; + const params = provider.params; + const isDefault = domain === PUBLIC_GITHUB_COPILOT_DOMAIN; + if (isDefault && !(params && "githubDomain" in params)) { + return cfg; + } + const nextParams: Record = { ...params }; + if (isDefault) { + delete nextParams.githubDomain; + } else { + nextParams.githubDomain = domain; + } + return { + ...cfg, + models: { + ...models, + providers: { + ...providers, + "github-copilot": { ...provider, params: nextParams }, + }, + }, + }; +} + export async function githubCopilotLoginCommand( opts: { profileId?: string; yes?: boolean; agentDir?: string }, runtime: RuntimeEnv, @@ -328,9 +382,25 @@ export async function githubCopilotLoginCommand( ); } + // Mint against the same host the runtime will route to. resolveGithubCopilotDomain + // is env-authoritative (COPILOT_GITHUB_DOMAIN wins), and runtime token exchange + // uses the same resolver, so honoring it here keeps the minted token and the + // runtime endpoint on the same tenant instead of minting a public token that + // then 401s against api.. + const domain = resolveGithubCopilotDomain(); + if (domain !== PUBLIC_GITHUB_COPILOT_DOMAIN) { + note( + `Using the GitHub Enterprise domain from COPILOT_GITHUB_DOMAIN (${domain}). Unset it to log in against github.com.`, + stylePromptTitle("GitHub Copilot"), + ); + } + const spin = spinner(); - spin.start("Requesting device code from GitHub..."); - const device = await requestDeviceCode({ scope: "read:user" }); + spin.start(`Requesting device code from ${domain}...`); + const device = await requestDeviceCode({ + scope: "read:user", + domain, + }); spin.stop("Device code ready"); note( @@ -346,6 +416,7 @@ export async function githubCopilotLoginCommand( deviceCode: device.deviceCode, intervalMs, expiresAt: device.expiresAt, + domain, }); polling.stop("GitHub access token acquired"); @@ -360,11 +431,14 @@ export async function githubCopilotLoginCommand( }); await updateConfig((cfg) => - applyAuthProfileConfig(cfg, { - provider: "github-copilot", - profileId, - mode: "token", - }), + withGithubCopilotDomainConfig( + applyAuthProfileConfig(cfg, { + provider: "github-copilot", + profileId, + mode: "token", + }), + domain, + ), ); logConfigUpdated(runtime); diff --git a/extensions/github-copilot/models.test.ts b/extensions/github-copilot/models.test.ts index 50b1dfc33c1..2915cbf1b2c 100644 --- a/extensions/github-copilot/models.test.ts +++ b/extensions/github-copilot/models.test.ts @@ -200,6 +200,30 @@ describe("resolveCopilotForwardCompatModel", () => { }); describe("fetchCopilotUsage", () => { + it("targets the public github.com usage endpoint by default", async () => { + let calledUrl: string | undefined; + const mockFetch = createProviderUsageFetch(async (url) => { + calledUrl = url; + return makeResponse(200, { copilot_plan: "pro" }); + }); + + await fetchCopilotUsage("token", 5000, mockFetch); + + expect(calledUrl).toBe("https://api.github.com/copilot_internal/user"); + }); + + it("routes usage through the tenant host for *.ghe.com domains", async () => { + let calledUrl: string | undefined; + const mockFetch = createProviderUsageFetch(async (url) => { + calledUrl = url; + return makeResponse(200, { copilot_plan: "business" }); + }); + + await fetchCopilotUsage("token", 5000, mockFetch, "acme.ghe.com"); + + expect(calledUrl).toBe("https://api.acme.ghe.com/copilot_internal/user"); + }); + it("returns HTTP errors for failed requests", async () => { const mockFetch = createProviderUsageFetch(async () => makeResponse(500, "boom")); const result = await fetchCopilotUsage("token", 5000, mockFetch); @@ -334,6 +358,7 @@ describe("github-copilot token", () => { expiresAt: now + 60 * 60 * 1000, updatedAt: now, integrationId: "vscode-chat", + domain: "github.com", }); const fetchImpl = vi.fn(); diff --git a/extensions/github-copilot/openclaw.plugin.json b/extensions/github-copilot/openclaw.plugin.json index 19b871f7799..61b10360d6f 100644 --- a/extensions/github-copilot/openclaw.plugin.json +++ b/extensions/github-copilot/openclaw.plugin.json @@ -172,13 +172,25 @@ "appGuidedSecret": true, "choiceLabel": "GitHub Copilot", "choiceHint": "Device login with your GitHub account", + "assistantPriority": 1, "groupId": "copilot", "groupLabel": "Copilot", - "groupHint": "GitHub + local proxy", + "groupHint": "GitHub, GitHub Enterprise + Local Proxy", "optionKey": "githubCopilotToken", "cliFlag": "--github-copilot-token", "cliOption": "--github-copilot-token ", "cliDescription": "GitHub Copilot OAuth token" + }, + { + "provider": "github-copilot", + "method": "device-enterprise", + "choiceId": "github-copilot-enterprise", + "choiceLabel": "GitHub Copilot (Enterprise / data residency)", + "choiceHint": "Device login against your GitHub Enterprise (*.ghe.com) tenant", + "assistantPriority": 2, + "groupId": "copilot", + "groupLabel": "Copilot", + "groupHint": "GitHub, GitHub Enterprise + Local Proxy" } ], "configSchema": { diff --git a/extensions/github-copilot/usage.ts b/extensions/github-copilot/usage.ts index 1058b55f689..e218e53ff39 100644 --- a/extensions/github-copilot/usage.ts +++ b/extensions/github-copilot/usage.ts @@ -9,6 +9,7 @@ import { type ProviderUsageSnapshot, type UsageWindow, } from "openclaw/plugin-sdk/provider-usage"; +import { PUBLIC_GITHUB_COPILOT_DOMAIN } from "./domain.js"; type CopilotUsageResponse = { quota_snapshots?: { @@ -22,9 +23,10 @@ export async function fetchCopilotUsage( token: string, timeoutMs: number, fetchFn: typeof fetch, + githubDomain: string = PUBLIC_GITHUB_COPILOT_DOMAIN, ): Promise { const res = await fetchJson( - "https://api.github.com/copilot_internal/user", + `https://api.${githubDomain}/copilot_internal/user`, { headers: { Authorization: `token ${token}`, @@ -42,10 +44,7 @@ export async function fetchCopilotUsage( }); } - const data = await readProviderJsonResponse( - res, - "github-copilot-usage", - ); + const data = await readProviderJsonResponse(res, "github-copilot-usage"); const windows: UsageWindow[] = []; if (data.quota_snapshots?.premium_interactions) { diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 5f89a20561f..9650a82739d 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -195,12 +195,12 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { ), publicExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", - 10464, + 10465, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", - 5221, + 5222, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/agents/simple-completion-runtime.test.ts b/src/agents/simple-completion-runtime.test.ts index fd9e14812ab..cb093b6f86a 100644 --- a/src/agents/simple-completion-runtime.test.ts +++ b/src/agents/simple-completion-runtime.test.ts @@ -246,6 +246,7 @@ describe("prepareSimpleCompletionModel", () => { expect(hoisted.resolveCopilotApiTokenMock).toHaveBeenCalledWith({ githubToken: "ghu_test", + config: undefined, }); expect(hoisted.setRuntimeApiKeyMock).toHaveBeenCalledWith( "github-copilot", @@ -309,7 +310,10 @@ describe("prepareSimpleCompletionModel", () => { modelId: "gpt-4.1", }); - expect(hoisted.resolveCopilotApiTokenMock).toHaveBeenCalledWith({ githubToken: sourceSecret }); + expect(hoisted.resolveCopilotApiTokenMock).toHaveBeenCalledWith({ + githubToken: sourceSecret, + config: undefined, + }); expectPreparedModelResult(result); expect(looksLikeSecretSentinel(result.auth.apiKey ?? "")).toBe(true); expect(resolveSecretSentinel(result.auth.apiKey ?? "")).toBe("copilot-runtime-token"); diff --git a/src/agents/simple-completion-runtime.ts b/src/agents/simple-completion-runtime.ts index a48ae385b43..6f05aef6e56 100644 --- a/src/agents/simple-completion-runtime.ts +++ b/src/agents/simple-completion-runtime.ts @@ -177,6 +177,7 @@ async function setRuntimeApiKeyForCompletion(params: { params.apiKey, "GitHub Copilot runtime auth exchange", ), + config: params.cfg, }); const protectedAuth = protectPreparedProviderRuntimeAuth({ sourceApiKey: params.apiKey, diff --git a/src/media-understanding/image.test.ts b/src/media-understanding/image.test.ts index b18a135afb2..885bf3557bf 100644 --- a/src/media-understanding/image.test.ts +++ b/src/media-understanding/image.test.ts @@ -1461,6 +1461,7 @@ describe("describeImageWithModel", () => { expect(providerStreamFn).toHaveBeenCalledOnce(); expect(resolveCopilotApiTokenMock).toHaveBeenCalledWith({ githubToken: "oauth-test", + config: {}, }); expect(setRuntimeApiKeyMock).toHaveBeenCalledWith("github-copilot", "copilot-api-token"); const [completionModel, context, options] = providerStreamFn.mock.calls[0] as unknown as [ @@ -1527,7 +1528,10 @@ describe("describeImageWithModel", () => { timeoutMs: 1000, }); - expect(resolveCopilotApiTokenMock).toHaveBeenCalledWith({ githubToken: sourceSecret }); + expect(resolveCopilotApiTokenMock).toHaveBeenCalledWith({ + githubToken: sourceSecret, + config: {}, + }); const storedToken = setRuntimeApiKeyMock.mock.calls[0]?.[1] as string; expect(looksLikeSecretSentinel(storedToken)).toBe(true); expect(resolveSecretSentinel(storedToken)).toBe("copilot-api-token"); diff --git a/src/media-understanding/image.ts b/src/media-understanding/image.ts index 3c62e0ef6d1..03d7d99b5f9 100644 --- a/src/media-understanding/image.ts +++ b/src/media-understanding/image.ts @@ -260,6 +260,7 @@ async function prepareResolvedImageRuntime( apiKey, "GitHub Copilot image-auth exchange", ), + config: params.cfg, }); const protectedAuth = protectPreparedProviderRuntimeAuth({ sourceApiKey: apiKey, diff --git a/src/plugin-sdk/provider-auth.test.ts b/src/plugin-sdk/provider-auth.test.ts index e7549209863..dbccbba1b23 100644 --- a/src/plugin-sdk/provider-auth.test.ts +++ b/src/plugin-sdk/provider-auth.test.ts @@ -566,3 +566,273 @@ describe("provider auth profile helpers", () => { ]); }); }); + +describe("Copilot data-residency domain resolution", () => { + afterEach(() => { + delete process.env.COPILOT_GITHUB_DOMAIN; + }); + + it("warns once when a configured domain is rejected during token resolution", async () => { + vi.resetModules(); + const logWarn = vi.fn(); + vi.doMock("../logger.js", async () => { + const actual = await vi.importActual("../logger.js"); + return { ...actual, logWarn }; + }); + const { resolveCopilotApiToken } = await import("./provider-auth.js"); + + const fetchImpl = vi.fn( + async () => + new Response(JSON.stringify({ token: "tok", expires_at: "+2000000000" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + const withDomain = (githubDomain: string) => + ({ + models: { providers: { "github-copilot": { params: { githubDomain } } } }, + }) as never; + const resolveWithConfigDomain = (githubDomain: string) => + resolveCopilotApiToken({ + githubToken: "github-token", + env: {}, + config: withDomain(githubDomain), + fetchImpl, + cachePath: "/tmp/copilot-token-warn.json", + loadJsonFileImpl: () => undefined, + saveJsonFileImpl: () => {}, + }); + + // Valid tenant + explicit public host never warn. + await resolveWithConfigDomain("acme.ghe.com"); + await resolveWithConfigDomain("github.com"); + expect(logWarn).not.toHaveBeenCalled(); + + // Typo (`.co`) fails the allowlist -> silent fallback -> warn once, not twice. + await resolveWithConfigDomain("acme.ghe.co"); + await resolveWithConfigDomain("acme.ghe.co"); + expect(logWarn).toHaveBeenCalledTimes(1); + expect(logWarn).toHaveBeenCalledWith(expect.stringContaining("acme.ghe.co")); + + vi.doUnmock("../logger.js"); + }); + + it("rejects unsafe hostnames and falls back to github.com", async () => { + vi.resetModules(); + const { normalizeGithubCopilotDomain } = await import("./provider-auth.js"); + + expect(normalizeGithubCopilotDomain("https://evil.com/login")).toBe("github.com"); + expect(normalizeGithubCopilotDomain("user@host")).toBe("github.com"); + expect(normalizeGithubCopilotDomain("acme.ghe.com")).toBe("acme.ghe.com"); + expect(normalizeGithubCopilotDomain(" ACME.GHE.COM ")).toBe("acme.ghe.com"); + }); + + it("locks the host allowlist to github.com and single-label *.ghe.com tenant roots", async () => { + vi.resetModules(); + const { normalizeGithubCopilotDomain } = await import("./provider-auth.js"); + + // Allowed: public host and single-label data-residency tenant roots. + expect(normalizeGithubCopilotDomain("github.com")).toBe("github.com"); + expect(normalizeGithubCopilotDomain("acme.ghe.com")).toBe("acme.ghe.com"); + + // Rejected: derived service hosts under a tenant. GitHub documents these as + // `*.SUBDOMAIN.ghe.com` endpoints; storing one would template broken hosts + // like `api.api.acme.ghe.com` for the token exchange. + expect(normalizeGithubCopilotDomain("api.acme.ghe.com")).toBe("github.com"); + expect(normalizeGithubCopilotDomain("copilot-api.acme.ghe.com")).toBe("github.com"); + expect(normalizeGithubCopilotDomain("a.b.ghe.com")).toBe("github.com"); + + // Rejected: arbitrary hosts, look-alikes, and the bare non-tenant apex. + expect(normalizeGithubCopilotDomain("evil.com")).toBe("github.com"); + expect(normalizeGithubCopilotDomain("ghe.com")).toBe("github.com"); + expect(normalizeGithubCopilotDomain("github.com.evil.com")).toBe("github.com"); + expect(normalizeGithubCopilotDomain("evilghe.com")).toBe("github.com"); + expect(normalizeGithubCopilotDomain("acme.ghe.com.evil.com")).toBe("github.com"); + }); + + it("targets the tenant token endpoint and copilot-api fallback for a GHE domain", async () => { + vi.resetModules(); + + const fetchImpl = vi.fn( + async () => + // GHE data-residency tokens carry a stamp but no proxy-ep hint. + new Response(JSON.stringify({ token: "ghe;st=prod-sdc-01", expires_at: "+2000000000" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const { resolveCopilotApiToken } = await import("./provider-auth.js"); + + const result = await resolveCopilotApiToken({ + githubToken: "github-token", + env: {}, + githubDomain: "acme.ghe.com", + fetchImpl, + cachePath: "/tmp/copilot-token-ghe.json", + loadJsonFileImpl: () => undefined, + saveJsonFileImpl: () => {}, + }); + + const [url] = fetchImpl.mock.calls[0] as unknown as [string]; + expect(url).toBe("https://api.acme.ghe.com/copilot_internal/v2/token"); + expect(result.source).toBe("fetched:https://api.acme.ghe.com/copilot_internal/v2/token"); + expect(result.baseUrl).toBe("https://copilot-api.acme.ghe.com"); + }); + + it("lets COPILOT_GITHUB_DOMAIN override the caller-provided domain", async () => { + vi.resetModules(); + + const fetchImpl = vi.fn( + async () => + new Response(JSON.stringify({ token: "ghe;st=prod-sdc-01", expires_at: "+2000000000" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const { resolveCopilotApiToken } = await import("./provider-auth.js"); + + const result = await resolveCopilotApiToken({ + githubToken: "github-token", + env: { COPILOT_GITHUB_DOMAIN: "env.ghe.com" }, + githubDomain: "config.ghe.com", + fetchImpl, + cachePath: "/tmp/copilot-token-env.json", + loadJsonFileImpl: () => undefined, + saveJsonFileImpl: () => {}, + }); + + const [url] = fetchImpl.mock.calls[0] as unknown as [string]; + expect(url).toBe("https://api.env.ghe.com/copilot_internal/v2/token"); + expect(result.baseUrl).toBe("https://copilot-api.env.ghe.com"); + }); + + it("does not reuse a cached token minted for a different domain", async () => { + vi.resetModules(); + + const saved: unknown[] = []; + const fetchImpl = vi.fn( + async () => + new Response(JSON.stringify({ token: "ghe;st=prod-sdc-01", expires_at: "+2000000000" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const { COPILOT_INTEGRATION_ID, resolveCopilotApiToken } = await import("./provider-auth.js"); + + // A valid, unexpired public-github.com token sits in the cache, but the + // request targets a GHE tenant, so it must be re-exchanged rather than + // sending a github.com token to api.acme.ghe.com. + const result = await resolveCopilotApiToken({ + githubToken: "github-token", + env: {}, + githubDomain: "acme.ghe.com", + fetchImpl, + cachePath: "/tmp/copilot-token-cross.json", + loadJsonFileImpl: () => ({ + token: "public;proxy-ep=proxy.individual.githubcopilot.com", + expiresAt: Number.MAX_SAFE_INTEGER - 1, + updatedAt: Date.now(), + integrationId: COPILOT_INTEGRATION_ID, + domain: "github.com", + }), + saveJsonFileImpl: (_path, value) => saved.push(value), + }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(result.source).toBe("fetched:https://api.acme.ghe.com/copilot_internal/v2/token"); + expect(saved).toEqual([expect.objectContaining({ domain: "acme.ghe.com" })]); + }); + + it("keeps legacy pre-domain cache entries usable for github.com across upgrade", async () => { + vi.resetModules(); + + const fetchImpl = vi.fn(); + const { COPILOT_INTEGRATION_ID, resolveCopilotApiToken } = await import("./provider-auth.js"); + + // Shipped caches predate the domain stamp and were only ever minted for + // public github.com. A valid legacy entry must stay a cache hit for the + // default domain instead of forcing a re-exchange on upgrade. + const result = await resolveCopilotApiToken({ + githubToken: "github-token", + env: {}, + fetchImpl: fetchImpl as unknown as typeof fetch, + cachePath: "/tmp/copilot-token-legacy.json", + loadJsonFileImpl: () => ({ + token: "legacy-public;proxy-ep=proxy.individual.githubcopilot.com", + expiresAt: Date.now() + 60 * 60 * 1000, + updatedAt: Date.now(), + integrationId: COPILOT_INTEGRATION_ID, + // no domain field + }), + saveJsonFileImpl: () => {}, + }); + + expect(fetchImpl).not.toHaveBeenCalled(); + expect(result.source).toBe("cache:/tmp/copilot-token-legacy.json"); + }); + + it("does not reuse a legacy pre-domain cache entry for a tenant domain", async () => { + vi.resetModules(); + + const saved: unknown[] = []; + const fetchImpl = vi.fn( + async () => + new Response(JSON.stringify({ token: "ghe;st=prod-sdc-01", expires_at: "+2000000000" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const { COPILOT_INTEGRATION_ID, resolveCopilotApiToken } = await import("./provider-auth.js"); + + const result = await resolveCopilotApiToken({ + githubToken: "github-token", + env: {}, + githubDomain: "acme.ghe.com", + fetchImpl, + cachePath: "/tmp/copilot-token-legacy-tenant.json", + loadJsonFileImpl: () => ({ + token: "legacy-public;proxy-ep=proxy.individual.githubcopilot.com", + expiresAt: Date.now() + 60 * 60 * 1000, + updatedAt: Date.now(), + integrationId: COPILOT_INTEGRATION_ID, + // no domain field — implies github.com, so a tenant request must miss + }), + saveJsonFileImpl: (_path, value) => saved.push(value), + }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(result.source).toBe("fetched:https://api.acme.ghe.com/copilot_internal/v2/token"); + expect(saved).toEqual([expect.objectContaining({ domain: "acme.ghe.com" })]); + }); + + it("reuses a cached token minted for the same domain", async () => { + vi.resetModules(); + + const fetchImpl = vi.fn(); + const { COPILOT_INTEGRATION_ID, resolveCopilotApiToken } = await import("./provider-auth.js"); + + const result = await resolveCopilotApiToken({ + githubToken: "github-token", + env: {}, + githubDomain: "acme.ghe.com", + fetchImpl: fetchImpl as unknown as typeof fetch, + cachePath: "/tmp/copilot-token-same.json", + loadJsonFileImpl: () => ({ + token: "tenant-cached;st=prod-sdc-01", + expiresAt: Date.now() + 60 * 60 * 1000, + updatedAt: Date.now(), + integrationId: COPILOT_INTEGRATION_ID, + domain: "acme.ghe.com", + }), + saveJsonFileImpl: () => {}, + }); + + expect(fetchImpl).not.toHaveBeenCalled(); + expect(result.source).toBe("cache:/tmp/copilot-token-same.json"); + expect(result.baseUrl).toBe("https://copilot-api.acme.ghe.com"); + }); +}); diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index 57a5815e6ac..f8a85cdf10b 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -27,6 +27,7 @@ import { readProviderJsonResponse } from "../agents/provider-http-errors.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; +import { logWarn } from "../logger.js"; import { resolveProviderEndpoint } from "./provider-model-shared.js"; export type { OpenClawConfig } from "../config/config.js"; @@ -127,11 +128,123 @@ export { buildCopilotIdeHeaders, } from "../agents/copilot-dynamic-headers.js"; -const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token"; - /** @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins. */ export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com"; +/** + * Data-residency GitHub Enterprise (`*.ghe.com`) support. + * + * Copilot on a data-residency GHE tenant lives at `` / `api.` / + * `copilot-api.` rather than the public github.com endpoints. The host + * is resolved (in priority order) from the `COPILOT_GITHUB_DOMAIN` env override, + * the persisted `models.providers.github-copilot.params.githubDomain` config, and + * finally public `github.com`. + */ +const COPILOT_PROVIDER_ID = "github-copilot"; + +const DEFAULT_GITHUB_COPILOT_DOMAIN = "github.com"; + +// Matches a data-residency GHE tenant root (`.ghe.com`, single label). +// GitHub defines a GHE.com enterprise as a dedicated `SUBDOMAIN.ghe.com` domain; +// nested hosts (`api..ghe.com`, `copilot-api..ghe.com`) are +// derived service endpoints, not tenants — accepting one would template broken +// hosts like `api.api..ghe.com` for the token exchange. Bare `ghe.com` +// is likewise excluded: it is not a tenant and hosts no Copilot endpoint. +const GHE_DATA_RESIDENCY_HOST = /^[a-z0-9-]+\.ghe\.com$/; + +/** + * Coerce a user/config-supplied GitHub host to a safe bare lowercase hostname. + * + * Fails closed to public `github.com`: only the public host and data-residency + * GHE tenants (`*.ghe.com`) are trusted. Any other value falls back to the + * default rather than being used verbatim, because the resolved host becomes the + * `api.` endpoint that receives the GitHub OAuth token during exchange — a + * typo or injected value like `evil.com` must never redirect that token. + * (Classic self-hosted GHE Server uses arbitrary hostnames but does not host + * Copilot, so it is deliberately out of scope.) + */ +export function normalizeGithubCopilotDomain(raw: string | undefined | null): string { + const trimmed = (raw ?? "").trim().toLowerCase(); + if (!trimmed) { + return DEFAULT_GITHUB_COPILOT_DOMAIN; + } + // Reject scheme/path/credentials so template URL construction cannot be hijacked. + if (!/^[a-z0-9.-]+$/.test(trimmed)) { + return DEFAULT_GITHUB_COPILOT_DOMAIN; + } + if (trimmed === DEFAULT_GITHUB_COPILOT_DOMAIN || GHE_DATA_RESIDENCY_HOST.test(trimmed)) { + return trimmed; + } + return DEFAULT_GITHUB_COPILOT_DOMAIN; +} + +function readGithubCopilotDomainFromConfig(config?: OpenClawConfig): string | undefined { + const params = config?.models?.providers?.[COPILOT_PROVIDER_ID]?.params; + const value = params && typeof params === "object" ? params.githubDomain : undefined; + if (typeof value !== "string" || value.trim().length === 0) { + return undefined; + } + const trimmed = value.trim(); + warnOnceOnRejectedConfigDomain(trimmed); + return trimmed; +} + +// Configured `githubDomain` values that fail the allowlist fall back to public +// github.com (fail-closed for the token). That silent fallback turns a typo like +// `acme.ghe.co` into an opaque 401 (tenant token vs public endpoint), so warn the +// user loudly — once per distinct bad value — that their config was ignored. +const warnedRejectedConfigDomains = new Set(); +function warnOnceOnRejectedConfigDomain(configured: string): void { + const lowered = configured.toLowerCase(); + if (lowered === DEFAULT_GITHUB_COPILOT_DOMAIN) { + return; + } + if (normalizeGithubCopilotDomain(configured) !== DEFAULT_GITHUB_COPILOT_DOMAIN) { + return; + } + if (warnedRejectedConfigDomains.has(lowered)) { + return; + } + warnedRejectedConfigDomains.add(lowered); + logWarn( + `Ignoring configured GitHub Copilot domain "${configured}": only github.com and *.ghe.com tenants are accepted. Falling back to github.com.`, + ); +} + +// Provider-internal host resolver (env > explicit caller value > persisted +// config), always passed through the fail-closed allowlist. Not exported: the +// provider extension owns its own copy so the SDK surface stays minimal. +function resolveGithubCopilotDomain(params?: { + env?: NodeJS.ProcessEnv; + explicit?: string; + config?: OpenClawConfig; +}): string { + const env = params?.env ?? process.env; + const fromEnv = env.COPILOT_GITHUB_DOMAIN?.trim(); + if (fromEnv) { + return normalizeGithubCopilotDomain(fromEnv); + } + if (params?.explicit) { + return normalizeGithubCopilotDomain(params.explicit); + } + return normalizeGithubCopilotDomain(readGithubCopilotDomainFromConfig(params?.config)); +} + +/** + * Data-residency GHE Copilot tokens carry no `proxy-ep`, so the completions base + * URL cannot be derived from the token. Point it at the tenant Copilot proxy + * (`copilot-api.`) instead of the public individual endpoint. + */ +function copilotTokenUrl(domain: string): string { + return `https://api.${domain}/copilot_internal/v2/token`; +} + +function copilotApiBaseFallback(domain: string): string { + return domain === DEFAULT_GITHUB_COPILOT_DOMAIN + ? DEFAULT_COPILOT_API_BASE_URL + : `https://copilot-api.${domain}`; +} + /** @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins. */ export type CachedCopilotToken = { /** Copilot API token returned by GitHub's internal exchange endpoint. */ @@ -142,16 +255,33 @@ export type CachedCopilotToken = { updatedAt: number; /** Copilot integration id that produced this cached token. */ integrationId?: string; + /** + * GitHub host this token was minted for. Guards against reusing a public + * `github.com` Copilot token against a `*.ghe.com` tenant host (or vice + * versa) after a domain switch. Shipped caches predate this field and were + * only ever minted for public github.com, so a missing value means + * `github.com` (keeps valid public entries usable across upgrade). + */ + domain?: string; }; function resolveCopilotTokenCachePath(env: NodeJS.ProcessEnv = process.env) { return path.join(resolveStateDir(env), "credentials", "github-copilot.token.json"); } -function isCopilotTokenUsable(cache: CachedCopilotToken, now = Date.now()): boolean { +function isCopilotTokenUsable( + cache: CachedCopilotToken, + domain: string, + now = Date.now(), +): boolean { const expiresAt = asDateTimestampMs(cache.expiresAt); + // Legacy entries (pre domain-stamp) could only have been minted for public + // github.com; defaulting keeps them usable across upgrade while tenant + // requests still force a re-exchange. + const cacheDomain = cache.domain ?? DEFAULT_GITHUB_COPILOT_DOMAIN; return ( cache.integrationId === COPILOT_INTEGRATION_ID && + cacheDomain === domain && expiresAt !== undefined && expiresAt - now > 5 * 60 * 1000 ); @@ -267,6 +397,18 @@ export async function resolveCopilotApiToken(params: { loadJsonFileImpl?: (path: string) => unknown; /** Cache writer override for tests and alternate storage backends. */ saveJsonFileImpl?: (path: string, value: CachedCopilotToken) => void; + /** + * Data-residency GitHub Enterprise host (e.g. `acme.ghe.com`). Resolved from + * config by callers that have it; the `COPILOT_GITHUB_DOMAIN` env override + * still wins. Defaults to `github.com`. + */ + githubDomain?: string; + /** + * OpenClaw config used to resolve the persisted `githubDomain` provider + * param when an explicit `githubDomain` is not supplied. Precedence is + * `COPILOT_GITHUB_DOMAIN` env > explicit `githubDomain` > config. + */ + config?: OpenClawConfig; }): Promise<{ /** Copilot API token, from cache or fresh exchange. */ token: string; @@ -278,25 +420,33 @@ export async function resolveCopilotApiToken(params: { baseUrl: string; }> { const env = params.env ?? process.env; + const domain = resolveGithubCopilotDomain({ + env, + explicit: params.githubDomain, + config: params.config, + }); const cachePath = params.cachePath?.trim() || resolveCopilotTokenCachePath(env); + const tokenUrl = copilotTokenUrl(domain); + const apiBaseFallback = copilotApiBaseFallback(domain); const loadJsonFileFn = params.loadJsonFileImpl ?? loadJsonFile; const saveJsonFileFn = params.saveJsonFileImpl ?? saveJsonFile; const cached = loadJsonFileFn(cachePath) as CachedCopilotToken | undefined; if (cached && typeof cached.token === "string" && typeof cached.expiresAt === "number") { - // Token cache entries are scoped to the current Copilot integration id so - // stale tokens from older editor identities are exchanged again. - if (isCopilotTokenUsable(cached)) { + // Token cache entries are scoped to the current Copilot integration id and + // GitHub host so stale tokens from older editor identities or a different + // domain are exchanged again. + if (isCopilotTokenUsable(cached, domain)) { return { token: cached.token, expiresAt: cached.expiresAt, source: `cache:${cachePath}`, - baseUrl: deriveCopilotApiBaseUrlFromToken(cached.token) ?? DEFAULT_COPILOT_API_BASE_URL, + baseUrl: deriveCopilotApiBaseUrlFromToken(cached.token) ?? apiBaseFallback, }; } } const fetchImpl = params.fetchImpl ?? fetch; - const res = await fetchImpl(COPILOT_TOKEN_URL, { + const res = await fetchImpl(tokenUrl, { method: "GET", headers: { Accept: "application/json", @@ -319,14 +469,15 @@ export async function resolveCopilotApiToken(params: { expiresAt: json.expiresAt, updatedAt: Date.now(), integrationId: COPILOT_INTEGRATION_ID, + domain, }; saveJsonFileFn(cachePath, payload); return { token: payload.token, expiresAt: payload.expiresAt, - source: `fetched:${COPILOT_TOKEN_URL}`, - baseUrl: deriveCopilotApiBaseUrlFromToken(payload.token) ?? DEFAULT_COPILOT_API_BASE_URL, + source: `fetched:${tokenUrl}`, + baseUrl: deriveCopilotApiBaseUrlFromToken(payload.token) ?? apiBaseFallback, }; } diff --git a/src/plugin-sdk/test-helpers/provider-discovery-contract.ts b/src/plugin-sdk/test-helpers/provider-discovery-contract.ts index a09d965bc8d..f49501e7288 100644 --- a/src/plugin-sdk/test-helpers/provider-discovery-contract.ts +++ b/src/plugin-sdk/test-helpers/provider-discovery-contract.ts @@ -170,6 +170,12 @@ function installDiscoveryHooks(state: DiscoveryState, options: DiscoveryContract ensureAuthProfileStore: ensureAuthProfileStoreMock, listProfilesForProvider: listProfilesForProviderMock, normalizeApiKeyInput: (value: unknown) => (typeof value === "string" ? value.trim() : ""), + normalizeGithubCopilotDomain: (raw: unknown) => { + const trimmed = typeof raw === "string" ? raw.trim().toLowerCase() : ""; + return trimmed === "github.com" || /^[a-z0-9-]+\.ghe\.com$/.test(trimmed) + ? trimmed + : "github.com"; + }, normalizeOptionalSecretInput: (value: unknown) => typeof value === "string" && value.trim() ? value.trim() : undefined, resolveNonEnvSecretRefApiKeyMarker: (source: unknown) => diff --git a/src/plugins/contracts/registry.contract.test.ts b/src/plugins/contracts/registry.contract.test.ts index af1df3ebb71..21e558293e6 100644 --- a/src/plugins/contracts/registry.contract.test.ts +++ b/src/plugins/contracts/registry.contract.test.ts @@ -179,14 +179,26 @@ describe("plugin contract registry", () => { choiceId: "github-copilot", choiceLabel: "GitHub Copilot", choiceHint: "Device login with your GitHub account", + assistantPriority: 1, groupId: "copilot", groupLabel: "Copilot", - groupHint: "GitHub + local proxy", + groupHint: "GitHub, GitHub Enterprise + Local Proxy", optionKey: "githubCopilotToken", cliFlag: "--github-copilot-token", cliOption: "--github-copilot-token ", cliDescription: "GitHub Copilot OAuth token", }, + { + provider: "github-copilot", + method: "device-enterprise", + choiceId: "github-copilot-enterprise", + choiceLabel: "GitHub Copilot (Enterprise / data residency)", + choiceHint: "Device login against your GitHub Enterprise (*.ghe.com) tenant", + assistantPriority: 2, + groupId: "copilot", + groupLabel: "Copilot", + groupHint: "GitHub, GitHub Enterprise + Local Proxy", + }, ]); });