@nanocollective/nanocoder
Version:
A local-first CLI coding agent that brings the power of agentic coding tools like Claude Code and Gemini CLI to local models or controlled APIs like OpenRouter
178 lines • 7.39 kB
JavaScript
/**
* GitHub Copilot subscription auth: device OAuth flow and token refresh.
* Uses the same approach as opencode-copilot-auth for compatibility.
* @see https://github.com/anomalyco/opencode-copilot-auth
*
* CLIENT_ID: VS Code Copilot OAuth client (Iv1.b507a08c87ecfe98). External
* dependency; changing it may break device flow compatibility.
*/
const GITHUB_COM = 'github.com';
const CLIENT_ID = 'Iv1.b507a08c87ecfe98';
/** Headers required by Copilot API; export for reuse in provider-factory. */
export const COPILOT_HEADERS = {
'User-Agent': 'GitHubCopilotChat/0.35.0',
'Editor-Version': 'vscode/1.107.0',
'Editor-Plugin-Version': 'copilot-chat/0.35.0',
'Copilot-Integration-Id': 'vscode-chat',
};
function normalizeDomain(url) {
return url.replace(/^https?:\/\//, '').replace(/\/$/, '');
}
export function getCopilotUrls(domain) {
const base = `https://${normalizeDomain(domain)}`;
return {
deviceCodeUrl: `${base}/login/device/code`,
accessTokenUrl: `${base}/login/oauth/access_token`,
copilotTokenUrl: domain === GITHUB_COM
? 'https://api.github.com/copilot_internal/v2/token'
: `https://api.${normalizeDomain(domain)}/copilot_internal/v2/token`,
};
}
/**
* Start GitHub device OAuth flow. User must visit verificationUri and enter userCode.
* @internal Used by runCopilotLoginFlow.
*/
async function startDeviceFlow(domain = GITHUB_COM, fetchFn = fetch) {
const urls = getCopilotUrls(domain);
const res = await fetchFn(urls.deviceCodeUrl, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': 'GitHubCopilotChat/0.35.0',
},
body: JSON.stringify({
client_id: CLIENT_ID,
scope: 'read:user',
}),
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Device code request failed (POST ${urls.deviceCodeUrl}): HTTP ${res.status} ${res.statusText}\nResponse: ${body}`);
}
const data = (await res.json());
return {
deviceCode: data.device_code,
userCode: data.user_code,
verificationUri: data.verification_uri,
interval: typeof data.interval === 'number' ? data.interval : 5,
};
}
/**
* Poll until user completes device flow; returns the GitHub OAuth access token
* (the `access_token` from the response). This token is stored as the credential
* and used to obtain short-lived Copilot API tokens via getCopilotAccessToken.
* @internal Used by runCopilotLoginFlow.
*/
const POLL_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
async function pollForOAuthToken(deviceCode, intervalSeconds, domain = GITHUB_COM, fetchFn = fetch) {
const urls = getCopilotUrls(domain);
const intervalMs = Math.max(intervalSeconds * 1000, 1000);
const deadline = Date.now() + POLL_TIMEOUT_MS;
// eslint-disable-next-line no-constant-condition
while (true) {
if (Date.now() > deadline) {
throw new Error('Device code flow timed out. Please run login again and complete the flow within 15 minutes.');
}
const res = await fetchFn(urls.accessTokenUrl, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': 'GitHubCopilotChat/0.35.0',
},
body: JSON.stringify({
client_id: CLIENT_ID,
device_code: deviceCode,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Token exchange failed: ${res.status} ${text}`);
}
const data = (await res.json());
if (data.access_token) {
return data.access_token;
}
if (data.error === 'authorization_pending') {
await new Promise(r => setTimeout(r, intervalMs));
continue;
}
if (data.error === 'expired_token') {
throw new Error('Device code expired. Please run login again.');
}
if (data.error) {
throw new Error(`Authorization failed: ${data.error}`);
}
await new Promise(r => setTimeout(r, intervalMs));
}
}
let cachedToken = null;
/**
* Get a short-lived access token for the Copilot API using the stored GitHub OAuth
* token (from device flow). Caches the result until close to expiry (5 min buffer).
* Cache key uses full token so different tokens never share an entry; key is only
* used in-process for lookup, not stored or logged.
*/
function tokenCacheKey(githubOAuthToken, domain) {
return `${domain}:${githubOAuthToken}`;
}
/**
* Clear the in-memory Copilot token cache so the next request fetches a new token.
* Use after credential change or logout so credentials can be invalidated without waiting for expiry.
*/
export function clearCopilotTokenCache() {
cachedToken = null;
}
export async function getCopilotAccessToken(githubOAuthToken, domain = GITHUB_COM, fetchFn = fetch) {
const cacheKey = tokenCacheKey(githubOAuthToken, domain);
const now = Date.now();
if (cachedToken?.key === cacheKey && cachedToken.result.expiresAt > now) {
return cachedToken.result;
}
const urls = getCopilotUrls(domain);
const res = await fetchFn(urls.copilotTokenUrl, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${githubOAuthToken}`,
...COPILOT_HEADERS,
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Copilot token refresh failed: ${res.status} ${text}`);
}
const data = (await res.json());
if (!data.token || typeof data.token !== 'string') {
throw new Error('Copilot token endpoint returned no token. Your Copilot subscription may not include chat access.');
}
const expiresAt = (data.expires_at ?? 0) * 1000 - 5 * 60 * 1000; // 5 min buffer
const result = { token: data.token, expiresAt };
cachedToken = { key: cacheKey, result };
return result;
}
export function getCopilotBaseUrl(domain) {
if (domain === GITHUB_COM) {
return 'https://api.githubcopilot.com';
}
return `https://copilot-api.${normalizeDomain(domain)}`;
}
/**
* Run the full Copilot device-flow login: start flow, show code via callback, poll for token, save credential.
* Shared by CLI (cli.tsx) and in-chat UI (copilot-login.tsx) so the flow logic lives in one place.
*/
export async function runCopilotLoginFlow(providerName, options) {
const { onShowCode, onPollingStart, delayBeforePollMs = 0, domain = GITHUB_COM, fetchFn = fetch, } = options;
const { saveCopilotCredential } = await import('../config/copilot-credentials.js');
const flow = await startDeviceFlow(domain, fetchFn);
onShowCode(flow.verificationUri, flow.userCode);
if (delayBeforePollMs > 0) {
await new Promise(r => setTimeout(r, delayBeforePollMs));
}
onPollingStart?.();
const oauthToken = await pollForOAuthToken(flow.deviceCode, flow.interval, domain, fetchFn);
saveCopilotCredential(providerName, oauthToken);
}
//# sourceMappingURL=github-copilot.js.map