From 8a86aaea626f7b811b210b46dc621321b5520492 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:09:38 +0900 Subject: [PATCH 1/6] fix(oauth): redact public authentication errors --- src/server/responses/core.ts | 19 ++++-- tests/oauth-status-privacy.test.ts | 33 +++++++++ tests/server-xai-oauth-401-replay.test.ts | 82 +++++++++++++++++++++-- 3 files changed, 122 insertions(+), 12 deletions(-) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 2df5160984..c1e7cd6db5 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -5,7 +5,6 @@ import { checkInputAdmission } from "./input-admission"; import { nativeContextLimits } from "../../codex/catalog"; import { describeUpstreamConnectFailure } from "./upstream-error"; import { - getConfigPath, multiAgentGuidanceEnabled, resolveEnvValue, } from "../../config"; @@ -62,6 +61,9 @@ import { getOAuthCredentialApiBaseUrl, getValidAccessTokenForAccount, getValidAccessTokenSnapshot, + OAuthLoginRequiredError, + OAuthTokenRefreshBusyError, + OAuthTokenRefreshStaleError, type OAuthAccessSnapshot, UnsupportedOAuthProviderError, } from "../../oauth"; @@ -349,7 +351,14 @@ function isFixedCodexAccount(authCtx: CodexAuthContext): boolean { && authCtx.fixedAccount === true; } - +function publicOAuthAuthenticationErrorMessage(error: unknown): string { + if ( + error instanceof OAuthLoginRequiredError + || error instanceof OAuthTokenRefreshBusyError + || error instanceof OAuthTokenRefreshStaleError + ) return error.message; + return "OAuth authentication failed. Check the OpenCodex account status and retry."; +} export function usesCodexForwardPoolAuth( authCtx: CodexAuthContext, @@ -2108,10 +2117,10 @@ async function handleResponsesInner( return formatErrorResponse( 400, "invalid_request_error", - `${err.message}. Remove or reconfigure provider '${route.providerName}' in ${getConfigPath()}.`, + `${err.message}. Remove or reconfigure provider '${route.providerName}' in the OpenCodex configuration.`, ); } - return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err)); + return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err)); } } route.provider = resolveProviderTransport( @@ -3707,7 +3716,7 @@ async function handleResponsesInner( refreshed = await forceRefreshOAuthAccessSnapshot(sentOAuthSnapshot); } catch (err) { cleanupUpstreamAbort(); - return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err)); + return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err)); } sentOAuthSnapshot = refreshed; replayOAuthCredentialSnapshot = { diff --git a/tests/oauth-status-privacy.test.ts b/tests/oauth-status-privacy.test.ts index 9121d14c51..51d5b94bf6 100644 --- a/tests/oauth-status-privacy.test.ts +++ b/tests/oauth-status-privacy.test.ts @@ -3,6 +3,8 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync import { join } from "node:path"; import { getLoginStatus, getValidAccessToken, UnsupportedOAuthProviderError } from "../src/oauth"; import { saveCredential } from "../src/oauth/store"; +import { handleResponses } from "../src/server/responses"; +import type { OcxConfig } from "../src/types"; const TEST_DIR = join(import.meta.dir, ".tmp-oauth-status-privacy-test"); let previousOpencodexHome: string | undefined; @@ -158,6 +160,37 @@ describe("OAuth status privacy", () => { await expect(getValidAccessToken("removed-provider")).rejects.toBeInstanceOf(UnsupportedOAuthProviderError); }); + test("stale OAuth provider responses do not disclose the config path", async () => { + await saveCredential("removed-provider", { + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }); + const config = { + defaultProvider: "removed-provider", + providers: { + "removed-provider": { + adapter: "openai-responses", + authMode: "oauth", + baseUrl: "https://provider.example/v1", + }, + }, + } as OcxConfig; + + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "test-model", input: "hello", stream: false }), + }), config, { model: "", provider: "" }); + const body = await response.text(); + + expect(response.status).toBe(400); + expect(body).toContain("Unsupported OAuth provider"); + expect(body).toContain("Remove or reconfigure provider 'removed-provider'"); + expect(body).not.toContain(TEST_DIR); + expect(body).not.toContain("config.json"); + }); + test("malformed oauth token store is backed up before a new credential save overwrites it", async () => { const authPath = join(TEST_DIR, "auth.json"); writeFileSync(authPath, "{not valid json", "utf8"); diff --git a/tests/server-xai-oauth-401-replay.test.ts b/tests/server-xai-oauth-401-replay.test.ts index 3c79b3a3ff..0d3e03d0a3 100644 --- a/tests/server-xai-oauth-401-replay.test.ts +++ b/tests/server-xai-oauth-401-replay.test.ts @@ -12,6 +12,10 @@ import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isol const TOKEN_ENDPOINT = "https://auth.x.ai/oauth/token"; const CHAT_ENDPOINT = `${XAI_GROK_CLI_BASE_URL}/chat/completions`; +const PUBLIC_OAUTH_AUTHENTICATION_ERROR = "OAuth authentication failed. Check the OpenCodex account status and retry."; +const WINDOWS_PATH_CANARY = "C:\\Users\\Alice\\.opencodex\\auth.json.ocx-tmp"; +const UNC_PATH_CANARY = "\\\\server\\share\\opencodex\\auth.json.ocx-tmp"; +const POSIX_PATH_CANARY = "/home/alice/.opencodex/auth.json.ocx-tmp"; let testDir = ""; let previousHome: string | undefined; @@ -35,11 +39,11 @@ afterEach(() => { if (testDir) rmSync(testDir, { recursive: true, force: true }); }); -function seedOAuth(): void { - saveCredential("xai", { +async function seedOAuth(expires = Date.now() + 3_600_000): Promise { + await saveCredential("xai", { access: "rejected-access", refresh: "initial-refresh", - expires: Date.now() + 3_600_000, + expires, accountId: "xai-test-account", source: "oauth", }); @@ -79,7 +83,10 @@ async function post(server: ReturnType): Promise { }); } -function installOAuthFetch(chatStatuses: number[]): { chatAuth: string[]; counts: { refresh: number } } { +function installOAuthFetch( + chatStatuses: number[], + options: { tokenErrorDescription?: string } = {}, +): { chatAuth: string[]; counts: { refresh: number } } { const chatAuth: string[] = []; const counts = { refresh: 0 }; globalThis.fetch = (async (input, init) => { @@ -92,6 +99,15 @@ function installOAuthFetch(chatStatuses: number[]): { chatAuth: string[]; counts } if (url === TOKEN_ENDPOINT) { counts.refresh += 1; + if (options.tokenErrorDescription !== undefined) { + return new Response(JSON.stringify({ + error: "temporarily_unavailable", + error_description: options.tokenErrorDescription, + }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } return new Response(JSON.stringify({ access_token: "fresh-access", refresh_token: "fresh-refresh", @@ -115,8 +131,60 @@ function installOAuthFetch(chatStatuses: number[]): { chatAuth: string[]; counts } describe("xAI OAuth upstream 401 replay", () => { + test("initial OAuth refresh projects raw provider failures before responding", async () => { + await seedOAuth(0); + saveConfig(xaiConfig()); + const observed = installOAuthFetch([], { + tokenErrorDescription: `EACCES writing ${WINDOWS_PATH_CANARY}, ${UNC_PATH_CANARY}, or ${POSIX_PATH_CANARY}`, + }); + const server = startServer(0); + try { + const response = await post(server); + const json = await response.json() as { error?: { code?: string; message?: string; type?: string } }; + const message = json.error?.message ?? ""; + expect(response.status).toBe(401); + expect(json.error?.type).toBe("authentication_error"); + expect(json.error?.code).toBe("invalid_api_key"); + expect(message).toBe(PUBLIC_OAUTH_AUTHENTICATION_ERROR); + expect(message).not.toContain(WINDOWS_PATH_CANARY); + expect(message).not.toContain(UNC_PATH_CANARY); + expect(message).not.toContain(POSIX_PATH_CANARY); + expect(message).not.toContain("auth.json"); + expect(observed.counts.refresh).toBe(1); + expect(observed.chatAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + + test("OAuth 401 replay projects raw refresh failures before responding", async () => { + await seedOAuth(); + saveConfig(xaiConfig()); + const observed = installOAuthFetch([401], { + tokenErrorDescription: `EACCES writing ${WINDOWS_PATH_CANARY}, ${UNC_PATH_CANARY}, or ${POSIX_PATH_CANARY}`, + }); + const server = startServer(0); + try { + const response = await post(server); + const json = await response.json() as { error?: { code?: string; message?: string; type?: string } }; + const message = json.error?.message ?? ""; + expect(response.status).toBe(401); + expect(json.error?.type).toBe("authentication_error"); + expect(json.error?.code).toBe("invalid_api_key"); + expect(message).toBe(PUBLIC_OAUTH_AUTHENTICATION_ERROR); + expect(message).not.toContain(WINDOWS_PATH_CANARY); + expect(message).not.toContain(UNC_PATH_CANARY); + expect(message).not.toContain(POSIX_PATH_CANARY); + expect(message).not.toContain("auth.json"); + expect(observed.counts.refresh).toBe(1); + expect(observed.chatAuth).toEqual(["Bearer rejected-access"]); + } finally { + await server.stop(true); + } + }); + test("401 then 200 performs one refresh and one replay", async () => { - seedOAuth(); + await seedOAuth(); saveConfig(xaiConfig()); const observed = installOAuthFetch([401, 200]); const server = startServer(0); @@ -133,7 +201,7 @@ describe("xAI OAuth upstream 401 replay", () => { }); test("401 then 401 replays once and propagates the second error", async () => { - seedOAuth(); + await seedOAuth(); saveConfig(xaiConfig()); const observed = installOAuthFetch([401, 401]); const server = startServer(0); @@ -181,7 +249,7 @@ describe("xAI OAuth upstream 401 replay", () => { }); test("concurrent 401 responses join one IdP refresh", async () => { - seedOAuth(); + await seedOAuth(); saveConfig(xaiConfig()); let refreshCalls = 0; let signalRefreshStarted!: () => void; From 446d365247c8f1274dc45bd29e425c114201387c Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:21:24 +0900 Subject: [PATCH 2/6] fix(oauth): redact remaining public auth errors --- src/codex/auth-api.ts | 14 +- src/oauth/index.ts | 13 ++ src/server/management/oauth-account-routes.ts | 8 +- src/server/responses/core.ts | 16 +-- src/vision/anthropic-describe.ts | 16 ++- src/web-search/anthropic-executor.ts | 14 +- tests/codex-auth-api.test.ts | 75 ++++++++++ tests/oauth-status-privacy.test.ts | 132 +++++++++++++++++- tests/vision-anthropic.test.ts | 92 +++++++++++- tests/web-search-anthropic.test.ts | 69 ++++++++- 10 files changed, 407 insertions(+), 42 deletions(-) diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index f2d08188b5..a6cb1d44ee 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -1791,7 +1791,7 @@ export async function handleCodexAuthAPI( const loginOwner: CodexLoginStateRow = { status: "starting", startedAt: Date.now() }; codexAuthLoginState.set(flowId, loginOwner); try { - const { startLoginFlow, getLoginStatus } = await import("../oauth"); + const { startLoginFlow, getLoginStatus, publicOAuthAuthenticationErrorMessage } = await import("../oauth"); const result = await startLoginFlow("chatgpt", { forceLogin: true }); // Open the browser server-side (same pattern as /api/oauth/login in management-api.ts). @@ -1993,7 +1993,11 @@ export async function handleCodexAuthAPI( break; } if (st.done && st.error) { - setCodexLoginState(flowId, { status: "error", error: st.error, doneAt: Date.now() }); + setCodexLoginState(flowId, { + status: "error", + error: publicOAuthAuthenticationErrorMessage(new Error(st.error)), + doneAt: Date.now(), + }); completed = true; break; } @@ -2011,7 +2015,7 @@ export async function handleCodexAuthAPI( ? "Configuration is busy; retry login shortly." : error instanceof CodexCredentialRefreshBusyError || error instanceof CodexCredentialRefreshStaleError ? "Credential refresh is busy; retry login shortly." - : error instanceof Error ? error.message : String(error); + : publicOAuthAuthenticationErrorMessage(error); setCodexLoginState(flowId, { status: "error", error: message, @@ -2028,7 +2032,7 @@ export async function handleCodexAuthAPI( } catch (e) { if (codexAuthLoginState.get(flowId) === loginOwner) codexAuthLoginState.delete(flowId); const msg = e instanceof Error ? e.message : String(e); - if (msg.includes("already in progress")) { + if (msg === "A login for chatgpt is already in progress") { return jsonResponse({ error: msg, status: "pending" }, 409); } if (e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) { @@ -2036,7 +2040,7 @@ export async function handleCodexAuthAPI( response.headers.set("Retry-After", "1"); return response; } - return jsonResponse({ error: msg }, 500); + return jsonResponse({ error: "OAuth authentication failed. Check the OpenCodex account status and retry." }, 500); } } diff --git a/src/oauth/index.ts b/src/oauth/index.ts index afc14a6621..540a0d92b7 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -292,12 +292,25 @@ export class UnsupportedOAuthProviderError extends Error { } export class OAuthLoginRequiredError extends Error { + readonly provider: string; + constructor(provider: string) { super(`Not logged in to ${provider}. Run: ocx login ${provider}`); this.name = "OAuthLoginRequiredError"; + this.provider = provider; } } +/** Project arbitrary OAuth failures onto the small, stable public error vocabulary. */ +export function publicOAuthAuthenticationErrorMessage(error: unknown): string { + if ( + (error instanceof OAuthLoginRequiredError && isOAuthProvider(error.provider)) + || error instanceof OAuthTokenRefreshBusyError + || error instanceof OAuthTokenRefreshStaleError + ) return error.message; + return "OAuth authentication failed. Check the OpenCodex account status and retry."; +} + function accessSnapshot(provider: string, accountId: string, cred: OAuthCredentials): OAuthAccessSnapshot { const storedKiroRouting = { ...(cred.kiro?.profileArn ? { profileArn: cred.kiro.profileArn } : {}), diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 649c5dd1de..8578211645 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -19,6 +19,7 @@ import { getLoginStatus, isPublicOAuthProvider, listOAuthProviders, + publicOAuthAuthenticationErrorMessage, startLoginFlow, submitManualLoginCode, } from "../../oauth"; @@ -175,7 +176,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< return jsonResponse({ url: authUrl, instructions, deviceCode }); } catch (err) { if (err instanceof OAuthMutationBusyError) throw err; - return jsonResponse({ error: err instanceof Error ? err.message : String(err) }, 409); + return jsonResponse({ error: publicOAuthAuthenticationErrorMessage(err) }, 409); } } @@ -208,7 +209,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< if (url.pathname === "/api/oauth/status" && req.method === "GET") { const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase(); if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400); - return jsonResponse(getLoginStatus(provider)); + const status = getLoginStatus(provider); + return jsonResponse(status.error + ? { ...status, error: publicOAuthAuthenticationErrorMessage(new Error(status.error)) } + : status); } if (url.pathname === "/api/oauth/logout" && req.method === "POST") { diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index c1e7cd6db5..a78faa6dba 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -61,9 +61,7 @@ import { getOAuthCredentialApiBaseUrl, getValidAccessTokenForAccount, getValidAccessTokenSnapshot, - OAuthLoginRequiredError, - OAuthTokenRefreshBusyError, - OAuthTokenRefreshStaleError, + publicOAuthAuthenticationErrorMessage, type OAuthAccessSnapshot, UnsupportedOAuthProviderError, } from "../../oauth"; @@ -351,15 +349,6 @@ function isFixedCodexAccount(authCtx: CodexAuthContext): boolean { && authCtx.fixedAccount === true; } -function publicOAuthAuthenticationErrorMessage(error: unknown): string { - if ( - error instanceof OAuthLoginRequiredError - || error instanceof OAuthTokenRefreshBusyError - || error instanceof OAuthTokenRefreshStaleError - ) return error.message; - return "OAuth authentication failed. Check the OpenCodex account status and retry."; -} - export function usesCodexForwardPoolAuth( authCtx: CodexAuthContext, provider: OcxProviderConfig, @@ -2114,10 +2103,11 @@ async function handleResponsesInner( } } catch (err) { if (err instanceof UnsupportedOAuthProviderError) { + const safeProviderName = redactSecretString(route.providerName); return formatErrorResponse( 400, "invalid_request_error", - `${err.message}. Remove or reconfigure provider '${route.providerName}' in the OpenCodex configuration.`, + `${redactSecretString(err.message)}. Remove or reconfigure provider '${safeProviderName}' in the OpenCodex configuration.`, ); } return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err)); diff --git a/src/vision/anthropic-describe.ts b/src/vision/anthropic-describe.ts index 131e2ba802..faa4cfb68f 100644 --- a/src/vision/anthropic-describe.ts +++ b/src/vision/anthropic-describe.ts @@ -3,7 +3,7 @@ import { CLAUDE_CODE_HEADERS, claudeCodeSessionId } from "../adapters/client-fin import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort"; import { sidecarEnter } from "../lib/sidecar-tracker"; import { fetchWithResetRetry } from "../lib/upstream-retry"; -import { getValidAccessToken } from "../oauth"; +import { getValidAccessToken, publicOAuthAuthenticationErrorMessage } from "../oauth"; import { ANTHROPIC_OAUTH_BETA, CLAUDE_CODE_SYSTEM_INSTRUCTION } from "../oauth/anthropic"; import type { DescribeOutcome, VisionSettings } from "./describe"; @@ -67,8 +67,8 @@ export async function parseAnthropicVisionSSE(res: Response): Promise = { @@ -166,7 +166,11 @@ export async function describeImageAnthropic( if (!res.ok) { const responseText = await res.text().catch(() => ""); console.warn(`[vision] anthropic sidecar HTTP ${res.status} (${Date.now() - startedAt}ms)`); - return { text: "", error: `anthropic vision sidecar HTTP ${res.status}: ${responseText.slice(0, 200)}` }; + if (res.status === 401) { + return { text: "", error: `anthropic vision sidecar auth failed: ${publicOAuthAuthenticationErrorMessage(new Error(responseText))}` }; + } + // Upstream bodies are untrusted and may contain credentials, paths, or provider diagnostics. + return { text: "", error: `anthropic vision sidecar HTTP ${res.status}` }; } const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal); try { @@ -177,7 +181,7 @@ export async function describeImageAnthropic( } catch (error) { const kind = error instanceof Error && error.name === "TimeoutError" ? "timeout" : "connect_error"; console.warn(`[vision] anthropic sidecar ${kind} (${Date.now() - startedAt}ms)`); - return { text: "", error: error instanceof Error ? error.message : String(error) }; + return { text: "", error: `anthropic vision sidecar ${kind}` }; } finally { sidecarExit(); linkedSignal.cleanup(); diff --git a/src/web-search/anthropic-executor.ts b/src/web-search/anthropic-executor.ts index bb58f89be9..aeba03a829 100644 --- a/src/web-search/anthropic-executor.ts +++ b/src/web-search/anthropic-executor.ts @@ -1,9 +1,8 @@ import type { OcxProviderConfig } from "../types"; -import { getValidAccessToken } from "../oauth"; +import { getValidAccessToken, publicOAuthAuthenticationErrorMessage } from "../oauth"; import { ANTHROPIC_OAUTH_BETA, CLAUDE_CODE_SYSTEM_INSTRUCTION } from "../oauth/anthropic"; import { CLAUDE_CODE_HEADERS, claudeCodeSessionId } from "../adapters/client-fingerprint"; import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort"; -import { redactSecretString } from "../lib/redact"; import { sidecarEnter } from "../lib/sidecar-tracker"; import { fetchWithResetRetry } from "../lib/upstream-retry"; import type { WebSearchSource } from "./parse"; @@ -127,7 +126,7 @@ export async function runAnthropicWebSearch( try { token = await getValidAccessToken(providerName); } catch (e) { - return { text: "", sources: [], error: `anthropic sidecar auth failed: ${e instanceof Error ? e.message : String(e)}` }; + return { text: "", sources: [], error: `anthropic sidecar auth failed: ${publicOAuthAuthenticationErrorMessage(e)}` }; } const headers: Record = { "Content-Type": "application/json", @@ -174,8 +173,11 @@ export async function runAnthropicWebSearch( const t = await res.text().catch(() => ""); detachBodyGuard(); console.warn(`[web-search] anthropic sidecar HTTP ${res.status} for query "${query.slice(0, 80)}" (${Date.now() - t0}ms)`); - // Redact before surfacing: the body can echo auth headers/tokens (#398 review). - return { text: "", sources: [], error: `sidecar HTTP ${res.status}: ${redactSecretString(t.slice(0, 200))}` }; + if (res.status === 401) { + return { text: "", sources: [], error: `anthropic sidecar auth failed: ${publicOAuthAuthenticationErrorMessage(new Error(t))}` }; + } + // Upstream bodies are untrusted and may contain credentials, paths, or provider diagnostics. + return { text: "", sources: [], error: `sidecar HTTP ${res.status}` }; } try { return await parseAnthropicSidecarSSE(res); @@ -185,7 +187,7 @@ export async function runAnthropicWebSearch( } catch (e) { const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error"; console.warn(`[web-search] anthropic sidecar ${kind} for query "${query.slice(0, 80)}" (${Date.now() - t0}ms)`); - return { text: "", sources: [], error: e instanceof Error ? e.message : String(e) }; + return { text: "", sources: [], error: `anthropic sidecar ${kind}` }; } finally { sidecarExit(); linkedSignal.cleanup(); diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index bcd7eb4399..c7d13b8ac4 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -3365,6 +3365,81 @@ describe("codex-auth API", () => { expect(data.status).toBe("expired"); }); + test("Codex OAuth login responses project raw provider errors", async () => { + const oauth = await import("../src/oauth"); + const startSpy = spyOn(oauth, "startLoginFlow").mockImplementation(async () => { + throw new Error("already in progress at C:\\Users\\Alice\\.opencodex\\auth.json.ocx-tmp sk-secret-provider-key"); + }); + try { + const req = new Request("http://localhost/api/codex-auth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + const data = await resp!.json() as { error?: string }; + + expect(resp!.status).toBe(500); + expect(data.error).toBe("OAuth authentication failed. Check the OpenCodex account status and retry."); + expect(JSON.stringify(data)).not.toContain("Alice"); + expect(JSON.stringify(data)).not.toContain("sk-secret-provider-key"); + } finally { + startSpy.mockRestore(); + } + }); + + test("Codex OAuth login status projects late provider errors", async () => { + const oauth = await import("../src/oauth"); + const openUrlMod = await import("../src/lib/open-url"); + const startSpy = spyOn(oauth, "startLoginFlow").mockResolvedValue({ url: "https://example.test/oauth" }); + const statusSpy = spyOn(oauth, "getLoginStatus").mockReturnValue({ + done: true, + loggedIn: false, + error: "late failure at /home/alice/.opencodex/auth.json.ocx-tmp sk-secret-provider-key", + } as ReturnType); + const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation(() => {}); + const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation((( + callback: (...args: unknown[]) => void, + delay?: number, + ...args: unknown[] + ) => { + if (delay === 2_000) queueMicrotask(() => callback(...args)); + return 0 as unknown as ReturnType; + }) as typeof setTimeout); + try { + const req = new Request("http://localhost/api/codex-auth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + const startResponse = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + const started = await startResponse!.json() as { flowId: string }; + expect(startResponse!.status).toBe(200); + + let state: { status?: string; error?: string } = {}; + for (let attempt = 0; attempt < 50 && state.status !== "error"; attempt += 1) { + const statusReq = new Request( + `http://localhost/api/codex-auth/login-status?flowId=${encodeURIComponent(started.flowId)}`, + ); + const statusResponse = await handleCodexAuthAPI(statusReq, new URL(statusReq.url), makeConfig()); + state = await statusResponse!.json() as typeof state; + if (state.status !== "error") await new Promise(resolve => setImmediate(resolve)); + } + + expect(state).toMatchObject({ + status: "error", + error: "OAuth authentication failed. Check the OpenCodex account status and retry.", + }); + expect(JSON.stringify(state)).not.toContain("/home/alice"); + expect(JSON.stringify(state)).not.toContain("sk-secret-provider-key"); + } finally { + timeoutSpy.mockRestore(); + openSpy.mockRestore(); + statusSpy.mockRestore(); + startSpy.mockRestore(); + } + }); + test("POST /api/codex-auth/login/cancel expires the pending flow", async () => { const flowId = "flow-cancel-test"; const req = new Request("http://localhost/api/codex-auth/login/cancel", { diff --git a/tests/oauth-status-privacy.test.ts b/tests/oauth-status-privacy.test.ts index 51d5b94bf6..0ec7f155a1 100644 --- a/tests/oauth-status-privacy.test.ts +++ b/tests/oauth-status-privacy.test.ts @@ -1,16 +1,31 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { getLoginStatus, getValidAccessToken, UnsupportedOAuthProviderError } from "../src/oauth"; +import { + clearLoginState, + getLoginStatus, + getValidAccessToken, + OAuthLoginRequiredError, + OAuthTokenRefreshBusyError, + OAuthTokenRefreshStaleError, + OAUTH_PROVIDERS, + publicOAuthAuthenticationErrorMessage, + UnsupportedOAuthProviderError, +} from "../src/oauth"; import { saveCredential } from "../src/oauth/store"; +import { handleManagementAPI } from "../src/server/management-api"; import { handleResponses } from "../src/server/responses"; import type { OcxConfig } from "../src/types"; +import { ManagementRequest } from "./helpers/management-auth"; -const TEST_DIR = join(import.meta.dir, ".tmp-oauth-status-privacy-test"); +const TEST_DIR = join(import.meta.dir, `.tmp-oauth-status-privacy-test-${process.pid}`); +const PUBLIC_OAUTH_ERROR = "OAuth authentication failed. Check the OpenCodex account status and retry."; +const PUBLIC_ERROR_CANARY = "C:\\Users\\Alice\\.opencodex\\auth.json.ocx-tmp \\\\server\\share\\auth.json /home/alice/.opencodex/auth.json"; let previousOpencodexHome: string | undefined; describe("OAuth status privacy", () => { beforeEach(() => { + clearLoginState("xai"); previousOpencodexHome = process.env.OPENCODEX_HOME; if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); @@ -18,6 +33,7 @@ describe("OAuth status privacy", () => { }); afterEach(() => { + clearLoginState("xai"); if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousOpencodexHome; if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); @@ -191,6 +207,118 @@ describe("OAuth status privacy", () => { expect(body).not.toContain("config.json"); }); + test("OAuth responses redact token-shaped custom provider names", async () => { + const providerName = "sk-secret-provider-key"; + const config = { + defaultProvider: providerName, + providers: { + [providerName]: { + adapter: "openai-responses", + authMode: "oauth", + baseUrl: "https://provider.example/v1", + }, + }, + } as OcxConfig; + + const request = () => new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "test-model", input: "hello", stream: false }), + }); + const missingCredential = await handleResponses(request(), config, { model: "", provider: "" }); + const missingCredentialBody = await missingCredential.text(); + + expect(missingCredential.status).toBe(401); + expect(missingCredentialBody).toContain(PUBLIC_OAUTH_ERROR); + expect(missingCredentialBody).not.toContain(providerName); + + await saveCredential(providerName, { + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }); + const unsupportedProvider = await handleResponses(request(), config, { model: "", provider: "" }); + const unsupportedProviderBody = await unsupportedProvider.text(); + + expect(unsupportedProvider.status).toBe(400); + expect(unsupportedProviderBody).toContain("Unsupported OAuth provider"); + expect(unsupportedProviderBody).not.toContain(providerName); + }); + + test("public OAuth errors preserve only the fixed operational allowlist", () => { + expect(publicOAuthAuthenticationErrorMessage(new Error(PUBLIC_ERROR_CANARY))).toBe(PUBLIC_OAUTH_ERROR); + expect(publicOAuthAuthenticationErrorMessage(new OAuthLoginRequiredError("xai"))).toBe( + "Not logged in to xai. Run: ocx login xai", + ); + expect(publicOAuthAuthenticationErrorMessage(new OAuthLoginRequiredError(PUBLIC_ERROR_CANARY))) + .toBe(PUBLIC_OAUTH_ERROR); + expect(publicOAuthAuthenticationErrorMessage(new OAuthTokenRefreshBusyError())).toBe( + "OAuth token refresh capacity reached", + ); + expect(publicOAuthAuthenticationErrorMessage(new OAuthTokenRefreshStaleError())).toBe( + "OAuth token refresh owner became stale", + ); + }); + + test("management OAuth login does not return raw provider or filesystem errors", async () => { + const originalLogin = OAUTH_PROVIDERS.xai.login; + OAUTH_PROVIDERS.xai.login = async () => { + throw new Error(`provider login failed at ${PUBLIC_ERROR_CANARY}`); + }; + try { + const config = { port: 0, defaultProvider: "xai", providers: {} } as OcxConfig; + const request = new ManagementRequest("http://localhost/api/oauth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "xai" }), + }); + const response = await handleManagementAPI(request, new URL(request.url), config); + const body = await response?.json() as { error?: string }; + + expect(response?.status).toBe(409); + expect(body.error).toBe(PUBLIC_OAUTH_ERROR); + expect(JSON.stringify(body)).not.toContain(PUBLIC_ERROR_CANARY); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + clearLoginState("xai"); + } + }); + + test("management OAuth status does not return late provider or filesystem errors", async () => { + const originalLogin = OAUTH_PROVIDERS.xai.login; + OAUTH_PROVIDERS.xai.login = async (controller) => { + controller.onAuth({ url: "https://auth.example.test/authorize" }); + throw new Error(`late provider login failure at ${PUBLIC_ERROR_CANARY}`); + }; + try { + const config = { port: 0, defaultProvider: "xai", providers: {} } as OcxConfig; + const startRequest = new ManagementRequest("http://localhost/api/oauth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "xai" }), + }); + const startResponse = await handleManagementAPI(startRequest, new URL(startRequest.url), config); + expect(startResponse?.status).toBe(200); + + const deadline = Date.now() + 2_000; + let statusBody: { done?: boolean; error?: string } = {}; + do { + const statusRequest = new ManagementRequest("http://localhost/api/oauth/status?provider=xai"); + const statusResponse = await handleManagementAPI(statusRequest, new URL(statusRequest.url), config); + expect(statusResponse?.status).toBe(200); + statusBody = await statusResponse?.json() as typeof statusBody; + if (!statusBody.done) await Bun.sleep(10); + } while (!statusBody.done && Date.now() < deadline); + + expect(statusBody.done).toBe(true); + expect(statusBody.error).toBe(PUBLIC_OAUTH_ERROR); + expect(JSON.stringify(statusBody)).not.toContain(PUBLIC_ERROR_CANARY); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + clearLoginState("xai"); + } + }); + test("malformed oauth token store is backed up before a new credential save overwrites it", async () => { const authPath = join(TEST_DIR, "auth.json"); writeFileSync(authPath, "{not valid json", "utf8"); diff --git a/tests/vision-anthropic.test.ts b/tests/vision-anthropic.test.ts index bfa7d02664..d493f65471 100644 --- a/tests/vision-anthropic.test.ts +++ b/tests/vision-anthropic.test.ts @@ -4,16 +4,25 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import * as oauthModule from "../src/oauth"; -mock.module("../src/oauth", () => ({ ...oauthModule, getValidAccessToken: async () => "anthropic-vision-token" })); +let oauthAccessError: Error | undefined; +mock.module("../src/oauth", () => ({ + ...oauthModule, + getValidAccessToken: async () => { + if (oauthAccessError) throw oauthAccessError; + return "anthropic-vision-token"; + }, +})); import { CLAUDE_CODE_SYSTEM_INSTRUCTION } from "../src/oauth/anthropic"; import { parseRequest } from "../src/responses/parser"; import { handleManagementAPI } from "../src/server/management-api"; import type { OcxConfig, OcxProviderConfig } from "../src/types"; import { + describeImagesInPlace, describeImageAnthropic, parseAnthropicVisionSSE, planVisionSidecar, + type VisionPlan, } from "../src/vision"; const DATA_IMAGE = "data:image/png;base64,aGVsbG8="; @@ -23,6 +32,8 @@ const anthropicProvider: OcxProviderConfig = { baseUrl: "https://api.anthropic.test/v1/", }; const settings = { model: "claude-sonnet-5", timeoutMs: 5000 }; +const AUTH_ERROR_CANARY = "\\\\server\\share\\opencodex\\auth.json.ocx-tmp /home/alice/.opencodex/auth.json.ocx-tmp"; +const PUBLIC_OAUTH_ERROR = "OAuth authentication failed. Check the OpenCodex account status and retry."; function sseResponse( frames: Array | string>, @@ -56,17 +67,86 @@ function successSse(text = "A clear description"): Response { describe("Anthropic vision executor", () => { const originalFetch = globalThis.fetch; - afterEach(() => { globalThis.fetch = originalFetch; }); + afterEach(() => { + globalThis.fetch = originalFetch; + oauthAccessError = undefined; + }); + + test("projects OAuth, upstream-auth, and transport failures onto safe replacement errors", async () => { + oauthAccessError = new Error(`credential read failed at ${AUTH_ERROR_CANARY}`); + const credentialFailure = await describeImageAnthropic( + DATA_IMAGE, "high", "", "anthropic-vision-test", anthropicProvider, settings, + ); + expect(credentialFailure.error).toBe(`anthropic vision sidecar auth failed: ${PUBLIC_OAUTH_ERROR}`); + expect(credentialFailure.error).not.toContain(AUTH_ERROR_CANARY); + + oauthAccessError = undefined; + globalThis.fetch = (async () => new Response(AUTH_ERROR_CANARY, { status: 401 })) as typeof fetch; + const upstreamAuthFailure = await describeImageAnthropic( + DATA_IMAGE, "high", "", "anthropic-vision-test", anthropicProvider, settings, + ); + expect(upstreamAuthFailure.error).toBe(`anthropic vision sidecar auth failed: ${PUBLIC_OAUTH_ERROR}`); + expect(upstreamAuthFailure.error).not.toContain(AUTH_ERROR_CANARY); + + globalThis.fetch = (async () => new Response(AUTH_ERROR_CANARY, { status: 403 })) as typeof fetch; + const permissionFailure = await describeImageAnthropic( + DATA_IMAGE, "high", "", "anthropic-vision-test", anthropicProvider, settings, + ); + expect(permissionFailure.error).toBe("anthropic vision sidecar HTTP 403"); + expect(permissionFailure.error).not.toContain(AUTH_ERROR_CANARY); + + globalThis.fetch = (async () => new Response(AUTH_ERROR_CANARY, { status: 500 })) as typeof fetch; + const upstreamFailure = await describeImageAnthropic( + DATA_IMAGE, "high", "", "anthropic-vision-test", anthropicProvider, settings, + ); + expect(upstreamFailure.error).toBe("anthropic vision sidecar HTTP 500"); + expect(upstreamFailure.error).not.toContain(AUTH_ERROR_CANARY); + + globalThis.fetch = (async () => { throw new Error(`connect failed at ${AUTH_ERROR_CANARY}`); }) as typeof fetch; + const transportFailure = await describeImageAnthropic( + DATA_IMAGE, "high", "", "anthropic-vision-test", anthropicProvider, settings, + ); + expect(transportFailure.error).toBe("anthropic vision sidecar connect_error"); + expect(transportFailure.error).not.toContain(AUTH_ERROR_CANARY); + + oauthAccessError = new Error(`credential read failed at ${AUTH_ERROR_CANARY}`); + const parsed = parseRequest({ + model: "routed/text-only", + input: [{ + type: "message", + role: "user", + content: [ + { type: "input_text", text: "describe this image" }, + { type: "input_image", image_url: DATA_IMAGE }, + ], + }], + }); + const plan: VisionPlan = { + backend: "anthropic", + anthropicSidecar: { providerName: "anthropic-vision-test", provider: anthropicProvider }, + settings, + maxDescriptionsPerTurn: 1, + }; + await describeImagesInPlace(parsed, plan, new Headers()); + const projectedMessages = JSON.stringify(parsed.context.messages); + const projectedRawBody = JSON.stringify(parsed._rawBody); + expect(projectedMessages).toContain(PUBLIC_OAUTH_ERROR); + expect(projectedRawBody).toContain(PUBLIC_OAUTH_ERROR); + expect(projectedMessages).not.toContain(AUTH_ERROR_CANARY); + expect(projectedRawBody).not.toContain(AUTH_ERROR_CANARY); + expect(projectedRawBody).not.toContain(DATA_IMAGE); + }); test("a terminal stream error after partial text returns an error (never cacheable — review F1)", async () => { const res = sseResponse([ { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "partial" } }, - { type: "error", error: { type: "overloaded_error", message: "overloaded" } }, + { type: "error", error: { type: "overloaded_error", message: AUTH_ERROR_CANARY } }, ]); const out = await parseAnthropicVisionSSE(res); expect(out.text).toBe(""); - expect(out.error).toBeDefined(); + expect(out.error).toBe("anthropic vision sidecar stream error"); + expect(JSON.stringify(out)).not.toContain(AUTH_ERROR_CANARY); }); test("POSTs /v1/messages with the Claude Code OAuth fingerprint and a base64 image block", async () => { @@ -152,7 +232,7 @@ describe("Anthropic vision executor", () => { const terminal = await parseAnthropicVisionSSE(sseResponse([ { type: "error", error: { type: "overloaded_error", message: "overloaded" } }, ], { unterminated: true })); - expect(terminal).toEqual({ text: "", error: "overloaded" }); + expect(terminal).toEqual({ text: "", error: "anthropic vision sidecar stream error" }); }); test("returns graceful errors for aborts and timeouts and cancels the pending fetch", async () => { @@ -357,4 +437,4 @@ describe("Anthropic vision planning and management config", () => { } }); }); -import { ManagementRequest as Request } from "./helpers/management-auth"; \ No newline at end of file +import { ManagementRequest as Request } from "./helpers/management-auth"; diff --git a/tests/web-search-anthropic.test.ts b/tests/web-search-anthropic.test.ts index c18a0f0850..4f884b0a8a 100644 --- a/tests/web-search-anthropic.test.ts +++ b/tests/web-search-anthropic.test.ts @@ -3,7 +3,14 @@ import * as oauthModule from "../src/oauth"; // Stub the stored-OAuth token fetch so the anthropic executor request-shape test is deterministic // and never touches the real credential store or network (mirrors tests/destination-policy-resolved). -mock.module("../src/oauth", () => ({ ...oauthModule, getValidAccessToken: async () => "test-token-xyz" })); +let oauthAccessError: Error | undefined; +mock.module("../src/oauth", () => ({ + ...oauthModule, + getValidAccessToken: async () => { + if (oauthAccessError) throw oauthAccessError; + return "test-token-xyz"; + }, +})); import { parseRequest } from "../src/responses/parser"; import { @@ -18,6 +25,8 @@ import type { OcxConfig, OcxProviderConfig } from "../src/types"; const routedProvider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://routed.test/v1", apiKey: "routed-key" }; const forwardProvider: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://chatgpt.test/v1", authMode: "forward" }; const anthropicProvider: OcxProviderConfig = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "oauth" }; +const AUTH_ERROR_CANARY = "C:\\Users\\Alice\\.opencodex\\auth.json.ocx-tmp /home/alice/.opencodex/auth.json.ocx-tmp"; +const PUBLIC_OAUTH_ERROR = "OAuth authentication failed. Check the OpenCodex account status and retry."; function config(overrides: Partial = {}): OcxConfig { return { port: 10100, defaultProvider: "routed", providers: { routed: routedProvider, chatgpt: forwardProvider }, ...overrides }; @@ -165,7 +174,63 @@ describe("parseAnthropicSidecarSSE", () => { describe("runAnthropicWebSearch request shape", () => { const originalFetch = globalThis.fetch; - afterEach(() => { globalThis.fetch = originalFetch; }); + afterEach(() => { + globalThis.fetch = originalFetch; + oauthAccessError = undefined; + }); + + test("projects OAuth, upstream-auth, and transport failures onto safe public errors", async () => { + oauthAccessError = new Error(`credential read failed at ${AUTH_ERROR_CANARY}`); + const credentialFailure = await runAnthropicWebSearch( + "private query", + "anthropic", + anthropicProvider, + { model: "claude-sonnet-5", reasoning: "low", timeoutMs: 5000, describeImages: false }, + ); + expect(credentialFailure.error).toBe(`anthropic sidecar auth failed: ${PUBLIC_OAUTH_ERROR}`); + expect(credentialFailure.error).not.toContain(AUTH_ERROR_CANARY); + + oauthAccessError = undefined; + globalThis.fetch = (async () => new Response(AUTH_ERROR_CANARY, { status: 401 })) as typeof fetch; + const upstreamAuthFailure = await runAnthropicWebSearch( + "private query", + "anthropic", + anthropicProvider, + { model: "claude-sonnet-5", reasoning: "low", timeoutMs: 5000, describeImages: false }, + ); + expect(upstreamAuthFailure.error).toBe(`anthropic sidecar auth failed: ${PUBLIC_OAUTH_ERROR}`); + expect(upstreamAuthFailure.error).not.toContain(AUTH_ERROR_CANARY); + + globalThis.fetch = (async () => new Response(AUTH_ERROR_CANARY, { status: 403 })) as typeof fetch; + const permissionFailure = await runAnthropicWebSearch( + "private query", + "anthropic", + anthropicProvider, + { model: "claude-sonnet-5", reasoning: "low", timeoutMs: 5000, describeImages: false }, + ); + expect(permissionFailure.error).toBe("sidecar HTTP 403"); + expect(permissionFailure.error).not.toContain(AUTH_ERROR_CANARY); + + globalThis.fetch = (async () => new Response(AUTH_ERROR_CANARY, { status: 500 })) as typeof fetch; + const upstreamFailure = await runAnthropicWebSearch( + "private query", + "anthropic", + anthropicProvider, + { model: "claude-sonnet-5", reasoning: "low", timeoutMs: 5000, describeImages: false }, + ); + expect(upstreamFailure.error).toBe("sidecar HTTP 500"); + expect(upstreamFailure.error).not.toContain(AUTH_ERROR_CANARY); + + globalThis.fetch = (async () => { throw new Error(`connect failed at ${AUTH_ERROR_CANARY}`); }) as typeof fetch; + const transportFailure = await runAnthropicWebSearch( + "private query", + "anthropic", + anthropicProvider, + { model: "claude-sonnet-5", reasoning: "low", timeoutMs: 5000, describeImages: false }, + ); + expect(transportFailure.error).toBe("anthropic sidecar connect_error"); + expect(transportFailure.error).not.toContain(AUTH_ERROR_CANARY); + }); test("POSTs /v1/messages with the OAuth fingerprint, disabled thinking, and the web_search tool", async () => { let captured: { url: string; headers: Record; body: Record } | null = null; From 02acbc683f1d9430feab3cfd85f26843a6237642 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:39:54 +0900 Subject: [PATCH 3/6] fix(oauth): preserve actionable async login errors --- src/codex/auth-api.ts | 4 +- src/oauth/index.ts | 4 +- src/server/management/oauth-account-routes.ts | 12 ++- tests/codex-auth-api.test.ts | 78 ++++++++++++++++-- tests/oauth-status-privacy.test.ts | 82 ++++++++++++++++++- 5 files changed, 164 insertions(+), 16 deletions(-) diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index a6cb1d44ee..a3148587c1 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -1995,7 +1995,9 @@ export async function handleCodexAuthAPI( if (st.done && st.error) { setCodexLoginState(flowId, { status: "error", - error: publicOAuthAuthenticationErrorMessage(new Error(st.error)), + // startLoginFlow projects background failures before storing login status, so + // fixed actionable OAuth messages retain their type-derived remediation here. + error: st.error, doneAt: Date.now(), }); completed = true; diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 540a0d92b7..3b3761b911 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -1416,7 +1416,7 @@ export async function startLoginFlow( const e = finalError; loginAbort.delete(provider); clearManualCodeSlot(provider); - const msg = e instanceof Error ? e.message : String(e); + const msg = publicOAuthAuthenticationErrorMessage(e); loginState.set(provider, { done: true, error: msg }); if (!urlResolved) reject(e); }; @@ -1429,7 +1429,7 @@ export async function startLoginFlow( // settle catches lifecycle failures, so this is only a defensive promise-boundary guard. loginAbort.delete(provider); clearManualCodeSlot(provider); - const msg = e instanceof Error ? e.message : String(e); + const msg = publicOAuthAuthenticationErrorMessage(e); loginState.set(provider, { done: true, error: msg }); if (!urlResolved) reject(e); }); diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 8578211645..d9a20e37f2 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -176,7 +176,13 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< return jsonResponse({ url: authUrl, instructions, deviceCode }); } catch (err) { if (err instanceof OAuthMutationBusyError) throw err; - return jsonResponse({ error: publicOAuthAuthenticationErrorMessage(err) }, 409); + const message = err instanceof Error ? err.message : String(err); + const duplicateLoginMessage = `A login for ${provider} is already in progress`; + return jsonResponse({ + error: message === duplicateLoginMessage + ? duplicateLoginMessage + : publicOAuthAuthenticationErrorMessage(err), + }, 409); } } @@ -210,9 +216,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase(); if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400); const status = getLoginStatus(provider); - return jsonResponse(status.error - ? { ...status, error: publicOAuthAuthenticationErrorMessage(new Error(status.error)) } - : status); + return jsonResponse(status); } if (url.pathname === "/api/oauth/logout" && req.method === "POST") { diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index c7d13b8ac4..f583a6e26f 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -3391,12 +3391,11 @@ describe("codex-auth API", () => { test("Codex OAuth login status projects late provider errors", async () => { const oauth = await import("../src/oauth"); const openUrlMod = await import("../src/lib/open-url"); - const startSpy = spyOn(oauth, "startLoginFlow").mockResolvedValue({ url: "https://example.test/oauth" }); - const statusSpy = spyOn(oauth, "getLoginStatus").mockReturnValue({ - done: true, - loggedIn: false, - error: "late failure at /home/alice/.opencodex/auth.json.ocx-tmp sk-secret-provider-key", - } as ReturnType); + const originalLogin = oauth.OAUTH_PROVIDERS.chatgpt.login; + oauth.OAUTH_PROVIDERS.chatgpt.login = async (controller) => { + controller.onAuth({ url: "https://example.test/oauth" }); + throw new Error("late failure at /home/alice/.opencodex/auth.json.ocx-tmp sk-secret-provider-key"); + }; const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation(() => {}); const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation((( callback: (...args: unknown[]) => void, @@ -3435,8 +3434,71 @@ describe("codex-auth API", () => { } finally { timeoutSpy.mockRestore(); openSpy.mockRestore(); - statusSpy.mockRestore(); - startSpy.mockRestore(); + oauth.OAUTH_PROVIDERS.chatgpt.login = originalLogin; + oauth.clearLoginState("chatgpt"); + } + }); + + test("Codex OAuth login status preserves actionable late OAuth errors", async () => { + const oauth = await import("../src/oauth"); + const openUrlMod = await import("../src/lib/open-url"); + const originalLogin = oauth.OAUTH_PROVIDERS.chatgpt.login; + const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation(() => {}); + const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation((( + callback: (...args: unknown[]) => void, + delay?: number, + ...args: unknown[] + ) => { + if (delay === 2_000) queueMicrotask(() => callback(...args)); + return 0 as unknown as ReturnType; + }) as typeof setTimeout); + const cases: Array<{ error: Error; expected: string }> = [ + { + error: new oauth.OAuthLoginRequiredError("chatgpt"), + expected: "Not logged in to chatgpt. Run: ocx login chatgpt", + }, + { + error: new oauth.OAuthTokenRefreshBusyError(), + expected: "OAuth token refresh capacity reached", + }, + { + error: new oauth.OAuthTokenRefreshStaleError(), + expected: "OAuth token refresh owner became stale", + }, + ]; + try { + for (const { error, expected } of cases) { + oauth.OAUTH_PROVIDERS.chatgpt.login = async (controller) => { + controller.onAuth({ url: "https://example.test/oauth" }); + throw error; + }; + const req = new Request("http://localhost/api/codex-auth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + const startResponse = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + const started = await startResponse!.json() as { flowId: string }; + expect(startResponse!.status).toBe(200); + + let state: { status?: string; error?: string } = {}; + for (let attempt = 0; attempt < 50 && state.status !== "error"; attempt += 1) { + const statusReq = new Request( + `http://localhost/api/codex-auth/login-status?flowId=${encodeURIComponent(started.flowId)}`, + ); + const statusResponse = await handleCodexAuthAPI(statusReq, new URL(statusReq.url), makeConfig()); + state = await statusResponse!.json() as typeof state; + if (state.status !== "error") await new Promise(resolve => setImmediate(resolve)); + } + + expect(state).toMatchObject({ status: "error", error: expected }); + oauth.clearLoginState("chatgpt"); + } + } finally { + timeoutSpy.mockRestore(); + openSpy.mockRestore(); + oauth.OAUTH_PROVIDERS.chatgpt.login = originalLogin; + oauth.clearLoginState("chatgpt"); } }); diff --git a/tests/oauth-status-privacy.test.ts b/tests/oauth-status-privacy.test.ts index 0ec7f155a1..69849bbf05 100644 --- a/tests/oauth-status-privacy.test.ts +++ b/tests/oauth-status-privacy.test.ts @@ -284,10 +284,41 @@ describe("OAuth status privacy", () => { } }); + test("management OAuth login preserves the exact duplicate-flow response", async () => { + const originalLogin = OAUTH_PROVIDERS.xai.login; + OAUTH_PROVIDERS.xai.login = async (controller) => { + controller.onAuth({ url: "" }); + await new Promise((_, reject) => { + controller.signal.addEventListener("abort", () => reject(new Error("Login cancelled")), { once: true }); + }); + }; + const config = { port: 0, defaultProvider: "xai", providers: {} } as OcxConfig; + const request = () => new ManagementRequest("http://localhost/api/oauth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "xai" }), + }); + try { + const firstResponse = await handleManagementAPI(request(), new URL("http://localhost/api/oauth/login"), config); + expect(firstResponse?.status).toBe(200); + + const duplicateResponse = await handleManagementAPI(request(), new URL("http://localhost/api/oauth/login"), config); + const duplicateBody = await duplicateResponse?.json() as { error?: string }; + + expect(duplicateResponse?.status).toBe(409); + expect(duplicateBody.error).toBe("A login for xai is already in progress"); + } finally { + clearLoginState("xai"); + await Bun.sleep(0); + clearLoginState("xai"); + OAUTH_PROVIDERS.xai.login = originalLogin; + } + }); + test("management OAuth status does not return late provider or filesystem errors", async () => { const originalLogin = OAUTH_PROVIDERS.xai.login; OAUTH_PROVIDERS.xai.login = async (controller) => { - controller.onAuth({ url: "https://auth.example.test/authorize" }); + controller.onAuth({ url: "" }); throw new Error(`late provider login failure at ${PUBLIC_ERROR_CANARY}`); }; try { @@ -319,6 +350,55 @@ describe("OAuth status privacy", () => { } }); + test("management OAuth status preserves actionable late OAuth errors", async () => { + const originalLogin = OAUTH_PROVIDERS.xai.login; + const cases: Array<{ error: Error; expected: string }> = [ + { + error: new OAuthLoginRequiredError("xai"), + expected: "Not logged in to xai. Run: ocx login xai", + }, + { + error: new OAuthTokenRefreshBusyError(), + expected: "OAuth token refresh capacity reached", + }, + { + error: new OAuthTokenRefreshStaleError(), + expected: "OAuth token refresh owner became stale", + }, + ]; + const config = { port: 0, defaultProvider: "xai", providers: {} } as OcxConfig; + try { + for (const { error, expected } of cases) { + OAUTH_PROVIDERS.xai.login = async (controller) => { + controller.onAuth({ url: "" }); + throw error; + }; + const startRequest = new ManagementRequest("http://localhost/api/oauth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "xai" }), + }); + const startResponse = await handleManagementAPI(startRequest, new URL(startRequest.url), config); + expect(startResponse?.status).toBe(200); + + const deadline = Date.now() + 2_000; + let statusBody: { done?: boolean; error?: string } = {}; + do { + const statusRequest = new ManagementRequest("http://localhost/api/oauth/status?provider=xai"); + const statusResponse = await handleManagementAPI(statusRequest, new URL(statusRequest.url), config); + statusBody = await statusResponse?.json() as typeof statusBody; + if (!statusBody.done) await Bun.sleep(10); + } while (!statusBody.done && Date.now() < deadline); + + expect(statusBody).toMatchObject({ done: true, error: expected }); + clearLoginState("xai"); + } + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + clearLoginState("xai"); + } + }); + test("malformed oauth token store is backed up before a new credential save overwrites it", async () => { const authPath = join(TEST_DIR, "auth.json"); writeFileSync(authPath, "{not valid json", "utf8"); From 5eb5563fc0677ad986fa692b3f9bc12790955db3 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:45:45 +0900 Subject: [PATCH 4/6] fix(codex): reuse public OAuth error projection --- src/codex/auth-api.ts | 3 ++- tests/codex-auth-api.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index a3148587c1..c31ac2471f 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -2042,7 +2042,8 @@ export async function handleCodexAuthAPI( response.headers.set("Retry-After", "1"); return response; } - return jsonResponse({ error: "OAuth authentication failed. Check the OpenCodex account status and retry." }, 500); + const { publicOAuthAuthenticationErrorMessage } = await import("../oauth"); + return jsonResponse({ error: publicOAuthAuthenticationErrorMessage(e) }, 500); } } diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index f583a6e26f..a78a49b1ff 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -3388,6 +3388,42 @@ describe("codex-auth API", () => { } }); + test("Codex OAuth login responses preserve actionable OAuth errors", async () => { + const oauth = await import("../src/oauth"); + const startSpy = spyOn(oauth, "startLoginFlow"); + const cases: Array<{ error: Error; expected: string }> = [ + { + error: new oauth.OAuthLoginRequiredError("chatgpt"), + expected: "Not logged in to chatgpt. Run: ocx login chatgpt", + }, + { + error: new oauth.OAuthTokenRefreshBusyError(), + expected: "OAuth token refresh capacity reached", + }, + { + error: new oauth.OAuthTokenRefreshStaleError(), + expected: "OAuth token refresh owner became stale", + }, + ]; + try { + for (const { error, expected } of cases) { + startSpy.mockRejectedValueOnce(error); + const req = new Request("http://localhost/api/codex-auth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + const body = await response!.json() as { error?: string }; + + expect(response!.status).toBe(500); + expect(body.error).toBe(expected); + } + } finally { + startSpy.mockRestore(); + } + }); + test("Codex OAuth login status projects late provider errors", async () => { const oauth = await import("../src/oauth"); const openUrlMod = await import("../src/lib/open-url"); From c8ec047a9ef71a8307f600dce375784548a435a3 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:53:13 +0900 Subject: [PATCH 5/6] fix(oauth): preserve bounded mutation busy errors --- src/oauth/index.ts | 5 +++++ tests/codex-auth-api.test.ts | 10 ++++++++++ tests/oauth-status-privacy.test.ts | 15 ++++++++++++++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 3b3761b911..8d5209c891 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -303,6 +303,11 @@ export class OAuthLoginRequiredError extends Error { /** Project arbitrary OAuth failures onto the small, stable public error vocabulary. */ export function publicOAuthAuthenticationErrorMessage(error: unknown): string { + if (error instanceof OAuthMutationBusyError) { + return error.message === "OAuth mutation queue wait timed out" + ? "OAuth mutation queue wait timed out" + : "OAuth mutation queue is busy"; + } if ( (error instanceof OAuthLoginRequiredError && isOAuthProvider(error.provider)) || error instanceof OAuthTokenRefreshBusyError diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index a78a49b1ff..133dfe035e 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -3390,6 +3390,7 @@ describe("codex-auth API", () => { test("Codex OAuth login responses preserve actionable OAuth errors", async () => { const oauth = await import("../src/oauth"); + const { OAuthMutationBusyError } = await import("../src/oauth/store"); const startSpy = spyOn(oauth, "startLoginFlow"); const cases: Array<{ error: Error; expected: string }> = [ { @@ -3404,6 +3405,10 @@ describe("codex-auth API", () => { error: new oauth.OAuthTokenRefreshStaleError(), expected: "OAuth token refresh owner became stale", }, + { + error: new OAuthMutationBusyError(), + expected: "OAuth mutation queue is busy", + }, ]; try { for (const { error, expected } of cases) { @@ -3477,6 +3482,7 @@ describe("codex-auth API", () => { test("Codex OAuth login status preserves actionable late OAuth errors", async () => { const oauth = await import("../src/oauth"); + const { OAuthMutationBusyError } = await import("../src/oauth/store"); const openUrlMod = await import("../src/lib/open-url"); const originalLogin = oauth.OAUTH_PROVIDERS.chatgpt.login; const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation(() => {}); @@ -3501,6 +3507,10 @@ describe("codex-auth API", () => { error: new oauth.OAuthTokenRefreshStaleError(), expected: "OAuth token refresh owner became stale", }, + { + error: new OAuthMutationBusyError(), + expected: "OAuth mutation queue is busy", + }, ]; try { for (const { error, expected } of cases) { diff --git a/tests/oauth-status-privacy.test.ts b/tests/oauth-status-privacy.test.ts index 69849bbf05..30aab45788 100644 --- a/tests/oauth-status-privacy.test.ts +++ b/tests/oauth-status-privacy.test.ts @@ -12,7 +12,7 @@ import { publicOAuthAuthenticationErrorMessage, UnsupportedOAuthProviderError, } from "../src/oauth"; -import { saveCredential } from "../src/oauth/store"; +import { OAuthMutationBusyError, saveCredential } from "../src/oauth/store"; import { handleManagementAPI } from "../src/server/management-api"; import { handleResponses } from "../src/server/responses"; import type { OcxConfig } from "../src/types"; @@ -258,6 +258,15 @@ describe("OAuth status privacy", () => { expect(publicOAuthAuthenticationErrorMessage(new OAuthTokenRefreshStaleError())).toBe( "OAuth token refresh owner became stale", ); + expect(publicOAuthAuthenticationErrorMessage(new OAuthMutationBusyError())).toBe( + "OAuth mutation queue is busy", + ); + expect(publicOAuthAuthenticationErrorMessage(new OAuthMutationBusyError("OAuth mutation queue wait timed out"))).toBe( + "OAuth mutation queue wait timed out", + ); + expect(publicOAuthAuthenticationErrorMessage(new OAuthMutationBusyError(PUBLIC_ERROR_CANARY))).toBe( + "OAuth mutation queue is busy", + ); }); test("management OAuth login does not return raw provider or filesystem errors", async () => { @@ -365,6 +374,10 @@ describe("OAuth status privacy", () => { error: new OAuthTokenRefreshStaleError(), expected: "OAuth token refresh owner became stale", }, + { + error: new OAuthMutationBusyError(), + expected: "OAuth mutation queue is busy", + }, ]; const config = { port: 0, defaultProvider: "xai", providers: {} } as OcxConfig; try { From e298b2d80467569c7b8e1f1f4f54447a9b88d66a Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:03:20 +0900 Subject: [PATCH 6/6] fix(oauth): preserve terminal login outcomes --- src/oauth/index.ts | 24 ++++++++++++++++---- tests/oauth-public-surface.test.ts | 36 ++++++++++++++++++++++++++---- tests/oauth-status-privacy.test.ts | 4 ++++ 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 8d5209c891..bd399da25e 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -301,6 +301,13 @@ export class OAuthLoginRequiredError extends Error { } } +export class OAuthProviderPublicationError extends Error { + constructor() { + super("OAuth credential was saved, but the provider entry was not written. Resolve the account namespace collision, then retry login."); + this.name = "OAuthProviderPublicationError"; + } +} + /** Project arbitrary OAuth failures onto the small, stable public error vocabulary. */ export function publicOAuthAuthenticationErrorMessage(error: unknown): string { if (error instanceof OAuthMutationBusyError) { @@ -310,6 +317,7 @@ export function publicOAuthAuthenticationErrorMessage(error: unknown): string { } if ( (error instanceof OAuthLoginRequiredError && isOAuthProvider(error.provider)) + || error instanceof OAuthProviderPublicationError || error instanceof OAuthTokenRefreshBusyError || error instanceof OAuthTokenRefreshStaleError ) return error.message; @@ -1154,10 +1162,7 @@ export async function runLogin( provider, ); if (lateCollision) { - throw new Error( - `${lateCollision}. The credential for "${provider}" was saved, but the provider entry was not written. ` - + "Rename the account selector, then re-run the login.", - ); + throw new OAuthProviderPublicationError(); } upsertOAuthProvider(latestConfig, provider); saveLatestConfig(latestConfig); @@ -1399,7 +1404,16 @@ export async function startLoginFlow( onManualCodeInput: (expectedState?: string) => waitForManualLoginCode(provider, abort.signal, expectedState), signal: abort.signal, }; + const abandonIfNotOwner = (error?: unknown): boolean => { + if (loginAbort.get(provider) === abort) return false; + if (!urlResolved) reject(error ?? new Error("OAuth login was superseded")); + return true; + }; const settle = async (error?: unknown): Promise => { + // Cancellation deletes this controller and records its own terminal result. A late provider + // rejection (or an older flow settling after a replacement starts) must not overwrite that + // state or delete the replacement flow's controller/manual-code slot. + if (abandonIfNotOwner(error)) return; let finalError = error; try { await lifecycle?.onSettled?.(); @@ -1408,6 +1422,7 @@ export async function startLoginFlow( // runtime config. For an already-failed login, keep the original recovery error. if (finalError === undefined) finalError = settleError; } + if (abandonIfNotOwner(finalError)) return; if (finalError === undefined) { loginAbort.delete(provider); clearManualCodeSlot(provider); @@ -1432,6 +1447,7 @@ export async function startLoginFlow( (e: unknown) => settle(e), ).catch((e: unknown) => { // settle catches lifecycle failures, so this is only a defensive promise-boundary guard. + if (abandonIfNotOwner(e)) return; loginAbort.delete(provider); clearManualCodeSlot(provider); const msg = publicOAuthAuthenticationErrorMessage(e); diff --git a/tests/oauth-public-surface.test.ts b/tests/oauth-public-surface.test.ts index 00f971cb39..788e5e4d56 100644 --- a/tests/oauth-public-surface.test.ts +++ b/tests/oauth-public-surface.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { + cancelLoginFlow, clearLoginState, getLoginStatus, isOAuthProvider, @@ -21,6 +22,7 @@ import { armClaudeCodeBaseline, loadConfig, saveConfig, saveConfigPreservingClau import { isApiAuthRequired, requireApiAuth } from "../src/server/auth-cors"; const TEST_DIR = join(import.meta.dir, ".tmp-oauth-public-surface"); +const PUBLIC_OAUTH_ERROR = "OAuth authentication failed. Check the OpenCodex account status and retry."; const previousHome = process.env.OPENCODEX_HOME; const canonical = { adapter: "openai-responses", @@ -203,7 +205,7 @@ describe("legacy ChatGPT OAuth public-surface exclusion", () => { try { await expect(runLogin("xai", {} as OAuthController)).rejects.toThrow( - /credential for "xai" was saved, but the provider entry was not written/, + "OAuth credential was saved, but the provider entry was not written. Resolve the account namespace collision, then retry login.", ); } finally { OAUTH_PROVIDERS.xai.login = originalLogin; @@ -386,7 +388,7 @@ describe("legacy ChatGPT OAuth public-surface exclusion", () => { }); const status = await waitForOAuthDone("xai"); expect(status.done).toBe(true); - expect(status.error).toBe("browser flow aborted"); + expect(status.error).toBe(PUBLIC_OAUTH_ERROR); } finally { OAUTH_PROVIDERS.xai.login = originalLogin; clearLoginState("xai"); @@ -415,7 +417,31 @@ describe("legacy ChatGPT OAuth public-surface exclusion", () => { }); const status = await waitForOAuthDone("xai"); expect(status.done).toBe(true); - expect(status.error).toBe("runtime reconciliation failed"); + expect(status.error).toBe(PUBLIC_OAUTH_ERROR); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + clearLoginState("xai"); + } + }); + + test("OAuth cancellation remains terminal after the provider rejects", async () => { + const originalLogin = OAUTH_PROVIDERS.xai.login; + OAUTH_PROVIDERS.xai.login = async (ctrl) => { + ctrl.onAuth({ url: "", deviceCode: "cancel-flow-device-code" }); + await new Promise((_, reject) => { + ctrl.signal.addEventListener("abort", () => reject(new Error("late provider abort after cancellation")), { once: true }); + }); + }; + + try { + await startLoginFlow("xai"); + expect(cancelLoginFlow("xai")).toBe(true); + await Bun.sleep(20); + + expect(getLoginStatus("xai")).toMatchObject({ + done: true, + error: "Login cancelled", + }); } finally { OAUTH_PROVIDERS.xai.login = originalLogin; clearLoginState("xai"); @@ -473,7 +499,9 @@ describe("legacy ChatGPT OAuth public-surface exclusion", () => { const status = await waitForOAuthDone("xai"); expect(status.loggedIn).toBe(true); - expect(status.error).toMatch(/credential for "xai" was saved, but the provider entry was not written/); + expect(status.error).toBe( + "OAuth credential was saved, but the provider entry was not written. Resolve the account namespace collision, then retry login.", + ); expect(getCredential("xai")?.access).toBe("route-collision-access"); expect(liveConfig).toMatchObject({ defaultProvider: "concurrent", diff --git a/tests/oauth-status-privacy.test.ts b/tests/oauth-status-privacy.test.ts index 30aab45788..d12861098a 100644 --- a/tests/oauth-status-privacy.test.ts +++ b/tests/oauth-status-privacy.test.ts @@ -6,6 +6,7 @@ import { getLoginStatus, getValidAccessToken, OAuthLoginRequiredError, + OAuthProviderPublicationError, OAuthTokenRefreshBusyError, OAuthTokenRefreshStaleError, OAUTH_PROVIDERS, @@ -252,6 +253,9 @@ describe("OAuth status privacy", () => { ); expect(publicOAuthAuthenticationErrorMessage(new OAuthLoginRequiredError(PUBLIC_ERROR_CANARY))) .toBe(PUBLIC_OAUTH_ERROR); + expect(publicOAuthAuthenticationErrorMessage(new OAuthProviderPublicationError())).toBe( + "OAuth credential was saved, but the provider entry was not written. Resolve the account namespace collision, then retry login.", + ); expect(publicOAuthAuthenticationErrorMessage(new OAuthTokenRefreshBusyError())).toBe( "OAuth token refresh capacity reached", );