feat(copilot): add GitHub Enterprise Server support (#2175)

* feat(copilot): add GitHub Enterprise Server support

* fix(copilot): address GHES PR review findings (P1 + 2×P2)

- P1: Use composite account ID (domain:user_id) for GHES to prevent
  cross-instance ID collisions; github.com keeps plain numeric ID for
  backward compatibilit
- P2-a: Use get_api_endpoint() for model list URL with automatic
  fallback to static URL when dynamic endpoint resolution fails
- P2-b: Add normalize_github_domain() as backend SSOT for domain
  normalization (lowercase, strip protocol/path/query, reject userinfo)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
hotelbe
2026-04-19 20:29:46 +08:00
committed by GitHub
parent 9871d3d1eb
commit 87635e7fc6
12 changed files with 441 additions and 68 deletions
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
@@ -45,6 +46,19 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
}) => {
const { t } = useTranslation();
const [copied, setCopied] = React.useState(false);
const [deploymentType, setDeploymentType] = React.useState<
"github.com" | "enterprise"
>("github.com");
const [enterpriseDomain, setEnterpriseDomain] = React.useState("");
// 根据部署类型计算实际的 GitHub 域名
const effectiveGithubDomain =
deploymentType === "enterprise" && enterpriseDomain.trim()
? enterpriseDomain
.trim()
.replace(/^https?:\/\//, "")
.replace(/\/$/, "")
: undefined;
const {
accounts,
@@ -63,7 +77,7 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
setDefaultAccount,
cancelAuth,
logout,
} = useCopilotAuth();
} = useCopilotAuth(effectiveGithubDomain);
// 复制用户码
const copyUserCode = async () => {
@@ -113,6 +127,41 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
</Badge>
</div>
{/* GitHub 部署类型选择 */}
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("copilot.deploymentType", "GitHub 部署类型")}
</Label>
<Select
value={deploymentType}
onValueChange={(v) =>
setDeploymentType(v as "github.com" | "enterprise")
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="github.com">
{t("copilot.deploymentGitHubCom", "GitHub.com")}
</SelectItem>
<SelectItem value="enterprise">
{t("copilot.deploymentEnterprise", "GitHub Enterprise Server")}
</SelectItem>
</SelectContent>
</Select>
{deploymentType === "enterprise" && (
<Input
placeholder={t(
"copilot.enterpriseDomainPlaceholder",
"例如:company.ghe.com",
)}
value={enterpriseDomain}
onChange={(e) => setEnterpriseDomain(e.target.value)}
/>
)}
</div>
{migrationError && (
<p className="text-sm text-amber-600 dark:text-amber-400">
{t("copilot.migrationFailed", {
@@ -179,6 +228,12 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
{t("copilot.defaultAccount", "默认")}
</Badge>
)}
{account.github_domain &&
account.github_domain !== "github.com" && (
<Badge variant="outline" className="text-xs">
{account.github_domain}
</Badge>
)}
{selectedAccountId === account.id && (
<Badge variant="outline" className="text-xs">
{t("copilot.selected", "已选中")}
@@ -223,6 +278,7 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
onClick={addAccount}
className="w-full"
variant="outline"
disabled={deploymentType === "enterprise" && !enterpriseDomain.trim()}
>
<Github className="mr-2 h-4 w-4" />
{t("copilot.loginWithGitHub", "使用 GitHub 登录")}
@@ -236,7 +292,10 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
onClick={addAccount}
className="w-full"
variant="outline"
disabled={isAddingAccount}
disabled={
isAddingAccount ||
(deploymentType === "enterprise" && !enterpriseDomain.trim())
}
>
<Plus className="mr-2 h-4 w-4" />
{t("copilot.addAnotherAccount", "添加其他账号")}
@@ -1,8 +1,8 @@
import type { GitHubAccount } from "@/lib/api";
import { useManagedAuth } from "./useManagedAuth";
export function useCopilotAuth() {
const managedAuth = useManagedAuth("github_copilot");
export function useCopilotAuth(githubDomain?: string) {
const managedAuth = useManagedAuth("github_copilot", githubDomain);
const defaultAccount =
managedAuth.accounts.find(
(account) => account.id === managedAuth.defaultAccountId,
@@ -10,7 +10,10 @@ import type {
type PollingState = "idle" | "polling" | "success" | "error";
export function useManagedAuth(authProvider: ManagedAuthProvider) {
export function useManagedAuth(
authProvider: ManagedAuthProvider,
githubDomain?: string,
) {
const queryClient = useQueryClient();
const queryKey = ["managed-auth-status", authProvider];
@@ -52,7 +55,7 @@ export function useManagedAuth(authProvider: ManagedAuthProvider) {
}, [stopPolling]);
const startLoginMutation = useMutation({
mutationFn: () => authApi.authStartLogin(authProvider),
mutationFn: () => authApi.authStartLogin(authProvider, githubDomain),
onSuccess: async (response) => {
setDeviceCode(response);
setPollingState("polling");
@@ -87,6 +90,7 @@ export function useManagedAuth(authProvider: ManagedAuthProvider) {
const newAccount = await authApi.authPollForAccount(
authProvider,
response.device_code,
githubDomain,
);
if (newAccount) {
stopPolling();
+5 -1
View File
@@ -889,7 +889,11 @@
"retry": "Retry",
"copyCode": "Copy code",
"migrationFailed": "Legacy auth migration failed: {{error}}",
"loadModelsFailed": "Failed to load Copilot models"
"loadModelsFailed": "Failed to load Copilot models",
"deploymentType": "GitHub Deployment Type",
"deploymentGitHubCom": "GitHub.com",
"deploymentEnterprise": "GitHub Enterprise Server",
"enterpriseDomainPlaceholder": "e.g. company.ghe.com"
},
"codexOauth": {
"authStatus": "Auth status",
+5 -1
View File
@@ -889,7 +889,11 @@
"retry": "再試行",
"copyCode": "コードをコピー",
"migrationFailed": "旧認証データの移行に失敗しました: {{error}}",
"loadModelsFailed": "Copilot モデル一覧の読み込みに失敗しました"
"loadModelsFailed": "Copilot モデル一覧の読み込みに失敗しました",
"deploymentType": "GitHub デプロイメントタイプ",
"deploymentGitHubCom": "GitHub.com",
"deploymentEnterprise": "GitHub Enterprise Server",
"enterpriseDomainPlaceholder": "例: company.ghe.com"
},
"codexOauth": {
"authStatus": "認証状態",
+5 -1
View File
@@ -890,7 +890,11 @@
"retry": "重试",
"copyCode": "复制代码",
"migrationFailed": "旧认证数据迁移失败:{{error}}",
"loadModelsFailed": "加载 Copilot 模型列表失败"
"loadModelsFailed": "加载 Copilot 模型列表失败",
"deploymentType": "GitHub 部署类型",
"deploymentGitHubCom": "GitHub.com",
"deploymentEnterprise": "GitHub Enterprise Server",
"enterpriseDomainPlaceholder": "例如:company.ghe.com"
},
"codexOauth": {
"authStatus": "认证状态",
+5
View File
@@ -9,6 +9,7 @@ export interface ManagedAuthAccount {
avatar_url: string | null;
authenticated_at: number;
is_default: boolean;
github_domain: string;
}
export interface ManagedAuthStatus {
@@ -30,19 +31,23 @@ export interface ManagedAuthDeviceCodeResponse {
export async function authStartLogin(
authProvider: ManagedAuthProvider,
githubDomain?: string,
): Promise<ManagedAuthDeviceCodeResponse> {
return invoke<ManagedAuthDeviceCodeResponse>("auth_start_login", {
authProvider,
githubDomain: githubDomain || null,
});
}
export async function authPollForAccount(
authProvider: ManagedAuthProvider,
deviceCode: string,
githubDomain?: string,
): Promise<ManagedAuthAccount | null> {
return invoke<ManagedAuthAccount | null>("auth_poll_for_account", {
authProvider,
deviceCode,
githubDomain: githubDomain || null,
});
}
+2
View File
@@ -30,6 +30,8 @@ export interface GitHubAccount {
avatar_url: string | null;
/** 认证时间戳(Unix 秒) */
authenticated_at: number;
/** GitHub 域名(github.com 或 GHES 域名) */
github_domain: string;
}
/**