diff --git a/extensions/openai/openai-codex-device-code.test.ts b/extensions/openai/openai-codex-device-code.test.ts index c1d03b821b6..28f99c80d88 100644 --- a/extensions/openai/openai-codex-device-code.test.ts +++ b/extensions/openai/openai-codex-device-code.test.ts @@ -19,74 +19,84 @@ function createJsonResponse(body: unknown, init?: { status?: number }) { describe("loginOpenAICodexDeviceCode", () => { it("requests a device code, polls for authorization, and exchanges OAuth tokens", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - createJsonResponse({ - device_auth_id: "device-auth-123", - user_code: "CODE-12345", - interval: "0", - }), - ) - .mockResolvedValueOnce(new Response(null, { status: 404 })) - .mockResolvedValueOnce( - createJsonResponse({ - authorization_code: "authorization-code-123", - code_challenge: "ignored", - code_verifier: "code-verifier-123", - }), - ) - .mockResolvedValueOnce( - createJsonResponse({ - access_token: createJwt({ - exp: Math.floor(Date.now() / 1000) + 600, - "https://api.openai.com/auth": { - chatgpt_account_id: "acct_123", - }, - "https://api.openai.com/profile": { - email: "codex@example.com", - }, + vi.useFakeTimers(); + try { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + createJsonResponse({ + device_auth_id: "device-auth-123", + user_code: "CODE-12345", + interval: "0", }), - refresh_token: "refresh-token-123", - id_token: createJwt({ - "https://api.openai.com/auth": { - chatgpt_account_id: "acct_123", - }, + ) + .mockResolvedValueOnce(new Response(null, { status: 404 })) + .mockResolvedValueOnce( + createJsonResponse({ + authorization_code: "authorization-code-123", + code_challenge: "ignored", + code_verifier: "code-verifier-123", }), - expires_in: 600, + ) + .mockResolvedValueOnce( + createJsonResponse({ + access_token: createJwt({ + exp: Math.floor(Date.now() / 1000) + 600, + "https://api.openai.com/auth": { + chatgpt_account_id: "acct_123", + }, + "https://api.openai.com/profile": { + email: "codex@example.com", + }, + }), + refresh_token: "refresh-token-123", + id_token: createJwt({ + "https://api.openai.com/auth": { + chatgpt_account_id: "acct_123", + }, + }), + expires_in: 600, + }), + ); + const onVerification = vi.fn(async () => {}); + const onProgress = vi.fn(); + + const credentialsPromise = loginOpenAICodexDeviceCode({ + fetchFn: fetchMock as typeof fetch, + onVerification, + onProgress, + }); + await vi.advanceTimersByTimeAsync(0); + expect(fetchMock).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(4_999); + expect(fetchMock).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + const credentials = await credentialsPromise; + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://auth.openai.com/api/accounts/deviceauth/usercode", + expect.objectContaining({ + method: "POST", }), ); - const onVerification = vi.fn(async () => {}); - const onProgress = vi.fn(); - - const credentials = await loginOpenAICodexDeviceCode({ - fetchFn: fetchMock as typeof fetch, - onVerification, - onProgress, - }); - - expect(fetchMock).toHaveBeenNthCalledWith( - 1, - "https://auth.openai.com/api/accounts/deviceauth/usercode", - expect.objectContaining({ - method: "POST", - }), - ); - expect(onVerification).toHaveBeenCalledWith({ - verificationUrl: "https://auth.openai.com/codex/device", - userCode: "CODE-12345", - expiresInMs: 900_000, - }); - expect(onProgress).toHaveBeenNthCalledWith(1, "Requesting device code…"); - expect(onProgress).toHaveBeenNthCalledWith(2, "Waiting for device authorization…"); - expect(onProgress).toHaveBeenNthCalledWith(3, "Exchanging device code…"); - expect(credentials).toMatchObject({ - access: expect.any(String), - refresh: "refresh-token-123", - accountId: "acct_123", - idToken: expect.any(String), - }); - expect(credentials.expires).toBeGreaterThan(Date.now()); + expect(onVerification).toHaveBeenCalledWith({ + verificationUrl: "https://auth.openai.com/codex/device", + userCode: "CODE-12345", + expiresInMs: 900_000, + }); + expect(onProgress).toHaveBeenNthCalledWith(1, "Requesting device code…"); + expect(onProgress).toHaveBeenNthCalledWith(2, "Waiting for device authorization…"); + expect(onProgress).toHaveBeenNthCalledWith(3, "Exchanging device code…"); + expect(credentials).toMatchObject({ + access: expect.any(String), + refresh: "refresh-token-123", + accountId: "acct_123", + }); + expect(credentials.expires).toBeGreaterThan(Date.now()); + } finally { + vi.useRealTimers(); + } }); it("treats JWT-derived expiry fallback as an absolute timestamp", async () => { @@ -129,17 +139,21 @@ describe("loginOpenAICodexDeviceCode", () => { }); it("surfaces user-code request failures", async () => { - const fetchMock = vi.fn().mockResolvedValueOnce(new Response(null, { status: 503 })); + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response(`down\r\n\u001B[31mnow\u001B[0m`, { + status: 503, + }), + ); await expect( loginOpenAICodexDeviceCode({ fetchFn: fetchMock as typeof fetch, onVerification: async () => {}, }), - ).rejects.toThrow("OpenAI device code request failed: HTTP 503"); + ).rejects.toThrow("OpenAI device code request failed: HTTP 503 down now"); }); - it("surfaces device authorization failures with payload details", async () => { + it("surfaces device authorization failures with sanitized payload details", async () => { const fetchMock = vi .fn() .mockResolvedValueOnce( @@ -152,8 +166,8 @@ describe("loginOpenAICodexDeviceCode", () => { .mockResolvedValueOnce( createJsonResponse( { - error: "authorization_declined", - error_description: "Denied", + error: "authorization_declined\r\n\u001B[31mspoofed\u001B[0m", + error_description: "Denied\r\nnext line", }, { status: 401 }, ), @@ -164,6 +178,8 @@ describe("loginOpenAICodexDeviceCode", () => { fetchFn: fetchMock as typeof fetch, onVerification: async () => {}, }), - ).rejects.toThrow("OpenAI device authorization failed: authorization_declined (Denied)"); + ).rejects.toThrow( + "OpenAI device authorization failed: authorization_declined spoofed (Denied next line)", + ); }); }); diff --git a/extensions/openai/openai-codex-device-code.ts b/extensions/openai/openai-codex-device-code.ts index 8179fe43f87..a2acef12722 100644 --- a/extensions/openai/openai-codex-device-code.ts +++ b/extensions/openai/openai-codex-device-code.ts @@ -9,6 +9,7 @@ const OPENAI_AUTH_BASE_URL = "https://auth.openai.com"; const OPENAI_CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; const OPENAI_CODEX_DEVICE_CODE_TIMEOUT_MS = 15 * 60_000; const OPENAI_CODEX_DEVICE_CODE_DEFAULT_INTERVAL_MS = 5_000; +const OPENAI_CODEX_DEVICE_CODE_MIN_INTERVAL_MS = 1_000; const OPENAI_CODEX_DEVICE_CALLBACK_URL = `${OPENAI_AUTH_BASE_URL}/deviceauth/callback`; type OpenAICodexDeviceCodePrompt = { @@ -22,7 +23,6 @@ type OpenAICodexDeviceCodeCredentials = { refresh: string; expires: number; accountId?: string; - idToken?: string; }; type DeviceCodeUserCodePayload = { @@ -58,11 +58,12 @@ type DeviceCodeAuthorizationCode = { }; function normalizePositiveMilliseconds(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value) && value >= 0) { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { return Math.trunc(value * 1000); } if (typeof value === "string" && /^\d+$/.test(value.trim())) { - return Number.parseInt(value.trim(), 10) * 1000; + const seconds = Number.parseInt(value.trim(), 10); + return seconds > 0 ? seconds * 1000 : undefined; } return undefined; } @@ -86,6 +87,27 @@ function parseJsonObject(text: string): Record | null { } } +function sanitizeDeviceCodeErrorText(value: string): string { + const esc = String.fromCharCode(0x1b); + const ansiCsiRegex = new RegExp(`${esc}\\[[\\u0020-\\u003f]*[\\u0040-\\u007e]`, "g"); + const osc8Regex = new RegExp(`${esc}\\]8;;.*?${esc}\\\\|${esc}\\]8;;${esc}\\\\`, "g"); + const c0Start = String.fromCharCode(0x00); + const c0End = String.fromCharCode(0x1f); + const del = String.fromCharCode(0x7f); + const controlCharsRegex = new RegExp(`[${c0Start}-${c0End}${del}]`, "g"); + return value + .replace(osc8Regex, "") + .replace(ansiCsiRegex, "") + .replace(controlCharsRegex, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function resolveNextDeviceCodePollDelayMs(intervalMs: number, deadlineMs: number): number { + const remainingMs = Math.max(0, deadlineMs - Date.now()); + return Math.min(Math.max(intervalMs, OPENAI_CODEX_DEVICE_CODE_MIN_INTERVAL_MS), remainingMs); +} + function formatDeviceCodeError(params: { prefix: string; status: number; @@ -94,13 +116,15 @@ function formatDeviceCodeError(params: { const body = parseJsonObject(params.bodyText); const error = trimNonEmptyString(body?.error); const description = trimNonEmptyString(body?.error_description); - if (error && description) { - return `${params.prefix}: ${error} (${description})`; + const safeError = error ? sanitizeDeviceCodeErrorText(error) : undefined; + const safeDescription = description ? sanitizeDeviceCodeErrorText(description) : undefined; + if (safeError && safeDescription) { + return `${params.prefix}: ${safeError} (${safeDescription})`; } - if (error) { - return `${params.prefix}: ${error}`; + if (safeError) { + return `${params.prefix}: ${safeError}`; } - const bodyText = params.bodyText.trim(); + const bodyText = sanitizeDeviceCodeErrorText(params.bodyText); return bodyText ? `${params.prefix}: HTTP ${params.status} ${bodyText}` : `${params.prefix}: HTTP ${params.status}`; @@ -185,7 +209,7 @@ async function pollOpenAICodexDeviceCode(params: { if (response.status === 403 || response.status === 404) { await new Promise((resolve) => - setTimeout(resolve, Math.max(0, Math.min(params.intervalMs, deadline - Date.now()))), + setTimeout(resolve, resolveNextDeviceCodePollDelayMs(params.intervalMs, deadline)), ); continue; } @@ -253,7 +277,6 @@ async function exchangeOpenAICodexDeviceCode(params: { refresh, expires, ...(accountId ? { accountId } : {}), - ...(idToken ? { idToken } : {}), }; } diff --git a/extensions/openai/openai-codex-provider.test.ts b/extensions/openai/openai-codex-provider.test.ts index b95b0f07c49..f4f4ec25549 100644 --- a/extensions/openai/openai-codex-provider.test.ts +++ b/extensions/openai/openai-codex-provider.test.ts @@ -253,7 +253,6 @@ describe("openai codex provider", () => { refresh: "device-refresh-token", expires: Date.now() + 60_000, accountId: "acct-device-123", - idToken: "device-id-token", }); const result = await deviceCodeMethod?.run({ @@ -285,12 +284,57 @@ describe("openai codex provider", () => { "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjdC1kZXZpY2UtMTIzIn19.signature", refresh: "device-refresh-token", accountId: "acct-device-123", - idToken: "device-id-token", }, }, ], defaultModel: "openai-codex/gpt-5.4", }); + expect(result?.profiles[0]?.credential).not.toHaveProperty("idToken"); + }); + + it("does not log the device pairing code in remote mode", async () => { + const provider = buildOpenAICodexProviderPlugin(); + const deviceCodeMethod = provider.auth?.find((method) => method.id === "device-code"); + const note = vi.fn(async () => {}); + const progress = { update: vi.fn(), stop: vi.fn() }; + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + loginOpenAICodexDeviceCodeMock.mockImplementationOnce(async ({ onVerification }) => { + await onVerification({ + verificationUrl: "https://auth.openai.com/codex/device", + userCode: "CODE-12345", + expiresInMs: 900_000, + }); + return { + access: + "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjdC1kZXZpY2UtMTIzIn19.signature", + refresh: "device-refresh-token", + expires: Date.now() + 60_000, + accountId: "acct-device-123", + }; + }); + + await expect( + deviceCodeMethod?.run({ + config: {}, + env: process.env, + prompter: { + note, + progress: vi.fn(() => progress), + } as never, + runtime: runtime as never, + isRemote: true, + openUrl: async () => {}, + oauth: { createVpsAwareHandlers: (() => ({})) as never }, + }), + ).resolves.toBeDefined(); + + const logOutput = runtime.log.mock.calls.flat().join("\n"); + expect(logOutput).toContain("https://auth.openai.com/codex/device"); + expect(logOutput).not.toContain("CODE-12345"); }); it("exposes Codex CLI auth as a runtime-only external profile", () => { diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index 263264a2cff..8c64d4f6c95 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -332,9 +332,7 @@ async function runOpenAICodexDeviceCode(ctx: ProviderAuthContext) { "OpenAI Codex device code", ); if (ctx.isRemote) { - ctx.runtime.log( - `\nOpen this URL in your LOCAL browser:\n\n${verificationUrl}\n\nEnter this code:\n\n${userCode}\n`, - ); + ctx.runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${verificationUrl}\n`); return; } try { @@ -359,10 +357,7 @@ async function runOpenAICodexDeviceCode(ctx: ProviderAuthContext) { expires: creds.expires, email: identity.email, profileName: identity.profileName, - credentialExtra: { - ...(trimNonEmptyString(creds.accountId) ? { accountId: creds.accountId } : {}), - ...(trimNonEmptyString(creds.idToken) ? { idToken: creds.idToken } : {}), - }, + credentialExtra: trimNonEmptyString(creds.accountId) ? { accountId: creds.accountId } : {}, }); } catch (error) { spin.stop("OpenAI device code failed");