fix(core): reject GitHub Copilot login without chat entitlement (#46959)

This commit is contained in:
Aiden Cline
2026-09-03 09:56:36 -05:00
committed by GitHub
parent 5716f8ba60
commit 206f51547c
2 changed files with 53 additions and 16 deletions
@@ -30,6 +30,8 @@ const Token = Schema.Struct({
interval: Schema.optional(Schema.Number),
})
const User = Schema.Struct({
chat_enabled: Schema.optional(Schema.Boolean),
can_signup_for_limited: Schema.optional(Schema.Boolean),
endpoints: Schema.optional(
Schema.Struct({
api: Schema.optional(Schema.String),
@@ -107,23 +109,30 @@ const oauth = (app: App.Info) =>
},
},
).pipe(
Effect.map((user) => Option.getOrUndefined(decodeUser(user))?.endpoints?.api?.replace(/\/+$/, "")),
Effect.map((user) => Option.getOrUndefined(decodeUser(user))),
// Only an explicit entitlement answer blocks login; a failed
// or malformed lookup must not turn a GitHub hiccup into a denial.
Effect.orElseSucceed(() => undefined),
Effect.map((apiEndpoint) =>
Credential.OAuth.make({
type: "oauth",
methodID,
refresh: access,
access,
expires: 0,
...((enterprise || apiEndpoint) && {
metadata: {
...(enterprise ? { enterpriseUrl: domain } : {}),
...(apiEndpoint ? { apiEndpoint } : {}),
},
Effect.flatMap((user) => {
const denied = user && copilotEntitlementError(user)
if (denied) return Effect.fail(new Error(denied))
const apiEndpoint = user?.endpoints?.api?.replace(/\/+$/, "")
return Effect.succeed(
Credential.OAuth.make({
type: "oauth",
methodID,
refresh: access,
access,
expires: 0,
...((enterprise || apiEndpoint) && {
metadata: {
...(enterprise ? { enterpriseUrl: domain } : {}),
...(apiEndpoint ? { apiEndpoint } : {}),
},
}),
}),
}),
),
)
}),
)
}
if (token.error === "authorization_pending")
@@ -298,6 +307,15 @@ function baseURL(enterprise?: string) {
return enterprise ? `https://copilot-api.${normalizeDomain(enterprise)}` : "https://api.githubcopilot.com"
}
// GitHub reports Copilot access on /copilot_internal/user; OAuth itself succeeds
// for any GitHub account, so this is the only signal that the account can chat.
export function copilotEntitlementError(user: { chat_enabled?: boolean; can_signup_for_limited?: boolean }) {
if (user.chat_enabled !== false) return
if (user.can_signup_for_limited)
return "This GitHub account is not signed up for GitHub Copilot. Sign up for Copilot Free at https://github.com/features/copilot/plans and connect again."
return "This GitHub account does not have GitHub Copilot access. It needs an active Copilot subscription or a seat assigned by an organization."
}
export function copilotBaseURL(metadata?: Readonly<Record<string, unknown>>) {
const endpoint = metadata?.apiEndpoint
if (typeof endpoint === "string" && endpoint) return endpoint
@@ -10,7 +10,12 @@ import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { copilotBaseURL, copilotFetch, GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
import {
copilotBaseURL,
copilotEntitlementError,
copilotFetch,
GithubCopilotPlugin,
} from "@opencode-ai/core/plugin/provider/github-copilot"
import { Provider } from "@opencode-ai/core/provider"
import { Integration } from "@opencode-ai/core/integration"
import { fakeSelectorSdk } from "../fixture/selector"
@@ -40,6 +45,20 @@ describe("GithubCopilotPlugin", () => {
).toBe("https://api.business.githubcopilot.com")
})
test("rejects accounts without Copilot chat access", () => {
expect(copilotEntitlementError({ chat_enabled: false, can_signup_for_limited: true })).toContain("Copilot Free")
expect(copilotEntitlementError({ chat_enabled: false, can_signup_for_limited: false })).toContain(
"subscription or a seat",
)
expect(copilotEntitlementError({ chat_enabled: false })).toContain("subscription or a seat")
})
test("only blocks on an explicit entitlement denial", () => {
expect(copilotEntitlementError({ chat_enabled: true })).toBeUndefined()
expect(copilotEntitlementError({ chat_enabled: true, can_signup_for_limited: true })).toBeUndefined()
expect(copilotEntitlementError({})).toBeUndefined()
})
it.effect("registers GitHub Copilot device OAuth", () =>
Effect.gen(function* () {
yield* addPlugin()