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 { errorMessage } from "../render/util.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 {
|
||||
perplexityFetchText as fetchAuth,
|
||||
type PerplexityFetchResponse as AuthFetchResponse,
|
||||
} from "../perplexity-fetch.js";
|
||||
|
||||
export { parseBrowserAuthInput } from "./browser.js";
|
||||
|
||||
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);
|
||||
|
||||
@@ -47,182 +47,15 @@ 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+)([^\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]);
|
||||
|
||||
+1
-15
@@ -1,5 +1,6 @@
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { browserLoginInstructions } from "../auth/browser.js";
|
||||
import { authenticate, saveBrowserAuthInput } from "../auth/login.js";
|
||||
import { clearToken } from "../auth/storage.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`;
|
||||
}
|
||||
|
||||
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 {
|
||||
pi.registerCommand(LOGIN_COMMAND_NAME, {
|
||||
description: "Authenticate Perplexity and persist token",
|
||||
|
||||
+2
-20
@@ -5,8 +5,8 @@ import { Type } from "@sinclair/typebox";
|
||||
import { registerPerplexityConfigCommand } from "./commands/config.js";
|
||||
import { registerPerplexityCommands } from "./commands/login.js";
|
||||
|
||||
import { browserLoginInstructions } from "./auth/browser.js";
|
||||
import { authenticate } from "./auth/login.js";
|
||||
import { clearToken } from "./auth/storage.js";
|
||||
import { loadConfig, resolveSearchDefaults } from "./config.js";
|
||||
import { formatForLLM } from "./search/format.js";
|
||||
import { searchPerplexity } from "./search/client.js";
|
||||
@@ -59,21 +59,7 @@ export default function (pi: ExtensionAPI) {
|
||||
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",
|
||||
);
|
||||
ctx?.ui?.notify?.(browserLoginInstructions(), "info");
|
||||
return promptInput(
|
||||
"Paste copied cURL command or Cookie header",
|
||||
"curl 'https://www.perplexity.ai/' -H 'Cookie: ...'",
|
||||
@@ -140,10 +126,6 @@ export default function (pi: ExtensionAPI) {
|
||||
}
|
||||
|
||||
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 {
|
||||
content: [{ type: "text", text: `Perplexity search failed: ${error.message}` }],
|
||||
details: { sourceCount, queryMs, isError: true },
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { afterEach, describe, expect, mock, test } from "./test-helpers.js";
|
||||
|
||||
import { SearchError } from "../src/search/types.js";
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
@@ -66,4 +68,43 @@ describe("perplexity_search execute", () => {
|
||||
expect(result.details.incognito).toBe(false);
|
||||
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