mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-20 03:05:24 +00:00
fix(cli): read the Copilot plugin token from %LOCALAPPDATA% on Windows
The quota adapter only ever looked at ~/.config/github-copilot. Windows has no such directory: the editor plugins write hosts.json and apps.json under %LOCALAPPDATA%\github-copilot, so every signed-in Windows user was reported as not connected and the Capacity Dock had nothing to show for Copilot. The credential paths become an ordered list of directories resolved per platform, and per call rather than at import, so the environment the paths come from can still be changed by the caller. The XDG directory stays as a second candidate on Windows for a token that arrived through a POSIX-style shell home.
This commit is contained in:
parent
3551e2177e
commit
f2224b5a70
2 changed files with 163 additions and 18 deletions
|
|
@ -7,8 +7,10 @@
|
|||
// normal connection states and never crash the panel.
|
||||
//
|
||||
// Credential: the GitHub OAuth token already on disk from a signed-in Copilot
|
||||
// plugin - ~/.config/github-copilot/hosts.json (keyed by host) falling back to
|
||||
// apps.json (keyed by app name). Read-only; no new storage.
|
||||
// plugin - hosts.json (keyed by host) falling back to apps.json (keyed by app
|
||||
// name). The plugins write those under ~/.config/github-copilot on macOS and
|
||||
// Linux but under %LOCALAPPDATA%\github-copilot on Windows, so the directory
|
||||
// is resolved per platform. Read-only; no new storage.
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
|
|
@ -26,18 +28,37 @@ const HEADERS = {
|
|||
|
||||
type HostRecord = Record<string, any> & { oauth_token?: unknown }
|
||||
|
||||
const CREDENTIAL_FILES = ['hosts.json', 'apps.json'] as const
|
||||
|
||||
export type CopilotDeps = {
|
||||
fetch: typeof fetch
|
||||
hostsPath: string
|
||||
appsPath: string
|
||||
/** Ordered credential directories; the first file that yields a token wins. */
|
||||
configDirs: string[]
|
||||
readFile: typeof readSecureFile
|
||||
}
|
||||
|
||||
const defaults: CopilotDeps = {
|
||||
fetch: globalThis.fetch,
|
||||
hostsPath: path.join(os.homedir(), '.config', 'github-copilot', 'hosts.json'),
|
||||
appsPath: path.join(os.homedir(), '.config', 'github-copilot', 'apps.json'),
|
||||
readFile: readSecureFile,
|
||||
/**
|
||||
* Where the signed-in Copilot plugins keep hosts.json / apps.json. Windows has
|
||||
* no ~/.config: the plugins follow the platform convention and write under
|
||||
* %LOCALAPPDATA%. The XDG path stays as a second candidate there, because a
|
||||
* token copied in from a POSIX-style shell (Git Bash, an MSYS home) lands in
|
||||
* it and is just as valid.
|
||||
*/
|
||||
export function copilotConfigDirs(
|
||||
platform: string = process.platform,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
home: string = os.homedir(),
|
||||
): string[] {
|
||||
const xdg = path.join(home, '.config', 'github-copilot')
|
||||
if (platform !== 'win32') return [xdg]
|
||||
const localAppData = env['LOCALAPPDATA']?.trim() || path.join(home, 'AppData', 'Local')
|
||||
return [path.join(localAppData, 'github-copilot'), xdg]
|
||||
}
|
||||
|
||||
// Resolved per call rather than once at import so a caller (and the tests) can
|
||||
// change the platform or the environment the paths are derived from.
|
||||
function defaultDeps(): CopilotDeps {
|
||||
return { fetch: globalThis.fetch, configDirs: copilotConfigDirs(), readFile: readSecureFile }
|
||||
}
|
||||
|
||||
function empty(connection: QuotaProvider['connection']): QuotaProvider {
|
||||
|
|
@ -54,14 +75,16 @@ function tokenFromMap(raw: string): string | null {
|
|||
}
|
||||
|
||||
async function credentialFromFiles(deps: CopilotDeps): Promise<string | null> {
|
||||
for (const filePath of [deps.hostsPath, deps.appsPath]) {
|
||||
try {
|
||||
const raw = await deps.readFile(filePath, 64 * 1024)
|
||||
if (!raw) continue
|
||||
const token = tokenFromMap(raw)
|
||||
if (token) return token
|
||||
} catch {
|
||||
// A malformed or unreadable file falls through to the next candidate.
|
||||
for (const dir of deps.configDirs) {
|
||||
for (const name of CREDENTIAL_FILES) {
|
||||
try {
|
||||
const raw = await deps.readFile(path.join(dir, name), 64 * 1024)
|
||||
if (!raw) continue
|
||||
const token = tokenFromMap(raw)
|
||||
if (token) return token
|
||||
} catch {
|
||||
// A malformed or unreadable file falls through to the next candidate.
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
|
|
@ -117,7 +140,7 @@ async function request(token: string, deps: CopilotDeps, parent?: AbortSignal):
|
|||
export type CopilotResult = { quota: QuotaProvider; retryAfterSeconds?: number }
|
||||
|
||||
export async function fetchCopilotQuota(options: Partial<CopilotDeps> & { signal?: AbortSignal } = {}): Promise<CopilotResult> {
|
||||
const deps = { ...defaults, ...options }
|
||||
const deps = { ...defaultDeps(), ...options }
|
||||
try {
|
||||
let token = await credentialFromFiles(deps)
|
||||
if (!token) return { quota: empty('disconnected') }
|
||||
|
|
|
|||
122
tests/quota-copilot-windows.test.ts
Normal file
122
tests/quota-copilot-windows.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// The Copilot quota adapter used to look only at ~/.config/github-copilot,
|
||||
// which does not exist on Windows: the plugins write hosts.json / apps.json
|
||||
// under %LOCALAPPDATA%\github-copilot there, so a signed-in Windows user read
|
||||
// as "not connected". These tests pin the per-platform directory order and
|
||||
// prove the Windows path is actually read.
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { copilotConfigDirs, fetchCopilotQuota } from '../src/quota/copilot.js'
|
||||
|
||||
const realPlatform = process.platform
|
||||
|
||||
function setPlatform(value: string): void {
|
||||
Object.defineProperty(process, 'platform', { value, configurable: true })
|
||||
}
|
||||
|
||||
function usageResponse(): Response {
|
||||
return new Response(
|
||||
JSON.stringify({ copilot_plan: 'individual', quota_snapshots: { premium_interactions: { percent_remaining: 70 } } }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}
|
||||
|
||||
let root: string
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(os.tmpdir(), 'codeburn-copilot-win-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
setPlatform(realPlatform)
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('Copilot credential directories', () => {
|
||||
it('prefers %LOCALAPPDATA% on Windows and keeps the XDG path as a fallback', () => {
|
||||
const dirs = copilotConfigDirs('win32', { LOCALAPPDATA: 'C:\\Users\\dev\\AppData\\Local' }, 'C:\\Users\\dev')
|
||||
expect(dirs).toEqual([
|
||||
path.join('C:\\Users\\dev\\AppData\\Local', 'github-copilot'),
|
||||
path.join('C:\\Users\\dev', '.config', 'github-copilot'),
|
||||
])
|
||||
})
|
||||
|
||||
it('derives the Windows directory from the home folder when LOCALAPPDATA is unset', () => {
|
||||
const dirs = copilotConfigDirs('win32', {}, 'C:\\Users\\dev')
|
||||
expect(dirs[0]).toBe(path.join('C:\\Users\\dev', 'AppData', 'Local', 'github-copilot'))
|
||||
})
|
||||
|
||||
it('reads only the XDG path off Windows', () => {
|
||||
expect(copilotConfigDirs('darwin', { LOCALAPPDATA: 'C:\\ignored' }, '/Users/dev'))
|
||||
.toEqual([path.join('/Users/dev', '.config', 'github-copilot')])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Copilot quota on Windows', () => {
|
||||
it('finds the plugin token under %LOCALAPPDATA% with no explicit path', async () => {
|
||||
await mkdir(path.join(root, 'github-copilot'), { recursive: true })
|
||||
await writeFile(
|
||||
path.join(root, 'github-copilot', 'hosts.json'),
|
||||
JSON.stringify({ 'github.com': { oauth_token: 'gho_windowsfixture' } }),
|
||||
'utf8',
|
||||
)
|
||||
setPlatform('win32')
|
||||
process.env['LOCALAPPDATA'] = root
|
||||
|
||||
const seen: string[] = []
|
||||
const result = await fetchCopilotQuota({
|
||||
fetch: (async (_url: string, init: RequestInit) => {
|
||||
seen.push(String((init.headers as Record<string, string>)['Authorization']))
|
||||
return usageResponse()
|
||||
}) as unknown as typeof fetch,
|
||||
})
|
||||
|
||||
expect(result.quota.connection).toBe('connected')
|
||||
expect(result.quota.primary).toEqual({ label: 'Premium requests', percent: 0.3, resetsAt: null })
|
||||
expect(seen).toEqual(['token gho_windowsfixture'])
|
||||
})
|
||||
|
||||
it('falls back to apps.json in the same directory', async () => {
|
||||
await mkdir(path.join(root, 'github-copilot'), { recursive: true })
|
||||
await writeFile(path.join(root, 'github-copilot', 'hosts.json'), '{ not json', 'utf8')
|
||||
await writeFile(
|
||||
path.join(root, 'github-copilot', 'apps.json'),
|
||||
JSON.stringify({ 'github.com:Iv1.abc': { oauth_token: 'gho_appsfixture' } }),
|
||||
'utf8',
|
||||
)
|
||||
setPlatform('win32')
|
||||
process.env['LOCALAPPDATA'] = root
|
||||
|
||||
const seen: string[] = []
|
||||
const result = await fetchCopilotQuota({
|
||||
fetch: (async (_url: string, init: RequestInit) => {
|
||||
seen.push(String((init.headers as Record<string, string>)['Authorization']))
|
||||
return usageResponse()
|
||||
}) as unknown as typeof fetch,
|
||||
})
|
||||
|
||||
expect(result.quota.connection).toBe('connected')
|
||||
expect(seen).toEqual(['token gho_appsfixture'])
|
||||
})
|
||||
|
||||
it('ignores the Windows directory on other platforms and never fetches', async () => {
|
||||
await mkdir(path.join(root, 'github-copilot'), { recursive: true })
|
||||
await writeFile(
|
||||
path.join(root, 'github-copilot', 'hosts.json'),
|
||||
JSON.stringify({ 'github.com': { oauth_token: 'gho_windowsfixture' } }),
|
||||
'utf8',
|
||||
)
|
||||
setPlatform('darwin')
|
||||
process.env['LOCALAPPDATA'] = root
|
||||
|
||||
const result = await fetchCopilotQuota({
|
||||
fetch: (() => { throw new Error('the test must not reach the network') }) as unknown as typeof fetch,
|
||||
configDirs: copilotConfigDirs('darwin', process.env, root),
|
||||
})
|
||||
|
||||
expect(result.quota.connection).toBe('disconnected')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue