Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 33 additions & 15 deletions packages/core/src/plugin/provider/github-copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion packages/core/test/plugin/provider-github-copilot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down
Loading