Skip to content

Commit 075b02a

Browse files
committed
fix(opencode): recover expired websocket auth
1 parent a382528 commit 075b02a

7 files changed

Lines changed: 180 additions & 12 deletions

File tree

packages/opencode/src/plugin/openai/codex.ts

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ import { setTimeout as sleep } from "node:timers/promises"
66
import { createServer } from "http"
77
import { OpenAIWebSocketPool } from "./ws-pool"
88
import { escapeHtml } from "@/util/html"
9+
import { ProviderError } from "@/provider/error"
910

1011
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
1112
const ISSUER = "https://auth.openai.com"
1213
const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
1314
const OAUTH_PORT = 1455
1415
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
16+
const TOKEN_REFRESH_WINDOW_MS = 5 * 60 * 1000
1517
const ALLOWED_MODELS = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
1618
const DISALLOWED_MODELS = new Set(["gpt-5.5-pro"])
1719

@@ -133,6 +135,25 @@ async function refreshAccessToken(refreshToken: string, issuer = ISSUER): Promis
133135
}).toString(),
134136
})
135137
if (!response.ok) {
138+
const body = await response.text()
139+
const code = (() => {
140+
try {
141+
const parsed = JSON.parse(body)
142+
return parsed?.error?.code ?? parsed?.code
143+
} catch {
144+
return undefined
145+
}
146+
})()
147+
if (
148+
response.status === 401 ||
149+
code === "refresh_token_expired" ||
150+
code === "refresh_token_reused" ||
151+
code === "refresh_token_invalidated"
152+
) {
153+
throw new ProviderError.AuthenticationError(
154+
"Your ChatGPT login could not be refreshed. Run `opencode auth login` to sign in again.",
155+
)
156+
}
136157
throw new Error(`Token refresh failed: ${response.status}`)
137158
}
138159
return response.json()
@@ -401,7 +422,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
401422
async loader(getAuth) {
402423
const auth = await getAuth()
403424
const websocketFetch = options.experimentalWebSockets
404-
? OpenAIWebSocketPool.createWebSocketFetch({ httpFetch: fetch })
425+
? OpenAIWebSocketPool.createWebSocketFetch({ httpFetch: fetch, recoverWithHttp: auth.type === "oauth" })
405426
: undefined
406427
if (websocketFetch) {
407428
websocketFetches.push(websocketFetch)
@@ -411,7 +432,9 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
411432

412433
let refreshPromise:
413434
| Promise<{
435+
refresh: string
414436
access: string
437+
expires: number
415438
accountId: string | undefined
416439
}>
417440
| undefined
@@ -436,24 +459,26 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
436459
return websocketFetch ? websocketFetch(requestInput, init) : fetch(requestInput, init)
437460

438461
const authWithAccount = currentAuth as typeof currentAuth & { accountId?: string }
439-
440-
if (!currentAuth.access || currentAuth.expires < Date.now()) {
462+
const refresh = async () => {
441463
if (!refreshPromise) {
442464
refreshPromise = refreshAccessToken(currentAuth.refresh, issuer)
443465
.then(async (tokens) => {
444466
const accountId = extractAccountId(tokens) || authWithAccount.accountId
467+
const expires = Date.now() + (tokens.expires_in ?? 3600) * 1000
445468
await input.client.auth.set({
446469
path: { id: "openai" },
447470
body: {
448471
type: "oauth",
449472
refresh: tokens.refresh_token,
450473
access: tokens.access_token,
451-
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
474+
expires,
452475
...(accountId && { accountId }),
453476
},
454477
})
455478
return {
479+
refresh: tokens.refresh_token,
456480
access: tokens.access_token,
481+
expires,
457482
accountId,
458483
}
459484
})
@@ -463,10 +488,14 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
463488
}
464489

465490
const refreshed = await refreshPromise
491+
currentAuth.refresh = refreshed.refresh
466492
currentAuth.access = refreshed.access
493+
currentAuth.expires = refreshed.expires
467494
authWithAccount.accountId = refreshed.accountId
468495
}
469496

497+
if (!currentAuth.access || currentAuth.expires < Date.now() + TOKEN_REFRESH_WINDOW_MS) await refresh()
498+
470499
const headers = new Headers()
471500
if (init?.headers) {
472501
if (init.headers instanceof Headers) {
@@ -482,9 +511,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
482511
}
483512
}
484513
headers.set("authorization", `Bearer ${currentAuth.access}`)
485-
if (authWithAccount.accountId) {
486-
headers.set("ChatGPT-Account-Id", authWithAccount.accountId)
487-
}
514+
if (authWithAccount.accountId) headers.set("ChatGPT-Account-Id", authWithAccount.accountId)
488515

489516
const parsed =
490517
requestInput instanceof URL
@@ -499,8 +526,24 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
499526
...init,
500527
headers,
501528
}
502-
if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit)
503-
return fetch(url, OpenAIWebSocketPool.withoutInternalHeaders(requestInit))
529+
const request = () =>
530+
websocketFetch && parsed.pathname.endsWith("/responses")
531+
? websocketFetch(url, requestInit)
532+
: fetch(url, OpenAIWebSocketPool.withoutInternalHeaders(requestInit))
533+
const response = await request()
534+
if (response.status !== 401) return response
535+
await response.body?.cancel()
536+
const latestAuth = await getAuth()
537+
if (latestAuth.type === "oauth") {
538+
currentAuth.refresh = latestAuth.refresh
539+
currentAuth.access = latestAuth.access
540+
currentAuth.expires = latestAuth.expires
541+
authWithAccount.accountId = (latestAuth as typeof latestAuth & { accountId?: string }).accountId
542+
}
543+
await refresh()
544+
headers.set("authorization", `Bearer ${currentAuth.access}`)
545+
if (authWithAccount.accountId) headers.set("ChatGPT-Account-Id", authWithAccount.accountId)
546+
return request()
504547
},
505548
}
506549
},

packages/opencode/src/plugin/openai/ws-pool.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface CreateWebSocketFetchOptions {
1212
idleTimeout?: number
1313
maxConnectionAge?: number
1414
streamRetries?: number
15+
recoverWithHttp?: boolean
1516
}
1617

1718
interface PoolEntry {
@@ -151,7 +152,7 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
151152

152153
recordStreamFailure(entry)
153154
invalidate(entry)
154-
if (entry.fallback) return httpFetch(input, httpInit)
155+
if (options?.recoverWithHttp || entry.fallback) return httpFetch(input, httpInit)
155156
return failedResponse(
156157
new ProviderError.ResponseStreamError(error instanceof Error ? error.message : String(error), {
157158
cause: error,

packages/opencode/src/provider/error.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ export class ResponseStreamError extends Error {
2020
}
2121
}
2222

23+
export class AuthenticationError extends Error {
24+
public override readonly name = "ProviderAuthenticationError"
25+
}
26+
2327
function isOpenAiErrorRetryable(e: APICallError) {
2428
const status = e.statusCode
2529
if (!status) return e.isRetryable

packages/opencode/src/session/message-v2.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,14 @@ export function fromError(
676676
},
677677
{ cause: e },
678678
).toObject()
679+
case e instanceof ProviderError.AuthenticationError:
680+
return new AuthError(
681+
{
682+
providerID: ctx.providerID,
683+
message: e.message,
684+
},
685+
{ cause: e },
686+
).toObject()
679687
case e instanceof ProviderError.ResponseStreamError:
680688
return new APIError(
681689
{

packages/opencode/test/plugin/codex.test.ts

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
renderOAuthError,
88
type IdTokenClaims,
99
} from "../../src/plugin/openai/codex"
10+
import { ProviderError } from "../../src/provider/error"
1011

1112
function createTestJwt(payload: object): string {
1213
const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url")
@@ -153,8 +154,8 @@ describe("plugin.codex", () => {
153154
let auth = {
154155
type: "oauth" as const,
155156
refresh: "refresh-old",
156-
access: "",
157-
expires: 0,
157+
access: "access-old",
158+
expires: Date.now() + 60_000,
158159
}
159160
const authUpdates: Array<{
160161
body: { refresh: string; access: string; expires: number; accountId?: string }
@@ -245,8 +246,95 @@ describe("plugin.codex", () => {
245246
{ authorization: "Bearer access-new", accountId: "acc-123" },
246247
])
247248
})
249+
250+
test("refreshes and retries once after an unauthorized response", async () => {
251+
let auth = {
252+
type: "oauth" as const,
253+
refresh: "refresh-old",
254+
access: "access-old",
255+
expires: Date.now() + 60 * 60 * 1000,
256+
}
257+
const authorizations: Array<string | null> = []
258+
let refreshRequests = 0
259+
260+
using server = Bun.serve({
261+
port: 0,
262+
async fetch(request) {
263+
const url = new URL(request.url)
264+
if (url.pathname === "/oauth/token") {
265+
refreshRequests += 1
266+
return Response.json({
267+
id_token: createTestJwt({ chatgpt_account_id: "acc-123" }),
268+
access_token: "access-new",
269+
refresh_token: "refresh-new",
270+
expires_in: 3600,
271+
})
272+
}
273+
if (url.pathname === "/backend-api/codex/responses") {
274+
authorizations.push(request.headers.get("authorization"))
275+
return new Response("{}", { status: authorizations.length === 1 ? 401 : 200 })
276+
}
277+
return new Response("unexpected request", { status: 500 })
278+
},
279+
})
280+
const hooks = await CodexAuthPlugin(
281+
pluginInput(async (next) => {
282+
auth = { type: "oauth", ...next.body }
283+
}),
284+
{
285+
issuer: server.url.origin,
286+
codexApiEndpoint: new URL("/backend-api/codex/responses", server.url).toString(),
287+
},
288+
)
289+
const loaded = await hooks.auth!.loader!(async () => auth as never, {} as never)
290+
291+
const response = await loaded.fetch!("https://api.openai.com/v1/responses")
292+
293+
expect(response.status).toBe(200)
294+
expect(refreshRequests).toBe(1)
295+
expect(authorizations).toEqual(["Bearer access-old", "Bearer access-new"])
296+
})
297+
298+
test("requests reauthentication when token refresh is permanently rejected", async () => {
299+
const auth = {
300+
type: "oauth" as const,
301+
refresh: "refresh-old",
302+
access: "access-old",
303+
expires: 0,
304+
}
305+
using server = Bun.serve({
306+
port: 0,
307+
fetch() {
308+
return Response.json({ error: { code: "refresh_token_invalidated" } }, { status: 401 })
309+
},
310+
})
311+
const hooks = await CodexAuthPlugin(
312+
pluginInput(async () => {}),
313+
{ issuer: server.url.origin },
314+
)
315+
const loaded = await hooks.auth!.loader!(async () => auth as never, {} as never)
316+
317+
const error = await loaded.fetch!("https://api.openai.com/v1/responses").catch((error: unknown) => error)
318+
319+
expect(error).toBeInstanceOf(ProviderError.AuthenticationError)
320+
expect(error.message).toContain("opencode auth login")
321+
})
248322
})
249323

324+
function pluginInput(
325+
set: (input: { body: { refresh: string; access: string; expires: number; accountId?: string } }) => Promise<void>,
326+
) {
327+
return {
328+
client: { auth: { set } },
329+
project: {},
330+
directory: "",
331+
worktree: "",
332+
experimental_workspace: { register() {} },
333+
serverUrl: new URL("https://example.com"),
334+
$: {},
335+
} as never
336+
}
337+
250338
async function waitFor(predicate: () => boolean) {
251339
const started = Date.now()
252340
while (!predicate()) {

packages/opencode/test/plugin/openai-ws.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,23 @@ describe("plugin.openai.ws-pool", () => {
188188
fetch.close()
189189
})
190190

191+
test("recovers websocket connection failures over HTTP when requested", async () => {
192+
let attempts = 0
193+
await using server = await createRejectingWebSocketServer(() => attempts++)
194+
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
195+
url: server.url,
196+
recoverWithHttp: true,
197+
httpFetch: Object.assign(async () => new Response("unauthorized", { status: 401 }), { preconnect() {} }),
198+
})
199+
200+
const response = await fetch(server.url, streamRequest())
201+
202+
expect(response.status).toBe(401)
203+
expect(await response.text()).toBe("unauthorized")
204+
expect(attempts).toBe(1)
205+
fetch.close()
206+
})
207+
191208
test("falls back to HTTP after websocket setup retries are exhausted", async () => {
192209
const attempts: string[] = []
193210
await using server = await createRejectingWebSocketServer(() => attempts.push("websocket"))

packages/opencode/test/session/retry.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,13 @@ describe("session.retry.retryable", () => {
182182
})
183183
})
184184

185+
test("does not retry provider authentication errors", () => {
186+
const request = MessageV2.fromError(new ProviderError.AuthenticationError("Sign in again"), { providerID })
187+
188+
expect(SessionV1.AuthError.isInstance(request)).toBe(true)
189+
expect(SessionRetry.retryable(request, retryProvider)).toBeUndefined()
190+
})
191+
185192
test("does not retry context overflow errors", () => {
186193
const error = new SessionV1.ContextOverflowError({
187194
message: "Input exceeds context window of this model",

0 commit comments

Comments
 (0)