diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000000..a6ac5073b30b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,76 @@ +name: Build OpenCode (patched fork) + +on: + push: + branches: + - fix/copilot-business-endpoint-routing + workflow_dispatch: + +jobs: + build: + runs-on: CustomRun + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Compute version from package.json + id: version + run: | + UPSTREAM_VER=$(python3 -c "import json; print(json.load(open('packages/opencode/package.json'))['version'])") + PATCHED_VER="${UPSTREAM_VER}-copilot-fix" + echo "upstream=$UPSTREAM_VER" >> "$GITHUB_OUTPUT" + echo "patched=$PATCHED_VER" >> "$GITHUB_OUTPUT" + echo "Version: upstream=$UPSTREAM_VER patched=$PATCHED_VER" + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Install dependencies + run: bun install + + - name: Build binary (linux-x64 only) + run: bun run script/build.ts --single + working-directory: packages/opencode + env: + OPENCODE_VERSION: "${{ steps.version.outputs.patched }}" + + - name: Verify binary exists + run: | + ls -lh packages/opencode/dist/opencode-linux-x64/bin/opencode + file packages/opencode/dist/opencode-linux-x64/bin/opencode + + - name: Smoke test — version + run: | + VERSION=$(packages/opencode/dist/opencode-linux-x64/bin/opencode --version 2>/dev/null || echo "FAILED") + echo "Version: $VERSION" + if [ "$VERSION" = "FAILED" ]; then + echo "ERROR: binary --version failed" + exit 1 + fi + + - name: Smoke test — patch signatures + run: | + BINARY="packages/opencode/dist/opencode-linux-x64/bin/opencode" + EXCHANGE=$(strings "$BINARY" | grep -c "exchangeCopilotToken" || true) + GHU_CHECK=$(strings "$BINARY" | grep -c "isGhuToken" || true) + VSCODE_ID=$(strings "$BINARY" | grep -c "Copilot-Integration-Id" || true) + echo "exchangeCopilotToken: $EXCHANGE" + echo "isGhuToken: $GHU_CHECK" + echo "Copilot-Integration-Id: $VSCODE_ID" + if [ "$EXCHANGE" -eq 0 ] || [ "$GHU_CHECK" -eq 0 ] || [ "$VSCODE_ID" -eq 0 ]; then + echo "ERROR: Patch signatures missing in binary!" + exit 1 + fi + echo "All patch signatures verified." + + - name: Upload binary + uses: actions/upload-artifact@v4 + with: + name: opencode-linux-x64-patched + path: packages/opencode/dist/opencode-linux-x64/bin/opencode + retention-days: 30 diff --git a/packages/opencode/src/plugin/github-copilot/copilot.ts b/packages/opencode/src/plugin/github-copilot/copilot.ts index 5f61f013d9b6..357f36720767 100644 --- a/packages/opencode/src/plugin/github-copilot/copilot.ts +++ b/packages/opencode/src/plugin/github-copilot/copilot.ts @@ -23,8 +23,178 @@ function getUrls(domain: string) { } } +const DEFAULT_COPILOT_API_URL = "https://api.githubcopilot.com" + +/** + * Detect whether an OAuth token is a GitHub App token (ghu_ prefix, issued by + * VS Code's client ID) vs an OAuth App token (gho_ prefix, issued by OpenCode's + * own client ID). ghu_ tokens require VS Code identity spoofing + bearer token + * exchange to work with the Copilot Business/Enterprise API. + */ +function isGhuToken(token: string): boolean { + return token.startsWith("ghu_") +} + +/** + * VS Code identity headers. Required when using a ghu_ token (issued by VS + * Code's client ID Iv1.b507a08c87ecfe98). The Copilot API gates model access + * per OAuth client ID, and ghu_ tokens are bound to VS Code's client. Requests + * using these tokens must present matching identity headers or the API returns + * HTTP 400 "model not supported". + * + * Every third-party tool that works with Copilot Business (copilot.vim, + * avante.nvim, LiteLLM) sends these exact headers. + */ +const VSCODE_IDENTITY_HEADERS = { + "User-Agent": "GitHubCopilotChat/0.35.0", + "Editor-Version": "vscode/1.107.0", + "Editor-Plugin-Version": "copilot-chat/0.35.0", + "Copilot-Integration-Id": "vscode-chat", +} + +/** + * Cache for the token exchange response from `copilot_internal/v2/token`. + * Stores both the API endpoint and the short-lived bearer token. The raw ghu_ + * token cannot be used directly as Authorization — it must be exchanged for an + * HMAC-signed bearer token that the Copilot API actually accepts. + * + * Keyed by OAuth token prefix to support multiple accounts. + */ +let copilotTokenCache: + | { + tokenPrefix: string + apiEndpoint: string + bearerToken: string + expiresAt: number + } + | undefined + +/** + * Exchange the OAuth token via `copilot_internal/v2/token` and cache both the + * plan-specific API endpoint and the short-lived bearer token. + * + * GitHub returns plan-specific endpoints (e.g. `api.business.githubcopilot.com` + * for Business users). The legacy unified endpoint `api.githubcopilot.com` is + * being deprecated (HTTP 466). + * + * For ghu_ tokens, the returned bearer token is mandatory — the Copilot API + * does not accept raw ghu_ tokens. For gho_ tokens, the raw token can be used + * directly but the endpoint discovery is still needed. + * + * The result is cached until the token's `expires_at` timestamp minus a 2-minute + * buffer to ensure refresh happens before expiry. + */ +async function exchangeCopilotToken(oauthToken: string, enterpriseDomain?: string): Promise<{ + apiEndpoint: string + bearerToken: string +}> { + // Enterprise Server users have their own endpoint pattern + if (enterpriseDomain) { + return { + apiEndpoint: `https://copilot-api.${normalizeDomain(enterpriseDomain)}`, + bearerToken: oauthToken, + } + } + + // Return cached result if still valid (with 2-minute early refresh buffer) + const prefix = oauthToken.slice(0, 8) + const REFRESH_BUFFER_MS = 2 * 60 * 1000 + if ( + copilotTokenCache && + copilotTokenCache.tokenPrefix === prefix && + Date.now() < copilotTokenCache.expiresAt - REFRESH_BUFFER_MS + ) { + return { + apiEndpoint: copilotTokenCache.apiEndpoint, + bearerToken: copilotTokenCache.bearerToken, + } + } + + // Use VS Code identity headers for ghu_ tokens, OpenCode identity for gho_ + const userAgent = isGhuToken(oauthToken) ? VSCODE_IDENTITY_HEADERS["User-Agent"] : `opencode/${Installation.VERSION}` + const exchangeHeaders: Record = { + Authorization: `token ${oauthToken}`, + Accept: "application/json", + "User-Agent": userAgent, + } + if (isGhuToken(oauthToken)) { + exchangeHeaders["Editor-Version"] = VSCODE_IDENTITY_HEADERS["Editor-Version"] + exchangeHeaders["Editor-Plugin-Version"] = VSCODE_IDENTITY_HEADERS["Editor-Plugin-Version"] + exchangeHeaders["Copilot-Integration-Id"] = VSCODE_IDENTITY_HEADERS["Copilot-Integration-Id"] + } + + try { + const response = await fetch("https://api.github.com/copilot_internal/v2/token", { + headers: exchangeHeaders, + signal: AbortSignal.timeout(5_000), + }) + + if (response.ok) { + const data = (await response.json()) as { + token: string + expires_at?: number + endpoints?: { + api?: string + proxy?: string + telemetry?: string + "origin-tracker"?: string + } + } + + const apiEndpoint = data.endpoints?.api || DEFAULT_COPILOT_API_URL + const bearerToken = data.token || oauthToken + const expiresAt = data.expires_at ? data.expires_at * 1000 : Date.now() + 25 * 60 * 1000 + + copilotTokenCache = { + tokenPrefix: prefix, + apiEndpoint, + bearerToken, + expiresAt, + } + + log.info("copilot token exchange succeeded", { + endpoint: apiEndpoint, + tokenType: isGhuToken(oauthToken) ? "ghu" : "gho", + expiresIn: Math.round((expiresAt - Date.now()) / 1000) + "s", + }) + + return { apiEndpoint, bearerToken } + } else { + log.warn("copilot token exchange failed", { + status: response.status, + statusText: response.statusText, + }) + } + } catch (error) { + log.warn("failed to exchange copilot token, using defaults", { error }) + } + + // Fallback: use raw token and default endpoint + return { + apiEndpoint: DEFAULT_COPILOT_API_URL, + bearerToken: oauthToken, + } +} + +/** + * Legacy wrapper for backward compatibility — returns just the API endpoint. + */ +async function getCopilotApiEndpoint(oauthToken: string, enterpriseDomain?: string): Promise { + const result = await exchangeCopilotToken(oauthToken, enterpriseDomain) + return result.apiEndpoint +} + +/** + * Get the correct bearer token for API calls. For ghu_ tokens, this returns the + * exchanged short-lived bearer. For gho_ tokens, returns the raw token. + */ +async function getCopilotBearerToken(oauthToken: string, enterpriseDomain?: string): Promise { + const result = await exchangeCopilotToken(oauthToken, enterpriseDomain) + return result.bearerToken +} + function base(enterpriseUrl?: string) { - return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com" + return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : DEFAULT_COPILOT_API_URL } function fix(model: Model, url: string): Model { @@ -49,18 +219,26 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { } const auth = ctx.auth - + const { apiEndpoint, bearerToken } = await exchangeCopilotToken(auth.refresh, auth.enterpriseUrl) + const modelHeaders: Record = { + Authorization: `Bearer ${bearerToken}`, + "User-Agent": isGhuToken(auth.refresh) + ? VSCODE_IDENTITY_HEADERS["User-Agent"] + : `opencode/${Installation.VERSION}`, + } + if (isGhuToken(auth.refresh)) { + modelHeaders["Editor-Version"] = VSCODE_IDENTITY_HEADERS["Editor-Version"] + modelHeaders["Editor-Plugin-Version"] = VSCODE_IDENTITY_HEADERS["Editor-Plugin-Version"] + modelHeaders["Copilot-Integration-Id"] = VSCODE_IDENTITY_HEADERS["Copilot-Integration-Id"] + } return CopilotModels.get( - base(auth.enterpriseUrl), - { - Authorization: `Bearer ${auth.refresh}`, - "User-Agent": `opencode/${Installation.VERSION}`, - }, + apiEndpoint, + modelHeaders, provider.models, ).catch((error) => { log.error("failed to fetch copilot models", { error }) return Object.fromEntries( - Object.entries(provider.models).map(([id, model]) => [id, fix(model, base(auth.enterpriseUrl))]), + Object.entries(provider.models).map(([id, model]) => [id, fix(model, apiEndpoint)]), ) }) }, @@ -71,12 +249,17 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { const info = await getAuth() if (!info || info.type !== "oauth") return {} + return { apiKey: "", async fetch(request: RequestInfo | URL, init?: RequestInit) { const info = await getAuth() if (info.type !== "oauth") return fetch(request, init) + // Exchange token on every fetch to ensure we have a fresh bearer + const { bearerToken } = await exchangeCopilotToken(info.refresh, info.enterpriseUrl) + const useVscodeIdentity = isGhuToken(info.refresh) + const url = request instanceof URL ? request.href : request.toString() const { isVision, isAgent } = iife(() => { try { @@ -134,11 +317,19 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { const headers: Record = { "x-initiator": isAgent ? "agent" : "user", ...(init?.headers as Record), - "User-Agent": `opencode/${Installation.VERSION}`, - Authorization: `Bearer ${info.refresh}`, + "User-Agent": useVscodeIdentity + ? VSCODE_IDENTITY_HEADERS["User-Agent"] + : `opencode/${Installation.VERSION}`, + Authorization: `Bearer ${bearerToken}`, "Openai-Intent": "conversation-edits", } + if (useVscodeIdentity) { + headers["Editor-Version"] = VSCODE_IDENTITY_HEADERS["Editor-Version"] + headers["Editor-Plugin-Version"] = VSCODE_IDENTITY_HEADERS["Editor-Plugin-Version"] + headers["Copilot-Integration-Id"] = VSCODE_IDENTITY_HEADERS["Copilot-Integration-Id"] + } + if (isVision) { headers["Copilot-Vision-Request"] = "true" }