refactor(auth): separate browser credential parsing
This commit is contained in:
@@ -0,0 +1,202 @@
|
|||||||
|
import type { StoredToken } from "../search/types.js";
|
||||||
|
|
||||||
|
export 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 SESSION_TOKEN_COOKIE_NAMES = [
|
||||||
|
"__Secure-next-auth.session-token",
|
||||||
|
"next-auth.session-token",
|
||||||
|
"perplexity_jwt",
|
||||||
|
"pplx_jwt",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function normalizeInput(value: string | null | undefined): string | null {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
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+)([^\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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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");
|
||||||
|
}
|
||||||
+8
-175
@@ -4,27 +4,27 @@ import { promisify } from "node:util";
|
|||||||
import { AuthError, type StoredToken } 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 {
|
||||||
|
BROWSER_AUTH_HELP,
|
||||||
|
browserAuthFailureMessage,
|
||||||
|
extractSessionTokenFromCookieHeader,
|
||||||
|
parseBrowserAuthInput,
|
||||||
|
} from "./browser.js";
|
||||||
import { PERPLEXITY_USER_AGENT, PERPLEXITY_API_VERSION } from "../constants.js";
|
import { PERPLEXITY_USER_AGENT, PERPLEXITY_API_VERSION } from "../constants.js";
|
||||||
import {
|
import {
|
||||||
perplexityFetchText as fetchAuth,
|
perplexityFetchText as fetchAuth,
|
||||||
type PerplexityFetchResponse as AuthFetchResponse,
|
type PerplexityFetchResponse as AuthFetchResponse,
|
||||||
} from "../perplexity-fetch.js";
|
} from "../perplexity-fetch.js";
|
||||||
|
|
||||||
|
export { parseBrowserAuthInput } from "./browser.js";
|
||||||
|
|
||||||
const DESKTOP_AUTH_HELP =
|
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 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 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);
|
||||||
|
|
||||||
@@ -47,182 +47,15 @@ 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 {
|
function stripBearerPrefix(value: string): string {
|
||||||
return value.replace(/^Bearer\s+/i, "").trim();
|
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+)([^\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 {
|
function credentialsFromTokenValue(value: string): StoredToken | null {
|
||||||
const token = normalizeInput(stripBearerPrefix(value));
|
const token = normalizeInput(stripBearerPrefix(value));
|
||||||
return token ? { type: "oauth", access: token } : null;
|
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 {
|
function credentialsFromEnvironment(): StoredToken | null {
|
||||||
for (const key of TOKEN_ENV_KEYS) {
|
for (const key of TOKEN_ENV_KEYS) {
|
||||||
const value = normalizeInput(process.env[key]);
|
const value = normalizeInput(process.env[key]);
|
||||||
|
|||||||
+1
-15
@@ -1,5 +1,6 @@
|
|||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
import { browserLoginInstructions } from "../auth/browser.js";
|
||||||
import { authenticate, saveBrowserAuthInput } 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";
|
||||||
@@ -51,21 +52,6 @@ function usageText(): string {
|
|||||||
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`;
|
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 {
|
||||||
pi.registerCommand(LOGIN_COMMAND_NAME, {
|
pi.registerCommand(LOGIN_COMMAND_NAME, {
|
||||||
description: "Authenticate Perplexity and persist token",
|
description: "Authenticate Perplexity and persist token",
|
||||||
|
|||||||
+2
-20
@@ -5,8 +5,8 @@ import { Type } from "@sinclair/typebox";
|
|||||||
import { registerPerplexityConfigCommand } from "./commands/config.js";
|
import { registerPerplexityConfigCommand } from "./commands/config.js";
|
||||||
import { registerPerplexityCommands } from "./commands/login.js";
|
import { registerPerplexityCommands } from "./commands/login.js";
|
||||||
|
|
||||||
|
import { browserLoginInstructions } from "./auth/browser.js";
|
||||||
import { authenticate } from "./auth/login.js";
|
import { authenticate } from "./auth/login.js";
|
||||||
import { clearToken } from "./auth/storage.js";
|
|
||||||
import { loadConfig, resolveSearchDefaults } from "./config.js";
|
import { loadConfig, resolveSearchDefaults } from "./config.js";
|
||||||
import { formatForLLM } from "./search/format.js";
|
import { formatForLLM } from "./search/format.js";
|
||||||
import { searchPerplexity } from "./search/client.js";
|
import { searchPerplexity } from "./search/client.js";
|
||||||
@@ -59,21 +59,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
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 () => {
|
promptForBrowserAuth: async () => {
|
||||||
ctx?.ui?.notify?.(
|
ctx?.ui?.notify?.(browserLoginInstructions(), "info");
|
||||||
[
|
|
||||||
"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(
|
return promptInput(
|
||||||
"Paste copied cURL command or Cookie header",
|
"Paste copied cURL command or Cookie header",
|
||||||
"curl 'https://www.perplexity.ai/' -H 'Cookie: ...'",
|
"curl 'https://www.perplexity.ai/' -H 'Cookie: ...'",
|
||||||
@@ -140,10 +126,6 @@ export default function (pi: ExtensionAPI) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof SearchError) {
|
if (error instanceof SearchError) {
|
||||||
if (error.code === "AUTH") {
|
|
||||||
// Clear cached token on auth rejection so next call triggers re-login.
|
|
||||||
await clearToken().catch(() => undefined);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: `Perplexity search failed: ${error.message}` }],
|
content: [{ type: "text", text: `Perplexity search failed: ${error.message}` }],
|
||||||
details: { sourceCount, queryMs, isError: true },
|
details: { sourceCount, queryMs, isError: true },
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { afterEach, describe, expect, mock, test } from "./test-helpers.js";
|
import { afterEach, describe, expect, mock, test } from "./test-helpers.js";
|
||||||
|
|
||||||
|
import { SearchError } from "../src/search/types.js";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mock.restore();
|
mock.restore();
|
||||||
});
|
});
|
||||||
@@ -66,4 +68,43 @@ describe("perplexity_search execute", () => {
|
|||||||
expect(result.details.incognito).toBe(false);
|
expect(result.details.incognito).toBe(false);
|
||||||
expect(result.details.model).toBe("gpt54");
|
expect(result.details.model).toBe("gpt54");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("does not clear cached credentials on Perplexity auth rejection", async () => {
|
||||||
|
const authenticate = mock(async () => "jwt-token");
|
||||||
|
const saveBrowserAuthInput = mock(async () => ({ type: "oauth", access: "jwt-token" }));
|
||||||
|
const clearToken = mock(async () => undefined);
|
||||||
|
const loadConfig = mock(async () => ({}));
|
||||||
|
const resolveSearchDefaults = mock(() => ({ model: "pplx_pro_upgraded", incognito: true }));
|
||||||
|
const searchPerplexity = mock(async () => {
|
||||||
|
throw new SearchError("AUTH", "Perplexity rejected authentication (401/403).");
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("../src/auth/login.js", () => ({ authenticate, saveBrowserAuthInput }));
|
||||||
|
mock.module("../src/auth/storage.js", () => ({ clearToken }));
|
||||||
|
mock.module("../src/config.js", () => ({
|
||||||
|
getConfigPath: () => "/tmp/pi-perplexity-config.json",
|
||||||
|
loadConfig,
|
||||||
|
resolveSearchDefaults,
|
||||||
|
saveConfig: mock(async () => undefined),
|
||||||
|
}));
|
||||||
|
mock.module("../src/search/client.js", () => ({ searchPerplexity }));
|
||||||
|
|
||||||
|
const { default: registerExtension } = await import(`../src/index.js?test=${crypto.randomUUID()}`);
|
||||||
|
|
||||||
|
let execute: ((toolCallId: string, params: any, signal?: AbortSignal, onUpdate?: any, ctx?: any) => Promise<any>) | undefined;
|
||||||
|
registerExtension({
|
||||||
|
registerCommand() {
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
registerTool(tool: { execute: typeof execute }) {
|
||||||
|
execute = tool.execute;
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const result = await execute!("tool-1", { query: "hello" }, undefined, undefined, { ui: {} });
|
||||||
|
|
||||||
|
expect(result.details.isError).toBe(true);
|
||||||
|
expect(String(result.content[0].text)).toContain("Perplexity search failed");
|
||||||
|
expect(clearToken).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user