Skip to content

Commit 0860ed9

Browse files
authored
Merge pull request #7 from Automations-Project/fix/0.8.43-auth-cli-hardening
fix: auth/CLI hardening v0.8.43 — closes #5, closes #6
2 parents 178f9a6 + cfcd8ff commit 0860ed9

33 files changed

Lines changed: 669 additions & 131 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@
5656
!packages/mcp-server/
5757
!packages/mcp-server/src/
5858
!packages/mcp-server/src/**
59+
!packages/mcp-server/scripts/
60+
!packages/mcp-server/scripts/**
5961
!packages/mcp-server/test/
6062
!packages/mcp-server/test/**
6163
!packages/mcp-server/package.json

CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,37 @@ All notable changes to this project are documented here. Format follows
66

77
## [Unreleased]
88

9+
## [0.8.43] — 2026-05-12 — Auth/CLI hardening (issues #5 + #6)
10+
11+
> 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.
12+
13+
### Fixed
14+
15+
- **`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.
16+
- **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.
17+
- **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.
18+
- **`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.
19+
- **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.
20+
- **`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.
21+
- **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.
22+
- **`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).
23+
24+
### Added
25+
26+
- **`src/is-main-module.js` + `src/is-main-module.d.ts`** — shared symlink-aware direct-run guard extracted from `cli.js` and `index.ts`.
27+
- **`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`.
28+
- **`probeKeychainState()`** exported from `vault.js` — single keychain probe entry point with caching and `PERPLEXITY_DISABLE_KEYCHAIN` awareness.
29+
30+
### Changed
31+
32+
- **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.
33+
- **`tsup.config.ts`**: `dts: true``dts: false`; comment updated to reflect the build script split.
34+
35+
### Verification
36+
37+
- All 130 test files pass (1151 tests, 2 intentionally skipped) on Node 22 / Windows.
38+
- Full typecheck clean across all four packages.
39+
940
## [0.8.41] — 2026-05-10 — Vault unseal hardening for external MCP clients
1041

1142
> 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.

packages/extension/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "perplexity-vscode",
33
"displayName": "Perplexity MCP",
4-
"version": "0.8.42",
4+
"version": "0.8.43",
55
"publisher": "Nskha",
66
"private": true,
77
"description": "Perplexity AI search, reasoning, research, and compute — MCP server, dashboard, and multi-IDE auto-config for VS Code.",

packages/extension/src/auth/session.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { existsSync, readFileSync, statSync } from "node:fs";
2-
import type { AccountSnapshot, ModelsConfigSource, RefreshTier } from "@perplexity-user-mcp/shared";
2+
import type { AccountSnapshot, DaemonAuthStatus, ModelsConfigSource, RefreshTier } from "@perplexity-user-mcp/shared";
33
import { MODELS_FALLBACK, MODELS_FALLBACK_CAPTURED_AT } from "@perplexity-user-mcp/shared";
44
import { getConfigDir, getProfilePaths, getActiveName } from "perplexity-user-mcp/profiles";
55
import type { AccountInfo } from "../browser/runtime.js";
@@ -91,6 +91,9 @@ export function getAccountSnapshot(): AccountSnapshot {
9191

9292
const speedBoost = getImpitStatus();
9393

94+
// Read live daemon auth state — null when file absent (stdio mode / first run).
95+
const daemonAuth = readJsonFile<DaemonAuthStatus>(paths.daemonStatus);
96+
9497
return {
9598
loggedIn,
9699
userId: null,
@@ -109,6 +112,7 @@ export function getAccountSnapshot(): AccountSnapshot {
109112
installedAt: speedBoost.installedAt,
110113
runtimeDir: speedBoost.runtimeDir,
111114
},
115+
daemonAuth,
112116
};
113117
}
114118

packages/extension/src/auth/vault-passphrase.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ export async function peekStoredVaultPassphrase(
126126
export async function probeKeytarAvailable(
127127
context: vscode.ExtensionContext,
128128
): Promise<boolean> {
129+
if (process.env.PERPLEXITY_DISABLE_KEYCHAIN === "1") {
130+
return false;
131+
}
129132
// Use the bundled mcp-server probe so we agree with the runner's own
130133
// detection. The file lives at `dist/mcp/server.mjs` when packaged; the
131134
// 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(
137140
// prepare-package-deps copies keytar into `dist/node_modules`.
138141
const code = `
139142
(async () => {
143+
if (process.env.PERPLEXITY_DISABLE_KEYCHAIN === "1") {
144+
process.exit(4);
145+
}
140146
try {
141147
const mod = await import("keytar");
142148
const kt = mod.default ?? mod;

packages/extension/src/auto-config/transports/stdio-daemon-proxy.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ export const stdioDaemonProxyBuilder: TransportBuilder = {
1717
}
1818

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

2425
if (typeof input.chromePath === "string" && input.chromePath.length > 0) {

packages/extension/src/daemon/runtime.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,11 +460,17 @@ async function spawnBundledDaemon(options: { configDir: string; host?: string; p
460460
`[daemon] PERPLEXITY_VAULT_PASSPHRASE: ${extraEnv.PERPLEXITY_VAULT_PASSPHRASE ? "set" : "unset"}`,
461461
);
462462

463+
// Strip launcher-scoped flags that must never reach the daemon's own
464+
// PerplexityClient.init() — they would force headless mode or stdio bypass.
465+
const baseEnv = { ...process.env };
466+
delete baseEnv.PERPLEXITY_HEADLESS_ONLY;
467+
delete baseEnv.PERPLEXITY_NO_DAEMON;
468+
463469
const child = spawn(process.execPath, args, {
464470
detached: true,
465471
stdio: ["ignore", logFd, logFd],
466472
env: {
467-
...process.env,
473+
...baseEnv,
468474
...extraEnv,
469475
// Hard-coded overrides — must come AFTER extraEnv so a buggy provider
470476
// cannot clobber them.

packages/extension/src/webview/DashboardProvider.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as crypto from "node:crypto";
2+
import * as fs from "node:fs";
23
import * as os from "node:os";
34
import * as path from "node:path";
45
import * as vscode from "vscode";
@@ -55,6 +56,7 @@ import {
5556
import {
5657
listProfiles,
5758
getActiveName,
59+
getProfilePaths,
5860
setActive,
5961
createProfile,
6062
deleteProfile,
@@ -119,6 +121,8 @@ export class DashboardProvider implements vscode.WebviewViewProvider {
119121
private otpResolvers = new Map<string, (s: string | null) => void>();
120122
private onMcpServerDefinitionsChanged?: () => void;
121123
private daemonEventsAbort: AbortController | null = null;
124+
private daemonStatusWatcher: fs.FSWatcher | null = null;
125+
private daemonStatusWatchedProfile: string | null = null;
122126
// v0.8.5: deps factory injected from extension.ts so the auto-regen hook
123127
// on `postStaleness` can reuse the live ApplyIdeConfigDeps without pulling
124128
// the daemon runtime singletons into the webview module.
@@ -231,6 +235,7 @@ export class DashboardProvider implements vscode.WebviewViewProvider {
231235

232236
const disposeHook = (webviewView as vscode.WebviewView & { onDidDispose?: (listener: () => void) => vscode.Disposable }).onDidDispose?.(() => {
233237
this.stopDaemonEventStream();
238+
this.stopDaemonStatusWatch();
234239
});
235240
if (disposeHook) {
236241
this.context.subscriptions.push(disposeHook);
@@ -249,8 +254,16 @@ export class DashboardProvider implements vscode.WebviewViewProvider {
249254
try {
250255
switch (message.type) {
251256
case "ready":
257+
debug("Handling ready");
258+
await this.refresh();
259+
break;
252260
case "dashboard:refresh":
253-
debug("Handling refresh/ready");
261+
debug("Handling refresh");
262+
// If the daemon is reporting anonymous but the profile has stored
263+
// credentials, touch .reinit so the daemon re-runs init() and
264+
// re-checks auth. The daemon-status.json watcher picks up the
265+
// result and calls refresh() automatically when it completes.
266+
this.triggerDaemonReinitIfStale();
254267
await this.refresh();
255268
break;
256269
case "auth:login":
@@ -1583,6 +1596,8 @@ export class DashboardProvider implements vscode.WebviewViewProvider {
15831596
return;
15841597
}
15851598

1599+
this.ensureDaemonStatusWatch();
1600+
15861601
await this.view.webview.postMessage({
15871602
type: "dashboard:state",
15881603
payload: this.buildState()
@@ -2056,6 +2071,85 @@ export class DashboardProvider implements vscode.WebviewViewProvider {
20562071
this.daemonEventsAbort = null;
20572072
}
20582073

2074+
// ── daemon-status.json watcher ────────────────────────────────────────────
2075+
2076+
/**
2077+
* Watch daemon-status.json for the active profile. When it changes (daemon
2078+
* finished init/reinit), push a fresh dashboard state to the webview so the
2079+
* daemonAuth indicator updates without the user manually clicking Refresh.
2080+
*/
2081+
private startDaemonStatusWatch(): void {
2082+
this.stopDaemonStatusWatch();
2083+
const profile = getActiveName() ?? "default";
2084+
const statusFile = getProfilePaths(profile).daemonStatus;
2085+
let debounce: ReturnType<typeof setTimeout> | null = null;
2086+
try {
2087+
this.daemonStatusWatcher = fs.watch(statusFile, () => {
2088+
if (debounce) clearTimeout(debounce);
2089+
debounce = setTimeout(() => {
2090+
debounce = null;
2091+
void this.refresh();
2092+
}, 300);
2093+
});
2094+
this.daemonStatusWatcher.on("error", () => {
2095+
// File may not exist yet (daemon not started). Re-arm on next refresh.
2096+
this.stopDaemonStatusWatch();
2097+
});
2098+
this.daemonStatusWatchedProfile = profile;
2099+
} catch {
2100+
// daemon-status.json doesn't exist yet — watcher will be re-armed
2101+
// the next time refresh() is called (startDaemonStatusWatch is called
2102+
// from refresh() → ensureWatcher() path below).
2103+
this.daemonStatusWatcher = null;
2104+
this.daemonStatusWatchedProfile = null;
2105+
}
2106+
}
2107+
2108+
private stopDaemonStatusWatch(): void {
2109+
this.daemonStatusWatcher?.close();
2110+
this.daemonStatusWatcher = null;
2111+
this.daemonStatusWatchedProfile = null;
2112+
}
2113+
2114+
/**
2115+
* Called from refresh() to ensure the watcher is tracking the current
2116+
* active profile. Re-arms if the profile changed or the previous watch
2117+
* failed because the file didn't exist yet.
2118+
*/
2119+
private ensureDaemonStatusWatch(): void {
2120+
const profile = getActiveName() ?? "default";
2121+
const statusFile = getProfilePaths(profile).daemonStatus;
2122+
// Re-arm when: watcher never started, file was missing last time, or
2123+
// the active profile has changed since the watcher was last started.
2124+
if (!this.daemonStatusWatcher || this.daemonStatusWatchedProfile !== profile) {
2125+
if (fs.existsSync(statusFile)) {
2126+
this.startDaemonStatusWatch();
2127+
}
2128+
}
2129+
}
2130+
2131+
/**
2132+
* If the daemon's last-known auth state is anonymous while the profile has
2133+
* stored credentials, touch the .reinit sentinel to ask the daemon to
2134+
* re-run init() and re-check auth. The daemon-status.json watcher will
2135+
* pick up the result automatically.
2136+
*/
2137+
private triggerDaemonReinitIfStale(): void {
2138+
try {
2139+
const snapshot = this.buildState().snapshot;
2140+
if (!snapshot.loggedIn) return;
2141+
if (!snapshot.daemonAuth) return;
2142+
if (snapshot.daemonAuth.authenticated) return;
2143+
// Stored login but daemon is anonymous → touch .reinit
2144+
const profile = getActiveName() ?? "default";
2145+
const reinitPath = getProfilePaths(profile).reinit;
2146+
fs.writeFileSync(reinitPath, String(Date.now()));
2147+
debug("[daemonStatusSync] Touched .reinit — daemon was anonymous with stored login");
2148+
} catch (err) {
2149+
debug(`[daemonStatusSync] triggerDaemonReinitIfStale error: ${(err as Error).message}`);
2150+
}
2151+
}
2152+
20592153
private async readDaemonEvents(body: ReadableStream<Uint8Array>, controller: AbortController): Promise<void> {
20602154
const reader = body.getReader();
20612155
const decoder = new TextDecoder();

packages/extension/tests/auto-config.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,9 +191,7 @@ describe("2026-05 IDE expansion", () => {
191191
type: "local",
192192
command: ["C:/node.exe", "C:/bundle/server.mjs"],
193193
enabled: true,
194-
environment: {
195-
PERPLEXITY_HEADLESS_ONLY: "1",
196-
},
194+
environment: {},
197195
});
198196
expect(nextConfig.mcp?.Perplexity.args).toBeUndefined();
199197
expect(nextConfig.mcp?.Perplexity.env).toBeUndefined();

packages/extension/tests/transports/stdio-daemon-proxy.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ describe("stdioDaemonProxyBuilder", () => {
3131
expect(result).toEqual({
3232
command: "node",
3333
args: ["/home/user/.perplexity-mcp/launcher.cjs"],
34-
env: { PERPLEXITY_HEADLESS_ONLY: "1" },
34+
env: {},
3535
});
3636
// Critical: proxy variant must NOT force the launcher into no-daemon mode.
3737
expect("env" in result ? result.env : {}).not.toHaveProperty("PERPLEXITY_NO_DAEMON");
@@ -67,7 +67,6 @@ describe("stdioDaemonProxyBuilder", () => {
6767
expect(env.PERPLEXITY_CHROME_PATH).toBe(
6868
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
6969
);
70-
expect(env.PERPLEXITY_HEADLESS_ONLY).toBe("1");
7170
expect(env).not.toHaveProperty("PERPLEXITY_NO_DAEMON");
7271
});
7372

@@ -108,7 +107,7 @@ describe("stdioDaemonProxyBuilder", () => {
108107
expect(result).toEqual({
109108
command: "node",
110109
args: ["/home/user/.perplexity-mcp/launcher.cjs"],
111-
env: { PERPLEXITY_HEADLESS_ONLY: "1" },
110+
env: {},
112111
});
113112

114113
const env = "env" in result ? result.env ?? {} : {};

0 commit comments

Comments
 (0)