diff --git a/docs/docs_map.md b/docs/docs_map.md
index 8008eae59a36..55f65713bbdb 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 aad1c4212491..db6f966843f6 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 a7f397572d33..9a9f34cef0be 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 000000000000..4fd14bdfd61e
--- /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 000000000000..4591ab78f670
--- /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 bd5cb091d4c5..2da4ae1ab163 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 b67be7d79667..bd707940dcc3 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 6ec07987f20f..733b4d546a6b 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 255a7ee92052..acd50cca3592 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 24deecaf89d2..34d30f93899b 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 50b1dfc33c1e..2915cbf1b2c9 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 19b871f77991..61b10360d6f0 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 1058b55f6892..e218e53ff391 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 5f89a20561fd..9650a82739d7 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 fd9e14812abe..cb093b6f86a2 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 a48ae385b438..6f05aef6e565 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 b18a135afb27..885bf3557bf0 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 3c62e0ef6d1f..03d7d99b5f9e 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 e75492098633..dbccbba1b23d 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 57a5815e6ac7..f8a85cdf10bf 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 a09d965bc8dd..f49501e7288f 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 af1df3ebb717..21e558293e64 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",
+ },
]);
});