Skip to content
Open
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
56 changes: 38 additions & 18 deletions packages/opencode/src/auth/github-copilot.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,31 @@
import z from "zod/v4"
import { Auth } from "./index"
import { NamedError } from "../util/error"
import { Config } from "../config/config"
import { normalizeDomain } from "../util/url"

export namespace AuthGithubCopilot {
const CLIENT_ID = "Iv1.b507a08c87ecfe98"
const DEVICE_CODE_URL = "https://github.com/login/device/code"
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
const COPILOT_API_KEY_URL = "https://api.github.com/copilot_internal/v2/token"

async function getBaseUrl(providerID: string, enterpriseUrl?: string): Promise<string> {
if (enterpriseUrl) return normalizeDomain(enterpriseUrl)

const config = await Config.get()
const providerConfig = config.provider?.[providerID]
const configUrl = providerConfig?.options?.enterpriseUrl

return configUrl ? normalizeDomain(configUrl) : "github.com"
}

async function getUrls(providerID = "github-copilot", enterpriseUrl?: string) {
const baseUrl = await getBaseUrl(providerID, enterpriseUrl)

return {
DEVICE_CODE_URL: `https://${baseUrl}/login/device/code`,
ACCESS_TOKEN_URL: `https://${baseUrl}/login/oauth/access_token`,
COPILOT_API_KEY_URL: `https://api.${baseUrl}/copilot_internal/v2/token`,
}
}

interface DeviceCodeResponse {
device_code: string
Expand All @@ -31,8 +50,9 @@ export namespace AuthGithubCopilot {
}
}

export async function authorize() {
const deviceResponse = await fetch(DEVICE_CODE_URL, {
export async function authorize(providerID = "github-copilot", enterpriseUrl?: string) {
const urls = await getUrls(providerID, enterpriseUrl)
const deviceResponse = await fetch(urls.DEVICE_CODE_URL, {
method: "POST",
headers: {
Accept: "application/json",
Expand All @@ -54,8 +74,9 @@ export namespace AuthGithubCopilot {
}
}

export async function poll(device_code: string) {
const response = await fetch(ACCESS_TOKEN_URL, {
export async function poll(device_code: string, providerID = "github-copilot", enterpriseUrl?: string) {
const urls = await getUrls(providerID, enterpriseUrl)
const response = await fetch(urls.ACCESS_TOKEN_URL, {
method: "POST",
headers: {
Accept: "application/json",
Expand All @@ -74,12 +95,12 @@ export namespace AuthGithubCopilot {
const data: AccessTokenResponse = await response.json()

if (data.access_token) {
// Store the GitHub OAuth token
await Auth.set("github-copilot", {
await Auth.set(providerID, {
type: "oauth",
refresh: data.access_token,
access: "",
expires: 0,
...(providerID === "github-copilot-enterprise" && enterpriseUrl ? { enterpriseUrl } : {}),
})
return "complete"
}
Expand All @@ -91,18 +112,18 @@ export namespace AuthGithubCopilot {
return "pending"
}

export async function access() {
const info = await Auth.get("github-copilot")
export async function access(providerID = "github-copilot") {
const info = await Auth.get(providerID)
if (!info || info.type !== "oauth") return
if (info.access && info.expires > Date.now()) return info.access

// Get new Copilot API token
const response = await fetch(COPILOT_API_KEY_URL, {
const urls = await getUrls(providerID, info.enterpriseUrl)
const response = await fetch(urls.COPILOT_API_KEY_URL, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${info.refresh}`,
"User-Agent": "GitHubCopilotChat/0.26.7",
"Editor-Version": "vscode/1.99.3",
"Editor-Version": "vscode/1.103.2",
"Editor-Plugin-Version": "copilot-chat/0.26.7",
},
})
Expand All @@ -111,10 +132,9 @@ export namespace AuthGithubCopilot {

const tokenData: CopilotTokenResponse = await response.json()

// Store the Copilot API token
await Auth.set("github-copilot", {
type: "oauth",
refresh: info.refresh,
// Store the Copilot API token, preserving all existing auth data
await Auth.set(providerID, {
...info,
access: tokenData.token,
expires: tokenData.expires_at * 1000,
})
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export namespace Auth {
refresh: z.string(),
access: z.string(),
expires: z.number(),
enterpriseUrl: z.string().optional(),
})
.meta({ ref: "OAuth" })

Expand Down
89 changes: 89 additions & 0 deletions packages/opencode/src/cli/cmd/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import os from "os"
import { Global } from "../../global"
import { Plugin } from "../../plugin"
import { Instance } from "../../project/instance"
import { AuthGithubCopilot } from "../../auth/github-copilot"

export const AuthCommand = cmd({
command: "auth",
Expand Down Expand Up @@ -137,6 +138,10 @@ export const AuthLoginCommand = cmd({

if (prompts.isCancel(provider)) throw new UI.CancelledError()

if (provider === "github-copilot") {
return await handleGithubCopilotAuth(provider)
}

const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
if (plugin && plugin.auth) {
let index = 0
Expand Down Expand Up @@ -292,3 +297,87 @@ export const AuthLogoutCommand = cmd({
prompts.outro("Logout successful")
},
})

async function handleGithubCopilotAuth(providerID: string) {
try {
const deploymentType = await prompts.select({
message: "Select GitHub deployment type",
options: [
{
label: "GitHub.com",
value: "github.com",
hint: "Public",
},
{
label: "GitHub Enterprise",
value: "enterprise",
hint: "Data residency or self-hosted",
},
],
})

if (prompts.isCancel(deploymentType)) throw new UI.CancelledError()

let enterpriseUrl: string | undefined
let actualProviderID = providerID

if (deploymentType === "enterprise") {
const enterpriseHost = await prompts.text({
message: "Enter your GitHub Enterprise URL or domain",
placeholder: "company.ghe.com or https://company.ghe.com",
validate: (value) => {
if (!value) return "URL or domain is required"
try {
// Test if it's a valid URL
const url = value.includes("://") ? new URL(value) : new URL(`https://${value}`)
if (!url.hostname) return "Please enter a valid URL or domain"
return undefined
} catch {
return "Please enter a valid URL (e.g., company.ghe.com or https://company.ghe.com)"
}
},
})

if (prompts.isCancel(enterpriseHost)) throw new UI.CancelledError()

enterpriseUrl = enterpriseHost.includes("://") ? enterpriseHost : `https://${enterpriseHost}`

actualProviderID = "github-copilot-enterprise"
}

const authorize = await AuthGithubCopilot.authorize(actualProviderID, enterpriseUrl)

prompts.log.info(`Go to: ${authorize.verification}`)
prompts.log.info(`Enter code: ${authorize.user}`)

const spinner = prompts.spinner()
spinner.start("Waiting for authorization...")

let attempts = 0
const maxAttempts = Math.ceil(authorize.expiry / authorize.interval)

while (attempts < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, authorize.interval * 1000))

const result = await AuthGithubCopilot.poll(authorize.device, actualProviderID, enterpriseUrl)

if (result === "complete") {
spinner.stop("Login successful")
prompts.outro("Done")
return
} else if (result === "failed") {
spinner.stop("Failed to authorize", 1)
prompts.outro("Authentication failed")
return
}

attempts++
}

spinner.stop("Authorization timed out", 1)
prompts.outro("Please try again")
} catch (error) {
prompts.log.error(`Authentication failed: ${error instanceof Error ? error.message : String(error)}`)
prompts.outro("Done")
}
}
50 changes: 40 additions & 10 deletions packages/opencode/src/cli/cmd/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,28 +78,50 @@ export const GithubInstallCommand = cmd({
// match https or git pattern
// ie. https://github.com/sst/opencode.git
// ie. https://github.com/sst/opencode
// ie. https://company.ghe.com/sst/opencode.git
// ie. https://company.ghe.com/sst/opencode
// ie. [email protected]:sst/opencode.git
// ie. [email protected]:sst/opencode
// ie. [email protected]:sst/opencode.git
// ie. [email protected]:sst/opencode
// ie. ssh://[email protected]/sst/opencode.git
// ie. ssh://[email protected]/sst/opencode
const parsed = info.match(/^(?:(?:https?|ssh):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/)
if (!parsed) {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
// ie. ssh://[email protected]/sst/opencode.git
// ie. ssh://[email protected]/sst/opencode
const githubComMatch = info.match(
/^(?:(?:https?|ssh):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/,
)
const gheMatch = info.match(
/^(?:(?:https?|ssh):\/\/)?(?:git@)?([^:/]+\.ghe\.com)[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/,
)

let owner: string, repo: string, githubHost: string

if (githubComMatch) {
;[, owner, repo] = githubComMatch
githubHost = "github.com"
} else if (gheMatch) {
;[, githubHost, owner, repo] = gheMatch
} else {
prompts.log.error(
`Could not find GitHub repository. Please run this command from a GitHub or GitHub Enterprise repository.`,
)
throw new UI.CancelledError()
}
const [, owner, repo] = parsed
return { owner, repo, root: Instance.worktree }

return { owner, repo, root: Instance.worktree, githubHost }
}

async function promptProvider() {
const priority: Record<string, number> = {
opencode: 0,
anthropic: 1,
"github-copilot": 2,
openai: 3,
google: 4,
openrouter: 5,
vercel: 6,
"github-copilot-enterprise": 3,
openai: 4,
google: 5,
openrouter: 6,
vercel: 7,
}
let provider = await prompts.select({
message: "Select provider",
Expand Down Expand Up @@ -149,7 +171,15 @@ export const GithubInstallCommand = cmd({
const s = prompts.spinner()
s.start("Installing GitHub app")

// Get installation
// For GitHub Enterprise, we can't automatically detect installations
if (app.githubHost !== "github.com") {
s.stop("GitHub Enterprise detected")
prompts.log.warn("For GitHub Enterprise, you need to:")
prompts.log.warn(`- Contact your GitHub Enterprise administrator to install the opencode agent app`)
prompts.log.info("Skipping app installation for GitHub Enterprise...")
return
}

const installation = await getInstallation()
if (installation) return s.stop("GitHub app already installed")

Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,7 @@ export namespace Config {
.object({
apiKey: z.string().optional(),
baseURL: z.string().optional(),
enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"),
timeout: z
.union([
z
Expand Down
42 changes: 42 additions & 0 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,33 @@ export namespace Provider {
},
}
},
"github-copilot-enterprise": async (provider) => {
const { AuthGithubCopilot } = await import("../auth/github-copilot")
const { Auth } = await import("../auth")
const { normalizeDomain, buildCopilotApiUrl } = await import("../util/url")

const authInfo = await Auth.get("github-copilot-enterprise")
const enterpriseUrl = authInfo && "enterpriseUrl" in authInfo ? authInfo.enterpriseUrl : undefined

let baseURL = provider?.api
if (enterpriseUrl) {
const domain = normalizeDomain(enterpriseUrl)
baseURL = buildCopilotApiUrl(domain)
}

const token = await AuthGithubCopilot.access("github-copilot-enterprise")

return {
autoload: false,
options: {
baseURL,
...(token && { apiKey: token }),
headers: {
"Editor-Version": "vscode/1.103.2",
},
},
}
},
}

const state = Instance.state(async () => {
Expand Down Expand Up @@ -191,6 +218,18 @@ export namespace Provider {

const configProviders = Object.entries(config.provider ?? {})

// Add GitHub Copilot Enterprise provider that inherits from GitHub Copilot
if (database["github-copilot"]) {
const githubCopilot = database["github-copilot"]
database["github-copilot-enterprise"] = {
...githubCopilot,
id: "github-copilot-enterprise",
name: "GitHub Copilot Enterprise",
// Enterprise uses a different API endpoint - will be set dynamically based on auth
api: undefined,
}
}

for (const [providerID, provider] of configProviders) {
const existing = database[providerID]
const parsed: ModelsDev.Provider = {
Expand Down Expand Up @@ -262,6 +301,9 @@ export namespace Provider {
if (provider.type === "api") {
mergeProvider(providerID, { apiKey: provider.key }, "api")
}
if (providerID === "github-copilot-enterprise" && provider.type === "oauth") {
mergeProvider(providerID, {}, "api")
}
}

// load custom
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/util/url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function normalizeDomain(url: string): string {
return url.replace(/^https?:\/\//, "").replace(/\/$/, "")
}

export function buildCopilotApiUrl(domain: string): string {
return `https://copilot-api.${domain}`
}