mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-14 07:56:08 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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": {
|
||||
|
||||
38
extensions/github-copilot/domain.test.ts
Normal file
38
extensions/github-copilot/domain.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
36
extensions/github-copilot/domain.ts
Normal file
36
extensions/github-copilot/domain.ts
Normal 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));
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -177,6 +177,7 @@ async function setRuntimeApiKeyForCompletion(params: {
|
||||
params.apiKey,
|
||||
"GitHub Copilot runtime auth exchange",
|
||||
),
|
||||
config: params.cfg,
|
||||
});
|
||||
const protectedAuth = protectPreparedProviderRuntimeAuth({
|
||||
sourceApiKey: params.apiKey,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -260,6 +260,7 @@ async function prepareResolvedImageRuntime(
|
||||
apiKey,
|
||||
"GitHub Copilot image-auth exchange",
|
||||
),
|
||||
config: params.cfg,
|
||||
});
|
||||
const protectedAuth = protectPreparedProviderRuntimeAuth({
|
||||
sourceApiKey: apiKey,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user