diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 60cfda588..ac44e9698 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -8,6 +8,7 @@ - Fixed OpenAI Completions, Amazon Bedrock, and Cursor providers ignoring `onPayload` replacement payloads. The hook now transforms the actual request body sent upstream on these providers, matching the Anthropic/Gemini/OpenAI Responses replacement contract. `devin-agent` still does not fire the hook (its payload is a protobuf object). - Fixed Codex requests failing outright when the signed-in ChatGPT account is not entitled to the requested model; the exact model denial is now classified as an account-policy error so credential rotation can reach an entitled sibling account +- Fixed Perplexity email-OTP login after its verification response renamed the encrypted session token from `token` to `challenge_token`. ## [17.3.7] - 2026-08-17 diff --git a/packages/ai/src/registry/oauth/perplexity.ts b/packages/ai/src/registry/oauth/perplexity.ts index 403e08c67..5fbe4fb4c 100644 --- a/packages/ai/src/registry/oauth/perplexity.ts +++ b/packages/ai/src/registry/oauth/perplexity.ts @@ -201,6 +201,7 @@ async function httpEmailLogin(ctrl: OAuthController): Promise const verifyData = (await verifyResponse.json()) as { token?: string; + challenge_token?: string; status?: string; error_code?: string; text?: string; @@ -215,14 +216,16 @@ async function httpEmailLogin(ctrl: OAuthController): Promise }); } - if (!verifyData.token) { - throw new AIError.OAuthError("Perplexity OTP verification response missing token", { + const token = verifyData.challenge_token || verifyData.token; + if (!token || verifyData.error_code || (verifyData.status && verifyData.status !== "success")) { + const reason = verifyData.text ?? verifyData.error_code ?? verifyData.status ?? "missing token"; + throw new AIError.OAuthError(`Perplexity OTP verification response rejected: ${reason}`, { kind: "validation", provider: "perplexity", }); } - return jwtToCredentials(verifyData.token, trimmedEmail); + return jwtToCredentials(token, trimmedEmail); } // --------------------------------------------------------------------------- diff --git a/packages/ai/test/perplexity-login.test.ts b/packages/ai/test/perplexity-login.test.ts index 825a859fe..c0b79e560 100644 --- a/packages/ai/test/perplexity-login.test.ts +++ b/packages/ai/test/perplexity-login.test.ts @@ -17,54 +17,57 @@ afterEach(() => { }); describe("Perplexity email OTP login", () => { - it("replays cookies across the CSRF, email, and OTP requests", async () => { - vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Unexpected global fetch")); - const requests: CapturedRequest[] = []; - const fetchMock: FetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { - const url = new URL(input instanceof Request ? input.url : input.toString()); - requests.push({ path: url.pathname, cookie: new Headers(init?.headers).get("Cookie") }); + it.each(["token", "challenge_token"] as const)( + "replays cookies and accepts the %s OTP response field", + async tokenField => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Unexpected global fetch")); + const requests: CapturedRequest[] = []; + const fetchMock: FetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : input.toString()); + requests.push({ path: url.pathname, cookie: new Headers(init?.headers).get("Cookie") }); - if (url.pathname.endsWith("/csrf")) { - const headers = new Headers({ "Content-Type": "application/json" }); - headers.append("Set-Cookie", "next-auth.csrf-token=csrf-cookie; Path=/; HttpOnly; Secure"); - headers.append("Set-Cookie", "__cf_bm=cloudflare-cookie; Path=/; Secure"); - return new Response(JSON.stringify({ csrfToken: "csrf-token" }), { status: 200, headers }); - } - if (url.pathname.endsWith("/signin-email")) { - return new Response("{}", { + if (url.pathname.endsWith("/csrf")) { + const headers = new Headers({ "Content-Type": "application/json" }); + headers.append("Set-Cookie", "next-auth.csrf-token=csrf-cookie; Path=/; HttpOnly; Secure"); + headers.append("Set-Cookie", "__cf_bm=cloudflare-cookie; Path=/; Secure"); + return new Response(JSON.stringify({ csrfToken: "csrf-token" }), { status: 200, headers }); + } + if (url.pathname.endsWith("/signin-email")) { + return new Response("{}", { + status: 200, + headers: { "Set-Cookie": "next-auth.callback-url=callback-cookie; Path=/; HttpOnly; Secure" }, + }); + } + return new Response(JSON.stringify({ [tokenField]: "perplexity-jwe", status: "success" }), { status: 200, - headers: { "Set-Cookie": "next-auth.callback-url=callback-cookie; Path=/; HttpOnly; Secure" }, + headers: { "Content-Type": "application/json" }, }); - } - return new Response(JSON.stringify({ token: "perplexity-jwt", status: "success" }), { - status: 200, - headers: { "Content-Type": "application/json" }, }); - }); - const answers = ["user@example.com", "123456"]; + const answers = ["user@example.com", "123456"]; - await withEnv({ PI_AUTH_NO_BORROW: "1" }, async () => { - const credentials = await loginPerplexity({ - fetch: fetchMock, - onPrompt: async () => answers.shift() ?? "", + await withEnv({ PI_AUTH_NO_BORROW: "1" }, async () => { + const credentials = await loginPerplexity({ + fetch: fetchMock, + onPrompt: async () => answers.shift() ?? "", + }); + expect(credentials.access).toBe("perplexity-jwe"); }); - expect(credentials.access).toBe("perplexity-jwt"); - }); - expect(requests.map(request => request.path)).toEqual([ - "/api/auth/csrf", - "/api/auth/signin-email", - "/api/auth/signin-otp", - ]); - expect(requests[0]?.cookie).toBeNull(); - expect(cookiePairs(requests[1]?.cookie ?? null)).toEqual( - new Set(["next-auth.csrf-token=csrf-cookie", "__cf_bm=cloudflare-cookie"]), - ); - expect(cookiePairs(requests[2]?.cookie ?? null)).toEqual( - new Set([ - "next-auth.csrf-token=csrf-cookie", - "__cf_bm=cloudflare-cookie", - "next-auth.callback-url=callback-cookie", - ]), - ); - }); + expect(requests.map(request => request.path)).toEqual([ + "/api/auth/csrf", + "/api/auth/signin-email", + "/api/auth/signin-otp", + ]); + expect(requests[0]?.cookie).toBeNull(); + expect(cookiePairs(requests[1]?.cookie ?? null)).toEqual( + new Set(["next-auth.csrf-token=csrf-cookie", "__cf_bm=cloudflare-cookie"]), + ); + expect(cookiePairs(requests[2]?.cookie ?? null)).toEqual( + new Set([ + "next-auth.csrf-token=csrf-cookie", + "__cf_bm=cloudflare-cookie", + "next-auth.callback-url=callback-cookie", + ]), + ); + }, + ); });