341 lines
10 KiB
TypeScript
341 lines
10 KiB
TypeScript
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<void>((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<string>()
|
|
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<never>((_, 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)
|
|
})
|