diff --git a/.gitignore b/.gitignore index 1c2b7b2..75ef70f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 50281fb..125aafd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/extension/package.json b/packages/extension/package.json index 2802384..1a30dd3 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -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.", diff --git a/packages/extension/src/auth/session.ts b/packages/extension/src/auth/session.ts index 18e1940..f8779a1 100644 --- a/packages/extension/src/auth/session.ts +++ b/packages/extension/src/auth/session.ts @@ -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"; @@ -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(paths.daemonStatus); + return { loggedIn, userId: null, @@ -109,6 +112,7 @@ export function getAccountSnapshot(): AccountSnapshot { installedAt: speedBoost.installedAt, runtimeDir: speedBoost.runtimeDir, }, + daemonAuth, }; } diff --git a/packages/extension/src/auth/vault-passphrase.ts b/packages/extension/src/auth/vault-passphrase.ts index 9bd3d49..eff40a1 100644 --- a/packages/extension/src/auth/vault-passphrase.ts +++ b/packages/extension/src/auth/vault-passphrase.ts @@ -126,6 +126,9 @@ export async function peekStoredVaultPassphrase( export async function probeKeytarAvailable( context: vscode.ExtensionContext, ): Promise { + 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 @@ -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; diff --git a/packages/extension/src/auto-config/transports/stdio-daemon-proxy.ts b/packages/extension/src/auto-config/transports/stdio-daemon-proxy.ts index b30f1c5..1de49fc 100644 --- a/packages/extension/src/auto-config/transports/stdio-daemon-proxy.ts +++ b/packages/extension/src/auto-config/transports/stdio-daemon-proxy.ts @@ -17,8 +17,9 @@ export const stdioDaemonProxyBuilder: TransportBuilder = { } const env: Record = { - 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) { diff --git a/packages/extension/src/daemon/runtime.ts b/packages/extension/src/daemon/runtime.ts index 217cdb3..0562ebb 100644 --- a/packages/extension/src/daemon/runtime.ts +++ b/packages/extension/src/daemon/runtime.ts @@ -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. diff --git a/packages/extension/src/webview/DashboardProvider.ts b/packages/extension/src/webview/DashboardProvider.ts index 9a664d7..00417f6 100644 --- a/packages/extension/src/webview/DashboardProvider.ts +++ b/packages/extension/src/webview/DashboardProvider.ts @@ -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"; @@ -55,6 +56,7 @@ import { import { listProfiles, getActiveName, + getProfilePaths, setActive, createProfile, deleteProfile, @@ -119,6 +121,8 @@ export class DashboardProvider implements vscode.WebviewViewProvider { private otpResolvers = new Map 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. @@ -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); @@ -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": @@ -1583,6 +1596,8 @@ export class DashboardProvider implements vscode.WebviewViewProvider { return; } + this.ensureDaemonStatusWatch(); + await this.view.webview.postMessage({ type: "dashboard:state", payload: this.buildState() @@ -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 | 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(); + } + } + } + + /** + * 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, controller: AbortController): Promise { const reader = body.getReader(); const decoder = new TextDecoder(); diff --git a/packages/extension/tests/auto-config.test.ts b/packages/extension/tests/auto-config.test.ts index 34cf2e8..71ab847 100644 --- a/packages/extension/tests/auto-config.test.ts +++ b/packages/extension/tests/auto-config.test.ts @@ -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(); diff --git a/packages/extension/tests/transports/stdio-daemon-proxy.test.ts b/packages/extension/tests/transports/stdio-daemon-proxy.test.ts index 8837b18..37204c2 100644 --- a/packages/extension/tests/transports/stdio-daemon-proxy.test.ts +++ b/packages/extension/tests/transports/stdio-daemon-proxy.test.ts @@ -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"); @@ -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"); }); @@ -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 ?? {} : {}; diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 20472e6..8a0d32f 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -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.", @@ -126,7 +126,7 @@ "CHANGELOG.md" ], "scripts": { - "build": "tsup", + "build": "tsup --no-dts && tsc --allowJs --emitDeclarationOnly && node scripts/post-build-shebang.mjs", "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" diff --git a/packages/mcp-server/scripts/post-build-shebang.mjs b/packages/mcp-server/scripts/post-build-shebang.mjs new file mode 100644 index 0000000..f589939 --- /dev/null +++ b/packages/mcp-server/scripts/post-build-shebang.mjs @@ -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"); diff --git a/packages/mcp-server/src/checks/profiles.js b/packages/mcp-server/src/checks/profiles.js index c756412..6bcc512 100644 --- a/packages/mcp-server/src/checks/profiles.js +++ b/packages/mcp-server/src/checks/profiles.js @@ -91,6 +91,43 @@ export async function run(opts = {}) { results.push({ category: CATEGORY, name: `${name}/models-cache`, status: "pass", message: `${Math.round(ageDays)}d old` }); } } + + const daemonStatusPath = join(pdir, "daemon-status.json"); + if (!existsSync(daemonStatusPath)) { + results.push({ category: CATEGORY, name: `${name}/daemon-status`, status: "skip", message: "no daemon status file (stdio-only or not yet started)" }); + } else { + try { + const ds = JSON.parse(readFileSync(daemonStatusPath, "utf8")); + if (!ds.authenticated) { + results.push({ + category: CATEGORY, + name: `${name}/daemon-status`, + status: "warn", + message: `daemon last init: ${ds.lastInit} — authenticated: false (tier: ${ds.tier})${ds.error ? `, error: ${ds.error}` : ""}`, + hint: "Open the extension dashboard and click 'Refresh state' to trigger a daemon reinit.", + }); + } else { + results.push({ + category: CATEGORY, + name: `${name}/daemon-status`, + status: "pass", + message: `authenticated as ${ds.tier}, last init: ${ds.lastInit} (${ds.initDurationMs}ms)`, + }); + } + } catch { + results.push({ category: CATEGORY, name: `${name}/daemon-status`, status: "warn", message: "daemon-status.json is corrupt or unreadable" }); + } + } + + const loginBrowserDataPath = join(pdir, "login-browser-data"); + if (existsSync(loginBrowserDataPath)) { + results.push({ + category: CATEGORY, + name: `${name}/login-browser-data`, + status: "info", + message: "login-browser-data directory present (leftover from a past login session; safe to ignore)", + }); + } } return results; diff --git a/packages/mcp-server/src/checks/vault.js b/packages/mcp-server/src/checks/vault.js index ee5724a..c021ae1 100644 --- a/packages/mcp-server/src/checks/vault.js +++ b/packages/mcp-server/src/checks/vault.js @@ -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); @@ -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)) { diff --git a/packages/mcp-server/src/cli.js b/packages/mcp-server/src/cli.js index 274b42b..49ea6b0 100644 --- a/packages/mcp-server/src/cli.js +++ b/packages/mcp-server/src/cli.js @@ -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); @@ -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"; @@ -1302,8 +1284,8 @@ 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); @@ -1311,4 +1293,4 @@ if (import.meta.url === pathToFileURL(process.argv[1]).href) { process.exit(res.code); }); } -/* v8 ignore stop */ + diff --git a/packages/mcp-server/src/client.ts b/packages/mcp-server/src/client.ts index 1486a3d..d287a5c 100644 --- a/packages/mcp-server/src/client.ts +++ b/packages/mcp-server/src/client.ts @@ -19,7 +19,6 @@ import { findBrowser, findChromeExecutable, resolveBrowserExecutable, - getOrCreateContext, getSavedCookies, type BrowserChannel, type ASIFile, @@ -34,6 +33,7 @@ import { isImpitAvailable, impitFetchJson } from "./refresh.js"; import { writeFileSync, readFileSync, mkdirSync, existsSync } from "fs"; import { join } from "path"; import { getActiveName, getConfigDir, getProfilePaths } from "./profiles.js"; +import type { DaemonAuthStatus } from "@perplexity-user-mcp/shared"; import { clearStaleSingletonLocks } from "./fs-utils.js"; function getActiveProfileName(): string { @@ -895,61 +895,77 @@ export class PerplexityClient { * Set env PERPLEXITY_HEADLESS_ONLY=1 to skip the headed phase (uses disk cache). */ async init(): Promise { - const activePaths = getActivePaths(); - if (!existsSync(activePaths.browserData)) { - mkdirSync(activePaths.browserData, { recursive: true }); - } + const _initAt = Date.now(); + try { + const activePaths = getActivePaths(); + if (!existsSync(activePaths.browserData)) { + mkdirSync(activePaths.browserData, { recursive: true }); + } - // Fail fast with a readable message if no browser is installed at all. - const browser = await resolveBrowserExecutable(); - console.error(`[perplexity-mcp] Using ${browser.source}: ${browser.path}`); + // Fail fast with a readable message if no browser is installed at all. + const browser = await resolveBrowserExecutable(); + console.error(`[perplexity-mcp] Using ${browser.source}: ${browser.path}`); - // Phase 1: Headed session — solve CF challenge + fetch account info - const skipHeaded = process.env.PERPLEXITY_HEADLESS_ONLY === "1"; - if (!skipHeaded) { - await this.headedBootstrap(); - } else { - console.error("[perplexity-mcp] Skipping headed session (PERPLEXITY_HEADLESS_ONLY=1)."); - this.loadCachedAccountInfo(); - } + // Phase 1: Headed session — solve CF challenge + fetch account info + const skipHeaded = process.env.PERPLEXITY_HEADLESS_ONLY === "1"; + if (!skipHeaded) { + await this.headedBootstrap(); + } else { + console.error("[perplexity-mcp] Skipping headed session (PERPLEXITY_HEADLESS_ONLY=1)."); + this.loadCachedAccountInfo(); + } - // Phase 2: Headless browser for search operations. - console.error("[perplexity-mcp] Launching headless 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, - }); + // Phase 2: Headless browser for search operations. + // 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.context = await chromium.launchPersistentContext( + activePaths.browserData, + launchOpts, + ); + this.browser = this.context.browser(); + + // 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) { + 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."); + } + } - // Inject saved cookies (session + cf_clearance from login) - 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.`); - } + this.page = await this.context.newPage(); - this.page = await this.context.newPage(); + // Navigate to Perplexity (headless — relies on fresh cf_clearance from headed phase) + try { + await this.page.goto(PERPLEXITY_URL, { waitUntil: "domcontentloaded", timeout: 30000 }); + await this.page.waitForTimeout(2000); + } catch (err) { + console.error("[perplexity-mcp] Navigation warning:", (err as Error).message); + } - // Navigate to Perplexity (headless — relies on fresh cf_clearance from headed phase) - try { - await this.page.goto(PERPLEXITY_URL, { waitUntil: "domcontentloaded", timeout: 30000 }); - await this.page.waitForTimeout(2000); - } catch (err) { - console.error("[perplexity-mcp] Navigation warning:", (err as Error).message); - } + await this.checkAuth(); - await this.checkAuth(); + // If headed phase was skipped or failed, try loading account info from headless + if (!this.accountInfo.modelsConfig) { + await this.loadAccountInfo(); + } - // If headed phase was skipped or failed, try loading account info from headless - if (!this.accountInfo.modelsConfig) { - await this.loadAccountInfo(); + this.writeDaemonStatus(_initAt, null); + } catch (err: unknown) { + this.writeDaemonStatus(_initAt, err instanceof Error ? err.message : String(err)); + throw err; } } @@ -2594,5 +2610,42 @@ export class PerplexityClient { await this.browser.close().catch(() => {}); this.browser = null; } + this.authenticated = false; + this.userId = null; + this.writeDaemonStatus(Date.now(), null); + } + + // ── Daemon status file ───────────────────────────────────────────────────── + + private daemonTier(): DaemonAuthStatus["tier"] { + if (!this.authenticated) return "Anonymous"; + if (this.accountInfo.isMax) return "Max"; + if (this.accountInfo.isPro) return "Pro"; + if (this.accountInfo.isEnterprise) return "Enterprise"; + return "Authenticated"; + } + + /** + * Write daemon-status.json so the extension UI can show live auth state + * instead of relying on the stale models-cache.json snapshot. + * @param startedAt - Date.now() captured at the start of init/reinit + * @param error - error message if init threw, null on success or shutdown + */ + private writeDaemonStatus(startedAt: number, error: string | null): void { + try { + const paths = getActivePaths(); + const status: DaemonAuthStatus = { + authenticated: this.authenticated, + tier: this.daemonTier(), + userId: this.userId, + pid: process.pid, + lastInit: new Date().toISOString(), + initDurationMs: Date.now() - startedAt, + error, + }; + writeFileSync(paths.daemonStatus, JSON.stringify(status, null, 2) + "\n"); + } catch { + // Never let a status write crash the daemon. + } } } diff --git a/packages/mcp-server/src/config.ts b/packages/mcp-server/src/config.ts index e869a21..bc93efd 100644 --- a/packages/mcp-server/src/config.ts +++ b/packages/mcp-server/src/config.ts @@ -405,16 +405,36 @@ export async function getSavedCookies(): Promise { // 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 []; } } diff --git a/packages/mcp-server/src/daemon/launcher.ts b/packages/mcp-server/src/daemon/launcher.ts index 8a17100..bae0b57 100644 --- a/packages/mcp-server/src/daemon/launcher.ts +++ b/packages/mcp-server/src/daemon/launcher.ts @@ -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, }, }); diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 322df8c..597ad00 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -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"; @@ -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); diff --git a/packages/mcp-server/src/is-main-module.d.ts b/packages/mcp-server/src/is-main-module.d.ts new file mode 100644 index 0000000..8f42651 --- /dev/null +++ b/packages/mcp-server/src/is-main-module.d.ts @@ -0,0 +1,6 @@ +/** + * Returns true when the importing module is the process entrypoint (i.e. was + * invoked as `node