Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/steady-codex-refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Prevent concurrent Kilo processes from reusing a ChatGPT Codex refresh token.
84 changes: 52 additions & 32 deletions packages/opencode/src/kilocode/provider/codex-refresh.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { PluginInput } from "@kilocode/plugin"
import { Flock } from "@opencode-ai/core/util/flock"

export class CodexAuthExpiredError extends Error {
constructor(
Expand All @@ -25,14 +25,24 @@ type Tokens = {
}

type Input = {
input: PluginInput
input: {
client: {
auth: {
set: (input: { path: { id: string }; body: Auth }) => Promise<unknown>
}
}
}
getAuth: () => Promise<unknown>
auth: Auth
refresh: (refresh: string) => Promise<Tokens>
refresh: (refresh: string, signal: AbortSignal) => Promise<Tokens>
account: (tokens: Tokens) => string | undefined
lock?: Flock.Options
timeout?: number
}

const pending = new Map<string, Promise<Auth>>()
const lock = "codex-auth-refresh:openai"
const timeout = 30_000

function valid(auth: Auth) {
return auth.access && auth.expires > Date.now()
Expand All @@ -59,43 +69,53 @@ function recoverable(err: unknown) {
}

export async function refreshCodexAuth(input: Input) {
const inflight = pending.get(input.auth.refresh)
const token = input.auth.refresh
const inflight = pending.get(token)
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 }),
const promise = Flock.withLock(
lock,
async () => {
const fresh = await input.getAuth()
const current = oauth(fresh)
if (current && valid(current)) return current

try {
const base = current && current.refresh !== token ? current : input.auth
const controller = new AbortController()
const timer = setTimeout(
() => controller.abort(new DOMException("The operation timed out.", "TimeoutError")),
input.timeout ?? timeout,
)
const tokens = await input.refresh(base.refresh, controller.signal).finally(() => clearTimeout(timer))
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, token)) return next

throw new CodexAuthExpiredError()
}
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))
},
input.lock,
).finally(() => pending.delete(token))

pending.set(input.auth.refresh, promise)
pending.set(token, promise)
const next = await promise
assign(input.auth, next)
return next
Expand Down
7 changes: 5 additions & 2 deletions packages/opencode/src/plugin/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,13 @@ async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: Pk
return response.json()
}

async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
// kilocode_change start
async function refreshAccessToken(refreshToken: string, signal?: AbortSignal): Promise<TokenResponse> {
const response = await fetch(`${ISSUER}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
signal,
headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": `kilo/${InstallationVersion}` },
// kilocode_change end
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ test("system utility agents ignore per-agent permission allows", async () => {
},
})

await WithInstance.provide({
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const title = await load(tmp.path, (svc) => svc.get("title"))
Expand Down Expand Up @@ -131,7 +131,7 @@ test("system utility agents deny tools after configured name override", async ()
},
})

await WithInstance.provide({
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const title = await load(tmp.path, (svc) => svc.get("title"))
Expand Down
Loading
Loading