Merge PR #8819: fix(ai): accept Perplexity OTP challenge token (@onsails)

This commit is contained in:
can1357
2026-08-19 01:36:58 +02:00
3 changed files with 54 additions and 47 deletions
+1
View File
@@ -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
+6 -3
View File
@@ -201,6 +201,7 @@ async function httpEmailLogin(ctrl: OAuthController): Promise<OAuthCredentials>
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<OAuthCredentials>
});
}
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);
}
// ---------------------------------------------------------------------------
+47 -44
View File
@@ -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",
]),
);
},
);
});