From 9c687262ed3b7c69513b1ee13a935a4b82ddb4fc Mon Sep 17 00:00:00 2001 From: shiruixing Date: Fri, 11 Sep 2026 11:16:21 +0800 Subject: [PATCH] fix: return AuthRequired for expired ChatGPT credentials Return the standard ACP authentication error for terminal ChatGPT auth failures even when the session still has a saved account. Preserve existing handling for configured API keys, custom providers, retryable errors, and negotiated typed failures. Add regression coverage and document the standard-client behavior. Validated with 62 related tests, typecheck, and build. Live expired-login recovery has not been tested end to end. --- README.md | 6 ++ src/CodexEventHandler.ts | 10 ++- .../CodexACPAgent/auth-error-events.test.ts | 70 +++++++++++++++++++ 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f43ad986..f63908a2 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,12 @@ The adapter advertises ACP auth methods during initialization. Clients can authe - API key via `CODEX_API_KEY` or `OPENAI_API_KEY`. - A custom OpenAI-compatible gateway, when the client opts in to the gateway auth capability. +For standard ACP clients, terminal ChatGPT authentication failures return +`AuthRequired` (`-32000`), even when the session still has a saved account. Clients +can use the advertised ChatGPT auth method to sign in again. Retryable errors do +not interrupt the prompt, and configured API-key or custom-provider failures keep +their existing error handling. + ## Runtime options - `CODEX_API_KEY` - API key used when the API-key auth method is selected. Takes precedence over `OPENAI_API_KEY`. diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 7b567541..9e466c3f 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -1102,9 +1102,13 @@ export class CodexEventHandler { this.createTurnErrorData(params.error), ); } else if (this.isAuthenticationRequiredError(error)) { - this.failure = this.sessionState.authConfigured - ? RequestError.internalError(this.createTurnErrorData(params.error)) - : RequestError.authRequired(this.createTurnErrorData(params.error), params.error.message); + // A saved ChatGPT account can outlive its credentials. Standard ACP + // clients need AuthRequired to offer login again after a terminal 401. + const canLoginAgain = this.sessionState.account?.type === "chatgpt" + && (this.sessionState.authProvider === null || this.sessionState.authProvider === "openai"); + this.failure = !this.sessionState.authConfigured || canLoginAgain + ? RequestError.authRequired(this.createTurnErrorData(params.error), params.error.message) + : RequestError.internalError(this.createTurnErrorData(params.error)); } return createAgentTextMessageChunk(`${params.error.message}\n\n`); } diff --git a/src/__tests__/CodexACPAgent/auth-error-events.test.ts b/src/__tests__/CodexACPAgent/auth-error-events.test.ts index 860740cc..8405b009 100644 --- a/src/__tests__/CodexACPAgent/auth-error-events.test.ts +++ b/src/__tests__/CodexACPAgent/auth-error-events.test.ts @@ -75,7 +75,77 @@ const typedFailureCapabilities: acp.ClientCapabilities = { _meta: {jetbrains: {air: {version: 1, capabilities: ["sessionFailure"]}}}, }; +const expiredChatGptError: ErrorNotification["error"] = { + message: "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again.", + codexErrorInfo: "unauthorized", + additionalDetails: null, + misalignment: null, +}; + describe("CodexEventHandler - auth error events", () => { + it.each([null, "openai"])("returns standard AuthRequired for expired ChatGPT credentials with provider %s", async (authProvider) => { + const {result} = await runPromptWithError(createTestSessionState({ + sessionId: "expired-chatgpt-session", + account: {type: "chatgpt", email: "test@example.com", planType: "pro"}, + authConfigured: true, + authProvider, + }), expiredChatGptError); + + // Check the JSON-RPC payload a standard ACP client receives, without extensions. + expect(JSON.parse(JSON.stringify(result))).toMatchObject({ + code: -32000, + data: { + message: expiredChatGptError.message, + codexErrorInfo: "unauthorized", + }, + }); + }); + + it("returns AuthRequired for a terminal ChatGPT HTTP 401", async () => { + const {result} = await runPromptWithError(createTestSessionState({ + account: {type: "chatgpt", email: "test@example.com", planType: "pro"}, + authConfigured: true, + }), { + ...expiredChatGptError, + codexErrorInfo: {responseStreamDisconnected: {httpStatusCode: 401}}, + }); + + expect(result).toMatchObject({code: -32000}); + }); + + it("does not request ChatGPT login for a custom provider using a cached ChatGPT account", async () => { + const {result} = await runPromptWithError(createTestSessionState({ + account: {type: "chatgpt", email: "test@example.com", planType: "pro"}, + authConfigured: true, + authProvider: "custom-provider", + }), expiredChatGptError); + + expect(result).toMatchObject({code: -32603}); + }); + + it("keeps retryable ChatGPT auth errors non-terminal", async () => { + const {result, updates} = await runPromptWithError(createTestSessionState({ + account: {type: "chatgpt", email: "test@example.com", planType: "pro"}, + authConfigured: true, + }), expiredChatGptError, true); + + expect(result).toMatchObject({stopReason: "end_turn"}); + expect(updates).toEqual([expect.objectContaining({ + sessionUpdate: "session_info_update", + })]); + }); + + it("preserves negotiated typed failures for expired ChatGPT credentials", async () => { + const {result} = await runPromptWithError(createTestSessionState({ + account: {type: "chatgpt", email: "test@example.com", planType: "pro"}, + authConfigured: true, + }), expiredChatGptError, false, typedFailureCapabilities); + + expect(result).toMatchObject({ + _meta: {jetbrains: {air: {sessionFailure: {category: "access", actions: ["login"]}}}}, + }); + }); + it("publishes a typed terminal failure instead of assistant text when AIR negotiated it", async () => { const {result, updates} = await runPromptWithError(createTestSessionState({ sessionId: "typed-failure-session",