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 54010a6538
- `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 <tobias.oort@ict.nl>
Co-authored-by: Gio Della-Libera <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Tobias Oort
2026-07-09 03:59:41 +02:00
committed by GitHub
parent f85d438164
commit 0307deacfa
22 changed files with 1458 additions and 66 deletions

View File

@@ -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

View File

@@ -101,6 +101,75 @@ provider or agent runtime in three different ways.
</Tab>
</Tabs>
## 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.
<Steps>
<Step title="Pick the Enterprise auth choice">
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
```
</Step>
<Step title="Domain is persisted to config">
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" } },
},
},
}
```
</Step>
</Steps>
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.
<Note>
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.
</Note>
<Note>
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.
</Note>
## Optional flags
| Command | Flag | Description |

View File

@@ -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": {

View File

@@ -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",
);
});
});

View File

@@ -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));
}

View File

@@ -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<string, string>;
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,
});

View File

@@ -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<T>(fn: () => Promise<T>): Promise<T> {
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<string, { params?: Record<string, unknown> }> };
}
)?.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<string, { params?: Record<string, unknown> }> };
}
)?.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<string, { params?: Record<string, unknown> }> };
}
)?.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<string, { params?: Record<string, unknown> }> };
}
)?.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<string, { params?: Record<string, unknown> }> };
}
)?.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];

View File

@@ -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<OpenClawConfig> {
const normalized = normalizeGithubCopilotDomain(domain);
return {
models: {
providers: {
[PROVIDER_ID]: { params: { githubDomain: normalized } },
},
},
} as unknown as Partial<OpenClawConfig>;
}
// 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<OpenClawConfig> {
return {
models: {
providers: {
[PROVIDER_ID]: { params: { githubDomain: undefined } },
},
},
} as unknown as Partial<OpenClawConfig>;
}
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<string, unknown>;
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<string | null> {
// 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.<tenant>.ghe.com,
// copilot-api.<tenant>.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<ProviderAuthResult> {
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({

View File

@@ -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<string, { params?: Record<string, unknown> }> } })
.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<string, { params?: Record<string, unknown> }> } }
).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);
});
});

View File

@@ -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<Record<string, unknown>> {
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<DeviceCodeResponse> {
async function requestDeviceCode(params: {
scope: string;
domain: string;
}): Promise<DeviceCodeResponse> {
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<string> {
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<GitHubCopilotDeviceFlowResult> {
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<OpenClawConfig["models"]> = cfg.models ?? {};
const providers: NonNullable<typeof models.providers> = 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<string, unknown> = { ...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.<tenant>.
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);

View File

@@ -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();

View File

@@ -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 <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": {

View File

@@ -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<ProviderUsageSnapshot> {
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<CopilotUsageResponse>(
res,
"github-copilot-usage",
);
const data = await readProviderJsonResponse<CopilotUsageResponse>(res, "github-copilot-usage");
const windows: UsageWindow[] = [];
if (data.quota_snapshots?.premium_interactions) {

View File

@@ -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(

View File

@@ -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");

View File

@@ -177,6 +177,7 @@ async function setRuntimeApiKeyForCompletion(params: {
params.apiKey,
"GitHub Copilot runtime auth exchange",
),
config: params.cfg,
});
const protectedAuth = protectPreparedProviderRuntimeAuth({
sourceApiKey: params.apiKey,

View File

@@ -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");

View File

@@ -260,6 +260,7 @@ async function prepareResolvedImageRuntime(
apiKey,
"GitHub Copilot image-auth exchange",
),
config: params.cfg,
});
const protectedAuth = protectPreparedProviderRuntimeAuth({
sourceApiKey: apiKey,

View File

@@ -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<typeof import("../logger.js")>("../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");
});
});

View File

@@ -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 `<domain>` / `api.<domain>` /
* `copilot-api.<domain>` 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 (`<tenant>.ghe.com`, single label).
// GitHub defines a GHE.com enterprise as a dedicated `SUBDOMAIN.ghe.com` domain;
// nested hosts (`api.<tenant>.ghe.com`, `copilot-api.<tenant>.ghe.com`) are
// derived service endpoints, not tenants — accepting one would template broken
// hosts like `api.api.<tenant>.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.<host>` 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<string>();
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.<domain>`) 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,
};
}

View File

@@ -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) =>

View File

@@ -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 <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",
},
]);
});