feat(auth): add browser cookie login fallback

This commit is contained in:
Ivan Pereira
2026-06-24 17:19:38 +01:00
parent 388235c602
commit d01cfc86bc
13 changed files with 716 additions and 45 deletions
+26 -6
View File
@@ -5,7 +5,7 @@ A [pi](https://github.com/badlogic/pi-mono) extension that gives your coding age
- [pi](https://github.com/badlogic/pi-mono) coding agent with its bundled Node runtime (Node 18.14.1+ if running outside pi) - [pi](https://github.com/badlogic/pi-mono) coding agent with its bundled Node runtime (Node 18.14.1+ if running outside pi)
- A **Perplexity Pro** or **Max** subscription - A **Perplexity Pro** or **Max** subscription
- macOS (for zero-interaction auth) _or_ an interactive terminal (for email OTP) - macOS (for zero-interaction auth), an interactive terminal (for email OTP), or a signed-in browser session cookie/token
## Installation ## Installation
@@ -21,16 +21,34 @@ pi install github:ivanrvpereira/pi-perplexity
## Authentication ## Authentication
Run the login command once to cache your token: Run login once:
``` ```
/perplexity-login /perplexity-login
``` ```
The extension tries two methods in order: This usually reuses an existing cached login, borrows the Perplexity macOS app login if available, or asks for your email OTP code.
1. **macOS Desktop App** _(zero interaction)_ — borrows the JWT directly from the Perplexity macOS app if it's installed and signed in. Nothing to type. If login fails with a Cloudflare “Just a moment...” page, use browser login instead:
2. **Email OTP** _(interactive fallback)_ — prompts for your Perplexity email, sends a one-time code, and prompts for the code.
```
/perplexity-login --browser
```
### Browser login
Use this on Linux/headless machines when direct OTP is blocked.
1. Open `https://www.perplexity.ai` and sign in.
2. Open browser DevTools → **Network**.
3. Reload the page, or ask one Perplexity question.
4. Right-click a `www.perplexity.ai` request, preferably `perplexity_ask`.
5. Choose **Copy****Copy as cURL**.
6. Paste the copied cURL command into the pi prompt.
The copied text must include cookies. A good copy contains one of `-b`, `--cookie`, or `Cookie:`, and should include `__Secure-next-auth.session-token`. If it does not, copy a different request.
You can also paste just the request `Cookie:` header, or just the `__Secure-next-auth.session-token` cookie value. Full cURL is recommended because it also includes Cloudflare cookies like `cf_clearance`.
The token is saved to `~/.config/pi-perplexity/auth.json` (mode `0600`) and reused across sessions. On auth failure, run `/perplexity-login --force` to clear and re-authenticate. The token is saved to `~/.config/pi-perplexity/auth.json` (mode `0600`) and reused across sessions. On auth failure, run `/perplexity-login --force` to clear and re-authenticate.
@@ -39,6 +57,8 @@ The token is saved to `~/.config/pi-perplexity/auth.json` (mode `0600`) and reus
| Variable | Description | | Variable | Description |
|---|---| |---|---|
| `PI_AUTH_NO_BORROW=1` | Skip macOS desktop app extraction and go straight to email OTP | | `PI_AUTH_NO_BORROW=1` | Skip macOS desktop app extraction and go straight to email OTP |
| `PI_PERPLEXITY_TOKEN` | Raw Perplexity session token/JWT/JWE copied from a browser or another machine |
| `PI_PERPLEXITY_COOKIE` / `PI_PERPLEXITY_COOKIES` | Full Perplexity `Cookie` header copied from a signed-in browser |
| `PI_PERPLEXITY_EMAIL` | Pre-fill the email prompt (useful for non-interactive setups) | | `PI_PERPLEXITY_EMAIL` | Pre-fill the email prompt (useful for non-interactive setups) |
| `PI_PERPLEXITY_OTP` | Pre-fill the OTP prompt | | `PI_PERPLEXITY_OTP` | Pre-fill the OTP prompt |
@@ -86,7 +106,7 @@ Queries default to `is_incognito: true`, but you can override that per call or v
## How It Works ## How It Works
The extension calls Perplexity's internal SSE endpoint (`perplexity_ask`) using your subscription credentials obtained from the macOS app or via email OTP. Responses stream as incremental events that are merged into a final result. Network calls use the Node runtime already provided by pi; no extra runtime is required. Email OTP auth requires `Headers.getSetCookie()` support so auth cookies are exposed reliably. The extension calls Perplexity's internal SSE endpoint (`perplexity_ask`) using your subscription credentials obtained from the macOS app, email OTP, or a browser-imported session. Responses stream as incremental events that are merged into a final result. Network calls use the Node runtime already provided by pi; no extra runtime is required. Email OTP auth requires `Headers.getSetCookie()` support so auth cookies are exposed reliably.
## Development ## Development
+300 -16
View File
@@ -1,7 +1,7 @@
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { AuthError } from "../search/types.js"; import { AuthError, type StoredToken } from "../search/types.js";
import { errorMessage } from "../render/util.js"; import { errorMessage } from "../render/util.js";
import { loadToken, saveToken } from "./storage.js"; import { loadToken, saveToken } from "./storage.js";
import { PERPLEXITY_USER_AGENT, PERPLEXITY_API_VERSION } from "../constants.js"; import { PERPLEXITY_USER_AGENT, PERPLEXITY_API_VERSION } from "../constants.js";
@@ -14,14 +14,32 @@ const DESKTOP_AUTH_HELP =
"Install the Perplexity desktop app and sign in, or set PI_AUTH_NO_BORROW=1 to skip desktop token borrowing."; "Install the Perplexity desktop app and sign in, or set PI_AUTH_NO_BORROW=1 to skip desktop token borrowing.";
const OTP_AUTH_HELP = const OTP_AUTH_HELP =
"Provide credentials via PI_PERPLEXITY_EMAIL and PI_PERPLEXITY_OTP, or run interactively to enter email and OTP."; "Provide credentials via PI_PERPLEXITY_EMAIL and PI_PERPLEXITY_OTP, or run interactively to enter email and OTP.";
const BROWSER_AUTH_HELP =
"If direct OTP is blocked by Cloudflare, sign in at https://www.perplexity.ai in a browser, then run /perplexity-login --browser and paste the copied cURL command, the Cookie request header, or the __Secure-next-auth.session-token value.";
const AUTH_BASE_URL = "https://www.perplexity.ai/api/auth"; const AUTH_BASE_URL = "https://www.perplexity.ai/api/auth";
const TOKEN_ENV_KEYS = ["PI_PERPLEXITY_TOKEN", "PI_PERPLEXITY_AUTH_TOKEN"] as const;
const COOKIE_ENV_KEYS = ["PI_PERPLEXITY_COOKIE", "PI_PERPLEXITY_COOKIES"] as const;
const SESSION_TOKEN_COOKIE_NAMES = [
"__Secure-next-auth.session-token",
"next-auth.session-token",
"perplexity_jwt",
"pplx_jwt",
] as const;
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
class BrowserChallengeError extends Error {
constructor(message: string) {
super(message);
this.name = "BrowserChallengeError";
}
}
export interface AuthenticateOptions { export interface AuthenticateOptions {
signal?: AbortSignal; signal?: AbortSignal;
promptForEmail?: () => Promise<string | null | undefined>; promptForEmail?: () => Promise<string | null | undefined>;
promptForOtp?: (email: string) => Promise<string | null | undefined>; promptForOtp?: (email: string) => Promise<string | null | undefined>;
promptForBrowserAuth?: () => Promise<string | null | undefined>;
} }
function normalizeInput(value: string | null | undefined): string | null { function normalizeInput(value: string | null | undefined): string | null {
@@ -29,6 +47,227 @@ function normalizeInput(value: string | null | undefined): string | null {
return trimmed ? trimmed : null; return trimmed ? trimmed : null;
} }
function stripMatchingQuotes(value: string): string {
const trimmed = value.trim();
if (trimmed.length >= 2) {
const first = trimmed[0];
const last = trimmed[trimmed.length - 1];
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
return trimmed.slice(1, -1).trim();
}
}
return trimmed;
}
function stripBearerPrefix(value: string): string {
return value.replace(/^Bearer\s+/i, "").trim();
}
function looksLikeToken(value: string): boolean {
const token = stripBearerPrefix(value);
const parts = token.split(".");
return token.length >= 40 && token.startsWith("eyJ") && (parts.length === 3 || parts.length === 5);
}
function decodeCookieValue(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function looksLikeCurlCommand(input: string): boolean {
return /^\s*curl(?:\s|$)/i.test(input);
}
function curlCommandHasCookieSource(input: string): boolean {
return (
/(?:^|\s)(?:-H|--header)(?:\s+|=)\$?(['"])Cookie\s*:/i.test(input) ||
/(?:^|\s)(?:-b|--cookie)(?:=|\s+)/i.test(input)
);
}
function extractCookieHeader(input: string): string {
const trimmed = stripMatchingQuotes(input);
const curlHeader = trimmed.match(/(?:^|\s)(?:-H|--header)(?:\s+|=)\$?(['"])Cookie:\s*([\s\S]*?)\1/i)?.[2];
if (curlHeader) {
return curlHeader.trim();
}
const quotedCurlCookieOption = trimmed.match(/(?:^|\s)(?:-b|--cookie)(?:\s+|=)\$?(['"])([\s\S]*?)\1/i)?.[2];
if (quotedCurlCookieOption) {
return quotedCurlCookieOption.trim();
}
const unquotedCurlCookieOption = trimmed.match(/(?:^|\s)(?:-b|--cookie)=([^\s\\]+)/i)?.[1];
if (unquotedCurlCookieOption) {
return unquotedCurlCookieOption.trim();
}
const cookieLine = trimmed.split(/\r?\n/).find((line) => /^\s*Cookie\s*:/i.test(line));
const candidate = cookieLine ?? trimmed;
return stripMatchingQuotes(candidate.replace(/^\s*Cookie\s*:\s*/i, ""));
}
function parseCookieHeader(cookieHeader: string): Map<string, string> {
const cookies = new Map<string, string>();
for (const part of cookieHeader.split(";")) {
const separator = part.indexOf("=");
if (separator < 0) {
continue;
}
const name = part.slice(0, separator).trim();
const value = stripMatchingQuotes(part.slice(separator + 1).trim());
if (name && value) {
cookies.set(name, value);
}
}
return cookies;
}
function cookieValueFromChunks(cookies: Map<string, string>, name: string): string | null {
const direct = cookies.get(name);
if (direct) {
return direct;
}
const chunks: string[] = [];
for (let index = 0; ; index += 1) {
const chunk = cookies.get(`${name}.${index}`);
if (!chunk) {
break;
}
chunks.push(chunk);
}
return chunks.length > 0 ? chunks.join("") : null;
}
function extractSessionTokenFromCookieHeader(cookieHeader: string): string | null {
const cookies = parseCookieHeader(cookieHeader);
for (const name of SESSION_TOKEN_COOKIE_NAMES) {
const value = cookieValueFromChunks(cookies, name);
const token = normalizeInput(value ? decodeCookieValue(value) : null);
if (token) {
return token;
}
}
return null;
}
function credentialsFromTokenValue(value: string): StoredToken | null {
const token = normalizeInput(stripBearerPrefix(value));
return token ? { type: "oauth", access: token } : null;
}
function credentialsFromCookieValue(value: string): StoredToken | null {
const cookies = normalizeInput(extractCookieHeader(value));
if (!cookies || !cookies.includes("=")) {
return null;
}
const credentials: StoredToken = { type: "oauth", cookies };
const sessionToken = extractSessionTokenFromCookieHeader(cookies);
if (sessionToken && looksLikeToken(sessionToken)) {
credentials.access = sessionToken;
}
return credentials;
}
export function parseBrowserAuthInput(input: string): StoredToken | null {
const normalized = normalizeInput(input);
if (!normalized) {
return null;
}
const bearerToken = normalizeInput(stripBearerPrefix(normalized));
if (bearerToken && looksLikeToken(bearerToken)) {
return { type: "oauth", access: bearerToken };
}
const cookieCredentials = credentialsFromCookieValue(normalized);
const cookies = cookieCredentials?.cookies;
if (cookieCredentials && cookies && cookies.includes("=")) {
const hasKnownSessionToken = Boolean(extractSessionTokenFromCookieHeader(cookies));
if (hasKnownSessionToken) {
return cookieCredentials;
}
}
return null;
}
function browserAuthFailureMessage(input: string): string {
const normalized = normalizeInput(input) ?? "";
if (looksLikeCurlCommand(normalized) && !curlCommandHasCookieSource(normalized)) {
return [
"The cURL command you pasted does not include cookies, so it cannot be used for login.",
"Copy a signed-in Perplexity request whose cURL contains `-b ...`, `--cookie ...`, or `-H 'Cookie: ...'`.",
"In DevTools → Network, reload Perplexity or ask a question, then right-click a `www.perplexity.ai` request such as `perplexity_ask` → Copy → Copy as cURL.",
"Make sure the copied text contains `__Secure-next-auth.session-token` and ideally `cf_clearance`.",
].join(" ");
}
const cookies = normalizeInput(extractCookieHeader(normalized));
if (cookies && cookies.includes("=") && !extractSessionTokenFromCookieHeader(cookies)) {
return [
"I found cookies in the pasted value, but not a Perplexity signed-in session cookie.",
"Make sure you are signed in at https://www.perplexity.ai, then copy a request whose cookies include `__Secure-next-auth.session-token`.",
"Copy as cURL from a `perplexity_ask` request usually works best.",
].join(" ");
}
return `Could not find a Perplexity session token in the pasted browser auth value. ${BROWSER_AUTH_HELP}`;
}
function credentialsFromEnvironment(): StoredToken | null {
for (const key of TOKEN_ENV_KEYS) {
const value = normalizeInput(process.env[key]);
if (value) {
return credentialsFromTokenValue(value);
}
}
for (const key of COOKIE_ENV_KEYS) {
const value = normalizeInput(process.env[key]);
if (value) {
const credentials = parseBrowserAuthInput(value);
if (!credentials?.cookies) {
throw new AuthError(
"NO_TOKEN",
`${key} is set but does not contain a signed-in Perplexity browser cookie. ${browserAuthFailureMessage(value)}`,
);
}
return credentials;
}
}
return null;
}
export async function saveBrowserAuthInput(input: string): Promise<StoredToken> {
const credentials = parseBrowserAuthInput(input);
if (!credentials) {
throw new AuthError("NO_TOKEN", browserAuthFailureMessage(input));
}
await saveToken(credentials);
return credentials;
}
async function promptForBrowserCredentials(
options: AuthenticateOptions,
): Promise<StoredToken | null> {
const input = normalizeInput(await options.promptForBrowserAuth?.());
if (!input) {
return null;
}
return saveBrowserAuthInput(input);
}
function buildAuthHeaders(includeJsonContentType = false): Record<string, string> { function buildAuthHeaders(includeJsonContentType = false): Record<string, string> {
return { return {
Accept: "application/json", Accept: "application/json",
@@ -55,6 +294,28 @@ function formatHttpFailure(action: string, response: AuthFetchResponse): string
return `${action} (HTTP ${response.status}${suffix}).`; return `${action} (HTTP ${response.status}${suffix}).`;
} }
function isBrowserChallengeResponse(response: AuthFetchResponse): boolean {
const body = response.bodyText.toLowerCase();
return (
body.includes("just a moment") ||
body.includes("enable javascript and cookies") ||
body.includes("_cf_chl_opt") ||
body.includes("cdn-cgi/challenge-platform") ||
body.includes("cf-browser-verification")
);
}
function throwHttpFailure(action: string, response: AuthFetchResponse): never {
const failure = formatHttpFailure(action, response);
if (isBrowserChallengeResponse(response)) {
throw new BrowserChallengeError(
`${failure} Perplexity returned a browser challenge that Node fetch cannot solve.`,
);
}
throw new Error(failure);
}
function cookieHeaderFrom(cookies: string[]): string { function cookieHeaderFrom(cookies: string[]): string {
return cookies.map((cookie) => cookie.split(";")[0]).join("; "); return cookies.map((cookie) => cookie.split(";")[0]).join("; ");
} }
@@ -84,7 +345,7 @@ async function loginWithEmailOtp(
}); });
if (!csrfResponse.ok) { if (!csrfResponse.ok) {
throw new Error(formatHttpFailure("Failed to fetch CSRF token", csrfResponse)); throwHttpFailure("Failed to fetch CSRF token", csrfResponse);
} }
const csrfPayload = parseJsonResponse("CSRF token response", csrfResponse); const csrfPayload = parseJsonResponse("CSRF token response", csrfResponse);
@@ -115,7 +376,7 @@ async function loginWithEmailOtp(
}); });
if (!emailResponse.ok) { if (!emailResponse.ok) {
throw new Error(formatHttpFailure("Failed to send OTP email", emailResponse)); throwHttpFailure("Failed to send OTP email", emailResponse);
} }
const otp = const otp =
@@ -140,11 +401,13 @@ async function loginWithEmailOtp(
}); });
if (!otpResponse.ok) { if (!otpResponse.ok) {
throw new Error(formatHttpFailure("OTP verification failed", otpResponse)); throwHttpFailure("OTP verification failed", otpResponse);
} }
const otpPayload = parseJsonResponse("OTP verification response", otpResponse); const otpPayload = parseJsonResponse("OTP verification response", otpResponse);
const token = extractTokenFromPayload(otpPayload); const token =
extractTokenFromPayload(otpPayload) ??
extractSessionTokenFromCookieHeader(cookieHeaderFrom(otpResponse.cookies));
if (!token) { if (!token) {
throw new Error("Perplexity OTP response did not include a token."); throw new Error("Perplexity OTP response did not include a token.");
} }
@@ -171,31 +434,44 @@ export async function extractFromDesktopApp(): Promise<string | null> {
} }
} }
/** Run auth strategy: load cached → try desktop extraction → savethrow AuthError if all fail. */ /** Run auth strategy: load cached → env token/cookies → desktop extraction → email OTPbrowser paste fallback. */
export async function authenticate(options: AuthenticateOptions = {}): Promise<string> { export async function authenticate(options: AuthenticateOptions = {}): Promise<StoredToken> {
const cached = await loadToken(); const cached = await loadToken();
if (cached) { if (cached) {
return cached.access; return cached;
}
const envCredentials = credentialsFromEnvironment();
if (envCredentials) {
await saveToken(envCredentials);
return envCredentials;
} }
const borrowDisabled = process.env.PI_AUTH_NO_BORROW === "1"; const borrowDisabled = process.env.PI_AUTH_NO_BORROW === "1";
if (!borrowDisabled) { if (!borrowDisabled) {
const desktopToken = await extractFromDesktopApp(); const desktopToken = await extractFromDesktopApp();
if (desktopToken) { if (desktopToken) {
await saveToken({ const credentials: StoredToken = {
type: "oauth", type: "oauth",
access: desktopToken, access: desktopToken,
}); };
return desktopToken; await saveToken(credentials);
return credentials;
} }
} }
const email = const email =
normalizeInput(process.env.PI_PERPLEXITY_EMAIL) ?? normalizeInput(process.env.PI_PERPLEXITY_EMAIL) ??
normalizeInput(await options.promptForEmail?.()); normalizeInput(await options.promptForEmail?.());
if (!email) { if (!email) {
const browserCredentials = await promptForBrowserCredentials(options);
if (browserCredentials) {
return browserCredentials;
}
throw new AuthError( throw new AuthError(
"NO_TOKEN", "NO_TOKEN",
`Could not find a desktop token and no email was provided for OTP fallback. ${DESKTOP_AUTH_HELP} ${OTP_AUTH_HELP}`, `Could not find a desktop token, browser token/cookie, or email for OTP fallback. ${DESKTOP_AUTH_HELP} ${OTP_AUTH_HELP} ${BROWSER_AUTH_HELP}`,
); );
} }
@@ -208,16 +484,24 @@ export async function authenticate(options: AuthenticateOptions = {}): Promise<s
throw error; throw error;
} }
if (error instanceof BrowserChallengeError) {
const browserCredentials = await promptForBrowserCredentials(options);
if (browserCredentials) {
return browserCredentials;
}
}
throw new AuthError( throw new AuthError(
"EXTRACTION_FAILED", "EXTRACTION_FAILED",
`Email OTP authentication failed: ${errorMessage(error)}. ${OTP_AUTH_HELP}`, `Email OTP authentication failed: ${errorMessage(error)}. ${OTP_AUTH_HELP} ${BROWSER_AUTH_HELP}`,
); );
} }
await saveToken({ const credentials: StoredToken = {
type: "oauth", type: "oauth",
access: otpToken, access: otpToken,
email, email,
}); };
return otpToken; await saveToken(credentials);
return credentials;
} }
+3 -2
View File
@@ -12,10 +12,11 @@ function isStoredToken(value: unknown): value is StoredToken {
} }
const candidate = value as Record<string, unknown>; const candidate = value as Record<string, unknown>;
const hasAccess = typeof candidate.access === "string" && candidate.access.length > 0;
const hasCookies = typeof candidate.cookies === "string" && candidate.cookies.length > 0;
return ( return (
candidate.type === "oauth" && candidate.type === "oauth" &&
typeof candidate.access === "string" && (hasAccess || hasCookies)
candidate.access.length > 0
); );
} }
+53 -5
View File
@@ -1,6 +1,6 @@
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { authenticate } from "../auth/login.js"; import { authenticate, saveBrowserAuthInput } from "../auth/login.js";
import { clearToken } from "../auth/storage.js"; import { clearToken } from "../auth/storage.js";
import { AuthError } from "../search/types.js"; import { AuthError } from "../search/types.js";
import { errorMessage } from "../render/util.js"; import { errorMessage } from "../render/util.js";
@@ -9,6 +9,7 @@ const LOGIN_COMMAND_NAME = "perplexity-login";
interface ParsedCommandArgs { interface ParsedCommandArgs {
forceRefresh: boolean; forceRefresh: boolean;
browserAuth: boolean;
showHelp: boolean; showHelp: boolean;
unknown: string[]; unknown: string[];
} }
@@ -20,6 +21,7 @@ function parseCommandArgs(args: string): ParsedCommandArgs {
.filter((token) => token.length > 0); .filter((token) => token.length > 0);
let forceRefresh = false; let forceRefresh = false;
let browserAuth = false;
let showHelp = false; let showHelp = false;
const unknown: string[] = []; const unknown: string[] = [];
@@ -34,14 +36,34 @@ function parseCommandArgs(args: string): ParsedCommandArgs {
continue; continue;
} }
if (token === "--browser" || token === "--cookie" || token === "--manual") {
browserAuth = true;
continue;
}
unknown.push(token); unknown.push(token);
} }
return { forceRefresh, showHelp, unknown }; return { forceRefresh, browserAuth, showHelp, unknown };
} }
function usageText(): string { function usageText(): string {
return `Usage: /${LOGIN_COMMAND_NAME} [--force]\n\nFlags:\n --force, --refresh, -f Clear cached token before login\n --help, -h Show this help`; return `Usage: /${LOGIN_COMMAND_NAME} [--force] [--browser]\n\nFlags:\n --force, --refresh, -f Clear cached token before login\n --browser, --cookie Import browser auth by pasting Copy as cURL, a Cookie header, or a session token\n --help, -h Show this help`;
}
function browserLoginInstructions(): string {
return [
"Browser login:",
"1. Open https://www.perplexity.ai and sign in.",
"2. Open DevTools → Network.",
"3. Reload the page or ask one Perplexity question.",
"4. Right-click a www.perplexity.ai request → Copy → Copy as cURL.",
"5. Paste the copied cURL command here.",
"",
"The copied text must include -b, --cookie, or Cookie:, and should include __Secure-next-auth.session-token.",
"If it does not, copy a different www.perplexity.ai request, preferably perplexity_ask.",
"Alternatives: paste the request Cookie header, or paste the __Secure-next-auth.session-token value.",
].join("\n");
} }
export function registerPerplexityCommands(pi: ExtensionAPI): void { export function registerPerplexityCommands(pi: ExtensionAPI): void {
@@ -77,13 +99,39 @@ export function registerPerplexityCommands(pi: ExtensionAPI): void {
return value?.trim() || undefined; return value?.trim() || undefined;
}; };
const promptForBrowserAuth = async (): Promise<string | undefined> => {
ctx.ui.notify(browserLoginInstructions(), "info");
const value = await ctx.ui.input(
"Paste copied cURL command or Cookie header",
"curl 'https://www.perplexity.ai/' -H 'Cookie: ...'",
);
return value?.trim() || undefined;
};
try { try {
await authenticate({ promptForEmail, promptForOtp }); if (parsed.browserAuth) {
const input = await promptForBrowserAuth();
if (!input) {
ctx.ui.notify("Perplexity browser login canceled.", "warning");
return;
}
await saveBrowserAuthInput(input);
ctx.ui.notify("Perplexity browser auth saved.", "info");
return;
}
await authenticate({ promptForEmail, promptForOtp, promptForBrowserAuth });
ctx.ui.notify("Perplexity login successful. Token saved.", "info"); ctx.ui.notify("Perplexity login successful. Token saved.", "info");
} catch (error) { } catch (error) {
if (error instanceof AuthError && error.code === "NO_TOKEN") { if (error instanceof AuthError && error.code === "NO_TOKEN") {
if (parsed.browserAuth) {
ctx.ui.notify(error.message, "warning");
return;
}
ctx.ui.notify( ctx.ui.notify(
"Perplexity login canceled. Re-run /perplexity-login and provide email + OTP, or set PI_PERPLEXITY_EMAIL and PI_PERPLEXITY_OTP.", "Perplexity login canceled. Re-run /perplexity-login for email + OTP, use /perplexity-login --browser, or set PI_PERPLEXITY_EMAIL/PI_PERPLEXITY_OTP/PI_PERPLEXITY_COOKIE.",
"warning", "warning",
); );
return; return;
+23 -2
View File
@@ -54,10 +54,31 @@ export default function (pi: ExtensionAPI) {
return ctx.ui.input(label, placeholder); return ctx.ui.input(label, placeholder);
}; };
const jwt = await authenticate({ const auth = await authenticate({
...(signal !== undefined ? { signal } : {}), ...(signal !== undefined ? { signal } : {}),
promptForEmail: async () => promptInput("Perplexity email", "you@example.com"), promptForEmail: async () => promptInput("Perplexity email", "you@example.com"),
promptForOtp: async (email) => promptInput(`Enter OTP sent to ${email}`, "123456"), promptForOtp: async (email) => promptInput(`Enter OTP sent to ${email}`, "123456"),
promptForBrowserAuth: async () => {
ctx?.ui?.notify?.(
[
"Browser login needed:",
"1. Open https://www.perplexity.ai and sign in.",
"2. Open DevTools → Network.",
"3. Reload the page or ask one Perplexity question.",
"4. Right-click a www.perplexity.ai request → Copy → Copy as cURL.",
"5. Paste the copied cURL command here.",
"",
"The copied text must include -b, --cookie, or Cookie:, and should include __Secure-next-auth.session-token.",
"If it does not, copy a different www.perplexity.ai request, preferably perplexity_ask.",
"Alternatives: paste the request Cookie header, or paste the __Secure-next-auth.session-token value.",
].join("\n"),
"info",
);
return promptInput(
"Paste copied cURL command or Cookie header",
"curl 'https://www.perplexity.ai/' -H 'Cookie: ...'",
);
},
}); });
if (signal?.aborted) { if (signal?.aborted) {
@@ -88,7 +109,7 @@ export default function (pi: ExtensionAPI) {
...(params.recency !== undefined ? { recency: params.recency } : {}), ...(params.recency !== undefined ? { recency: params.recency } : {}),
...(params.limit !== undefined ? { limit: params.limit } : {}), ...(params.limit !== undefined ? { limit: params.limit } : {}),
}, },
jwt, auth,
signal, signal,
); );
+18 -7
View File
@@ -1,5 +1,5 @@
import { mergeEvent, readSseEvents } from "./stream.js"; import { mergeEvent, readSseEvents } from "./stream.js";
import type { SearchResult, StreamEvent, WebResult } from "./types.js"; import type { SearchResult, StoredToken, StreamEvent, WebResult } from "./types.js";
import { SearchError } from "./types.js"; import { SearchError } from "./types.js";
import { errorMessage } from "../render/util.js"; import { errorMessage } from "../render/util.js";
import { PERPLEXITY_USER_AGENT, PERPLEXITY_API_VERSION } from "../constants.js"; import { PERPLEXITY_USER_AGENT, PERPLEXITY_API_VERSION } from "../constants.js";
@@ -132,9 +132,12 @@ function buildRequestBody(params: SearchParams): Record<string, unknown> {
}; };
} }
function buildRequestHeaders(jwt: string, requestId: string): Record<string, string> { type AuthCredentials = string | StoredToken;
return {
Authorization: `Bearer ${jwt}`, function buildRequestHeaders(auth: AuthCredentials, requestId: string): Record<string, string> {
const access = typeof auth === "string" ? auth : auth.access;
const cookies = typeof auth === "string" ? undefined : auth.cookies;
const headers: Record<string, string> = {
"Content-Type": "application/json", "Content-Type": "application/json",
Accept: "text/event-stream", Accept: "text/event-stream",
Origin: "https://www.perplexity.ai", Origin: "https://www.perplexity.ai",
@@ -145,13 +148,21 @@ function buildRequestHeaders(jwt: string, requestId: string): Record<string, str
"X-Perplexity-Request-Reason": "submit", "X-Perplexity-Request-Reason": "submit",
"X-Request-ID": requestId, "X-Request-ID": requestId,
}; };
if (cookies) {
headers.Cookie = cookies;
} else if (access) {
headers.Authorization = `Bearer ${access}`;
}
return headers;
} }
function mapHttpError(status: number): SearchError { function mapHttpError(status: number): SearchError {
if (status === 401 || status === 403) { if (status === 401 || status === 403) {
return new SearchError( return new SearchError(
"AUTH", "AUTH",
"Perplexity rejected authentication (401/403). Sign in to Perplexity desktop app and retry.", "Perplexity rejected authentication (401/403). Re-run /perplexity-login --force, or use /perplexity-login --browser if direct OTP is blocked.",
); );
} }
@@ -171,12 +182,12 @@ function mapHttpError(status: number): SearchError {
/** Execute a Perplexity search: POST SSE, stream/merge events, extract answer + sources. Throws SearchError on failure. */ /** Execute a Perplexity search: POST SSE, stream/merge events, extract answer + sources. Throws SearchError on failure. */
export async function searchPerplexity( export async function searchPerplexity(
params: SearchParams, params: SearchParams,
jwt: string, auth: AuthCredentials,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<SearchResult> { ): Promise<SearchResult> {
const requestId = crypto.randomUUID(); const requestId = crypto.randomUUID();
const requestBody = buildRequestBody(params); const requestBody = buildRequestBody(params);
const requestHeaders = buildRequestHeaders(jwt, requestId); const requestHeaders = buildRequestHeaders(auth, requestId);
let response: Response; let response: Response;
try { try {
+2 -1
View File
@@ -42,7 +42,8 @@ export interface StreamSource {
export interface StoredToken { export interface StoredToken {
type: "oauth"; type: "oauth";
access: string; access?: string;
cookies?: string;
email?: string; email?: string;
} }
+218 -2
View File
@@ -6,6 +6,8 @@ const originalFetch = globalThis.fetch;
const originalBorrow = process.env.PI_AUTH_NO_BORROW; const originalBorrow = process.env.PI_AUTH_NO_BORROW;
const originalEmail = process.env.PI_PERPLEXITY_EMAIL; const originalEmail = process.env.PI_PERPLEXITY_EMAIL;
const originalOtp = process.env.PI_PERPLEXITY_OTP; const originalOtp = process.env.PI_PERPLEXITY_OTP;
const originalToken = process.env.PI_PERPLEXITY_TOKEN;
const originalCookie = process.env.PI_PERPLEXITY_COOKIE;
function createJwt(expiryMs: number): string { function createJwt(expiryMs: number): string {
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
@@ -46,6 +48,18 @@ function restoreEnv(): void {
} else { } else {
process.env.PI_PERPLEXITY_OTP = originalOtp; process.env.PI_PERPLEXITY_OTP = originalOtp;
} }
if (originalToken === undefined) {
delete process.env.PI_PERPLEXITY_TOKEN;
} else {
process.env.PI_PERPLEXITY_TOKEN = originalToken;
}
if (originalCookie === undefined) {
delete process.env.PI_PERPLEXITY_COOKIE;
} else {
process.env.PI_PERPLEXITY_COOKIE = originalCookie;
}
} }
afterEach(() => { afterEach(() => {
@@ -158,7 +172,7 @@ describe("auth/login", () => {
const token = await authenticate(); const token = await authenticate();
expect(token).toBe(cachedToken); expect(token.access).toBe(cachedToken);
expect(loadTokenMock).toHaveBeenCalledTimes(1); expect(loadTokenMock).toHaveBeenCalledTimes(1);
expect(saveTokenMock).toHaveBeenCalledTimes(0); expect(saveTokenMock).toHaveBeenCalledTimes(0);
expect(clearTokenMock).toHaveBeenCalledTimes(0); expect(clearTokenMock).toHaveBeenCalledTimes(0);
@@ -216,7 +230,7 @@ describe("auth/login", () => {
promptForOtp: async () => "123456", promptForOtp: async () => "123456",
}); });
expect(token).toBe(otpToken); expect(token.access).toBe(otpToken);
expect(loadTokenMock).toHaveBeenCalledTimes(1); expect(loadTokenMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(3); expect(fetchMock).toHaveBeenCalledTimes(3);
expect(saveTokenMock).toHaveBeenCalledTimes(1); expect(saveTokenMock).toHaveBeenCalledTimes(1);
@@ -242,6 +256,208 @@ describe("auth/login", () => {
expect(clearTokenMock).toHaveBeenCalledTimes(0); expect(clearTokenMock).toHaveBeenCalledTimes(0);
}); });
test("authenticate saves PI_PERPLEXITY_TOKEN without desktop or OTP calls", async () => {
process.env.PI_AUTH_NO_BORROW = "1";
process.env.PI_PERPLEXITY_TOKEN = "env-token";
const loadTokenMock = mock(async () => null);
const saveTokenMock = mock(async (_token: StoredToken) => undefined);
const clearTokenMock = mock(async () => undefined);
mock.module("../../src/auth/storage.js", () => ({
loadToken: loadTokenMock,
saveToken: saveTokenMock,
clearToken: clearTokenMock,
}));
const { authenticate } = await importLoginModule();
const token = await authenticate();
expect(token.access).toBe("env-token");
expect(saveTokenMock).toHaveBeenCalledTimes(1);
expect(saveTokenMock.mock.calls[0]?.[0]).toEqual({ type: "oauth", access: "env-token" });
});
test("authenticate saves browser Cookie header from PI_PERPLEXITY_COOKIE", async () => {
process.env.PI_AUTH_NO_BORROW = "1";
const browserToken = createJwt(Date.now() + 2 * 60 * 60 * 1000);
process.env.PI_PERPLEXITY_COOKIE =
`pplx.visitor-id=visitor; __Secure-next-auth.session-token=${browserToken}; cf_clearance=clearance`;
const loadTokenMock = mock(async () => null);
const saveTokenMock = mock(async (_token: StoredToken) => undefined);
const clearTokenMock = mock(async () => undefined);
mock.module("../../src/auth/storage.js", () => ({
loadToken: loadTokenMock,
saveToken: saveTokenMock,
clearToken: clearTokenMock,
}));
const { authenticate } = await importLoginModule();
const token = await authenticate();
expect(token.cookies).toContain("__Secure-next-auth.session-token=");
expect(token.access).toBe(browserToken);
expect(saveTokenMock).toHaveBeenCalledTimes(1);
});
test("authenticate rejects PI_PERPLEXITY_COOKIE without a signed-in session cookie", async () => {
process.env.PI_AUTH_NO_BORROW = "1";
process.env.PI_PERPLEXITY_COOKIE = "pplx.visitor-id=visitor; cf_clearance=clearance";
const loadTokenMock = mock(async () => null);
const saveTokenMock = mock(async (_token: StoredToken) => undefined);
const clearTokenMock = mock(async () => undefined);
mock.module("../../src/auth/storage.js", () => ({
loadToken: loadTokenMock,
saveToken: saveTokenMock,
clearToken: clearTokenMock,
}));
const { authenticate } = await importLoginModule();
let thrown: unknown;
try {
await authenticate();
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(AuthError);
expect((thrown as AuthError).code).toBe("NO_TOKEN");
expect((thrown as Error).message).toContain("PI_PERPLEXITY_COOKIE is set");
expect((thrown as Error).message).toContain("not a Perplexity signed-in session cookie");
expect(saveTokenMock).toHaveBeenCalledTimes(0);
});
test("parseBrowserAuthInput extracts cookies from Copy as cURL", async () => {
const browserToken = createJwt(Date.now() + 2 * 60 * 60 * 1000);
const curl = `curl 'https://www.perplexity.ai/rest/sse/perplexity_ask' \\
-H 'accept: text/event-stream' \\
-H 'cookie: pplx.visitor-id=visitor; __Secure-next-auth.session-token=${browserToken}; cf_clearance=clearance' \\
--data-raw '{"query":"hello"}'`;
const { parseBrowserAuthInput } = await importLoginModule();
const parsed = parseBrowserAuthInput(curl);
expect(parsed?.cookies).toBe(
`pplx.visitor-id=visitor; __Secure-next-auth.session-token=${browserToken}; cf_clearance=clearance`,
);
expect(parsed?.access).toBe(browserToken);
});
test("parseBrowserAuthInput extracts cookies from --cookie= cURL form", async () => {
const browserToken = createJwt(Date.now() + 2 * 60 * 60 * 1000);
const curl = `curl 'https://www.perplexity.ai/rest/sse/perplexity_ask' \\
--cookie='pplx.visitor-id=visitor; __Secure-next-auth.session-token=${browserToken}; cf_clearance=clearance' \\
--data-raw '{"query":"hello"}'`;
const { parseBrowserAuthInput } = await importLoginModule();
const parsed = parseBrowserAuthInput(curl);
expect(parsed?.cookies).toBe(
`pplx.visitor-id=visitor; __Secure-next-auth.session-token=${browserToken}; cf_clearance=clearance`,
);
expect(parsed?.access).toBe(browserToken);
});
test("saveBrowserAuthInput explains Copy as cURL without cookies", async () => {
const curl = `curl 'https://www.perplexity.ai/' \\
-H 'Upgrade-Insecure-Requests: 1' \\
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36' \\
-H 'sec-ch-ua: "Chromium";v="149", "Not)A;Brand";v="24"' \\
-H 'sec-ch-ua-mobile: ?0' \\
-H 'sec-ch-ua-platform: "macOS"'`;
const { saveBrowserAuthInput } = await importLoginModule();
let thrown: unknown;
try {
await saveBrowserAuthInput(curl);
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(AuthError);
expect((thrown as AuthError).code).toBe("NO_TOKEN");
expect((thrown as Error).message).toContain("The cURL command you pasted does not include cookies");
expect((thrown as Error).message).toContain("-b");
expect((thrown as Error).message).toContain("__Secure-next-auth.session-token");
});
test("authenticate reproduces Cloudflare CSRF failure without browser fallback", async () => {
process.env.PI_AUTH_NO_BORROW = "1";
const loadTokenMock = mock(async () => null);
const saveTokenMock = mock(async (_token: StoredToken) => undefined);
const clearTokenMock = mock(async () => undefined);
mock.module("../../src/auth/storage.js", () => ({
loadToken: loadTokenMock,
saveToken: saveTokenMock,
clearToken: clearTokenMock,
}));
const fetchMock = mock(async () =>
new Response("<!DOCTYPE html><html><head><title>Just a moment...</title></head></html>", {
status: 403,
}),
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
const { authenticate } = await importLoginModule();
let thrown: unknown;
try {
await authenticate({
promptForEmail: async () => "user@example.com",
});
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(AuthError);
expect((thrown as AuthError).code).toBe("EXTRACTION_FAILED");
expect((thrown as Error).message).toContain("Failed to fetch CSRF token");
expect((thrown as Error).message).toContain("browser challenge");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(saveTokenMock).toHaveBeenCalledTimes(0);
});
test("authenticate falls back to browser auth when OTP CSRF hits Cloudflare", async () => {
process.env.PI_AUTH_NO_BORROW = "1";
const browserToken = createJwt(Date.now() + 2 * 60 * 60 * 1000);
const loadTokenMock = mock(async () => null);
const saveTokenMock = mock(async (_token: StoredToken) => undefined);
const clearTokenMock = mock(async () => undefined);
mock.module("../../src/auth/storage.js", () => ({
loadToken: loadTokenMock,
saveToken: saveTokenMock,
clearToken: clearTokenMock,
}));
const fetchMock = mock(async () =>
new Response("<!DOCTYPE html><title>Just a moment...</title>", { status: 403 }),
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
const { authenticate } = await importLoginModule();
const token = await authenticate({
promptForEmail: async () => "user@example.com",
promptForBrowserAuth: async () => `__Secure-next-auth.session-token=${browserToken}; cf_clearance=ok`,
});
expect(token.cookies).toContain("cf_clearance=ok");
expect(saveTokenMock).toHaveBeenCalledTimes(1);
});
test("authenticate throws NO_TOKEN when no cached token and no OTP email input", async () => { test("authenticate throws NO_TOKEN when no cached token and no OTP email input", async () => {
process.env.PI_AUTH_NO_BORROW = "1"; process.env.PI_AUTH_NO_BORROW = "1";
+6 -2
View File
@@ -10,6 +10,8 @@ const originalFetch = globalThis.fetch;
const originalBorrow = process.env.PI_AUTH_NO_BORROW; const originalBorrow = process.env.PI_AUTH_NO_BORROW;
const originalEmail = process.env.PI_PERPLEXITY_EMAIL; const originalEmail = process.env.PI_PERPLEXITY_EMAIL;
const originalOtp = process.env.PI_PERPLEXITY_OTP; const originalOtp = process.env.PI_PERPLEXITY_OTP;
const originalToken = process.env.PI_PERPLEXITY_TOKEN;
const originalCookie = process.env.PI_PERPLEXITY_COOKIE;
// --- Fixtures from real Perplexity responses (scripts/debug-login-dump.json) --- // --- Fixtures from real Perplexity responses (scripts/debug-login-dump.json) ---
@@ -45,6 +47,8 @@ function restoreEnv(): void {
["PI_AUTH_NO_BORROW", originalBorrow], ["PI_AUTH_NO_BORROW", originalBorrow],
["PI_PERPLEXITY_EMAIL", originalEmail], ["PI_PERPLEXITY_EMAIL", originalEmail],
["PI_PERPLEXITY_OTP", originalOtp], ["PI_PERPLEXITY_OTP", originalOtp],
["PI_PERPLEXITY_TOKEN", originalToken],
["PI_PERPLEXITY_COOKIE", originalCookie],
] as const) { ] as const) {
if (original === undefined) { if (original === undefined) {
delete process.env[key]; delete process.env[key];
@@ -131,7 +135,7 @@ describe("OTP login flow (from real captured responses)", () => {
promptForOtp: async () => TEST_OTP, promptForOtp: async () => TEST_OTP,
}); });
expect(token).toBe(REAL_JWE_TOKEN); expect(token.access).toBe(REAL_JWE_TOKEN);
expect(fetchMock).toHaveBeenCalledTimes(3); expect(fetchMock).toHaveBeenCalledTimes(3);
expect(saveTokenMock).toHaveBeenCalledTimes(1); expect(saveTokenMock).toHaveBeenCalledTimes(1);
@@ -212,7 +216,7 @@ describe("OTP login flow (from real captured responses)", () => {
const token = await authenticate({ promptForEmail, promptForOtp }); const token = await authenticate({ promptForEmail, promptForOtp });
expect(token).toBe(REAL_JWE_TOKEN); expect(token.access).toBe(REAL_JWE_TOKEN);
expect(promptForEmail).toHaveBeenCalledTimes(0); expect(promptForEmail).toHaveBeenCalledTimes(0);
expect(promptForOtp).toHaveBeenCalledTimes(0); expect(promptForOtp).toHaveBeenCalledTimes(0);
}); });
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, mock, test } from "../test-helpers.js";
import { AuthError } from "../../src/search/types.js";
async function importCommandModule() {
return import(`../../src/commands/login.js?test=${crypto.randomUUID()}`);
}
afterEach(() => {
mock.restore();
});
describe("perplexity-login command", () => {
test("shows browser auth parse errors instead of generic cancellation", async () => {
const expectedMessage = "The cURL command you pasted does not include cookies";
const authenticate = mock(async () => ({ type: "oauth", access: "token" }));
const saveBrowserAuthInput = mock(async () => {
throw new AuthError("NO_TOKEN", expectedMessage);
});
mock.module("../../src/auth/login.js", () => ({ authenticate, saveBrowserAuthInput }));
const registered = {
handler: undefined as undefined | ((args: string, ctx: unknown) => Promise<void>),
};
const registerCommand = mock((name: string, command: { handler: (args: string, ctx: unknown) => Promise<void> }) => {
expect(name).toBe("perplexity-login");
registered.handler = command.handler;
});
const { registerPerplexityCommands } = await importCommandModule();
registerPerplexityCommands({ registerCommand } as never);
const input = mock(async () => "curl 'https://www.perplexity.ai/' -H 'User-Agent: browser'");
const notify = mock((_message: string, _level: string) => undefined);
await registered.handler?.("--browser", { ui: { input, notify } });
const lastNotification = notify.mock.calls.at(-1);
expect(lastNotification?.[0]).toBe(expectedMessage);
expect(lastNotification?.[1]).toBe("warning");
expect(saveBrowserAuthInput).toHaveBeenCalledTimes(1);
});
});
+1 -1
View File
@@ -53,7 +53,7 @@ describe("Perplexity model selection e2e", () => {
incognito: true, incognito: true,
limit: 1, limit: 1,
}, },
token.access, token,
); );
expect(result.answer.trim().startsWith("OK")).toBe(true); expect(result.answer.trim().startsWith("OK")).toBe(true);
+2 -1
View File
@@ -7,6 +7,7 @@ afterEach(() => {
describe("perplexity_search execute", () => { describe("perplexity_search execute", () => {
test("includes effective config values in the search request and result details", async () => { test("includes effective config values in the search request and result details", async () => {
const authenticate = mock(async () => "jwt-token"); const authenticate = mock(async () => "jwt-token");
const saveBrowserAuthInput = mock(async () => ({ type: "oauth", access: "jwt-token" }));
const loadConfig = mock(async () => ({ model: "gpt54", incognito: false })); const loadConfig = mock(async () => ({ model: "gpt54", incognito: false }));
const resolveSearchDefaults = mock(() => ({ model: "gpt54", incognito: false })); const resolveSearchDefaults = mock(() => ({ model: "gpt54", incognito: false }));
const searchPerplexity = mock(async () => ({ const searchPerplexity = mock(async () => ({
@@ -16,7 +17,7 @@ describe("perplexity_search execute", () => {
uuid: "req-123", uuid: "req-123",
})); }));
mock.module("../src/auth/login.js", () => ({ authenticate })); mock.module("../src/auth/login.js", () => ({ authenticate, saveBrowserAuthInput }));
mock.module("../src/config.js", () => ({ mock.module("../src/config.js", () => ({
getConfigPath: () => "/tmp/pi-perplexity-config.json", getConfigPath: () => "/tmp/pi-perplexity-config.json",
loadConfig, loadConfig,
+20
View File
@@ -120,6 +120,26 @@ describe("searchPerplexity", () => {
expect(body.params.is_incognito).toBe(false); expect(body.params.is_incognito).toBe(false);
}); });
test("uses Cookie header for browser-cookie credentials", async () => {
let capturedInit: RequestInit | undefined;
globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => {
capturedInit = init;
return createSseResponse([
{ status: "COMPLETED", final: true, text: "answer", blocks: [] },
]);
}) as unknown as typeof fetch;
await searchPerplexity(
{ query: "q", model: "pplx_pro_upgraded", incognito: true },
{ type: "oauth", cookies: "__Secure-next-auth.session-token=session; cf_clearance=clearance" },
);
const headers = new Headers(capturedInit?.headers);
expect(headers.get("Cookie")).toBe("__Secure-next-auth.session-token=session; cf_clearance=clearance");
expect(headers.get("Authorization")).toBeNull();
});
test("passes incognito true through to request body", async () => { test("passes incognito true through to request body", async () => {
let capturedInit: RequestInit | undefined; let capturedInit: RequestInit | undefined;