diff --git a/codex-cli/src/cli.tsx b/codex-cli/src/cli.tsx index c7e5d9ff31..9f5b69e025 100644 --- a/codex-cli/src/cli.tsx +++ b/codex-cli/src/cli.tsx @@ -39,6 +39,7 @@ import { } from "./utils/config"; import { getApiKey as fetchApiKey, + getGithubCopilotApiKey as fetchGithubCopilotApiKey, maybeRedeemCredits, } from "./utils/get-api-key"; import { createInputItem } from "./utils/input-utils"; @@ -322,13 +323,43 @@ try { if (data.OPENAI_API_KEY && !expired) { apiKey = data.OPENAI_API_KEY; } + if ( + data.GITHUBCOPILOT_API_KEY && + provider.toLowerCase() === "githubcopilot" + ) { + apiKey = data.GITHUBCOPILOT_API_KEY; + } } } catch { // ignore errors } -if (cli.flags.login) { - apiKey = await fetchApiKey(client.issuer, client.client_id); +if (provider.toLowerCase() === "githubcopilot" && !apiKey) { + apiKey = await fetchGithubCopilotApiKey(); + try { + const home = os.homedir(); + const authDir = path.join(home, ".codex"); + const authFile = path.join(authDir, "auth.json"); + fs.writeFileSync( + authFile, + JSON.stringify( + { + GITHUBCOPILOT_API_KEY: apiKey, + }, + null, + 2, + ), + "utf-8", + ); + } catch { + /* ignore */ + } +} else if (cli.flags.login) { + if (provider.toLowerCase() === "githubcopilot") { + apiKey = await fetchGithubCopilotApiKey(); + } else { + apiKey = await fetchApiKey(client.issuer, client.client_id); + } try { const home = os.homedir(); const authDir = path.join(home, ".codex"); @@ -341,10 +372,14 @@ if (cli.flags.login) { /* ignore */ } } else if (!apiKey) { - apiKey = await fetchApiKey(client.issuer, client.client_id); + if (provider.toLowerCase() === "githubcopilot") { + apiKey = await fetchGithubCopilotApiKey(); + } else { + apiKey = await fetchApiKey(client.issuer, client.client_id); + } } // Ensure the API key is available as an environment variable for legacy code -process.env["OPENAI_API_KEY"] = apiKey; +process.env[`${provider.toUpperCase()}_API_KEY`] = apiKey; if (cli.flags.free) { // eslint-disable-next-line no-console diff --git a/codex-cli/src/utils/agent/agent-loop.ts b/codex-cli/src/utils/agent/agent-loop.ts index cc57239b40..137887f7ae 100644 --- a/codex-cli/src/utils/agent/agent-loop.ts +++ b/codex-cli/src/utils/agent/agent-loop.ts @@ -31,6 +31,7 @@ import { } from "../session.js"; import { applyPatchToolInstructions } from "./apply-patch.js"; import { handleExecCommand } from "./handle-exec-command.js"; +import { GithubCopilotClient } from "../openai-client.js"; import { HttpsProxyAgent } from "https-proxy-agent"; import { spawnSync } from "node:child_process"; import { randomUUID } from "node:crypto"; @@ -350,6 +351,24 @@ export class AgentLoop { }); } + if (this.provider.toLowerCase() === "githubcopilot") { + this.oai = new GithubCopilotClient({ + ...(apiKey ? { apiKey } : {}), + baseURL, + defaultHeaders: { + originator: ORIGIN, + version: CLI_VERSION, + session_id: this.sessionId, + ...(OPENAI_ORGANIZATION + ? { "OpenAI-Organization": OPENAI_ORGANIZATION } + : {}), + ...(OPENAI_PROJECT ? { "OpenAI-Project": OPENAI_PROJECT } : {}), + }, + httpAgent: PROXY_URL ? new HttpsProxyAgent(PROXY_URL) : undefined, + ...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}), + }); + } + setSessionId(this.sessionId); setCurrentModel(this.model); diff --git a/codex-cli/src/utils/agent/exec.ts b/codex-cli/src/utils/agent/exec.ts index 1f56dde1e3..579fbb70d3 100644 --- a/codex-cli/src/utils/agent/exec.ts +++ b/codex-cli/src/utils/agent/exec.ts @@ -120,6 +120,7 @@ export function execApplyPatch( stdout: result, stderr: "", exitCode: 0, + pid: 0, }; } catch (error: unknown) { // @ts-expect-error error might not be an object or have a message property. @@ -128,6 +129,7 @@ export function execApplyPatch( stdout: "", stderr: stderr, exitCode: 1, + pid: 0, }; } } diff --git a/codex-cli/src/utils/agent/sandbox/interface.ts b/codex-cli/src/utils/agent/sandbox/interface.ts index d0237c6e55..54cf5fa75a 100644 --- a/codex-cli/src/utils/agent/sandbox/interface.ts +++ b/codex-cli/src/utils/agent/sandbox/interface.ts @@ -18,6 +18,8 @@ export type ExecResult = { stdout: string; stderr: string; exitCode: number; + /** PID of the spawned process. 0 if spawn failed */ + pid: number; }; /** diff --git a/codex-cli/src/utils/agent/sandbox/raw-exec.ts b/codex-cli/src/utils/agent/sandbox/raw-exec.ts index 9e7ce41b32..5e152a670d 100644 --- a/codex-cli/src/utils/agent/sandbox/raw-exec.ts +++ b/codex-cli/src/utils/agent/sandbox/raw-exec.ts @@ -41,6 +41,7 @@ export function exec( stdout: "", stderr: "command[0] is not a string", exitCode: 1, + pid: 0, }); } @@ -124,7 +125,7 @@ export function exec( if (!child.killed) { killTarget("SIGKILL"); } - }, 2000).unref(); + }, 250).unref(); }; if (abortSignal.aborted) { abortHandler(); @@ -186,6 +187,7 @@ export function exec( stdout, stderr, exitCode, + pid: child.pid ?? 0, }; resolve( addTruncationWarningsIfNecessary( @@ -201,6 +203,7 @@ export function exec( stdout: "", stderr: String(err), exitCode: 1, + pid: child.pid ?? 0, }; resolve( addTruncationWarningsIfNecessary( @@ -224,7 +227,7 @@ function addTruncationWarningsIfNecessary( if (!hitMaxStdout && !hitMaxStderr) { return execResult; } else { - const { stdout, stderr, exitCode } = execResult; + const { stdout, stderr, exitCode, pid } = execResult; return { stdout: hitMaxStdout ? stdout + "\n\n[Output truncated: too many lines or bytes]" @@ -233,6 +236,7 @@ function addTruncationWarningsIfNecessary( ? stderr + "\n\n[Output truncated: too many lines or bytes]" : stderr, exitCode, + pid, }; } } diff --git a/codex-cli/src/utils/get-api-key.tsx b/codex-cli/src/utils/get-api-key.tsx index 520f92efdd..e102aef36d 100644 --- a/codex-cli/src/utils/get-api-key.tsx +++ b/codex-cli/src/utils/get-api-key.tsx @@ -2,10 +2,12 @@ import type { Choice } from "./get-api-key-components"; import type { Request, Response } from "express"; import { ApiKeyPrompt, WaitingForAuth } from "./get-api-key-components"; +import { GithubCopilotClient } from "./openai-client.js"; +import Spinner from "../components/vendor/ink-spinner.js"; import chalk from "chalk"; import express from "express"; import fs from "fs/promises"; -import { render } from "ink"; +import { Box, Text, render } from "ink"; import crypto from "node:crypto"; import { URL } from "node:url"; import open from "open"; @@ -763,4 +765,29 @@ export async function getApiKey( } } +export async function getGithubCopilotApiKey(): Promise { + const { device_code, user_code, verification_uri } = + await GithubCopilotClient.getLoginURL(); + const spinner = render( + + + + {" "} + Please visit {verification_uri} and enter code {user_code} + + , + ); + try { + const key = await GithubCopilotClient.pollForAccessToken(device_code); + spinner.clear(); + spinner.unmount(); + process.env["GITHUBCOPILOT_API_KEY"] = key; + return key; + } catch (err) { + spinner.clear(); + spinner.unmount(); + throw err; + } +} + export { maybeRedeemCredits }; diff --git a/codex-cli/src/utils/openai-client.ts b/codex-cli/src/utils/openai-client.ts index fb8117fed0..0bb850c3bc 100644 --- a/codex-cli/src/utils/openai-client.ts +++ b/codex-cli/src/utils/openai-client.ts @@ -1,4 +1,6 @@ import type { AppConfig } from "./config.js"; +import type { ClientOptions } from "openai"; +import type * as Core from "openai/core"; import { getBaseUrl, @@ -9,6 +11,7 @@ import { OPENAI_PROJECT, } from "./config.js"; import OpenAI, { AzureOpenAI } from "openai"; +import * as Errors from "openai/error"; type OpenAIClientConfig = { provider: string; @@ -42,6 +45,15 @@ export function createOpenAIClient( }); } + if (config.provider?.toLowerCase() === "githubcopilot") { + return new GithubCopilotClient({ + apiKey: getApiKey(config.provider), + baseURL: getBaseUrl(config.provider), + timeout: OPENAI_TIMEOUT_MS, + defaultHeaders: headers, + }); + } + return new OpenAI({ apiKey: getApiKey(config.provider), baseURL: getBaseUrl(config.provider), @@ -49,3 +61,150 @@ export function createOpenAIClient( defaultHeaders: headers, }); } + +export class GithubCopilotClient extends OpenAI { + private copilotToken: string | null = null; + private copilotTokenExpiration = new Date(); + private githubAPIKey: string; + + constructor(opts: ClientOptions = {}) { + super(opts); + if (!opts.apiKey) { + throw new Errors.OpenAIError("missing github copilot token"); + } + this.githubAPIKey = opts.apiKey; + } + + private async _getGithubCopilotToken(): Promise { + if ( + this.copilotToken && + this.copilotTokenExpiration.getTime() > Date.now() + ) { + return this.copilotToken; + } + const resp = await fetch( + "https://api.github.com/copilot_internal/v2/token", + { + method: "GET", + headers: GithubCopilotClient._mergeGithubHeaders({ + "Authorization": `bearer ${this.githubAPIKey}`, + "Accept": "application/json", + "Content-Type": "application/json", + }), + }, + ); + if (!resp.ok) { + const text = await resp.text(); + throw new Error("unable to get github copilot auth token: " + text); + } + const text = await resp.text(); + const { token, refresh_in } = JSON.parse(text); + if (typeof token !== "string" || typeof refresh_in !== "number") { + throw new Errors.OpenAIError( + `unexpected response from copilot auth: ${text}`, + ); + } + this.copilotToken = token; + this.copilotTokenExpiration = new Date(Date.now() + refresh_in * 1000); + return token; + } + + protected override authHeaders( + _opts: Core.FinalRequestOptions, + ): Core.Headers { + return {}; + } + + protected override async prepareOptions( + opts: Core.FinalRequestOptions, + ): Promise { + const token = await this._getGithubCopilotToken(); + opts.headers ??= {}; + if (token) { + opts.headers["Authorization"] = `Bearer ${token}`; + opts.headers = GithubCopilotClient._mergeGithubHeaders(opts.headers); + } else { + throw new Errors.OpenAIError("Unable to handle auth"); + } + return super.prepareOptions(opts); + } + + static async getLoginURL(): Promise<{ + device_code: string; + user_code: string; + verification_uri: string; + }> { + const resp = await fetch("https://github.com/login/device/code", { + method: "POST", + headers: this._mergeGithubHeaders({ + "Content-Type": "application/json", + "accept": "application/json", + }), + body: JSON.stringify({ + client_id: "Iv1.b507a08c87ecfe98", + scope: "read:user", + }), + }); + if (!resp.ok) { + const text = await resp.text(); + throw new Errors.OpenAIError("Unable to get login device code: " + text); + } + return resp.json(); + } + + static async pollForAccessToken(deviceCode: string): Promise { + /*eslint no-await-in-loop: "off"*/ + const MAX_ATTEMPTS = 36; + let lastErr: unknown = null; + for (let i = 0; i < MAX_ATTEMPTS; ++i) { + try { + const resp = await fetch( + "https://github.com/login/oauth/access_token", + { + method: "POST", + headers: this._mergeGithubHeaders({ + "Content-Type": "application/json", + "accept": "application/json", + }), + body: JSON.stringify({ + client_id: "Iv1.b507a08c87ecfe98", + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }, + ); + if (!resp.ok) { + continue; + } + const info = await resp.json(); + if (info.access_token) { + return info.access_token as string; + } else if (info.error === "authorization_pending") { + lastErr = null; + } else { + throw new Errors.OpenAIError( + "unexpected response when polling for access token: " + + JSON.stringify(info), + ); + } + } catch (err) { + lastErr = err; + } + await new Promise((resolve) => setTimeout(resolve, 5_000)); + } + throw new Errors.OpenAIError( + "timed out waiting for access token", + lastErr != null ? { cause: lastErr } : {}, + ); + } + + private static _mergeGithubHeaders< + T extends Core.Headers | Record, + >(headers: T): T { + const copy = { ...headers } as Record & T; + copy["User-Agent"] = "GithubCopilot/1.155.0"; + copy["editor-version"] = "vscode/1.85.1"; + copy["editor-plugin-version"] = "copilot/1.155.0"; + return copy as T; + } +} diff --git a/codex-cli/src/utils/providers.ts b/codex-cli/src/utils/providers.ts index 2fa85377ce..88da52097e 100644 --- a/codex-cli/src/utils/providers.ts +++ b/codex-cli/src/utils/providers.ts @@ -52,4 +52,9 @@ export const providers: Record< baseURL: "https://conductor.arcee.ai/v1", envKey: "ARCEEAI_API_KEY", }, + githubcopilot: { + name: "GithubCopilot", + baseURL: "https://api.githubcopilot.com", + envKey: "GITHUBCOPILOT_API_KEY", + }, }; diff --git a/codex-cli/tests/cancel-exec.test.ts b/codex-cli/tests/cancel-exec.test.ts index 86ff15d0ee..23631feb13 100644 --- a/codex-cli/tests/cancel-exec.test.ts +++ b/codex-cli/tests/cancel-exec.test.ts @@ -25,6 +25,7 @@ describe("exec cancellation", () => { abortController.abort(); const result = await promise; + expect(result.pid).toBeGreaterThan(0); const durationMs = Date.now() - start; // The process should have been terminated rapidly (well under the 5s the @@ -49,6 +50,7 @@ describe("exec cancellation", () => { const cmd = ["node", "-e", "console.log('finished')"]; const result = await rawExec(cmd, {}, config, abortController.signal); + expect(result.pid).toBeGreaterThan(0); expect(result.exitCode).toBe(0); expect(result.stdout.trim()).toBe("finished"); diff --git a/codex-cli/tests/invalid-command-handling.test.ts b/codex-cli/tests/invalid-command-handling.test.ts index c36f8aea29..cd0e129e0b 100644 --- a/codex-cli/tests/invalid-command-handling.test.ts +++ b/codex-cli/tests/invalid-command-handling.test.ts @@ -11,6 +11,7 @@ describe("rawExec – invalid command handling", () => { const cmd = ["definitely-not-a-command-1234567890"]; const config = { model: "any", instructions: "" } as AppConfig; const result = await rawExec(cmd, {}, config); + expect(result.pid).toBe(0); expect(result.exitCode).not.toBe(0); expect(result.stderr.length).toBeGreaterThan(0); diff --git a/codex-cli/tests/raw-exec-process-group.test.ts b/codex-cli/tests/raw-exec-process-group.test.ts index 11db40116b..16249d7ef9 100644 --- a/codex-cli/tests/raw-exec-process-group.test.ts +++ b/codex-cli/tests/raw-exec-process-group.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import fs from "fs"; import { exec as rawExec } from "../src/utils/agent/sandbox/raw-exec.js"; import type { AppConfig } from "src/utils/config.js"; @@ -37,7 +38,11 @@ describe("rawExec – abort kills entire process group", () => { // - spawns a background `sleep 30` // - prints the PID of the `sleep` // - waits for `sleep` to exit - const { stdout, exitCode } = await (async () => { + const { + stdout, + exitCode, + pid: rootPid, + } = await (async () => { const p = rawExec(cmd, {}, config, abortController.signal); // Give Bash a tiny bit of time to start and print the PID. @@ -52,6 +57,7 @@ describe("rawExec – abort kills entire process group", () => { // We expect a non‑zero exit code because the process was killed. expect(exitCode).not.toBe(0); + expect(rootPid).toBeGreaterThan(0); // Extract the PID of the sleep process that bash printed const pid = Number(stdout.trim().match(/^\d+/)?.[0]); @@ -68,11 +74,19 @@ describe("rawExec – abort kills entire process group", () => { * @throws {Error} If the process is still alive after 500ms */ async function ensureProcessGone(pid: number) { - const timeout = 500; + const timeout = 1000; const deadline = Date.now() + timeout; while (Date.now() < deadline) { try { process.kill(pid, 0); // check if process still exists + try { + const stat = await fs.promises.readFile(`/proc/${pid}/stat`, "utf8"); + if (stat.split(" ")[2] === "Z") { + return; // zombie processes are effectively dead + } + } catch { + /* ignore */ + } await new Promise((r) => setTimeout(r, 50)); // wait and retry } catch (e: any) { if (e.code === "ESRCH") { @@ -81,6 +95,23 @@ async function ensureProcessGone(pid: number) { throw e; // unexpected error — rethrow } } + try { + process.kill(pid, "SIGKILL"); + } catch { + /* ignore */ + } + const extraDeadline = Date.now() + 250; + while (Date.now() < extraDeadline) { + try { + process.kill(pid, 0); + await new Promise((r) => setTimeout(r, 50)); + } catch (e: any) { + if (e.code === "ESRCH") { + return; + } + throw e; + } + } throw new Error( `Process with PID ${pid} failed to terminate within ${timeout}ms`, );