Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,37 @@ All notable changes to this project are documented here. Format follows

## [Unreleased]

## [0.8.43] — 2026-05-12 — Auth/CLI hardening (issues #5 + #6)

> Refs [#5](https://github.com/Automations-Project/VSCode-Perplexity-MCP/issues/5) and [#6](https://github.com/Automations-Project/VSCode-Perplexity-MCP/issues/6). Two stacked regressions caused the daemon to report anonymous mode after a successful login: `PERPLEXITY_HEADLESS_ONLY=1` leaked from the launcher env into the daemon's spawn env (disabling the headed bootstrap entirely), and Phase 2 used a non-persistent browser context that discarded the fresh `cf_clearance` acquired in Phase 1. Separately, a symlink-detection bug silently no-oped the CLI on POSIX Homebrew/npm global installs, and repeated keychain probes triggered macOS Keychain permission prompts on every diagnostic pass.

### Fixed

- **`PERPLEXITY_HEADLESS_ONLY` and `PERPLEXITY_NO_DAEMON` are now stripped from the daemon spawn env** in all three spawn sites (`daemon/launcher.ts`, `extension/src/daemon/runtime.ts`, `extension/src/auto-config/transports/stdio-daemon-proxy.ts`). These are launcher-scoped flags that must never reach the daemon's own `PerplexityClient.init()` — leaking them forced the daemon to skip the headed bootstrap (the only phase that can solve CF challenges) or bypass daemon attach entirely. Fixes the "anonymous mode despite successful login" regression.
- **Phase 2 (headless search) now uses a persistent browser context** (`chromium.launchPersistentContext(browserData, ...)`) sharing the same profile directory as Phase 1. The fresh `cf_clearance` written to disk by the headed bootstrap is loaded automatically, eliminating the CF-challenge failure that caused every subsequent search to see anonymous mode. Vault cookies are injected selectively — only cookies not already present on disk are added, so a freshly-written `cf_clearance` from Phase 1 is never overwritten by a stale vault copy.
- **CLI and daemon entrypoints now resolve symlinks in the direct-run guard** (`isMainModule()` in new `src/is-main-module.js`). The previous `import.meta.url === pathToFileURL(process.argv[1]).href` check silently returned `false` when the binary was invoked through a symlink (npm global install, Homebrew Cellar, `node_modules/.bin/`), causing the CLI and daemon to exit `0` with no output. Both `cli.js` and `index.ts` now use `realpathSync` on both sides with a defensive fallback.
- **`dist/cli.mjs` now has a shebang and executable mode** after build. A new `scripts/post-build-shebang.mjs` post-build script prepends `#!/usr/bin/env node` and `chmod 755`s the file. tsup intentionally omits the shebang (it trips vitest/esbuild during test imports); the post-build script targets only `dist/cli.mjs`. No-op on Windows where npm uses `.cmd` wrappers.
- **Keytar probe results are cached per-process** in `vault.js`. Previously `tryKeytar()` re-imported the native module on every vault read, triggering macOS Keychain permission dialogs repeatedly within a single session. The new `_keytarModuleCache` variable caches success and failure alike; `__resetKeyCache()` clears it on profile-state changes. `probeKeychainState()` is exported for shared use across `cli.js` and `checks/vault.js`, removing three duplicate inline probe implementations.
- **`PERPLEXITY_DISABLE_KEYCHAIN=1`** environment variable disables all keychain access. Useful in headless test environments and CI where no credstore is available and the "no keychain" code path should be taken without a failed import attempt.
- **Login runners now use a persistent browser context** (`login-browser-data/` under the active profile directory, separate from the daemon's `browser-data/`). Google SSO and Cloudflare state accumulated across login sessions — eliminating the "log in constantly" UX paper cut. The separate directory avoids Chromium singleton-lock collisions with the daemon's headed bootstrap.
- **`getSavedCookies` now emits specific diagnostic log lines** for each empty-return path: no `vault.enc` (run login first), vault exists but `cookies` key absent, cookies value is not an array, and JSON parse failure. An `unsealFailed` flag prevents the "key absent" message from firing when the real cause is an unseal error (which is already logged by the catch path).

### Added

- **`src/is-main-module.js` + `src/is-main-module.d.ts`** — shared symlink-aware direct-run guard extracted from `cli.js` and `index.ts`.
- **`scripts/post-build-shebang.mjs`** — post-build shebang injection + chmod for `dist/cli.mjs`. Runs as part of `npm run build` in `packages/mcp-server`.
- **`probeKeychainState()`** exported from `vault.js` — single keychain probe entry point with caching and `PERPLEXITY_DISABLE_KEYCHAIN` awareness.

### Changed

- **Build script** (`packages/mcp-server/package.json`): `tsup` → `tsup --no-dts && tsc --allowJs --emitDeclarationOnly && node scripts/post-build-shebang.mjs`. tsup's rollup-dts worker OOMs on this package's 47+ entry points under Node 22+/Windows; `tsc --allowJs --emitDeclarationOnly` is faster and avoids the worker heap ceiling.
- **`tsup.config.ts`**: `dts: true` → `dts: false`; comment updated to reflect the build script split.

### Verification

- All 130 test files pass (1151 tests, 2 intentionally skipped) on Node 22 / Windows.
- Full typecheck clean across all four packages.

## [0.8.41] — 2026-05-10 — Vault unseal hardening for external MCP clients

> Refs [#3](https://github.com/Automations-Project/VSCode-Perplexity-MCP/issues/3). Driver: an external user (Claude Code on Win11) hit "Vault locked" because the extension-managed daemon never received the SecretStorage passphrase, AND the launcher silently fell back to direct vault access in the client's runtime.
Expand Down
2 changes: 1 addition & 1 deletion packages/extension/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "perplexity-vscode",
"displayName": "Perplexity MCP",
"version": "0.8.42",
"version": "0.8.43",
"publisher": "Nskha",
"private": true,
"description": "Perplexity AI search, reasoning, research, and compute — MCP server, dashboard, and multi-IDE auto-config for VS Code.",
Expand Down
6 changes: 6 additions & 0 deletions packages/extension/src/auth/vault-passphrase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ export async function peekStoredVaultPassphrase(
export async function probeKeytarAvailable(
context: vscode.ExtensionContext,
): Promise<boolean> {
if (process.env.PERPLEXITY_DISABLE_KEYCHAIN === "1") {
return false;
}
// Use the bundled mcp-server probe so we agree with the runner's own
// detection. The file lives at `dist/mcp/server.mjs` when packaged; the
// runner scripts sit next to it. We spawn a 1-shot inline script so we don't
Expand All @@ -137,6 +140,9 @@ export async function probeKeytarAvailable(
// prepare-package-deps copies keytar into `dist/node_modules`.
const code = `
(async () => {
if (process.env.PERPLEXITY_DISABLE_KEYCHAIN === "1") {
process.exit(4);
}
try {
const mod = await import("keytar");
const kt = mod.default ?? mod;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ export const stdioDaemonProxyBuilder: TransportBuilder = {
}

const env: Record<string, string> = {
PERPLEXITY_HEADLESS_ONLY: "1",
// NOTE: no PERPLEXITY_NO_DAEMON — launcher multiplexes onto the shared daemon.
// NOTE: no PERPLEXITY_HEADLESS_ONLY — the daemon decides headless vs headed
// based on its own availability probe, not the IDE's env block.
};

if (typeof input.chromePath === "string" && input.chromePath.length > 0) {
Expand Down
8 changes: 7 additions & 1 deletion packages/extension/src/daemon/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,11 +460,17 @@ async function spawnBundledDaemon(options: { configDir: string; host?: string; p
`[daemon] PERPLEXITY_VAULT_PASSPHRASE: ${extraEnv.PERPLEXITY_VAULT_PASSPHRASE ? "set" : "unset"}`,
);

// Strip launcher-scoped flags that must never reach the daemon's own
// PerplexityClient.init() — they would force headless mode or stdio bypass.
const baseEnv = { ...process.env };
delete baseEnv.PERPLEXITY_HEADLESS_ONLY;
delete baseEnv.PERPLEXITY_NO_DAEMON;

const child = spawn(process.execPath, args, {
detached: true,
stdio: ["ignore", logFd, logFd],
env: {
...process.env,
...baseEnv,
...extraEnv,
// Hard-coded overrides — must come AFTER extraEnv so a buggy provider
// cannot clobber them.
Expand Down
4 changes: 1 addition & 3 deletions packages/extension/tests/auto-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,7 @@ describe("2026-05 IDE expansion", () => {
type: "local",
command: ["C:/node.exe", "C:/bundle/server.mjs"],
enabled: true,
environment: {
PERPLEXITY_HEADLESS_ONLY: "1",
},
environment: {},
});
expect(nextConfig.mcp?.Perplexity.args).toBeUndefined();
expect(nextConfig.mcp?.Perplexity.env).toBeUndefined();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe("stdioDaemonProxyBuilder", () => {
expect(result).toEqual({
command: "node",
args: ["/home/user/.perplexity-mcp/launcher.cjs"],
env: { PERPLEXITY_HEADLESS_ONLY: "1" },
env: {},
});
// Critical: proxy variant must NOT force the launcher into no-daemon mode.
expect("env" in result ? result.env : {}).not.toHaveProperty("PERPLEXITY_NO_DAEMON");
Expand Down Expand Up @@ -67,7 +67,6 @@ describe("stdioDaemonProxyBuilder", () => {
expect(env.PERPLEXITY_CHROME_PATH).toBe(
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
);
expect(env.PERPLEXITY_HEADLESS_ONLY).toBe("1");
expect(env).not.toHaveProperty("PERPLEXITY_NO_DAEMON");
});

Expand Down Expand Up @@ -108,7 +107,7 @@ describe("stdioDaemonProxyBuilder", () => {
expect(result).toEqual({
command: "node",
args: ["/home/user/.perplexity-mcp/launcher.cjs"],
env: { PERPLEXITY_HEADLESS_ONLY: "1" },
env: {},
});

const env = "env" in result ? result.env ?? {} : {};
Expand Down
4 changes: 2 additions & 2 deletions packages/mcp-server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "perplexity-user-mcp",
"version": "0.8.42",
"version": "0.8.43",
"mcpName": "io.github.Automations-Project/perplexity-user-mcp",
"type": "module",
"description": "Perplexity AI MCP server — browser automation for search, reasoning, research, and compute. Not affiliated with Perplexity AI, Inc.",
Expand Down Expand Up @@ -126,7 +126,7 @@
"CHANGELOG.md"
],
"scripts": {
"build": "tsup",
"build": "tsup --no-dts && tsc --allowJs --emitDeclarationOnly && node scripts/post-build-shebang.mjs",
Comment thread
ARHAEEM marked this conversation as resolved.
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "cd ../.. && npx vitest run packages/mcp-server/test",
"test:coverage": "cd ../.. && npx vitest run --coverage packages/mcp-server/test"
Expand Down
14 changes: 2 additions & 12 deletions packages/mcp-server/src/checks/vault.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,9 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { probeKeychainState } from "../vault.js";

const CATEGORY = "vault";

async function tryKeychain() {
try {
const mod = await import("keytar");
const keytar = mod.default ?? mod;
const hex = await keytar.getPassword("perplexity-user-mcp", "vault-master-key");
return { available: true, hasKey: !!hex };
} catch {
return { available: false, hasKey: false };
}
}

function keychainExpected() {
return process.platform === "win32" || process.platform === "darwin" ||
(process.platform === "linux" && !process.env.CI);
Expand All @@ -26,7 +16,7 @@ export async function run(opts = {}) {
const enc = join(dir, "profiles", profile, "vault.enc");
const plain = join(dir, "profiles", profile, "vault.json");
const envPass = process.env.PERPLEXITY_VAULT_PASSPHRASE;
const kc = await tryKeychain();
const kc = await probeKeychainState();

// Encryption mode (separate from unseal path so plaintext opt-out is always a warn, not a skip).
if (existsSync(plain)) {
Expand Down
30 changes: 6 additions & 24 deletions packages/mcp-server/src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { fileURLToPath, pathToFileURL } from "node:url";
import { isMainModule } from "./is-main-module.js";
import { probeKeychainState } from "./vault.js";

const execFile = promisify(execFileCallback);

Expand Down Expand Up @@ -91,27 +93,7 @@ function normalizeExportFormat(value) {
* advice stays consistent with what the runner will actually do.
*/
async function probeVaultState({ profile } = {}) {
let keychainAvailable = false;
let keychainHasKey = false;
try {
const mod = await import("keytar");
const keytar = mod.default ?? mod;
if (keytar && typeof keytar.getPassword === "function") {
keychainAvailable = true;
try {
const hex = await keytar.getPassword("perplexity-user-mcp", "vault-master-key");
keychainHasKey = !!hex;
} catch {
// getPassword can throw on broken credstore backends (e.g. headless
// Linux without libsecret). The binding loaded but isn't usable —
// treat that as "available but no key", same posture as a fresh
// box. vault.js falls back to env var when keychain returns null.
keychainHasKey = false;
}
}
} catch {
keychainAvailable = false;
}
const { available: keychainAvailable, hasKey: keychainHasKey } = await probeKeychainState();
const envPassphraseSet = !!process.env.PERPLEXITY_VAULT_PASSPHRASE;
const hasTty = process.stdin?.isTTY === true && process.env.PERPLEXITY_MCP_STDIO !== "1";

Expand Down Expand Up @@ -1302,13 +1284,13 @@ Environment:
PERPLEXITY_NO_DAEMON=1 'daemon attach' runs in-process stdio (bypass daemon)
`;

/* v8 ignore start -- only runs when cli.js is executed as a script */
if (import.meta.url === pathToFileURL(process.argv[1]).href) {

if (isMainModule(import.meta.url)) {
const parsed = parseArgs(process.argv.slice(2));
routeCommand(parsed).then((res) => {
if (res.stdout) process.stdout.write(res.stdout);
if (res.stderr) process.stderr.write(res.stderr);
process.exit(res.code);
});
}
/* v8 ignore stop */

39 changes: 23 additions & 16 deletions packages/mcp-server/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
findBrowser,
findChromeExecutable,
resolveBrowserExecutable,
getOrCreateContext,
getSavedCookies,
type BrowserChannel,
type ASIFile,
Expand Down Expand Up @@ -914,25 +913,33 @@ export class PerplexityClient {
}

// Phase 2: Headless browser for search operations.
console.error("[perplexity-mcp] Launching headless browser...");
// Use the SAME persistent browserData directory as Phase 1 so that
// any cf_clearance cookie acquired during the headed bootstrap is
// already on disk and loaded automatically. This fixes the bug where
// Phase 2 used a non-persistent context and only had stale vault
// cookies (issue #5).
console.error("[perplexity-mcp] Launching headless persistent browser...");
const launchOpts = buildLaunchOptions(true);
this.browser = await chromium.launch({
headless: launchOpts.headless,
args: launchOpts.args,
...(launchOpts.executablePath ? { executablePath: launchOpts.executablePath } : {}),
...(launchOpts.channel ? { channel: launchOpts.channel } : {}),
ignoreDefaultArgs: launchOpts.ignoreDefaultArgs,
});
this.context = await getOrCreateContext(this.browser, {
viewport: launchOpts.viewport,
userAgent: launchOpts.userAgent,
});
this.context = await chromium.launchPersistentContext(
activePaths.browserData,
launchOpts,
);
this.browser = this.context.browser();

// Inject saved cookies (session + cf_clearance from login)
// Inject vault cookies only for cookies not already present on disk.
// The headed bootstrap may have refreshed cf_clearance; we must not
// overwrite the fresh disk cookie with the stale vault copy.
const saved = await getSavedCookies();
if (saved.length > 0) {
await this.context.addCookies(saved);
console.error(`[perplexity-mcp] Injected ${saved.length} saved cookies into browser context.`);
const current = await this.context.cookies();
const currentNames = new Set(current.map((c) => c.name));
const toInject = saved.filter((c) => !currentNames.has(c.name));
if (toInject.length > 0) {
await this.context.addCookies(toInject);
console.error(`[perplexity-mcp] Injected ${toInject.length} missing cookies from vault.`);
} else {
console.error("[perplexity-mcp] All vault cookies already present on disk; skipping injection.");
}
}

this.page = await this.context.newPage();
Expand Down
30 changes: 25 additions & 5 deletions packages/mcp-server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,16 +405,36 @@ export async function getSavedCookies(): Promise<PlaywrightCookie[]> {
// return [] so the caller can report "no-cookies", but log the real reason
// so the extension output channel shows why — otherwise the user sees
// "run Login first" for a profile they already logged into.
const raw = await _vault.get(activeName(), "cookies").catch((err: unknown) => {
const profile = activeName();
let unsealFailed = false;
const raw = await _vault.get(profile, "cookies").catch((err: unknown) => {
unsealFailed = true;
const msg = err instanceof Error ? err.message : String(err);
console.error(`[vault] getSavedCookies failed for profile ${activeName()}: ${msg}`);
console.error(`[vault] getSavedCookies failed for profile '${profile}': ${msg}`);
return null;
});
if (!raw) return [];
if (!raw) {
if (!unsealFailed) {
// Distinguish "no vault.enc" from "vault exists but has no cookies key"
const paths = getProfilePaths(profile);
if (!existsSync(paths.vault)) {
console.error(`[vault] getSavedCookies: no vault.enc for profile '${profile}' — run login first`);
} else {
console.error(`[vault] getSavedCookies: vault.enc exists for profile '${profile}' but 'cookies' key is absent`);
}
}
return [];
}
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
if (!Array.isArray(parsed)) {
console.error(`[vault] getSavedCookies: 'cookies' value for profile '${profile}' is not an array (${typeof parsed})`);
return [];
}
return parsed;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[vault] getSavedCookies: JSON parse failed for profile '${profile}': ${msg}`);
return [];
}
}
Expand Down
8 changes: 7 additions & 1 deletion packages/mcp-server/src/daemon/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -931,11 +931,17 @@ async function spawnDetachedDaemon(options: {
args.push("--tunnel");
}

// Strip launcher-scoped flags that must never reach the daemon's own
// PerplexityClient.init() — they would force headless mode or stdio bypass.
const env = { ...process.env };
delete env.PERPLEXITY_HEADLESS_ONLY;
delete env.PERPLEXITY_NO_DAEMON;

const child = spawn(process.execPath, args, {
detached: true,
stdio: "ignore",
env: {
...process.env,
...env,
PERPLEXITY_CONFIG_DIR: options.configDir,
},
});
Expand Down
4 changes: 2 additions & 2 deletions packages/mcp-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node

import { pathToFileURL } from "node:url";
import { isMainModule } from "./is-main-module.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { PerplexityClient } from "./client.js";
Expand Down Expand Up @@ -191,7 +191,7 @@ export async function main() {
}
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
if (isMainModule(import.meta.url)) {
runEntrypoint().catch(async (error) => {
console.error("[perplexity-mcp] Fatal error:", error);
await shutdownClientWithTimeout(client);
Expand Down
6 changes: 6 additions & 0 deletions packages/mcp-server/src/is-main-module.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* Returns true when the importing module is the process entrypoint (i.e. was
* invoked as `node <script>` or via a bin symlink), false when it was imported
* by another module.
*/
export function isMainModule(metaUrl: string): boolean;
Loading
Loading