+
{content}
) : (
diff --git a/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/useChatSession.ts b/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/useChatSession.ts
index 68bdcee96..1e0e41885 100644
--- a/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/useChatSession.ts
+++ b/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/useChatSession.ts
@@ -502,6 +502,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
if (pending.action) {
setTextToAction((prev) => {
const next = new Map(prev)
+ // biome-ignore lint/style/noNonNullAssertion: guarded by if (pending.action) above
next.set(pending.text, pending.action!)
return next
})
diff --git a/packages/browseros-agent/apps/agent/lib/constants/analyticsEvents.ts b/packages/browseros-agent/apps/agent/lib/constants/analyticsEvents.ts
index 2c41a9f71..7d80ab101 100644
--- a/packages/browseros-agent/apps/agent/lib/constants/analyticsEvents.ts
+++ b/packages/browseros-agent/apps/agent/lib/constants/analyticsEvents.ts
@@ -41,6 +41,18 @@ export const CHATGPT_PRO_OAUTH_COMPLETED_EVENT =
export const CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT =
'settings.chatgpt_pro.oauth_disconnected'
+/** @public */
+export const GITHUB_COPILOT_OAUTH_STARTED_EVENT =
+ 'settings.github_copilot.oauth_started'
+
+/** @public */
+export const GITHUB_COPILOT_OAUTH_COMPLETED_EVENT =
+ 'settings.github_copilot.oauth_completed'
+
+/** @public */
+export const GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT =
+ 'settings.github_copilot.oauth_disconnected'
+
/** @public */
export const HUB_PROVIDER_ADDED_EVENT = 'settings.hub_provider.added'
diff --git a/packages/browseros-agent/apps/agent/lib/llm-providers/providerIcons.tsx b/packages/browseros-agent/apps/agent/lib/llm-providers/providerIcons.tsx
index ed57330f0..3c1a742b8 100644
--- a/packages/browseros-agent/apps/agent/lib/llm-providers/providerIcons.tsx
+++ b/packages/browseros-agent/apps/agent/lib/llm-providers/providerIcons.tsx
@@ -9,7 +9,7 @@ import {
OpenAI,
OpenRouter,
} from '@lobehub/icons'
-import { Bot } from 'lucide-react'
+import { Bot, Github } from 'lucide-react'
import type { FC, SVGProps } from 'react'
import ProductLogoSvg from '@/assets/product_logo.svg'
import type { ProviderType } from './types'
@@ -33,6 +33,7 @@ const providerIconMap: Record
= {
browseros: null,
moonshot: Kimi,
'chatgpt-pro': OpenAI,
+ 'github-copilot': Github,
}
interface ProviderIconProps {
diff --git a/packages/browseros-agent/apps/agent/lib/llm-providers/providerTemplates.ts b/packages/browseros-agent/apps/agent/lib/llm-providers/providerTemplates.ts
index aa682ca3d..bc2c451ce 100644
--- a/packages/browseros-agent/apps/agent/lib/llm-providers/providerTemplates.ts
+++ b/packages/browseros-agent/apps/agent/lib/llm-providers/providerTemplates.ts
@@ -29,6 +29,15 @@ export const providerTemplates: ProviderTemplate[] = [
contextWindow: 400000,
setupGuideUrl: 'https://docs.browseros.com/features/chatgpt-pro-oauth',
},
+ {
+ id: 'github-copilot',
+ name: 'GitHub Copilot',
+ defaultBaseUrl: 'https://api.githubcopilot.com',
+ defaultModelId: 'gpt-5-mini',
+ supportsImages: true,
+ contextWindow: 128000,
+ setupGuideUrl: 'https://docs.browseros.com/features/github-copilot-oauth',
+ },
{
id: 'moonshot',
name: 'Moonshot AI',
@@ -139,6 +148,7 @@ export const providerTemplates: ProviderTemplate[] = [
*/
export const providerTypeOptions: { value: ProviderType; label: string }[] = [
{ value: 'chatgpt-pro', label: 'ChatGPT Plus/Pro' },
+ { value: 'github-copilot', label: 'GitHub Copilot' },
{ value: 'moonshot', label: 'Moonshot AI' },
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'openai', label: 'OpenAI' },
@@ -168,6 +178,7 @@ export const getProviderTemplate = (
*/
export const DEFAULT_BASE_URLS: Record = {
'chatgpt-pro': 'https://chatgpt.com/backend-api',
+ 'github-copilot': 'https://api.githubcopilot.com',
moonshot: 'https://api.moonshot.ai/v1',
anthropic: 'https://api.anthropic.com/v1',
openai: 'https://api.openai.com/v1',
diff --git a/packages/browseros-agent/apps/agent/lib/llm-providers/types.ts b/packages/browseros-agent/apps/agent/lib/llm-providers/types.ts
index 33ed42e85..63a7477e7 100644
--- a/packages/browseros-agent/apps/agent/lib/llm-providers/types.ts
+++ b/packages/browseros-agent/apps/agent/lib/llm-providers/types.ts
@@ -15,6 +15,7 @@ export type ProviderType =
| 'browseros'
| 'moonshot'
| 'chatgpt-pro'
+ | 'github-copilot'
/**
* LLM Provider configuration
diff --git a/packages/browseros-agent/apps/eval/scripts/annotate-screenshots.ts b/packages/browseros-agent/apps/eval/scripts/annotate-screenshots.ts
index ee2a95e57..436806a23 100644
--- a/packages/browseros-agent/apps/eval/scripts/annotate-screenshots.ts
+++ b/packages/browseros-agent/apps/eval/scripts/annotate-screenshots.ts
@@ -173,7 +173,9 @@ async function annotateScreenshot(
const image = sharp(inputPath)
const metadata = await image.metadata()
+ // biome-ignore lint/style/noNonNullAssertion: sharp metadata always has dimensions for valid images
const imgWidth = metadata.width!
+ // biome-ignore lint/style/noNonNullAssertion: sharp metadata always has dimensions for valid images
const imgHeight = metadata.height!
const sx = Math.round(action.cssX * dpr)
diff --git a/packages/browseros-agent/apps/eval/scripts/debug-long-run.ts b/packages/browseros-agent/apps/eval/scripts/debug-long-run.ts
index 078698f48..115c8e7bf 100644
--- a/packages/browseros-agent/apps/eval/scripts/debug-long-run.ts
+++ b/packages/browseros-agent/apps/eval/scripts/debug-long-run.ts
@@ -49,10 +49,13 @@ async function callMcpTool(
const result = await Promise.race([toolPromise, timeoutPromise])
const duration = Date.now() - start
- if ((result as any).isError) {
+ const res = result as Record
+ if (res.isError) {
+ const content = res.content as
+ | Array<{ type: string; text?: string }>
+ | undefined
const errorText =
- (result as any).content?.find((c: any) => c.type === 'text')?.text ||
- 'Unknown error'
+ content?.find((c) => c.type === 'text')?.text || 'Unknown error'
return { success: false, error: errorText, duration }
}
@@ -96,13 +99,19 @@ async function main() {
})
// Try structured content first
- windowId = (result as any).structuredContent?.windowId
- tabId = (result as any).structuredContent?.tabId
+ const createRes = result as Record
+ const structured = createRes.structuredContent as
+ | Record
+ | undefined
+ windowId = structured?.windowId ?? 0
+ tabId = structured?.tabId ?? 0
// Fall back to parsing text
if (!windowId || !tabId) {
- const text =
- (result as any).content?.find((c: any) => c.type === 'text')?.text || ''
+ const content = createRes.content as
+ | Array<{ type: string; text?: string }>
+ | undefined
+ const text = content?.find((c) => c.type === 'text')?.text || ''
const windowMatch = text.match(/window\s+(\d+)/i)
const tabMatch =
text.match(/Tab ID:\s*(\d+)/i) || text.match(/tab\s+(\d+)/i)
diff --git a/packages/browseros-agent/apps/server/package.json b/packages/browseros-agent/apps/server/package.json
index 6c47a0df2..6b0b4143b 100644
--- a/packages/browseros-agent/apps/server/package.json
+++ b/packages/browseros-agent/apps/server/package.json
@@ -63,8 +63,8 @@
"@ai-sdk/amazon-bedrock": "^4.0.62",
"@ai-sdk/anthropic": "^3.0.46",
"@ai-sdk/azure": "^3.0.31",
- "@ai-sdk/google": "^3.0.30",
"@ai-sdk/devtools": "^0.0.15",
+ "@ai-sdk/google": "^3.0.30",
"@ai-sdk/mcp": "^1.0.21",
"@ai-sdk/openai": "^3.0.30",
"@ai-sdk/openai-compatible": "^2.0.30",
@@ -93,6 +93,7 @@
"pino": "^9.6.0",
"posthog-node": "^4.17.0",
"puppeteer-core": "24.23.0",
+ "sharp": "^0.34.5",
"ws": "^8.18.0",
"zod": "^3.24.2",
"zod-from-json-schema": "^0.1.0"
diff --git a/packages/browseros-agent/apps/server/src/agent/provider-factory.ts b/packages/browseros-agent/apps/server/src/agent/provider-factory.ts
index c5ff30a80..b6005dc91 100644
--- a/packages/browseros-agent/apps/server/src/agent/provider-factory.ts
+++ b/packages/browseros-agent/apps/server/src/agent/provider-factory.ts
@@ -4,10 +4,12 @@ import { createAzure } from '@ai-sdk/azure'
import { createGoogleGenerativeAI } from '@ai-sdk/google'
import { createOpenAI } from '@ai-sdk/openai'
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
+import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
import type { LanguageModel } from 'ai'
import { createCodexFetch } from '../lib/clients/oauth/codex-fetch'
+import { createCopilotFetch } from '../lib/clients/oauth/copilot-fetch'
import { logger } from '../lib/logger'
import { createOpenRouterCompatibleFetch } from '../lib/openrouter-fetch'
import type { ResolvedAgentConfig } from './types'
@@ -149,6 +151,19 @@ function createMoonshotFactory(
})
}
+function createGitHubCopilotFactory(
+ config: ResolvedAgentConfig,
+): (modelId: string) => unknown {
+ if (!config.apiKey)
+ throw new Error('GitHub Copilot requires OAuth authentication')
+ return createOpenAICompatible({
+ name: 'github-copilot',
+ baseURL: EXTERNAL_URLS.GITHUB_COPILOT_API,
+ apiKey: config.apiKey,
+ fetch: createCopilotFetch() as typeof globalThis.fetch,
+ })
+}
+
function createChatGPTProFactory(
config: ResolvedAgentConfig,
): (modelId: string) => unknown {
@@ -173,6 +188,7 @@ const PROVIDER_FACTORIES: Record = {
[LLM_PROVIDERS.OPENAI_COMPATIBLE]: createOpenAICompatibleFactory,
[LLM_PROVIDERS.MOONSHOT]: createMoonshotFactory,
[LLM_PROVIDERS.CHATGPT_PRO]: createChatGPTProFactory,
+ [LLM_PROVIDERS.GITHUB_COPILOT]: createGitHubCopilotFactory,
}
export function createLanguageModel(
diff --git a/packages/browseros-agent/apps/server/src/api/routes/oauth.ts b/packages/browseros-agent/apps/server/src/api/routes/oauth.ts
index 592e29074..afb319fad 100644
--- a/packages/browseros-agent/apps/server/src/api/routes/oauth.ts
+++ b/packages/browseros-agent/apps/server/src/api/routes/oauth.ts
@@ -29,6 +29,17 @@ export function createOAuthRoutes(deps: OAuthRouteDeps) {
}
try {
+ // Device Code flow: return JSON with user code for the extension to display
+ if (provider.authFlow === 'device-code') {
+ const result = await tokenManager.startDeviceCodeFlow(providerId)
+ return c.json({
+ userCode: result.userCode,
+ verificationUri: result.verificationUri,
+ expiresIn: result.expiresIn,
+ })
+ }
+
+ // PKCE flow: redirect to auth server
const authUrl = await tokenManager.generateAuthorizationUrl(
providerId,
redirectBackUrl,
diff --git a/packages/browseros-agent/apps/server/src/lib/clients/llm/config.ts b/packages/browseros-agent/apps/server/src/lib/clients/llm/config.ts
index 747f2b87e..8f9cbb92e 100644
--- a/packages/browseros-agent/apps/server/src/lib/clients/llm/config.ts
+++ b/packages/browseros-agent/apps/server/src/lib/clients/llm/config.ts
@@ -17,10 +17,13 @@ export async function resolveLLMConfig(
config: LLMConfig,
browserosId?: string,
): Promise {
- // ChatGPT Pro: resolve OAuth token from server-side storage
+ // OAuth providers: resolve token from server-side storage
if (config.provider === LLM_PROVIDERS.CHATGPT_PRO) {
return resolveChatGPTProConfig(config, browserosId)
}
+ if (config.provider === LLM_PROVIDERS.GITHUB_COPILOT) {
+ return resolveGitHubCopilotConfig(config, browserosId)
+ }
// BrowserOS gateway: fetch config from remote service
if (config.provider === LLM_PROVIDERS.BROWSEROS) {
@@ -61,6 +64,32 @@ async function resolveChatGPTProConfig(
}
}
+async function resolveGitHubCopilotConfig(
+ config: LLMConfig,
+ browserosId?: string,
+): Promise {
+ const tokenManager = getOAuthTokenManager()
+ if (!tokenManager || !browserosId) {
+ throw new Error(
+ 'Not authenticated with GitHub Copilot. Please login first.',
+ )
+ }
+
+ // GitHub tokens never expire — no refresh needed
+ const tokens = tokenManager.getTokens('github-copilot')
+ if (!tokens) {
+ throw new Error(
+ 'Not authenticated with GitHub Copilot. Please login first.',
+ )
+ }
+
+ return {
+ ...config,
+ model: config.model || 'gpt-5-mini',
+ apiKey: tokens.accessToken,
+ }
+}
+
async function resolveBrowserOSConfig(
config: LLMConfig,
browserosId?: string,
diff --git a/packages/browseros-agent/apps/server/src/lib/clients/llm/provider.ts b/packages/browseros-agent/apps/server/src/lib/clients/llm/provider.ts
index 984fd3321..f8beaa868 100644
--- a/packages/browseros-agent/apps/server/src/lib/clients/llm/provider.ts
+++ b/packages/browseros-agent/apps/server/src/lib/clients/llm/provider.ts
@@ -12,12 +12,14 @@ import { createAzure } from '@ai-sdk/azure'
import { createGoogleGenerativeAI } from '@ai-sdk/google'
import { createOpenAI } from '@ai-sdk/openai'
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
+import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
import type { LanguageModel } from 'ai'
import { logger } from '../../logger'
import { createOpenRouterCompatibleFetch } from '../../openrouter-fetch'
import { createCodexFetch } from '../oauth/codex-fetch'
+import { createCopilotFetch } from '../oauth/copilot-fetch'
import type { ResolvedLLMConfig } from './types'
type ProviderFactory = (config: ResolvedLLMConfig) => LanguageModel
@@ -135,6 +137,17 @@ function createMoonshotModel(config: ResolvedLLMConfig): LanguageModel {
})(config.model)
}
+function createGitHubCopilotModel(config: ResolvedLLMConfig): LanguageModel {
+ if (!config.apiKey)
+ throw new Error('GitHub Copilot requires OAuth authentication')
+ return createOpenAICompatible({
+ name: 'github-copilot',
+ baseURL: EXTERNAL_URLS.GITHUB_COPILOT_API,
+ apiKey: config.apiKey,
+ fetch: createCopilotFetch() as typeof globalThis.fetch,
+ })(config.model)
+}
+
function createChatGPTProModel(config: ResolvedLLMConfig): LanguageModel {
if (!config.apiKey)
throw new Error('ChatGPT Plus/Pro requires OAuth authentication')
@@ -157,6 +170,7 @@ const PROVIDER_FACTORIES: Record = {
[LLM_PROVIDERS.OPENAI_COMPATIBLE]: createOpenAICompatibleModel,
[LLM_PROVIDERS.MOONSHOT]: createMoonshotModel,
[LLM_PROVIDERS.CHATGPT_PRO]: createChatGPTProModel,
+ [LLM_PROVIDERS.GITHUB_COPILOT]: createGitHubCopilotModel,
}
export function createLLMProvider(config: ResolvedLLMConfig): LanguageModel {
diff --git a/packages/browseros-agent/apps/server/src/lib/clients/oauth/copilot-fetch.ts b/packages/browseros-agent/apps/server/src/lib/clients/oauth/copilot-fetch.ts
new file mode 100644
index 000000000..fd391f7e3
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/lib/clients/oauth/copilot-fetch.ts
@@ -0,0 +1,135 @@
+/**
+ * @license
+ * Copyright 2025 BrowserOS
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * Custom fetch wrapper for GitHub Copilot API requests.
+ * Injects required Copilot headers and resizes images following
+ * VS Code's algorithm (max 2048px longest side, 768px shortest side).
+ */
+
+import sharp from 'sharp'
+import { logger } from '../../logger'
+
+const MAX_LONG_SIDE = 2048
+const MAX_SHORT_SIDE = 768
+
+export function createCopilotFetch() {
+ return async (input: RequestInfo | URL, init?: RequestInit) => {
+ const headers = new Headers(init?.headers as HeadersInit)
+
+ headers.set('Openai-Intent', 'conversation-edits')
+ headers.set('x-initiator', 'user')
+
+ let body = init?.body
+ if (body && typeof body === 'string') {
+ try {
+ const json = JSON.parse(body)
+ if (hasImageContent(json)) {
+ headers.set('Copilot-Vision-Request', 'true')
+ await shrinkImages(json)
+ body = JSON.stringify(json)
+ }
+ } catch {
+ // Not JSON or resize failed, send as-is
+ }
+ }
+
+ return fetch(input, { ...init, headers, body })
+ }
+}
+
+function hasImageContent(body: Record): boolean {
+ if (!Array.isArray(body.messages)) return false
+ for (const msg of body.messages) {
+ if (!Array.isArray(msg?.content)) continue
+ for (const part of msg.content) {
+ if (part?.type === 'image_url') return true
+ }
+ }
+ return false
+}
+
+// Resize images following VS Code's algorithm for OpenAI vision token optimization
+async function shrinkImages(body: Record): Promise {
+ if (!Array.isArray(body.messages)) return
+
+ for (const msg of body.messages) {
+ if (!Array.isArray(msg?.content)) continue
+ for (const part of msg.content) {
+ if (part?.type !== 'image_url' || !part.image_url) continue
+
+ const url = part.image_url.url as string
+ if (!url?.startsWith('data:')) continue
+
+ try {
+ const resized = await resizeDataUrl(url)
+ if (resized) part.image_url.url = resized
+ } catch (err) {
+ logger.warn('Failed to resize image for Copilot', {
+ error: err instanceof Error ? err.message : String(err),
+ })
+ }
+ }
+ }
+}
+
+async function resizeDataUrl(dataUrl: string): Promise {
+ const commaIdx = dataUrl.indexOf(',')
+ if (commaIdx === -1) return null
+
+ const base64Data = dataUrl.substring(commaIdx + 1)
+ const buffer = Buffer.from(base64Data, 'base64')
+
+ const image = sharp(buffer)
+ const metadata = await image.metadata()
+ if (!metadata.width || !metadata.height) return null
+
+ let { width, height } = metadata
+
+ // Skip if already within both limits (no resize step will fire)
+ if (
+ Math.max(width, height) <= MAX_LONG_SIDE &&
+ Math.min(width, height) <= MAX_SHORT_SIDE
+ ) {
+ return null
+ }
+
+ // Step 1: scale longest side to 2048
+ if (width > MAX_LONG_SIDE || height > MAX_LONG_SIDE) {
+ const scale = MAX_LONG_SIDE / Math.max(width, height)
+ width = Math.round(width * scale)
+ height = Math.round(height * scale)
+ }
+
+ // Step 2: scale shortest side to 768
+ const shortSide = Math.min(width, height)
+ if (shortSide > MAX_SHORT_SIDE) {
+ const scale = MAX_SHORT_SIDE / shortSide
+ width = Math.round(width * scale)
+ height = Math.round(height * scale)
+ }
+
+ // Preserve PNG for images with alpha, use JPEG otherwise
+ const hasAlpha = metadata.channels === 4 || metadata.hasAlpha
+ const resizedBuffer = hasAlpha
+ ? await sharp(buffer)
+ .resize(width, height, { fit: 'inside' })
+ .png()
+ .toBuffer()
+ : await sharp(buffer)
+ .resize(width, height, { fit: 'inside' })
+ .jpeg({ quality: 75 })
+ .toBuffer()
+
+ const mime = hasAlpha ? 'image/png' : 'image/jpeg'
+ const originalKB = Math.round(base64Data.length / 1024)
+ const resizedB64 = resizedBuffer.toString('base64')
+ const resizedKB = Math.round(resizedB64.length / 1024)
+ logger.debug('Resized image for Copilot', {
+ original: `${metadata.width}x${metadata.height} (${originalKB}KB)`,
+ resized: `${width}x${height} (${resizedKB}KB)`,
+ })
+
+ return `data:${mime};base64,${resizedB64}`
+}
diff --git a/packages/browseros-agent/apps/server/src/lib/clients/oauth/providers.ts b/packages/browseros-agent/apps/server/src/lib/clients/oauth/providers.ts
index d74dfba0a..d3d4c30d9 100644
--- a/packages/browseros-agent/apps/server/src/lib/clients/oauth/providers.ts
+++ b/packages/browseros-agent/apps/server/src/lib/clients/oauth/providers.ts
@@ -15,6 +15,7 @@ export interface OAuthProviderConfig {
scopes: string[]
extraAuthParams?: Record
upstreamLLMProvider: string
+ authFlow?: 'pkce' | 'device-code'
}
export const OAUTH_PROVIDERS: Record = {
@@ -32,6 +33,16 @@ export const OAUTH_PROVIDERS: Record = {
},
upstreamLLMProvider: 'openai',
},
+ 'github-copilot': {
+ id: 'github-copilot',
+ name: 'GitHub Copilot',
+ clientId: 'Ov23li8tweQw6odWQebz',
+ authEndpoint: EXTERNAL_URLS.GITHUB_DEVICE_CODE,
+ tokenEndpoint: EXTERNAL_URLS.GITHUB_OAUTH_TOKEN,
+ scopes: ['read:user'],
+ upstreamLLMProvider: 'github-copilot',
+ authFlow: 'device-code',
+ },
}
export function getOAuthProvider(
diff --git a/packages/browseros-agent/apps/server/src/lib/clients/oauth/token-manager.ts b/packages/browseros-agent/apps/server/src/lib/clients/oauth/token-manager.ts
index 803e65e17..704fa8ec9 100644
--- a/packages/browseros-agent/apps/server/src/lib/clients/oauth/token-manager.ts
+++ b/packages/browseros-agent/apps/server/src/lib/clients/oauth/token-manager.ts
@@ -25,6 +25,26 @@ interface OAuthTokenResponse {
id_token?: string
}
+export interface DeviceCodeResult {
+ userCode: string
+ verificationUri: string
+ expiresIn: number
+}
+
+interface GitHubDeviceCodeResponse {
+ device_code: string
+ user_code: string
+ verification_uri: string
+ expires_in: number
+ interval: number
+}
+
+interface GitHubTokenPollResponse {
+ access_token?: string
+ error?: string
+ interval?: number
+}
+
export class OAuthTokenManager {
private readonly pendingFlows = new Map()
private readonly refreshLocks = new Map<
@@ -37,6 +57,8 @@ export class OAuthTokenManager {
private readonly browserosId: string,
) {}
+ // --- PKCE flow (ChatGPT Plus/Pro) ---
+
async generateAuthorizationUrl(
providerId: string,
redirectBackUrl?: string,
@@ -138,16 +160,150 @@ export class OAuthTokenManager {
return { tokens, redirectBackUrl: flow.redirectBackUrl }
}
- // Mutex-protected refresh: concurrent callers share one in-flight refresh
+ // --- Device Code flow (GitHub Copilot) ---
+
+ private readonly activeDeviceFlows = new Set()
+
+ async startDeviceCodeFlow(providerId: string): Promise {
+ const provider = getOAuthProvider(providerId)
+ if (!provider) throw new Error(`Unknown OAuth provider: ${providerId}`)
+
+ // Cancel any existing flow — user may be retrying
+ this.activeDeviceFlows.delete(providerId)
+
+ // Request a device code from GitHub
+ const response = await fetch(provider.authEndpoint, {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ client_id: provider.clientId,
+ scope: provider.scopes.join(' '),
+ }),
+ })
+
+ if (!response.ok) {
+ throw new Error(`Failed to request device code: ${response.status}`)
+ }
+
+ const data = (await response.json()) as GitHubDeviceCodeResponse
+
+ // GitHub can return 200 with an error payload (e.g. invalid scope)
+ const dataObj = data as unknown as Record
+ if ('error' in dataObj) {
+ throw new Error(`GitHub device code error: ${dataObj.error}`)
+ }
+ if (!data.device_code || !data.user_code) {
+ throw new Error('Invalid device code response from GitHub')
+ }
+
+ // Start background polling with error handling
+ this.activeDeviceFlows.add(providerId)
+ this.pollDeviceCode(
+ providerId,
+ provider,
+ data.device_code,
+ data.interval,
+ data.expires_in,
+ ).finally(() => this.activeDeviceFlows.delete(providerId))
+
+ return {
+ userCode: data.user_code,
+ verificationUri: data.verification_uri,
+ expiresIn: data.expires_in,
+ }
+ }
+
+ private async pollDeviceCode(
+ providerId: string,
+ provider: ReturnType & {},
+ deviceCode: string,
+ initialInterval: number,
+ expiresIn: number,
+ ): Promise {
+ let interval = initialInterval
+ const deadline = Date.now() + expiresIn * 1000
+
+ while (Date.now() < deadline) {
+ // Wait before polling (interval + safety margin per OpenCode pattern)
+ await sleep(interval * 1000 + TIMEOUTS.DEVICE_CODE_POLL_SAFETY_MARGIN)
+
+ try {
+ const response = await fetch(provider.tokenEndpoint, {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ client_id: provider.clientId,
+ device_code: deviceCode,
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
+ }),
+ })
+
+ const data = (await response.json()) as GitHubTokenPollResponse
+
+ // Token received — store it and return
+ if (data.access_token) {
+ const tokens: StoredOAuthTokens = {
+ accessToken: data.access_token,
+ refreshToken: '',
+ expiresAt: 0,
+ email: undefined,
+ accountId: undefined,
+ }
+ this.store.upsertTokens(this.browserosId, providerId, tokens)
+ logger.info('Device code OAuth successful', { provider: providerId })
+ return
+ }
+
+ // Handle polling errors per RFC 8628
+ if (data.error === 'authorization_pending') continue
+ if (data.error === 'slow_down') {
+ interval = (data.interval ?? interval) + 5
+ continue
+ }
+ if (data.error === 'expired_token' || data.error === 'access_denied') {
+ logger.warn('Device code flow ended', {
+ provider: providerId,
+ error: data.error,
+ })
+ return
+ }
+
+ logger.warn('Unexpected device code poll response', {
+ provider: providerId,
+ error: data.error,
+ })
+ return
+ } catch (err) {
+ // Transient network error — loop continues to retry
+ logger.warn('Device code poll request failed, retrying', {
+ provider: providerId,
+ error: err instanceof Error ? err.message : String(err),
+ })
+ }
+ }
+
+ logger.warn('Device code flow timed out', { provider: providerId })
+ }
+
+ // --- Token refresh (PKCE providers only) ---
+
async refreshIfExpired(provider: string): Promise {
const tokens = this.store.getTokens(this.browserosId, provider)
if (!tokens) return null
+ // GitHub Copilot tokens never expire (expiresAt = 0)
+ if (tokens.expiresAt === 0) return tokens
+
if (Date.now() < tokens.expiresAt - TIMEOUTS.OAUTH_TOKEN_EXPIRY_BUFFER) {
return tokens
}
- // If a refresh is already in progress, await it instead of starting another
const existing = this.refreshLocks.get(provider)
if (existing) return existing
@@ -214,6 +370,12 @@ export class OAuthTokenManager {
return refreshed
}
+ // --- Shared ---
+
+ getTokens(provider: string): StoredOAuthTokens | null {
+ return this.store.getTokens(this.browserosId, provider)
+ }
+
getStatus(provider: string) {
return this.store.getStatus(this.browserosId, provider)
}
@@ -257,6 +419,10 @@ function base64UrlEncode(bytes: Uint8Array): string {
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
// Extracts claims without signature verification — safe because the token
// comes directly from OpenAI's HTTPS token endpoint. Do not reuse for
// caller-supplied or externally-sourced tokens.
diff --git a/packages/browseros-agent/biome.json b/packages/browseros-agent/biome.json
index d98bd072d..d414afc9b 100644
--- a/packages/browseros-agent/biome.json
+++ b/packages/browseros-agent/biome.json
@@ -1,5 +1,5 @@
{
- "$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
+ "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
diff --git a/packages/browseros-agent/bun.lock b/packages/browseros-agent/bun.lock
index 522881c9d..0327d26e2 100644
--- a/packages/browseros-agent/bun.lock
+++ b/packages/browseros-agent/bun.lock
@@ -6,7 +6,7 @@
"name": "browseros-monorepo",
"devDependencies": {
"@aws-sdk/client-s3": "^3.933.0",
- "@biomejs/biome": "2.4.5",
+ "@biomejs/biome": "2.4.8",
"@sentry/cli": "^2.42.2",
"@types/bun": "^1.3.5",
"@types/node": "^24.3.3",
@@ -207,6 +207,7 @@
"pino": "^9.6.0",
"posthog-node": "^4.17.0",
"puppeteer-core": "24.23.0",
+ "sharp": "^0.34.5",
"ws": "^8.18.0",
"zod": "^3.24.2",
"zod-from-json-schema": "^0.1.0",
@@ -450,23 +451,23 @@
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="],
- "@biomejs/biome": ["@biomejs/biome@2.4.5", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.5", "@biomejs/cli-darwin-x64": "2.4.5", "@biomejs/cli-linux-arm64": "2.4.5", "@biomejs/cli-linux-arm64-musl": "2.4.5", "@biomejs/cli-linux-x64": "2.4.5", "@biomejs/cli-linux-x64-musl": "2.4.5", "@biomejs/cli-win32-arm64": "2.4.5", "@biomejs/cli-win32-x64": "2.4.5" }, "bin": { "biome": "bin/biome" } }, "sha512-OWNCyMS0Q011R6YifXNOg6qsOg64IVc7XX6SqGsrGszPbkVCoaO7Sr/lISFnXZ9hjQhDewwZ40789QmrG0GYgQ=="],
+ "@biomejs/biome": ["@biomejs/biome@2.4.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.8", "@biomejs/cli-darwin-x64": "2.4.8", "@biomejs/cli-linux-arm64": "2.4.8", "@biomejs/cli-linux-arm64-musl": "2.4.8", "@biomejs/cli-linux-x64": "2.4.8", "@biomejs/cli-linux-x64-musl": "2.4.8", "@biomejs/cli-win32-arm64": "2.4.8", "@biomejs/cli-win32-x64": "2.4.8" }, "bin": { "biome": "bin/biome" } }, "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA=="],
- "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lGS4Nd5O3KQJ6TeWv10mElnx1phERhBxqGP/IKq0SvZl78kcWDFMaTtVK+w3v3lusRFxJY78n07PbKplirsU5g=="],
+ "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ=="],
- "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-6MoH4tyISIBNkZ2Q5T1R7dLd5BsITb2yhhhrU9jHZxnNSNMWl+s2Mxu7NBF8Y3a7JJcqq9nsk8i637z4gqkJxQ=="],
+ "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ=="],
- "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-U1GAG6FTjhAO04MyH4xn23wRNBkT6H7NentHh+8UxD6ShXKBm5SY4RedKJzkUThANxb9rUKIPc7B8ew9Xo/cWg=="],
+ "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig=="],
- "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-iqLDgpzobG7gpBF0fwEVS/LT8kmN7+S0E2YKFDtqliJfzNLnAiV2Nnyb+ehCDCJgAZBASkYHR2o60VQWikpqIg=="],
+ "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA=="],
- "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-NdODlSugMzTlENPTa4z0xB82dTUlCpsrOxc43///aNkTLblIYH4XpYflBbf5ySlQuP8AA4AZd1qXhV07IdrHdQ=="],
+ "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw=="],
- "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-NlKa7GpbQmNhZf9kakQeddqZyT7itN7jjWdakELeXyTU3pg/83fTysRRDPJD0akTfKDl6vZYNT9Zqn4MYZVBOA=="],
+ "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw=="],
- "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-EBfrTqRIWOFSd7CQb/0ttjHMR88zm3hGravnDwUA9wHAaCAYsULKDebWcN5RmrEo1KBtl/gDVJMrFjNR0pdGUw=="],
+ "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ=="],
- "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Pmhv9zT95YzECfjEHNl3mN9Vhusw9VA5KHY0ZvlGsxsjwS5cb7vpRnHzJIv0vG7jB0JI7xEaMH9ddfZm/RozBw=="],
+ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.8", "", { "os": "win32", "cpu": "x64" }, "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg=="],
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="],
diff --git a/packages/browseros-agent/package.json b/packages/browseros-agent/package.json
index a5cda1b16..151342fe7 100644
--- a/packages/browseros-agent/package.json
+++ b/packages/browseros-agent/package.json
@@ -45,7 +45,7 @@
"homepage": "https://github.com/browseros-ai/BrowserOS#readme",
"devDependencies": {
"@aws-sdk/client-s3": "^3.933.0",
- "@biomejs/biome": "2.4.5",
+ "@biomejs/biome": "2.4.8",
"@sentry/cli": "^2.42.2",
"@types/bun": "^1.3.5",
"@types/node": "^24.3.3",
diff --git a/packages/browseros-agent/packages/shared/src/constants/timeouts.ts b/packages/browseros-agent/packages/shared/src/constants/timeouts.ts
index 4436bf58f..0d0480ec2 100644
--- a/packages/browseros-agent/packages/shared/src/constants/timeouts.ts
+++ b/packages/browseros-agent/packages/shared/src/constants/timeouts.ts
@@ -54,6 +54,7 @@ export const TIMEOUTS = {
OAUTH_TOKEN_EXPIRY_BUFFER: 300_000,
OAUTH_POLL_INTERVAL: 2_000,
OAUTH_POLL_TIMEOUT: 300_000,
+ DEVICE_CODE_POLL_SAFETY_MARGIN: 3_000,
} as const
export type TimeoutKey = keyof typeof TIMEOUTS
diff --git a/packages/browseros-agent/packages/shared/src/constants/urls.ts b/packages/browseros-agent/packages/shared/src/constants/urls.ts
index 2995e0955..290b4b3eb 100644
--- a/packages/browseros-agent/packages/shared/src/constants/urls.ts
+++ b/packages/browseros-agent/packages/shared/src/constants/urls.ts
@@ -13,4 +13,7 @@ export const EXTERNAL_URLS = {
OPENAI_AUTH: 'https://auth.openai.com/oauth/authorize',
OPENAI_TOKEN: 'https://auth.openai.com/oauth/token',
SKILLS_CATALOG: 'https://cdn.browseros.com/skills/v1/catalog.json',
+ GITHUB_DEVICE_CODE: 'https://github.com/login/device/code',
+ GITHUB_OAUTH_TOKEN: 'https://github.com/login/oauth/access_token',
+ GITHUB_COPILOT_API: 'https://api.githubcopilot.com',
} as const
diff --git a/packages/browseros-agent/packages/shared/src/schemas/llm.ts b/packages/browseros-agent/packages/shared/src/schemas/llm.ts
index 01f2acb98..1b8bf5aea 100644
--- a/packages/browseros-agent/packages/shared/src/schemas/llm.ts
+++ b/packages/browseros-agent/packages/shared/src/schemas/llm.ts
@@ -25,6 +25,7 @@ export const LLM_PROVIDERS = {
OPENAI_COMPATIBLE: 'openai-compatible',
MOONSHOT: 'moonshot',
CHATGPT_PRO: 'chatgpt-pro',
+ GITHUB_COPILOT: 'github-copilot',
} as const
/**
@@ -44,6 +45,7 @@ export const LLMProviderSchema: z.ZodEnum<
'openai-compatible',
'moonshot',
'chatgpt-pro',
+ 'github-copilot',
]
> = z.enum([
LLM_PROVIDERS.ANTHROPIC,
@@ -58,6 +60,7 @@ export const LLMProviderSchema: z.ZodEnum<
LLM_PROVIDERS.OPENAI_COMPATIBLE,
LLM_PROVIDERS.MOONSHOT,
LLM_PROVIDERS.CHATGPT_PRO,
+ LLM_PROVIDERS.GITHUB_COPILOT,
])
export type LLMProvider = z.infer