diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 00ec5f4541c4..088b9b4bfd14 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -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>) { const endpoint = metadata?.apiEndpoint if (typeof endpoint === "string" && endpoint) return endpoint diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index 8072986bf696..9acf7b8f1d8b 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -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()