Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
!packages/mcp-server/
!packages/mcp-server/src/
!packages/mcp-server/src/**
!packages/mcp-server/scripts/
!packages/mcp-server/scripts/**
!packages/mcp-server/test/
!packages/mcp-server/test/**
!packages/mcp-server/package.json
Expand Down
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: 5 additions & 1 deletion packages/extension/src/auth/session.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { existsSync, readFileSync, statSync } from "node:fs";
import type { AccountSnapshot, ModelsConfigSource, RefreshTier } from "@perplexity-user-mcp/shared";
import type { AccountSnapshot, DaemonAuthStatus, ModelsConfigSource, RefreshTier } from "@perplexity-user-mcp/shared";
import { MODELS_FALLBACK, MODELS_FALLBACK_CAPTURED_AT } from "@perplexity-user-mcp/shared";
import { getConfigDir, getProfilePaths, getActiveName } from "perplexity-user-mcp/profiles";
import type { AccountInfo } from "../browser/runtime.js";
Expand Down Expand Up @@ -91,6 +91,9 @@ export function getAccountSnapshot(): AccountSnapshot {

const speedBoost = getImpitStatus();

// Read live daemon auth state — null when file absent (stdio mode / first run).
const daemonAuth = readJsonFile<DaemonAuthStatus>(paths.daemonStatus);

return {
loggedIn,
userId: null,
Expand All @@ -109,6 +112,7 @@ export function getAccountSnapshot(): AccountSnapshot {
installedAt: speedBoost.installedAt,
runtimeDir: speedBoost.runtimeDir,
},
daemonAuth,
};
}

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
96 changes: 95 additions & 1 deletion packages/extension/src/webview/DashboardProvider.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as crypto from "node:crypto";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import * as vscode from "vscode";
Expand Down Expand Up @@ -55,6 +56,7 @@ import {
import {
listProfiles,
getActiveName,
getProfilePaths,
setActive,
createProfile,
deleteProfile,
Expand Down Expand Up @@ -119,6 +121,8 @@ export class DashboardProvider implements vscode.WebviewViewProvider {
private otpResolvers = new Map<string, (s: string | null) => void>();
private onMcpServerDefinitionsChanged?: () => void;
private daemonEventsAbort: AbortController | null = null;
private daemonStatusWatcher: fs.FSWatcher | null = null;
private daemonStatusWatchedProfile: string | null = null;
// v0.8.5: deps factory injected from extension.ts so the auto-regen hook
// on `postStaleness` can reuse the live ApplyIdeConfigDeps without pulling
// the daemon runtime singletons into the webview module.
Expand Down Expand Up @@ -231,6 +235,7 @@ export class DashboardProvider implements vscode.WebviewViewProvider {

const disposeHook = (webviewView as vscode.WebviewView & { onDidDispose?: (listener: () => void) => vscode.Disposable }).onDidDispose?.(() => {
this.stopDaemonEventStream();
this.stopDaemonStatusWatch();
});
if (disposeHook) {
this.context.subscriptions.push(disposeHook);
Expand All @@ -249,8 +254,16 @@ export class DashboardProvider implements vscode.WebviewViewProvider {
try {
switch (message.type) {
case "ready":
debug("Handling ready");
await this.refresh();
break;
case "dashboard:refresh":
debug("Handling refresh/ready");
debug("Handling refresh");
// If the daemon is reporting anonymous but the profile has stored
// credentials, touch .reinit so the daemon re-runs init() and
// re-checks auth. The daemon-status.json watcher picks up the
// result and calls refresh() automatically when it completes.
this.triggerDaemonReinitIfStale();
await this.refresh();
break;
case "auth:login":
Expand Down Expand Up @@ -1583,6 +1596,8 @@ export class DashboardProvider implements vscode.WebviewViewProvider {
return;
}

this.ensureDaemonStatusWatch();

await this.view.webview.postMessage({
type: "dashboard:state",
payload: this.buildState()
Expand Down Expand Up @@ -2056,6 +2071,85 @@ export class DashboardProvider implements vscode.WebviewViewProvider {
this.daemonEventsAbort = null;
}

// ── daemon-status.json watcher ────────────────────────────────────────────

/**
* Watch daemon-status.json for the active profile. When it changes (daemon
* finished init/reinit), push a fresh dashboard state to the webview so the
* daemonAuth indicator updates without the user manually clicking Refresh.
*/
private startDaemonStatusWatch(): void {
this.stopDaemonStatusWatch();
const profile = getActiveName() ?? "default";
const statusFile = getProfilePaths(profile).daemonStatus;
let debounce: ReturnType<typeof setTimeout> | null = null;
try {
this.daemonStatusWatcher = fs.watch(statusFile, () => {
if (debounce) clearTimeout(debounce);
debounce = setTimeout(() => {
debounce = null;
void this.refresh();
}, 300);
});
this.daemonStatusWatcher.on("error", () => {
// File may not exist yet (daemon not started). Re-arm on next refresh.
this.stopDaemonStatusWatch();
});
this.daemonStatusWatchedProfile = profile;
} catch {
// daemon-status.json doesn't exist yet — watcher will be re-armed
// the next time refresh() is called (startDaemonStatusWatch is called
// from refresh() → ensureWatcher() path below).
this.daemonStatusWatcher = null;
this.daemonStatusWatchedProfile = null;
}
}

private stopDaemonStatusWatch(): void {
this.daemonStatusWatcher?.close();
this.daemonStatusWatcher = null;
this.daemonStatusWatchedProfile = null;
}

/**
* Called from refresh() to ensure the watcher is tracking the current
* active profile. Re-arms if the profile changed or the previous watch
* failed because the file didn't exist yet.
*/
private ensureDaemonStatusWatch(): void {
const profile = getActiveName() ?? "default";
const statusFile = getProfilePaths(profile).daemonStatus;
// Re-arm when: watcher never started, file was missing last time, or
// the active profile has changed since the watcher was last started.
if (!this.daemonStatusWatcher || this.daemonStatusWatchedProfile !== profile) {
if (fs.existsSync(statusFile)) {
this.startDaemonStatusWatch();
}
Comment thread
ARHAEEM marked this conversation as resolved.
}
}

/**
* If the daemon's last-known auth state is anonymous while the profile has
* stored credentials, touch the .reinit sentinel to ask the daemon to
* re-run init() and re-check auth. The daemon-status.json watcher will
* pick up the result automatically.
*/
private triggerDaemonReinitIfStale(): void {
try {
const snapshot = this.buildState().snapshot;
if (!snapshot.loggedIn) return;
if (!snapshot.daemonAuth) return;
if (snapshot.daemonAuth.authenticated) return;
// Stored login but daemon is anonymous → touch .reinit
const profile = getActiveName() ?? "default";
const reinitPath = getProfilePaths(profile).reinit;
fs.writeFileSync(reinitPath, String(Date.now()));
debug("[daemonStatusSync] Touched .reinit — daemon was anonymous with stored login");
} catch (err) {
debug(`[daemonStatusSync] triggerDaemonReinitIfStale error: ${(err as Error).message}`);
}
}

private async readDaemonEvents(body: ReadableStream<Uint8Array>, controller: AbortController): Promise<void> {
const reader = body.getReader();
const decoder = new TextDecoder();
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
32 changes: 32 additions & 0 deletions packages/mcp-server/scripts/post-build-shebang.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/usr/bin/env node
/**
* Post-build shebang injection for dist/cli.mjs.
*
* tsup intentionally omits shebang from bundled output because a shebang in
* source files trips vitest/esbuild during test imports. However, the npm
* package.json `bin` field points directly at `dist/cli.mjs`; on POSIX npm
* creates a symlink (not a wrapper script), so the kernel needs the shebang
* to exec the file directly.
*
* This script prepends `#!/usr/bin/env node` and ensures the file is
* executable (0o755). It is a no-op on Windows where the kernel ignores
* shebangs and npm uses `.cmd` wrappers anyway.
*/
import { readFileSync, writeFileSync, chmodSync, existsSync } from "node:fs";
import { join } from "node:path";

const file = join(import.meta.dirname, "..", "dist", "cli.mjs");
if (!existsSync(file)) {
console.error("post-build-shebang: dist/cli.mjs not found");
process.exit(1);
}

const content = readFileSync(file, "utf8");
if (content.startsWith("#!/usr/bin/env node")) {
console.log("post-build-shebang: already present, skipping");
process.exit(0);
}

writeFileSync(file, "#!/usr/bin/env node\n" + content);
chmodSync(file, 0o755);
console.log("post-build-shebang: injected shebang + chmod 755");
Loading
Loading