diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index f2d08188b5..c31ac2471f 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,13 @@ export async function handleCodexAuthAPI( break; } if (st.done && st.error) { - setCodexLoginState(flowId, { status: "error", error: st.error, doneAt: Date.now() }); + setCodexLoginState(flowId, { + status: "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; break; } @@ -2011,7 +2017,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 +2034,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 +2042,8 @@ export async function handleCodexAuthAPI( response.headers.set("Retry-After", "1"); return response; } - return jsonResponse({ error: msg }, 500); + const { publicOAuthAuthenticationErrorMessage } = await import("../oauth"); + return jsonResponse({ error: publicOAuthAuthenticationErrorMessage(e) }, 500); } } diff --git a/src/oauth/index.ts b/src/oauth/index.ts index afc14a6621..bd399da25e 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -292,12 +292,38 @@ 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; } } +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) { + 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 OAuthProviderPublicationError + || 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 } : {}), @@ -1136,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); @@ -1381,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?.(); @@ -1390,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); @@ -1403,7 +1436,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); }; @@ -1414,9 +1447,10 @@ 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 = 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 649c5dd1de..d9a20e37f2 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,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: err instanceof Error ? err.message : String(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); } } @@ -208,7 +215,8 @@ 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); } if (url.pathname === "/api/oauth/logout" && req.method === "POST") { diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 2df5160984..a78faa6dba 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,7 @@ import { getOAuthCredentialApiBaseUrl, getValidAccessTokenForAccount, getValidAccessTokenSnapshot, + publicOAuthAuthenticationErrorMessage, type OAuthAccessSnapshot, UnsupportedOAuthProviderError, } from "../../oauth"; @@ -349,8 +349,6 @@ function isFixedCodexAccount(authCtx: CodexAuthContext): boolean { && authCtx.fixedAccount === true; } - - export function usesCodexForwardPoolAuth( authCtx: CodexAuthContext, provider: OcxProviderConfig, @@ -2105,13 +2103,14 @@ 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 ${getConfigPath()}.`, + `${redactSecretString(err.message)}. Remove or reconfigure provider '${safeProviderName}' 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 +3706,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/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..133dfe035e 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -3365,6 +3365,189 @@ 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 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 }> = [ + { + 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", + }, + { + error: new OAuthMutationBusyError(), + expected: "OAuth mutation queue is busy", + }, + ]; + 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"); + 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, + 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(); + 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 { 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(() => {}); + 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", + }, + { + error: new OAuthMutationBusyError(), + expected: "OAuth mutation queue is busy", + }, + ]; + 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"); + } + }); + 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-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 9121d14c51..d12861098a 100644 --- a/tests/oauth-status-privacy.test.ts +++ b/tests/oauth-status-privacy.test.ts @@ -1,14 +1,32 @@ 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 { saveCredential } from "../src/oauth/store"; +import { + clearLoginState, + getLoginStatus, + getValidAccessToken, + OAuthLoginRequiredError, + OAuthProviderPublicationError, + OAuthTokenRefreshBusyError, + OAuthTokenRefreshStaleError, + OAUTH_PROVIDERS, + publicOAuthAuthenticationErrorMessage, + UnsupportedOAuthProviderError, +} from "../src/oauth"; +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"; +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 }); @@ -16,6 +34,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 }); @@ -158,6 +177,245 @@ 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("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 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", + ); + 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 () => { + 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 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: "" }); + 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("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", + }, + { + error: new OAuthMutationBusyError(), + expected: "OAuth mutation queue is busy", + }, + ]; + 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"); 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; 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;