Fix expired Codex auth recovery (#10136)

* fix: recover expired Codex auth

* fix: minimize Codex auth shared changes

* fix: format Codex auth prompt
This commit is contained in:
Marius
2026-05-12 09:27:32 +02:00
committed by GitHub
parent 9fd631c43c
commit 8af638e7e2
27 changed files with 430 additions and 15 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"kilo-code": patch
"@kilocode/cli": patch
---
Show ChatGPT sign-in again when Codex authentication expires.
@@ -3,6 +3,7 @@ import type { AssistantMessage } from "@kilocode/sdk/v2"
import {
unwrapError,
parseAssistantError,
parseProviderAuthError,
isUnauthorizedPaidModelError,
isUnauthorizedPromotionLimitError,
} from "../../webview-ui/src/utils/errorUtils"
@@ -97,6 +98,26 @@ describe("parseAssistantError", () => {
})
})
describe("parseProviderAuthError", () => {
it("extracts provider auth errors", () => {
const error: AssistantError = {
name: "ProviderAuthError",
data: { providerID: "openai", message: "Sign in again" },
}
expect(parseProviderAuthError(error)).toEqual({ providerID: "openai", message: "Sign in again" })
})
it("returns null for non-provider-auth errors", () => {
const error: AssistantError = {
name: "APIError",
data: { statusCode: 401, message: "Unauthorized", isRetryable: false },
}
expect(parseProviderAuthError(error)).toBeNull()
})
})
describe("isUnauthorizedPaidModelError", () => {
it("returns true for 401 + PAID_MODEL_AUTH_REQUIRED", () => {
expect(isUnauthorizedPaidModelError({ statusCode: 401, code: "PAID_MODEL_AUTH_REQUIRED" })).toBe(true)
@@ -1,16 +1,20 @@
import { Component, createMemo, Switch, Match } from "solid-js"
import { Card } from "@kilocode/kilo-ui/card"
import { Collapsible } from "@kilocode/kilo-ui/collapsible"
import { useDialog } from "@kilocode/kilo-ui/context/dialog"
import { ErrorDetails } from "@kilocode/kilo-ui/error-details"
import { Button } from "@kilocode/kilo-ui/button"
import type { AssistantMessage } from "@kilocode/sdk/v2"
import { useLanguage } from "../../context/language"
import { useProvider } from "../../context/provider"
import {
unwrapError,
parseAssistantError,
parseProviderAuthError,
isUnauthorizedPaidModelError,
isUnauthorizedPromotionLimitError,
} from "../../utils/errorUtils"
import ProviderConnectDialog from "../settings/ProviderConnectDialog"
export interface ErrorDisplayProps {
error: NonNullable<AssistantMessage["error"]>
@@ -19,7 +23,28 @@ export interface ErrorDisplayProps {
export const ErrorDisplay: Component<ErrorDisplayProps> = (props) => {
const { t } = useLanguage()
const dialog = useDialog()
const provider = useProvider()
const parsed = createMemo(() => parseAssistantError(props.error))
const auth = createMemo(() => parseProviderAuthError(props.error))
const authProvider = createMemo(() => {
const err = auth()
if (!err) return
return provider.providers()[err.providerID]
})
const canAuth = createMemo(() => {
const err = auth()
if (!err || !authProvider()) return false
return (provider.authMethods()[err.providerID] ?? []).length > 0
})
const oauth = createMemo(() => {
const err = auth()
if (!err) return false
return (
err.providerID === "openai" &&
(provider.authMethods()[err.providerID] ?? []).some((method) => method.type === "oauth")
)
})
const errorText = createMemo(() => {
const msg = props.error.data?.message
@@ -28,6 +53,12 @@ export const ErrorDisplay: Component<ErrorDisplayProps> = (props) => {
return unwrapError(String(msg))
})
function connectProvider() {
const err = auth()
if (!err) return
dialog.show(() => <ProviderConnectDialog providerID={err.providerID} oauthOnly={oauth()} />)
}
return (
<Switch
fallback={
@@ -69,6 +100,28 @@ export const ErrorDisplay: Component<ErrorDisplayProps> = (props) => {
</Button>
</div>
</Match>
<Match when={canAuth()}>
<div data-component="auth-prompt">
<div data-slot="auth-prompt-header">
<span data-slot="auth-prompt-icon"></span>
<span data-slot="auth-prompt-title">
{oauth()
? t("error.providerAuth.chatgpt.title")
: t("error.providerAuth.title", { provider: authProvider()?.name ?? auth()?.providerID ?? "provider" })}
</span>
</div>
<p data-slot="auth-prompt-description">
{oauth()
? t("error.providerAuth.chatgpt.description")
: t("error.providerAuth.description", {
provider: authProvider()?.name ?? auth()?.providerID ?? "provider",
})}
</p>
<Button variant="primary" onClick={connectProvider}>
{oauth() ? t("settings.providers.action.signInChatGPT") : t("common.connect")}
</Button>
</div>
</Match>
</Switch>
)
}
+5
View File
@@ -462,6 +462,11 @@ export const dict = {
"error.promotionLimit.description":
"سجّل مجانًا للمتابعة واستكشاف أكثر من 500 نموذج. يستغرق دقيقتين، بدون بطاقة ائتمان. أو عُد لاحقًا.",
"error.promotionLimit.action": "التسجيل",
"error.providerAuth.title": "تم تسجيل خروجك من {{provider}}",
"error.providerAuth.description": "أعد الاتصال بـ {{provider}}، ثم أرسل رسالتك مرة أخرى.",
"error.providerAuth.chatgpt.title": "تسجيل الدخول باستخدام ChatGPT مرة أخرى",
"error.providerAuth.chatgpt.description":
"سجل الدخول باستخدام ChatGPT مرة أخرى، ثم أرسل رسالتك مرة أخرى لمواصلة استخدام نماذج Codex.",
"error.chain.unknown": "خطأ غير معروف",
"error.chain.causedBy": "بسبب:",
+5
View File
@@ -465,6 +465,11 @@ export const dict = {
"error.promotionLimit.description":
"Cadastre-se gratuitamente para continuar e explorar mais de 500 modelos. Leva 2 minutos, sem cartão de crédito. Ou volte mais tarde.",
"error.promotionLimit.action": "Cadastrar-se",
"error.providerAuth.title": "{{provider}} desconectou você",
"error.providerAuth.description": "Reconecte o {{provider}} e envie sua mensagem novamente.",
"error.providerAuth.chatgpt.title": "OpenAI desconectou você",
"error.providerAuth.chatgpt.description":
"Faça login no ChatGPT novamente e envie sua mensagem de novo para continuar usando os modelos Codex.",
"error.chain.unknown": "Erro desconhecido",
"error.chain.causedBy": "Causado por:",
+5
View File
@@ -469,6 +469,11 @@ export const dict = {
"error.promotionLimit.description":
"Registrujte se besplatno da nastavite i istražite preko 500 modela. Traje 2 minute, bez kreditne kartice. Ili se vratite kasnije.",
"error.promotionLimit.action": "Registracija",
"error.providerAuth.title": "{{provider}} vas je odjavio",
"error.providerAuth.description": "Ponovo se povežite sa {{provider}}, a zatim ponovo pošaljite poruku.",
"error.providerAuth.chatgpt.title": "OpenAI vas je odjavio",
"error.providerAuth.chatgpt.description":
"Ponovo se prijavite na ChatGPT, a zatim ponovo pošaljite poruku da nastavite koristiti Codex modele.",
"error.chain.unknown": "Nepoznata greška",
"error.chain.causedBy": "Uzrok:",
+5
View File
@@ -466,6 +466,11 @@ export const dict = {
"error.promotionLimit.description":
"Tilmeld dig gratis for at fortsætte og udforske over 500 modeller. Tager 2 minutter, intet kreditkort nødvendigt. Eller kom tilbage senere.",
"error.promotionLimit.action": "Tilmeld dig",
"error.providerAuth.title": "{{provider}} har logget dig af",
"error.providerAuth.description": "Forbind til {{provider}} igen, og send din besked på ny.",
"error.providerAuth.chatgpt.title": "OpenAI har logget dig af",
"error.providerAuth.chatgpt.description":
"Log ind med ChatGPT igen, og send din besked på ny for at fortsætte med at bruge Codex-modeller.",
"error.chain.unknown": "Ukendt fejl",
"error.chain.causedBy": "Forårsaget af:",
+5
View File
@@ -471,6 +471,11 @@ export const dict = {
"error.promotionLimit.description":
"Registriere dich kostenlos, um weiterzumachen und über 500 Modelle zu entdecken. Dauert 2 Minuten, keine Kreditkarte nötig. Oder komm später wieder.",
"error.promotionLimit.action": "Registrieren",
"error.providerAuth.title": "{{provider}} hat Sie abgemeldet",
"error.providerAuth.description": "Verbinden Sie {{provider}} erneut und senden Sie Ihre Nachricht noch einmal.",
"error.providerAuth.chatgpt.title": "OpenAI hat Sie abgemeldet",
"error.providerAuth.chatgpt.description":
"Melden Sie sich erneut bei ChatGPT an und senden Sie Ihre Nachricht noch einmal, um weiterhin Codex-Modelle zu verwenden.",
"error.chain.unknown": "Unbekannter Fehler",
"error.chain.causedBy": "Verursacht durch:",
@@ -467,6 +467,11 @@ export const dict = {
"error.promotionLimit.description":
"Sign up for free to continue and explore 500 other models. Takes 2 minutes, no credit card required. Or come back later.",
"error.promotionLimit.action": "Sign Up",
"error.providerAuth.title": "{{provider}} signed you out",
"error.providerAuth.description": "Reconnect {{provider}}, then send your message again.",
"error.providerAuth.chatgpt.title": "OpenAI signed you out",
"error.providerAuth.chatgpt.description":
"Sign in with ChatGPT again, then send your message again to keep using Codex models.",
"error.chain.unknown": "Unknown error",
"error.chain.causedBy": "Caused by:",
+5
View File
@@ -469,6 +469,11 @@ export const dict = {
"error.promotionLimit.description":
"Regístrate gratis para continuar y explorar más de 500 modelos. Solo 2 minutos, sin tarjeta de crédito. O vuelve más tarde.",
"error.promotionLimit.action": "Registrarse",
"error.providerAuth.title": "{{provider}} cerró tu sesión",
"error.providerAuth.description": "Vuelve a conectar {{provider}} y envía tu mensaje de nuevo.",
"error.providerAuth.chatgpt.title": "OpenAI cerró tu sesión",
"error.providerAuth.chatgpt.description":
"Vuelve a iniciar sesión con ChatGPT y envía tu mensaje de nuevo para seguir usando los modelos Codex.",
"error.chain.unknown": "Error desconocido",
"error.chain.causedBy": "Causado por:",
+5
View File
@@ -473,6 +473,11 @@ export const dict = {
"error.promotionLimit.description":
"Inscrivez-vous gratuitement pour continuer et explorer plus de 500 modèles. 2 minutes, sans carte bancaire. Ou revenez plus tard.",
"error.promotionLimit.action": "S'inscrire",
"error.providerAuth.title": "{{provider}} vous a déconnecté",
"error.providerAuth.description": "Reconnectez {{provider}}, puis renvoyez votre message.",
"error.providerAuth.chatgpt.title": "OpenAI vous a déconnecté",
"error.providerAuth.chatgpt.description":
"Reconnectez-vous à ChatGPT, puis renvoyez votre message pour continuer à utiliser les modèles Codex.",
"error.chain.unknown": "Erreur inconnue",
"error.chain.causedBy": "Causé par :",
+5
View File
@@ -463,6 +463,11 @@ export const dict = {
"error.promotionLimit.description":
"無料でサインアップして、500以上のモデルを探索しましょう。2分で完了、クレジットカード不要。または後でお戻りください。",
"error.promotionLimit.action": "サインアップ",
"error.providerAuth.title": "{{provider}} からログアウトしました",
"error.providerAuth.description": "{{provider}} に再接続してから、メッセージを再送信してください。",
"error.providerAuth.chatgpt.title": "OpenAI からログアウトしました",
"error.providerAuth.chatgpt.description":
"Codex モデルを引き続き使用するには、ChatGPT に再度ログインしてから、メッセージを再送信してください。",
"error.chain.unknown": "不明なエラー",
"error.chain.causedBy": "原因:",
+5
View File
@@ -465,6 +465,11 @@ export const dict = {
"error.promotionLimit.description":
"무료로 가입하여 500개 이상의 모델을 탐색하세요. 2분이면 완료, 신용카드 불필요. 또는 나중에 다시 오세요.",
"error.promotionLimit.action": "가입하기",
"error.providerAuth.title": "{{provider}}에서 로그아웃되었습니다",
"error.providerAuth.description": "{{provider}}에 다시 연결한 후 메시지를 다시 보내주세요.",
"error.providerAuth.chatgpt.title": "OpenAI에서 로그아웃되었습니다",
"error.providerAuth.chatgpt.description":
"ChatGPT에 다시 로그인한 후 메시지를 다시 보내 Codex 모델을 계속 사용하세요.",
"error.chain.unknown": "알 수 없는 오류",
"error.chain.causedBy": "원인:",
+5
View File
@@ -469,6 +469,11 @@ export const dict = {
"error.promotionLimit.description":
"Registreer je gratis om door te gaan en 500 andere modellen te ontdekken. Duurt 2 minuten, geen creditcard vereist. Of kom later terug.",
"error.promotionLimit.action": "Registreren",
"error.providerAuth.title": "{{provider}} heeft je afgemeld",
"error.providerAuth.description": "Verbind opnieuw met {{provider}} en stuur je bericht nog een keer.",
"error.providerAuth.chatgpt.title": "OpenAI heeft je afgemeld",
"error.providerAuth.chatgpt.description":
"Meld je opnieuw aan bij ChatGPT en stuur je bericht nog een keer om Codex-modellen te blijven gebruiken.",
"error.chain.unknown": "Onbekende fout",
"error.chain.causedBy": "Veroorzaakt door:",
+5
View File
@@ -470,6 +470,11 @@ export const dict = {
"error.promotionLimit.description":
"Registrer deg gratis for å fortsette og utforske over 500 modeller. Tar 2 minutter, ingen kredittkort nødvendig. Eller kom tilbake senere.",
"error.promotionLimit.action": "Registrer deg",
"error.providerAuth.title": "{{provider}} logget deg ut",
"error.providerAuth.description": "Koble til {{provider}} på nytt, og send meldingen din igjen.",
"error.providerAuth.chatgpt.title": "OpenAI logget deg ut",
"error.providerAuth.chatgpt.description":
"Logg på ChatGPT igjen, og send meldingen din på nytt for å fortsette å bruke Codex-modeller.",
"error.chain.unknown": "Ukjent feil",
"error.chain.causedBy": "Forårsaket av:",
+5
View File
@@ -467,6 +467,11 @@ export const dict = {
"error.promotionLimit.description":
"Zarejestruj się za darmo, aby kontynuować i odkryć ponad 500 modeli. Zajmie to 2 minuty, bez karty kredytowej. Lub wróć później.",
"error.promotionLimit.action": "Zarejestruj się",
"error.providerAuth.title": "{{provider}} wylogował Cię",
"error.providerAuth.description": "Połącz się ponownie z {{provider}}, a następnie wyślij wiadomość jeszcze raz.",
"error.providerAuth.chatgpt.title": "OpenAI wylogowało Cię",
"error.providerAuth.chatgpt.description":
"Zaloguj się ponownie do ChatGPT, a następnie wyślij wiadomość jeszcze raz, aby kontynuować korzystanie z modeli Codex.",
"error.chain.unknown": "Nieznany błąd",
"error.chain.causedBy": "Spowodowany przez:",
+5
View File
@@ -466,6 +466,11 @@ export const dict = {
"error.promotionLimit.description":
"Зарегистрируйтесь бесплатно, чтобы продолжить и исследовать более 500 моделей. Займёт 2 минуты, кредитная карта не нужна. Или вернитесь позже.",
"error.promotionLimit.action": "Зарегистрироваться",
"error.providerAuth.title": "Сеанс {{provider}} завершен",
"error.providerAuth.description": "Подключитесь к {{provider}} снова, затем отправьте сообщение еще раз.",
"error.providerAuth.chatgpt.title": "Сеанс OpenAI завершен",
"error.providerAuth.chatgpt.description":
"Войдите в ChatGPT снова, затем отправьте сообщение еще раз, чтобы продолжить использование моделей Codex.",
"error.chain.unknown": "Неизвестная ошибка",
"error.chain.causedBy": "Причина:",
+5
View File
@@ -464,6 +464,11 @@ export const dict = {
"error.promotionLimit.description":
"สมัครฟรีเพื่อดำเนินการต่อและสำรวจโมเดลกว่า 500 รายการ ใช้เวลา 2 นาที ไม่ต้องใช้บัตรเครดิต หรือกลับมาทีหลัง",
"error.promotionLimit.action": "สมัครสมาชิก",
"error.providerAuth.title": "{{provider}} ออกจากระบบของคุณแล้ว",
"error.providerAuth.description": "เชื่อมต่อ {{provider}} อีกครั้ง จากนั้นส่งข้อความของคุณใหม่",
"error.providerAuth.chatgpt.title": "OpenAI ออกจากระบบของคุณแล้ว",
"error.providerAuth.chatgpt.description":
"เข้าสู่ระบบ ChatGPT อีกครั้ง จากนั้นส่งข้อความของคุณใหม่เพื่อใช้งานโมเดล Codex ต่อไป",
"error.chain.unknown": "ข้อผิดพลาดที่ไม่รู้จัก",
"error.chain.causedBy": "สาเหตุ:",
+5
View File
@@ -466,6 +466,11 @@ export const dict = {
"error.promotionLimit.description":
"Devam etmek ve 500'den fazla modeli keşfetmek için ücretsiz kayıt olun. 2 dakika sürer, kredi kartı gerekmez. Ya da daha sonra gelin.",
"error.promotionLimit.action": "Kayıt Ol",
"error.providerAuth.title": "{{provider}} oturumunuzu kapattı",
"error.providerAuth.description": "{{provider}} bağlantısını yeniden kurun ve mesajınızı tekrar gönderin.",
"error.providerAuth.chatgpt.title": "OpenAI oturumunuzu kapattı",
"error.providerAuth.chatgpt.description":
"Codex modellerini kullanmaya devam etmek için ChatGPT ile tekrar giriş yapın ve mesajınızı yeniden gönderin.",
"error.chain.unknown": "Bilinmeyen hata",
"error.chain.causedBy": "Nedeni:",
+5
View File
@@ -470,6 +470,11 @@ export const dict = {
"error.promotionLimit.description":
"Зареєструйтеся безкоштовно для продовження та доступу до 500+ моделей. Займе 2 хвилини, кредитна картка не потрібна. Або поверніться пізніше.",
"error.promotionLimit.action": "Зареєструватися",
"error.providerAuth.title": "Сеанс {{provider}} завершено",
"error.providerAuth.description": "Підключіться до {{provider}} знову, а потім надішліть повідомлення ще раз.",
"error.providerAuth.chatgpt.title": "Сеанс OpenAI завершено",
"error.providerAuth.chatgpt.description":
"Увійдіть у ChatGPT знову, а потім надішліть повідомлення ще раз, щоб продовжити використання моделей Codex.",
"error.chain.unknown": "Невідома помилка",
"error.chain.causedBy": "Спричинено:",
+4
View File
@@ -459,6 +459,10 @@ export const dict = {
"error.promotionLimit.title": "您需要注册才能继续",
"error.promotionLimit.description": "免费注册以继续探索500多个模型。只需2分钟,无需信用卡。或稍后再来。",
"error.promotionLimit.action": "注册",
"error.providerAuth.title": "{{provider}} 已将您登出",
"error.providerAuth.description": "请重新连接 {{provider}},然后再次发送消息。",
"error.providerAuth.chatgpt.title": "OpenAI 已将您登出",
"error.providerAuth.chatgpt.description": "请再次登录 ChatGPT,然后重新发送消息以继续使用 Codex 模型。",
"error.chain.unknown": "未知错误",
"error.chain.causedBy": "原因:",
+4
View File
@@ -459,6 +459,10 @@ export const dict = {
"error.promotionLimit.title": "您需要註冊才能繼續",
"error.promotionLimit.description": "免費註冊以繼續探索500多個模型。只需2分鐘,無需信用卡。或稍後再來。",
"error.promotionLimit.action": "註冊",
"error.providerAuth.title": "{{provider}} 已將您登出",
"error.providerAuth.description": "請重新連接 {{provider}},然後再次發送訊息。",
"error.providerAuth.chatgpt.title": "OpenAI 已將您登出",
"error.providerAuth.chatgpt.description": "請再次登入 ChatGPT,然後重新發送訊息以繼續使用 Codex 模型。",
"error.chain.unknown": "未知錯誤",
"error.chain.causedBy": "原因:",
@@ -53,6 +53,11 @@ export interface ParsedError {
message?: string
}
export interface ParsedProviderAuthError {
providerID: string
message: string
}
export function parseAssistantError(error: AssistantMessage["error"] | null | undefined): ParsedError | null {
if (!error) return null
if (error.name !== "APIError") return null
@@ -81,6 +86,20 @@ export function parseAssistantError(error: AssistantMessage["error"] | null | un
return { statusCode, code, message }
}
export function parseProviderAuthError(
error: AssistantMessage["error"] | null | undefined,
): ParsedProviderAuthError | null {
if (!error) return null
if (error.name !== "ProviderAuthError") return null
const data = error.data
if (!data) return null
const providerID = typeof data.providerID === "string" ? data.providerID : undefined
const message = typeof data.message === "string" ? data.message : undefined
if (!providerID || !message) return null
return { providerID, message }
}
export function isUnauthorizedPaidModelError(parsed: ParsedError | null): boolean {
if (!parsed) return false
return parsed.statusCode === 401 && parsed.code === errorCodes.PAID_MODEL_AUTH_REQUIRED
@@ -0,0 +1,102 @@
import type { PluginInput } from "@kilocode/plugin"
export class CodexAuthExpiredError extends Error {
constructor(
message = "Your ChatGPT sign-in expired or was revoked. Sign in with ChatGPT again to continue using Codex models.",
) {
super(message)
this.name = "CodexAuthExpiredError"
}
}
type Auth = {
type: "oauth"
refresh: string
access: string
expires: number
accountId?: string
}
type Tokens = {
id_token: string
access_token: string
refresh_token: string
expires_in?: number
}
type Input = {
input: PluginInput
getAuth: () => Promise<unknown>
auth: Auth
refresh: (refresh: string) => Promise<Tokens>
account: (tokens: Tokens) => string | undefined
}
const pending = new Map<string, Promise<Auth>>()
function valid(auth: Auth) {
return auth.access && auth.expires > Date.now()
}
function usable(auth: Auth, refresh: string) {
return auth.refresh !== refresh || valid(auth)
}
function oauth(auth: unknown): Auth | undefined {
if (!auth || typeof auth !== "object" || !("type" in auth) || auth.type !== "oauth") return
return auth as Auth
}
function assign(auth: Auth, next: Auth) {
auth.access = next.access
auth.refresh = next.refresh
auth.expires = next.expires
auth.accountId = next.accountId
}
function recoverable(err: unknown) {
return err instanceof Error && /^Token refresh failed: 401\b/.test(err.message)
}
export async function refreshCodexAuth(input: Input) {
const inflight = pending.get(input.auth.refresh)
if (inflight) {
const next = await inflight
assign(input.auth, next)
return next
}
const promise = (async () => {
const fresh = await input.getAuth()
const current = oauth(fresh)
if (current && valid(current)) return current
try {
const base = current && current.refresh !== input.auth.refresh ? current : input.auth
const tokens = await input.refresh(base.refresh)
const id = input.account(tokens) || base.accountId
const next = {
type: "oauth" as const,
refresh: tokens.refresh_token,
access: tokens.access_token,
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
...(id && { accountId: id }),
}
await input.input.client.auth.set({ path: { id: "openai" }, body: next })
return next
} catch (err) {
if (!recoverable(err)) throw err
const latest = await input.getAuth()
const next = oauth(latest)
if (next && usable(next, input.auth.refresh)) return next
throw new CodexAuthExpiredError()
}
})().finally(() => pending.delete(input.auth.refresh))
pending.set(input.auth.refresh, promise)
const next = await promise
assign(input.auth, next)
return next
}
+4 -15
View File
@@ -1,11 +1,11 @@
import type { Hooks, PluginInput } from "@kilocode/plugin"
import * as Log from "@opencode-ai/core/util/log"
import { Installation } from "../installation"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { OAUTH_DUMMY_KEY } from "../auth"
import os from "os"
import { setTimeout as sleep } from "node:timers/promises"
import { createServer } from "http"
import { refreshCodexAuth } from "@/kilocode/provider/codex-refresh" // kilocode_change
const log = Log.create({ service: "plugin.codex" })
@@ -439,20 +439,9 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
// Check if token needs refresh
if (!currentAuth.access || currentAuth.expires < Date.now()) {
log.info("refreshing codex access token")
const tokens = await refreshAccessToken(currentAuth.refresh)
const newAccountId = extractAccountId(tokens) || authWithAccount.accountId
await input.client.auth.set({
path: { id: "openai" },
body: {
type: "oauth",
refresh: tokens.refresh_token,
access: tokens.access_token,
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
...(newAccountId && { accountId: newAccountId }),
},
})
currentAuth.access = tokens.access_token
authWithAccount.accountId = newAccountId
// kilocode_change start
await refreshCodexAuth({ input, getAuth, auth: currentAuth, refresh: refreshAccessToken, account: extractAccountId })
// kilocode_change end
}
// Build headers
@@ -23,6 +23,7 @@ import type { SystemError } from "bun"
import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { SessionNetwork } from "./network" // kilocode_change
import { CodexAuthExpiredError } from "@/kilocode/provider/codex-refresh" // kilocode_change
import { Effect, Schema, Types } from "effect"
import { zod, ZodOverride } from "@/util/effect-zod"
import { NonNegativeInt, withStatics } from "@/util/schema"
@@ -1240,6 +1241,14 @@ export function fromError(
},
{ cause: e },
).toObject()
case e instanceof CodexAuthExpiredError: // kilocode_change start
return new AuthError(
{
providerID: "openai",
message: e.message,
},
{ cause: e },
).toObject() // kilocode_change end
case SessionNetwork.disconnected(e): // kilocode_change start
return new APIError(
{
@@ -0,0 +1,123 @@
import { describe, expect, test } from "bun:test"
import { CodexAuthExpiredError, refreshCodexAuth } from "../../src/kilocode/provider/codex-refresh"
import type { PluginInput } from "@kilocode/plugin"
import { MessageV2 } from "../../src/session/message-v2"
import { ProviderID } from "../../src/provider/schema"
type Auth = {
type: "oauth"
refresh: string
access: string
expires: number
accountId?: string
}
const expired = (): Auth => ({
type: "oauth",
access: "old-access",
refresh: "old-refresh",
expires: 0,
})
function plugin(persist: (auth: Auth) => void): PluginInput {
const set = async (req: { body: Auth }) => {
persist(req.body)
}
return {
client: {
auth: { set },
},
} as unknown as PluginInput
}
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)
})
})