feat(auth): add browser cookie login fallback
This commit is contained in:
@@ -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)
|
||||
- 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
|
||||
|
||||
@@ -21,16 +21,34 @@ pi install github:ivanrvpereira/pi-perplexity
|
||||
|
||||
## Authentication
|
||||
|
||||
Run the login command once to cache your token:
|
||||
Run login once:
|
||||
|
||||
```
|
||||
/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.
|
||||
2. **Email OTP** _(interactive fallback)_ — prompts for your Perplexity email, sends a one-time code, and prompts for the code.
|
||||
If login fails with a Cloudflare “Just a moment...” page, use browser login instead:
|
||||
|
||||
```
|
||||
/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.
|
||||
|
||||
@@ -39,6 +57,8 @@ The token is saved to `~/.config/pi-perplexity/auth.json` (mode `0600`) and reus
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `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_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
|
||||
|
||||
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
|
||||
|
||||
|
||||
+300
-16
@@ -1,7 +1,7 @@
|
||||
import { execFile } from "node:child_process";
|
||||
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 { loadToken, saveToken } from "./storage.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.";
|
||||
const OTP_AUTH_HELP =
|
||||
"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 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);
|
||||
|
||||
class BrowserChallengeError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "BrowserChallengeError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthenticateOptions {
|
||||
signal?: AbortSignal;
|
||||
promptForEmail?: () => Promise<string | null | undefined>;
|
||||
promptForOtp?: (email: string) => Promise<string | null | undefined>;
|
||||
promptForBrowserAuth?: () => Promise<string | null | undefined>;
|
||||
}
|
||||
|
||||
function normalizeInput(value: string | null | undefined): string | null {
|
||||
@@ -29,6 +47,227 @@ function normalizeInput(value: string | null | undefined): string | 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> {
|
||||
return {
|
||||
Accept: "application/json",
|
||||
@@ -55,6 +294,28 @@ function formatHttpFailure(action: string, response: AuthFetchResponse): string
|
||||
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 {
|
||||
return cookies.map((cookie) => cookie.split(";")[0]).join("; ");
|
||||
}
|
||||
@@ -84,7 +345,7 @@ async function loginWithEmailOtp(
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -115,7 +376,7 @@ async function loginWithEmailOtp(
|
||||
});
|
||||
|
||||
if (!emailResponse.ok) {
|
||||
throw new Error(formatHttpFailure("Failed to send OTP email", emailResponse));
|
||||
throwHttpFailure("Failed to send OTP email", emailResponse);
|
||||
}
|
||||
|
||||
const otp =
|
||||
@@ -140,11 +401,13 @@ async function loginWithEmailOtp(
|
||||
});
|
||||
|
||||
if (!otpResponse.ok) {
|
||||
throw new Error(formatHttpFailure("OTP verification failed", otpResponse));
|
||||
throwHttpFailure("OTP verification failed", otpResponse);
|
||||
}
|
||||
|
||||
const otpPayload = parseJsonResponse("OTP verification response", otpResponse);
|
||||
const token = extractTokenFromPayload(otpPayload);
|
||||
const token =
|
||||
extractTokenFromPayload(otpPayload) ??
|
||||
extractSessionTokenFromCookieHeader(cookieHeaderFrom(otpResponse.cookies));
|
||||
if (!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 → save → throw AuthError if all fail. */
|
||||
export async function authenticate(options: AuthenticateOptions = {}): Promise<string> {
|
||||
/** Run auth strategy: load cached → env token/cookies → desktop extraction → email OTP → browser paste fallback. */
|
||||
export async function authenticate(options: AuthenticateOptions = {}): Promise<StoredToken> {
|
||||
const cached = await loadToken();
|
||||
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";
|
||||
if (!borrowDisabled) {
|
||||
const desktopToken = await extractFromDesktopApp();
|
||||
if (desktopToken) {
|
||||
await saveToken({
|
||||
const credentials: StoredToken = {
|
||||
type: "oauth",
|
||||
access: desktopToken,
|
||||
});
|
||||
return desktopToken;
|
||||
};
|
||||
await saveToken(credentials);
|
||||
return credentials;
|
||||
}
|
||||
}
|
||||
|
||||
const email =
|
||||
normalizeInput(process.env.PI_PERPLEXITY_EMAIL) ??
|
||||
normalizeInput(await options.promptForEmail?.());
|
||||
if (!email) {
|
||||
const browserCredentials = await promptForBrowserCredentials(options);
|
||||
if (browserCredentials) {
|
||||
return browserCredentials;
|
||||
}
|
||||
|
||||
throw new AuthError(
|
||||
"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;
|
||||
}
|
||||
|
||||
if (error instanceof BrowserChallengeError) {
|
||||
const browserCredentials = await promptForBrowserCredentials(options);
|
||||
if (browserCredentials) {
|
||||
return browserCredentials;
|
||||
}
|
||||
}
|
||||
|
||||
throw new AuthError(
|
||||
"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",
|
||||
access: otpToken,
|
||||
email,
|
||||
});
|
||||
return otpToken;
|
||||
};
|
||||
await saveToken(credentials);
|
||||
return credentials;
|
||||
}
|
||||
|
||||
+3
-2
@@ -12,10 +12,11 @@ function isStoredToken(value: unknown): value is StoredToken {
|
||||
}
|
||||
|
||||
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 (
|
||||
candidate.type === "oauth" &&
|
||||
typeof candidate.access === "string" &&
|
||||
candidate.access.length > 0
|
||||
(hasAccess || hasCookies)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+53
-5
@@ -1,6 +1,6 @@
|
||||
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 { AuthError } from "../search/types.js";
|
||||
import { errorMessage } from "../render/util.js";
|
||||
@@ -9,6 +9,7 @@ const LOGIN_COMMAND_NAME = "perplexity-login";
|
||||
|
||||
interface ParsedCommandArgs {
|
||||
forceRefresh: boolean;
|
||||
browserAuth: boolean;
|
||||
showHelp: boolean;
|
||||
unknown: string[];
|
||||
}
|
||||
@@ -20,6 +21,7 @@ function parseCommandArgs(args: string): ParsedCommandArgs {
|
||||
.filter((token) => token.length > 0);
|
||||
|
||||
let forceRefresh = false;
|
||||
let browserAuth = false;
|
||||
let showHelp = false;
|
||||
const unknown: string[] = [];
|
||||
|
||||
@@ -34,14 +36,34 @@ function parseCommandArgs(args: string): ParsedCommandArgs {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === "--browser" || token === "--cookie" || token === "--manual") {
|
||||
browserAuth = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
unknown.push(token);
|
||||
}
|
||||
|
||||
return { forceRefresh, showHelp, unknown };
|
||||
return { forceRefresh, browserAuth, showHelp, unknown };
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -77,13 +99,39 @@ export function registerPerplexityCommands(pi: ExtensionAPI): void {
|
||||
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 {
|
||||
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");
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError && error.code === "NO_TOKEN") {
|
||||
if (parsed.browserAuth) {
|
||||
ctx.ui.notify(error.message, "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
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",
|
||||
);
|
||||
return;
|
||||
|
||||
+23
-2
@@ -54,10 +54,31 @@ export default function (pi: ExtensionAPI) {
|
||||
return ctx.ui.input(label, placeholder);
|
||||
};
|
||||
|
||||
const jwt = await authenticate({
|
||||
const auth = await authenticate({
|
||||
...(signal !== undefined ? { signal } : {}),
|
||||
promptForEmail: async () => promptInput("Perplexity email", "you@example.com"),
|
||||
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) {
|
||||
@@ -88,7 +109,7 @@ export default function (pi: ExtensionAPI) {
|
||||
...(params.recency !== undefined ? { recency: params.recency } : {}),
|
||||
...(params.limit !== undefined ? { limit: params.limit } : {}),
|
||||
},
|
||||
jwt,
|
||||
auth,
|
||||
signal,
|
||||
);
|
||||
|
||||
|
||||
+18
-7
@@ -1,5 +1,5 @@
|
||||
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 { errorMessage } from "../render/util.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> {
|
||||
return {
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
type AuthCredentials = string | StoredToken;
|
||||
|
||||
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",
|
||||
Accept: "text/event-stream",
|
||||
Origin: "https://www.perplexity.ai",
|
||||
@@ -145,13 +148,21 @@ function buildRequestHeaders(jwt: string, requestId: string): Record<string, str
|
||||
"X-Perplexity-Request-Reason": "submit",
|
||||
"X-Request-ID": requestId,
|
||||
};
|
||||
|
||||
if (cookies) {
|
||||
headers.Cookie = cookies;
|
||||
} else if (access) {
|
||||
headers.Authorization = `Bearer ${access}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function mapHttpError(status: number): SearchError {
|
||||
if (status === 401 || status === 403) {
|
||||
return new SearchError(
|
||||
"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. */
|
||||
export async function searchPerplexity(
|
||||
params: SearchParams,
|
||||
jwt: string,
|
||||
auth: AuthCredentials,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SearchResult> {
|
||||
const requestId = crypto.randomUUID();
|
||||
const requestBody = buildRequestBody(params);
|
||||
const requestHeaders = buildRequestHeaders(jwt, requestId);
|
||||
const requestHeaders = buildRequestHeaders(auth, requestId);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
|
||||
+2
-1
@@ -42,7 +42,8 @@ export interface StreamSource {
|
||||
|
||||
export interface StoredToken {
|
||||
type: "oauth";
|
||||
access: string;
|
||||
access?: string;
|
||||
cookies?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
|
||||
+218
-2
@@ -6,6 +6,8 @@ const originalFetch = globalThis.fetch;
|
||||
const originalBorrow = process.env.PI_AUTH_NO_BORROW;
|
||||
const originalEmail = process.env.PI_PERPLEXITY_EMAIL;
|
||||
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 {
|
||||
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
|
||||
@@ -46,6 +48,18 @@ function restoreEnv(): void {
|
||||
} else {
|
||||
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(() => {
|
||||
@@ -158,7 +172,7 @@ describe("auth/login", () => {
|
||||
|
||||
const token = await authenticate();
|
||||
|
||||
expect(token).toBe(cachedToken);
|
||||
expect(token.access).toBe(cachedToken);
|
||||
expect(loadTokenMock).toHaveBeenCalledTimes(1);
|
||||
expect(saveTokenMock).toHaveBeenCalledTimes(0);
|
||||
expect(clearTokenMock).toHaveBeenCalledTimes(0);
|
||||
@@ -216,7 +230,7 @@ describe("auth/login", () => {
|
||||
promptForOtp: async () => "123456",
|
||||
});
|
||||
|
||||
expect(token).toBe(otpToken);
|
||||
expect(token.access).toBe(otpToken);
|
||||
expect(loadTokenMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(saveTokenMock).toHaveBeenCalledTimes(1);
|
||||
@@ -242,6 +256,208 @@ describe("auth/login", () => {
|
||||
|
||||
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 () => {
|
||||
process.env.PI_AUTH_NO_BORROW = "1";
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ const originalFetch = globalThis.fetch;
|
||||
const originalBorrow = process.env.PI_AUTH_NO_BORROW;
|
||||
const originalEmail = process.env.PI_PERPLEXITY_EMAIL;
|
||||
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) ---
|
||||
|
||||
@@ -45,6 +47,8 @@ function restoreEnv(): void {
|
||||
["PI_AUTH_NO_BORROW", originalBorrow],
|
||||
["PI_PERPLEXITY_EMAIL", originalEmail],
|
||||
["PI_PERPLEXITY_OTP", originalOtp],
|
||||
["PI_PERPLEXITY_TOKEN", originalToken],
|
||||
["PI_PERPLEXITY_COOKIE", originalCookie],
|
||||
] as const) {
|
||||
if (original === undefined) {
|
||||
delete process.env[key];
|
||||
@@ -131,7 +135,7 @@ describe("OTP login flow (from real captured responses)", () => {
|
||||
promptForOtp: async () => TEST_OTP,
|
||||
});
|
||||
|
||||
expect(token).toBe(REAL_JWE_TOKEN);
|
||||
expect(token.access).toBe(REAL_JWE_TOKEN);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(saveTokenMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -212,7 +216,7 @@ describe("OTP login flow (from real captured responses)", () => {
|
||||
|
||||
const token = await authenticate({ promptForEmail, promptForOtp });
|
||||
|
||||
expect(token).toBe(REAL_JWE_TOKEN);
|
||||
expect(token.access).toBe(REAL_JWE_TOKEN);
|
||||
expect(promptForEmail).toHaveBeenCalledTimes(0);
|
||||
expect(promptForOtp).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,7 @@ describe("Perplexity model selection e2e", () => {
|
||||
incognito: true,
|
||||
limit: 1,
|
||||
},
|
||||
token.access,
|
||||
token,
|
||||
);
|
||||
|
||||
expect(result.answer.trim().startsWith("OK")).toBe(true);
|
||||
|
||||
@@ -7,6 +7,7 @@ afterEach(() => {
|
||||
describe("perplexity_search execute", () => {
|
||||
test("includes effective config values in the search request and result details", async () => {
|
||||
const authenticate = mock(async () => "jwt-token");
|
||||
const saveBrowserAuthInput = mock(async () => ({ type: "oauth", access: "jwt-token" }));
|
||||
const loadConfig = mock(async () => ({ model: "gpt54", incognito: false }));
|
||||
const resolveSearchDefaults = mock(() => ({ model: "gpt54", incognito: false }));
|
||||
const searchPerplexity = mock(async () => ({
|
||||
@@ -16,7 +17,7 @@ describe("perplexity_search execute", () => {
|
||||
uuid: "req-123",
|
||||
}));
|
||||
|
||||
mock.module("../src/auth/login.js", () => ({ authenticate }));
|
||||
mock.module("../src/auth/login.js", () => ({ authenticate, saveBrowserAuthInput }));
|
||||
mock.module("../src/config.js", () => ({
|
||||
getConfigPath: () => "/tmp/pi-perplexity-config.json",
|
||||
loadConfig,
|
||||
|
||||
@@ -120,6 +120,26 @@ describe("searchPerplexity", () => {
|
||||
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 () => {
|
||||
let capturedInit: RequestInit | undefined;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user