From e9109192dfe53aae1622facc3c7d914d90ad1513 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/7] 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 59d7f1bfd4..cf492d449d 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -9,7 +9,6 @@ import { checkInputAdmission } from "./input-admission"; import { nativeContextLimits } from "../../codex/catalog"; import { describeUpstreamConnectFailure } from "./upstream-error"; import { - getConfigPath, multiAgentGuidanceEnabled, resolveEnvValue, } from "../../config"; @@ -67,6 +66,9 @@ import { getOAuthCredentialApiBaseUrl, getValidAccessTokenForAccount, getValidAccessTokenSnapshot, + OAuthLoginRequiredError, + OAuthTokenRefreshBusyError, + OAuthTokenRefreshStaleError, type OAuthAccessSnapshot, UnsupportedOAuthProviderError, } from "../../oauth"; @@ -373,7 +375,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, @@ -2164,10 +2173,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( @@ -3807,7 +3816,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 47f4c1aba2a63aa6b7d9755afa10b1dec7d389bc 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/7] 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 c2acb8a1bd..4fb261ed43 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -1818,7 +1818,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). @@ -2020,7 +2020,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; } @@ -2038,7 +2042,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, @@ -2055,7 +2059,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) { @@ -2063,7 +2067,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 cf492d449d..d0cb1b5b13 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -66,9 +66,7 @@ import { getOAuthCredentialApiBaseUrl, getValidAccessTokenForAccount, getValidAccessTokenSnapshot, - OAuthLoginRequiredError, - OAuthTokenRefreshBusyError, - OAuthTokenRefreshStaleError, + publicOAuthAuthenticationErrorMessage, type OAuthAccessSnapshot, UnsupportedOAuthProviderError, } from "../../oauth"; @@ -375,15 +373,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, @@ -2170,10 +2159,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 38f1611a83..e77aec6a44 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -3508,6 +3508,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 1656da582a550d17cd8620e99248b672dbfbf005 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/7] 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 4fb261ed43..9b1ac1e777 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -2022,7 +2022,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 e77aec6a44..4a7c2c2414 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -3534,12 +3534,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, @@ -3578,8 +3577,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 3eb47d20c9951165577939c98999269298723c8b 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/7] 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 9b1ac1e777..2f20d25f8d 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -2069,7 +2069,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 4a7c2c2414..4140c12bf7 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -3531,6 +3531,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 5b8c024cb9090f715820c40d491dcf49900ccd22 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/7] 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 4140c12bf7..f096c33c8d 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -3533,6 +3533,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 }> = [ { @@ -3547,6 +3548,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) { @@ -3620,6 +3625,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(() => {}); @@ -3644,6 +3650,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 d5dbb28c47f546ee7900c0c8d706987b42095dbf 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/7] 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", ); From e1e43133281cba5f952dfa3226a4d55d505365b5 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 20:15:24 +0900 Subject: [PATCH 7/7] fix(oauth): preserve typed reauth identity errors in public projection The public OAuth error projection from #1842 collapsed the fixed reauth-identity remediation messages (identity mismatch, unverifiable legacy identity) into the generic authentication failure, so the dashboard could no longer tell the user to sign in with the selected account. Represent both outcomes as bounded typed errors (OAuthReauthIdentityMismatchError, OAuthReauthIdentityUnverifiedError) whose messages carry no account, token, or email data, allowlist them in publicOAuthAuthenticationErrorMessage, and cover them in the projector allowlist and management status-polling regressions. Resolves the unresolved P2 review on #1842. Credit: original redaction work by @luvs01 in #1842. --- src/oauth/index.ts | 22 ++++++++++++++++++++-- tests/oauth-status-privacy.test.ts | 16 ++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/oauth/index.ts b/src/oauth/index.ts index bd399da25e..0162492f9a 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -308,6 +308,20 @@ export class OAuthProviderPublicationError extends Error { } } +export class OAuthReauthIdentityMismatchError extends Error { + constructor() { + super("Signed-in account does not match the selected account. Sign in with the same account."); + this.name = "OAuthReauthIdentityMismatchError"; + } +} + +export class OAuthReauthIdentityUnverifiedError extends Error { + constructor() { + super("Could not verify signed-in account identity for reauth."); + this.name = "OAuthReauthIdentityUnverifiedError"; + } +} + /** Project arbitrary OAuth failures onto the small, stable public error vocabulary. */ export function publicOAuthAuthenticationErrorMessage(error: unknown): string { if (error instanceof OAuthMutationBusyError) { @@ -318,6 +332,10 @@ export function publicOAuthAuthenticationErrorMessage(error: unknown): string { if ( (error instanceof OAuthLoginRequiredError && isOAuthProvider(error.provider)) || error instanceof OAuthProviderPublicationError + // Reauth identity outcomes carry fixed, account-free remediation text. Dropping them to the + // generic message hides WHICH failure the user must fix (sign in with the selected account). + || error instanceof OAuthReauthIdentityMismatchError + || error instanceof OAuthReauthIdentityUnverifiedError || error instanceof OAuthTokenRefreshBusyError || error instanceof OAuthTokenRefreshStaleError ) return error.message; @@ -1137,7 +1155,7 @@ export async function runLogin( const existing = getAccountCredential(provider, opts.reauthAccountId); if (!existing) throw new Error(`Unknown account for reauth: ${opts.reauthAccountId}`); if (!existing.accountId && !existing.email) { - throw new Error("Could not verify signed-in account identity for reauth."); + throw new OAuthReauthIdentityUnverifiedError(); } const identityMatches = existing.accountId && cred.accountId ? existing.accountId === cred.accountId @@ -1145,7 +1163,7 @@ export async function runLogin( ? existing.email.toLowerCase() === cred.email.toLowerCase() : false; if (!identityMatches) { - throw new Error("Signed-in account does not match the selected account. Sign in with the same account."); + throw new OAuthReauthIdentityMismatchError(); } await (deps.saveAccountCredential ?? saveAccountCredential)(provider, opts.reauthAccountId, cred); } else { diff --git a/tests/oauth-status-privacy.test.ts b/tests/oauth-status-privacy.test.ts index d12861098a..71dc18b6bb 100644 --- a/tests/oauth-status-privacy.test.ts +++ b/tests/oauth-status-privacy.test.ts @@ -7,6 +7,8 @@ import { getValidAccessToken, OAuthLoginRequiredError, OAuthProviderPublicationError, + OAuthReauthIdentityMismatchError, + OAuthReauthIdentityUnverifiedError, OAuthTokenRefreshBusyError, OAuthTokenRefreshStaleError, OAUTH_PROVIDERS, @@ -256,6 +258,12 @@ describe("OAuth status privacy", () => { 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 OAuthReauthIdentityMismatchError())).toBe( + "Signed-in account does not match the selected account. Sign in with the same account.", + ); + expect(publicOAuthAuthenticationErrorMessage(new OAuthReauthIdentityUnverifiedError())).toBe( + "Could not verify signed-in account identity for reauth.", + ); expect(publicOAuthAuthenticationErrorMessage(new OAuthTokenRefreshBusyError())).toBe( "OAuth token refresh capacity reached", ); @@ -370,6 +378,14 @@ describe("OAuth status privacy", () => { error: new OAuthLoginRequiredError("xai"), expected: "Not logged in to xai. Run: ocx login xai", }, + { + error: new OAuthReauthIdentityMismatchError(), + expected: "Signed-in account does not match the selected account. Sign in with the same account.", + }, + { + error: new OAuthReauthIdentityUnverifiedError(), + expected: "Could not verify signed-in account identity for reauth.", + }, { error: new OAuthTokenRefreshBusyError(), expected: "OAuth token refresh capacity reached",