import { describe, expect, test } from "bun:test" import { CodexAuthExpiredError, refreshCodexAuth } from "../../src/kilocode/provider/codex-refresh" import { MessageV2 } from "../../src/session/message-v2" import { ProviderID } from "../../src/provider/schema" import { spawn } from "child_process" import fs from "fs/promises" import os from "os" import path from "path" type Auth = { type: "oauth" refresh: string access: string expires: number accountId?: string } type Lock = { staleMs: number timeoutMs: number baseDelayMs: number maxDelayMs: number } const expired = (): Auth => ({ type: "oauth", access: "old-access", refresh: "old-refresh", expires: 0, }) const root = path.join(import.meta.dir, "../..") const worker = path.join(import.meta.dir, "fixture/codex-auth-refresh-worker.ts") function plugin(persist: (auth: Auth) => void) { const set = async (req: { body: Auth }) => { persist(req.body) } return { client: { auth: { set }, }, } } function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } async function wait(file: string) { const stop = Date.now() + 10_000 while (Date.now() < stop) { if ( await fs .stat(file) .then(() => true) .catch(() => false) ) return await sleep(10) } throw new Error(`Timed out waiting for file: ${file}`) } function run(input: { root: string; url: string; ready: string; start: string; lock?: Lock }) { const proc = spawn(process.execPath, [worker, JSON.stringify(input)], { cwd: root, windowsHide: true, }) const stdout: Buffer[] = [] const stderr: Buffer[] = [] proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data))) proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data))) return { proc, done: new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => { proc.on("close", (code) => { resolve({ code: code ?? 1, stdout: Buffer.concat(stdout).toString(), stderr: Buffer.concat(stderr).toString(), }) }) }), } } async function race(input: { reuse: "early" | "late"; delay: number; lock?: Lock }) { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-auth-refresh-")) const calls: string[] = [] const used = new Set() const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(req) { const body = new URLSearchParams(await req.text()) const token = body.get("refresh_token") if (!token) return new Response("missing refresh token", { status: 400 }) calls.push(token) if (used.has(token)) { if (input.reuse === "late") await sleep(input.delay + 50) return new Response("refresh token reused", { status: 401 }) } used.add(token) await sleep(input.delay) return Response.json({ id_token: "", access_token: "next-access", refresh_token: "next-refresh", expires_in: 60, }) }, }) try { const data = path.join(dir, "share", "kilo") const start = path.join(dir, "start") const first = path.join(dir, "first") const second = path.join(dir, "second") await fs.mkdir(data, { recursive: true }) await fs.writeFile(path.join(data, "auth.json"), JSON.stringify({ openai: expired() })) const url = `http://127.0.0.1:${server.port}/oauth/token` const a = run({ root: dir, url, ready: first, start, lock: input.lock }) const b = run({ root: dir, url, ready: second, start, lock: input.lock }) try { await Promise.all([wait(first), wait(second)]) await fs.writeFile(start, "") const out = await Promise.all([a.done, b.done]) return { calls, out } } finally { if (a.proc.exitCode === null) a.proc.kill() if (b.proc.exitCode === null) b.proc.kill() } } finally { await server.stop(true) await fs.rm(dir, { recursive: true, force: true }) } } describe("Codex auth refresh", () => { test("serializes expired Codex auth as ProviderAuthError", () => { const result = MessageV2.fromError(new CodexAuthExpiredError(), { providerID: ProviderID.make("openai") }) expect(result).toStrictEqual({ name: "ProviderAuthError", data: { providerID: "openai", message: "Your ChatGPT sign-in expired or was revoked. Sign in with ChatGPT again to continue using Codex models.", }, }) }) test("coalesces concurrent refreshes and persists rotated tokens", async () => { const calls: string[] = [] const writes: Auth[] = [] const first = expired() const second = expired() const refresh = async (token: string) => { calls.push(token) await new Promise((resolve) => setTimeout(resolve, 1)) return { id_token: "", access_token: "next-access", refresh_token: "next-refresh", expires_in: 60 } } const [a, b] = await Promise.all([ refreshCodexAuth({ input: plugin((auth) => writes.push(auth)), getAuth: async () => first, auth: first, refresh, account: () => undefined, }), refreshCodexAuth({ input: plugin((auth) => writes.push(auth)), getAuth: async () => second, auth: second, refresh, account: () => undefined, }), ]) expect(calls).toEqual(["old-refresh"]) expect(writes).toHaveLength(1) expect(a.access).toBe("next-access") expect(b.refresh).toBe("next-refresh") expect(first.access).toBe("next-access") expect(second.access).toBe("next-access") }) test("uses a newer stored token after refresh 401", async () => { const fresh = { type: "oauth" as const, access: "fresh-access", refresh: "fresh-refresh", expires: Date.now() + 60_000, } const auth = expired() let count = 0 const getAuth = async () => { count++ return count === 1 ? auth : fresh } const result = await refreshCodexAuth({ input: plugin(() => {}), getAuth, auth, refresh: async () => { throw new Error("Token refresh failed: 401") }, account: () => undefined, }) expect(result).toBe(fresh) }) test("throws reauth error when refresh 401 has no newer stored token", async () => { const auth = expired() await expect( refreshCodexAuth({ input: plugin(() => {}), getAuth: async () => auth, auth, refresh: async () => { throw new Error("Token refresh failed: 401") }, account: () => undefined, }), ).rejects.toBeInstanceOf(CodexAuthExpiredError) }) test("refreshes a newer stored token instead of the stale caller token", async () => { const auth = expired() const fresh = { ...expired(), refresh: "fresh-refresh", accountId: "account-1" } const calls: string[] = [] const result = await refreshCodexAuth({ input: plugin(() => {}), getAuth: async () => fresh, auth, refresh: async (token) => { calls.push(token) return { id_token: "", access_token: "next-access", refresh_token: "next-refresh", expires_in: 60 } }, account: () => undefined, }) expect(calls).toEqual(["fresh-refresh"]) expect(result.accountId).toBe("account-1") }) test("releases the lock and pending entry after a transient failure", async () => { const auth = expired() const calls: string[] = [] const failed = await refreshCodexAuth({ input: plugin(() => {}), getAuth: async () => auth, auth, refresh: async (token) => { calls.push(token) throw new Error("offline") }, account: () => undefined, }).catch((err) => err) expect(failed).toEqual(new Error("offline")) await refreshCodexAuth({ input: plugin(() => {}), getAuth: async () => auth, auth, refresh: async (token) => { calls.push(token) return { id_token: "", access_token: "next-access", refresh_token: "next-refresh", expires_in: 60 } }, account: () => undefined, }) expect(calls).toEqual(["old-refresh", "old-refresh"]) }) test("aborts a stalled refresh and releases the lock", async () => { const auth = expired() const calls: string[] = [] const failed = await refreshCodexAuth({ input: plugin(() => {}), getAuth: async () => auth, auth, refresh: async (token, signal) => { calls.push(token) return new Promise((_, reject) => { signal.addEventListener("abort", () => reject(signal.reason), { once: true }) }) }, account: () => undefined, timeout: 20, }).catch((err) => err) expect(failed).toBeInstanceOf(DOMException) expect(failed.name).toBe("TimeoutError") await refreshCodexAuth({ input: plugin(() => {}), getAuth: async () => auth, auth, refresh: async (token) => { calls.push(token) return { id_token: "", access_token: "next-access", refresh_token: "next-refresh", expires_in: 60 } }, account: () => undefined, }) expect(calls).toEqual(["old-refresh", "old-refresh"]) }) test("serializes refreshes across processes before token reuse", async () => { for (const reuse of ["early", "late"] as const) { for (let trial = 0; trial < 5; trial++) { const result = await race({ reuse, delay: 100 }) expect(result.calls).toEqual(["old-refresh"]) expect(result.out.map((x) => x.code)).toEqual([0, 0]) expect(result.out.map((x) => x.stderr)).toEqual(["", ""]) } } }, 30_000) test("keeps the process lock alive during a delayed token response", async () => { const lock = { staleMs: 300, timeoutMs: 10_000, baseDelayMs: 20, maxDelayMs: 30, } const result = await race({ reuse: "early", delay: 1_000, lock }) expect(result.calls).toEqual(["old-refresh"]) expect(result.out.map((x) => x.code)).toEqual([0, 0]) expect(result.out.map((x) => x.stderr)).toEqual(["", ""]) }, 15_000) })