Skip to content
Open
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
10 changes: 7 additions & 3 deletions src/CodexEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}
Expand Down
70 changes: 70 additions & 0 deletions src/__tests__/CodexACPAgent/auth-error-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down