Skip to content

Commit fe367e5

Browse files
A.R.claude
andcommitted
fix: daemon unlocks passphrase-protected profiles on switch/refresh
A user's diagnostics showed the dashboard "Session active and ready" while the daemon stayed "anonymous" after switching to a passphrase-protected profile, and "Refresh state" never recovered. Root cause: the long-lived daemon reads PERPLEXITY_VAULT_PASSPHRASE from SecretStorage only ONCE at spawn; a profile switch just touched .reinit, so init() re-ran with the stale/absent passphrase ("Vault locked: no keychain, no env var, no TTY"), and the vault unseal cache pinned the first profile's material ("wrong passphrase"). The extension could read the vault (user typed the passphrase) — hence the two badges disagreed. Fix (authed reinit endpoint + global passphrase, per maintainer decision): - daemon/server.ts: new loopback-only, bearer-authed POST /daemon/reinit {passphrase} → sets the daemon's env, __resetKeyCache(), hot-reloads the client (no restart, no dropped MCP connections). Blocked from tunnel by the H11 admin allowlist (404) + an in-handler loopback check; audit logs only method+path, never the body. - client.ts: reinit({passphrase}) sets env (when supplied) and ALWAYS resets the vault cache before re-init, so a profile switch isn't blocked by the previous profile's pinned material. DaemonAuthStatus gains a machine-readable reason (ok / vault-locked / not-logged-in), surfaced via config.ts wasLastVaultLocked(). - DashboardProvider.ts + extension.ts: Refresh state, webview profile:switch, and command-palette switchAccount/addAccount POST the SecretStorage passphrase to /daemon/reinit (non-spawning — only targets an already-running daemon; falls back to .reinit on HTTP failure / non-2xx). - views.tsx: daemon badge is reason-aware (vault-locked vs not-logged-in). Tests: reinit-http (bearer/passphrase/empty), /daemon/reinit in the tunnel admin-allowlist suite (tunnel 404 / loopback handler), wasLastVaultLocked coverage. Adversarial review (4 dims) — all confirmed findings fixed. Full typecheck + build clean; 1154 unit tests pass. Version 0.8.46 -> 0.8.47. Deferred: reconnecting loader. Needs live Windows daemon smoke to confirm. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d13f06e commit fe367e5

13 files changed

Lines changed: 298 additions & 23 deletions

File tree

CHANGELOG.md

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

77
## [Unreleased]
88

9+
## [0.8.49] — 2026-06-04 — Daemon unlocks passphrase-protected profiles on switch/refresh
10+
11+
> Reported via a diagnostics bundle: after switching to a **passphrase-protected** profile, the dashboard showed "Session active and ready" while the daemon showed "Daemon sees anonymous session — use Refresh state to reconnect", and Refresh did not help. Root cause: the long-lived daemon is spawned with `PERPLEXITY_VAULT_PASSPHRASE` read from SecretStorage **once at spawn**; a profile switch only touched `.reinit`, which re-ran `init()` with the *stale/absent* passphrase, so the daemon could not unseal the new profile's vault (`Vault locked: no keychain, no env var, no TTY`). The vault unseal cache also pinned the first profile's material (`Vault decrypt failed: wrong passphrase`). The extension could read the vault (the user had typed the passphrase) — hence the two badges disagreed.
12+
13+
### Fixed
14+
15+
- **The daemon now re-receives the vault passphrase on profile-switch / "Refresh state"** instead of staying anonymous. New **loopback-only, bearer-authed `POST /daemon/reinit {passphrase}`** ([`daemon/server.ts`](packages/mcp-server/src/daemon/server.ts)) sets `PERPLEXITY_VAULT_PASSPHRASE` in the running daemon, clears the vault unseal cache, and hot-reloads the client — no daemon restart, no dropped MCP connections. The endpoint is blocked from tunnel callers by the H11 admin allowlist (404) plus an in-handler loopback check (defense-in-depth); the audit log records only method+path, never the body.
16+
- **`client.reinit({ passphrase })`** ([`client.ts`](packages/mcp-server/src/client.ts)) sets the env (when supplied) and **always `vault.__resetKeyCache()`** before re-init, so a profile switch is never blocked by the previous profile's pinned `_unsealMaterialCache`/`_keyCache`. The daemon's own active-profile-switch reinit benefits automatically.
17+
- **The extension re-supplies the passphrase** ([`DashboardProvider.ts`](packages/extension/src/webview/DashboardProvider.ts)): "Refresh state", webview `profile:switch`, and the command-palette `Perplexity.switchAccount` / `Perplexity.addAccount` now POST the SecretStorage passphrase to `/daemon/reinit` (non-spawning — it only targets an already-running daemon; falls back to touching `.reinit` if the HTTP path is unavailable or returns non-2xx).
18+
19+
### Added
20+
21+
- **`DaemonAuthStatus.reason`** (`ok` / `vault-locked` / `not-logged-in`) in [shared](packages/shared/src/models.ts), surfaced from `getSavedCookies()``wasLastVaultLocked()``init()``writeDaemonStatus()`. The dashboard's daemon badge is now **reason-aware** ([`views.tsx`](packages/webview/src/views.tsx)) — it distinguishes "daemon can't unlock this profile's vault" from "not logged in" instead of always saying "use Refresh state".
22+
23+
### Known follow-up
24+
25+
- An animated "reconnecting…" loader while a daemon reinit is in flight is deferred (needs transient webview-store state). One vault passphrase per machine is intentional (global SecretStorage key); per-profile passphrases are out of scope.
26+
27+
### Verification
28+
29+
- New unit tests: `test/daemon/reinit-http.test.js` (bearer required, passphrase forwarded, empty/missing handled), `/daemon/reinit` added to the tunnel-admin-allowlist suite (tunnel→404, loopback→handler), and `wasLastVaultLocked()` coverage in `test/config-getSavedCookies.test.js`. Adversarial review (4 dimensions) — all confirmed findings fixed (command-palette wiring, `res.ok` fallback, no-spawn). Full typecheck + build clean across all four packages. **Live daemon + passphrase-profile-switch flow on Windows still needs the manual smoke** (the SecretStorage→daemon round-trip can't be exercised without a real daemon).
30+
931
## [0.8.46] — 2026-06-04 — Fix browser-data singleton-lock deadlock (issue #8)
1032

1133
> Ref [#8](https://github.com/Automations-Project/VSCode-Perplexity-MCP/issues/8). Regression from the 0.8.43 issue-#5 fix (commit `c841124`): Phase 2 (headless search) switched from an *ephemeral* `chromium.launch()` to a long-lived `launchPersistentContext(browser-data)` so it could reuse the on-disk `cf_clearance` written by Phase 1. The side effect — the daemon now holds the `browser-data` Chromium process-singleton lock for its entire lifetime, and Chromium forbids a second `launchPersistentContext` on the same `--user-data-dir`. Three independent callers still targeted that dir: the Doctor probe (a separate process that ran its own `init()` against `browser-data`), the daemon's own Phase 1→Phase 2 transition, and the reinit cycle. On Windows each collision surfaced as `exitCode 21` / "Target page, context or browser has been closed", leaving the daemon stuck in anonymous mode in a self-reinforcing deadlock that only a full restart cleared. The fix makes the daemon the **single owner** of `browser-data` and hardens the lock handling — without reverting the `cf_clearance` reuse.

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.48",
4+
"version": "0.8.49",
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/extension.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -878,6 +878,10 @@ async function activateInner(context: vscode.ExtensionContext): Promise<void> {
878878
if (pick) {
879879
setActive(pick);
880880
serverDefinitionsChanged.fire();
881+
// Re-supply the new profile's vault passphrase to the running daemon
882+
// (same fix as the webview profile:switch — otherwise a passphrase-
883+
// protected account stays anonymous in the daemon).
884+
dashboard.reinitDaemonAfterProfileChange();
881885
await dashboard.refresh();
882886
}
883887
})
@@ -892,6 +896,7 @@ async function activateInner(context: vscode.ExtensionContext): Promise<void> {
892896
createProfile(config.name, { loginMode: config.mode });
893897
setActive(config.name);
894898
serverDefinitionsChanged.fire();
899+
dashboard.reinitDaemonAfterProfileChange();
895900
await dashboard.refresh();
896901
await dashboard.postNotice("info", `Created profile '${config.name}'. Starting login…`);
897902
} catch (err) {

packages/extension/src/webview/DashboardProvider.ts

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,10 @@ export class DashboardProvider implements vscode.WebviewViewProvider {
517517
case "profile:switch": {
518518
setActive(message.payload.name);
519519
this.onMcpServerDefinitionsChanged?.();
520+
// The active profile changed → re-supply its vault passphrase to
521+
// the running daemon so it can unlock the new profile (otherwise it
522+
// stays anonymous with the previous profile's passphrase).
523+
void this.reinitDaemonWithPassphrase();
520524
await this.postActionResult(message.id, true);
521525
await this.postProfileList();
522526
await this.refresh();
@@ -2130,26 +2134,74 @@ export class DashboardProvider implements vscode.WebviewViewProvider {
21302134

21312135
/**
21322136
* 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.
2137+
* stored credentials, re-supply the vault passphrase to the daemon and ask
2138+
* it to re-run init(). The daemon-status.json watcher picks up the result.
21362139
*/
21372140
private triggerDaemonReinitIfStale(): void {
21382141
try {
21392142
const snapshot = this.buildState().snapshot;
21402143
if (!snapshot.loggedIn) return;
21412144
if (!snapshot.daemonAuth) return;
21422145
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");
2146+
// Stored login but daemon is anonymous → re-supply the passphrase + reinit.
2147+
void this.reinitDaemonWithPassphrase();
21482148
} catch (err) {
21492149
debug(`[daemonStatusSync] triggerDaemonReinitIfStale error: ${(err as Error).message}`);
21502150
}
21512151
}
21522152

2153+
/**
2154+
* Re-supply the current SecretStorage vault passphrase to the RUNNING daemon
2155+
* and hot-reload it (POST /daemon/reinit, loopback + bearer). This is what
2156+
* makes the daemon unlock passphrase-protected profiles whose passphrase was
2157+
* not in the daemon's spawn env (the daemon-anonymous-after-profile-switch
2158+
* bug). Fire-and-forget: the daemon-status watcher refreshes the UI when the
2159+
* reinit completes. Falls back to touching .reinit (keychain-unlockable
2160+
* profiles need no passphrase) if the daemon isn't reachable over HTTP.
2161+
*/
2162+
/**
2163+
* Public entry for profile changes made OUTSIDE the webview (the command-
2164+
* palette Perplexity.switchAccount / Perplexity.addAccount). Fire-and-forget.
2165+
*/
2166+
public reinitDaemonAfterProfileChange(): void {
2167+
void this.reinitDaemonWithPassphrase();
2168+
}
2169+
2170+
private async reinitDaemonWithPassphrase(): Promise<void> {
2171+
try {
2172+
// Non-spawning: only POST to a daemon that is ALREADY running. Using
2173+
// ensureBundledDaemon() here would spawn a daemon on stdio-only installs
2174+
// (review finding). getBundledDaemonStatus() never spawns.
2175+
const status = await getBundledDaemonStatus().catch(() => null);
2176+
const record = status?.running ? status.record : null;
2177+
if (!record?.port || !record?.bearerToken) {
2178+
throw new Error("no running daemon");
2179+
}
2180+
const passphrase = await peekStoredVaultPassphrase(this.context);
2181+
const res = await fetch(`http://127.0.0.1:${record.port}/daemon/reinit`, {
2182+
method: "POST",
2183+
headers: {
2184+
Authorization: `Bearer ${record.bearerToken}`,
2185+
"Content-Type": "application/json",
2186+
},
2187+
body: JSON.stringify(passphrase ? { passphrase } : {}),
2188+
});
2189+
// A non-2xx (403/500/…) must trigger the fallback, not be silently
2190+
// ignored — fetch only rejects on network errors (review finding).
2191+
if (!res.ok) throw new Error(`/daemon/reinit returned HTTP ${res.status}`);
2192+
debug(`[daemonStatusSync] POST /daemon/reinit -> ${res.status} (passphrase ${passphrase ? "sent" : "none"})`);
2193+
} catch (err) {
2194+
// HTTP path unavailable — fall back to the .reinit sentinel so a
2195+
// keychain-unlockable profile still reinits.
2196+
try {
2197+
const profile = getActiveName() ?? "default";
2198+
fs.writeFileSync(getProfilePaths(profile).reinit, String(Date.now()));
2199+
debug("[daemonStatusSync] /daemon/reinit path unavailable; touched .reinit instead");
2200+
} catch { /* ignore */ }
2201+
debug(`[daemonStatusSync] reinitDaemonWithPassphrase: ${(err as Error).message}`);
2202+
}
2203+
}
2204+
21532205
private async readDaemonEvents(body: ReadableStream<Uint8Array>, controller: AbortController): Promise<void> {
21542206
const reader = body.getReader();
21552207
const decoder = new TextDecoder();

packages/mcp-server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "perplexity-user-mcp",
3-
"version": "0.8.48",
3+
"version": "0.8.49",
44
"mcpName": "io.github.Automations-Project/perplexity-user-mcp",
55
"type": "module",
66
"description": "Perplexity AI MCP server — browser automation for search, reasoning, research, and compute. Not affiliated with Perplexity AI, Inc.",

packages/mcp-server/src/client.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
findChromeExecutable,
2121
resolveBrowserExecutable,
2222
getSavedCookies,
23+
wasLastVaultLocked,
2324
type BrowserChannel,
2425
type ASIFile,
2526
type SearchResult,
@@ -1248,8 +1249,24 @@ export class PerplexityClient {
12481249
* vault cookies are picked up. Called by the `.reinit` sentinel watcher
12491250
* after a child login-runner completes.
12501251
*/
1251-
async reinit(): Promise<void> {
1252+
async reinit(opts: { passphrase?: string } = {}): Promise<void> {
12521253
console.error("[perplexity-mcp] Reinit requested — closing current context and reloading cookies.");
1254+
// When the extension re-supplies the vault passphrase (e.g. after a profile
1255+
// switch to a passphrase-protected account), update the process env so the
1256+
// daemon can unseal the new profile's vault. The daemon's passphrase is
1257+
// otherwise fixed at spawn time and a plain reinit stays anonymous.
1258+
if (opts.passphrase) {
1259+
process.env.PERPLEXITY_VAULT_PASSPHRASE = opts.passphrase;
1260+
}
1261+
// Always clear the vault unseal cache: a profile switch (or a freshly
1262+
// supplied passphrase) must not be blocked by the previous profile's
1263+
// pinned `_unsealMaterialCache`/`_keyCache` (issue: daemon vault-locked).
1264+
try {
1265+
const vault = await import("./vault.js");
1266+
vault.__resetKeyCache();
1267+
} catch {
1268+
// best-effort — never let a cache reset crash reinit
1269+
}
12531270
await this.shutdown().catch(() => {});
12541271
this.browser = null;
12551272
this.context = null;
@@ -2652,6 +2669,15 @@ export class PerplexityClient {
26522669
private writeDaemonStatus(startedAt: number, error: string | null): void {
26532670
try {
26542671
const paths = getActivePaths();
2672+
// Machine-readable reason so the UI can distinguish "daemon can't unlock
2673+
// this profile's vault" (a plain reinit won't recover — the passphrase
2674+
// must be re-supplied) from "not logged in". Derived from the last
2675+
// getSavedCookies() unseal outcome (issue: daemon vault-locked).
2676+
const reason: DaemonAuthStatus["reason"] = this.authenticated
2677+
? "ok"
2678+
: wasLastVaultLocked()
2679+
? "vault-locked"
2680+
: "not-logged-in";
26552681
const status: DaemonAuthStatus = {
26562682
authenticated: this.authenticated,
26572683
tier: this.daemonTier(),
@@ -2660,6 +2686,7 @@ export class PerplexityClient {
26602686
lastInit: new Date().toISOString(),
26612687
initDurationMs: Date.now() - startedAt,
26622688
error,
2689+
reason,
26632690
};
26642691
writeFileSync(paths.daemonStatus, JSON.stringify(status, null, 2) + "\n");
26652692
} catch {

packages/mcp-server/src/config.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,21 @@ export interface PlaywrightCookie {
372372

373373
const _vault = new Vault();
374374

375+
// Tracks whether the most recent getSavedCookies() returned [] because the
376+
// vault could not be UNSEALED (passphrase missing/wrong in this process), as
377+
// opposed to the profile simply having no saved session. init() reads this to
378+
// record a machine-readable reason in daemon-status.json so the UI can tell
379+
// "daemon can't unlock this profile" apart from "not logged in" (issue:
380+
// daemon vault-locked after profile switch).
381+
let _lastVaultLocked = false;
382+
383+
/** True when the last getSavedCookies() returned [] because vault unseal failed. */
384+
export function wasLastVaultLocked(): boolean {
385+
return _lastVaultLocked;
386+
}
387+
375388
export async function getSavedCookies(): Promise<PlaywrightCookie[]> {
389+
_lastVaultLocked = false;
376390
// 1. Env var override (unchanged behavior)
377391
if (process.env.PERPLEXITY_SESSION_TOKEN) {
378392
const cookies: PlaywrightCookie[] = [{
@@ -423,6 +437,7 @@ export async function getSavedCookies(): Promise<PlaywrightCookie[]> {
423437
console.error(`[vault] getSavedCookies: vault.enc exists for profile '${profile}' but 'cookies' key is absent`);
424438
}
425439
}
440+
_lastVaultLocked = unsealFailed;
426441
return [];
427442
}
428443
try {

packages/mcp-server/src/daemon/server.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,31 @@ export async function startDaemonServer(options: StartDaemonServerOptions = {}):
574574
});
575575
});
576576

577+
// Re-supply the vault passphrase to the RUNNING daemon and hot-reload the
578+
// client. LOOPBACK ONLY (never reachable over the tunnel) because the body
579+
// carries the vault passphrase; the audit middleware logs only method+path,
580+
// never the body, so the passphrase is not persisted. The extension host
581+
// calls this on profile-switch and on "Refresh state" so passphrase-
582+
// protected profiles can unseal without a full daemon restart (issue: daemon
583+
// stuck anonymous because PERPLEXITY_VAULT_PASSPHRASE was fixed at spawn).
584+
app.post("/daemon/reinit", requireBearer, async (req: any, res: any, next: any) => {
585+
try {
586+
if (req._pplx?.source === "tunnel") {
587+
res.status(403).json({ ok: false, error: "loopback_only" });
588+
return;
589+
}
590+
const passphrase =
591+
typeof req.body?.passphrase === "string" && req.body.passphrase.length > 0
592+
? req.body.passphrase
593+
: undefined;
594+
const client = await getClient();
595+
await client.reinit(passphrase ? { passphrase } : {});
596+
res.json({ ok: true, authenticated: !!client.authenticated });
597+
} catch (error) {
598+
next(error);
599+
}
600+
});
601+
577602
app.post("/daemon/enable-tunnel", requireBearer, async (_req: any, res: any, next: any) => {
578603
try {
579604
await options.onEnableTunnel?.();

packages/mcp-server/test/config-getSavedCookies.test.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ describe("getSavedCookies diagnostic logging (issue #5.3)", () => {
2626
delete process.env.PERPLEXITY_CONFIG_DIR;
2727
delete process.env.PERPLEXITY_PROFILE;
2828
delete process.env.PERPLEXITY_VAULT_PASSPHRASE;
29+
delete process.env.PERPLEXITY_DISABLE_KEYCHAIN;
2930
console.error = originalConsoleError;
3031
});
3132

@@ -52,4 +53,34 @@ describe("getSavedCookies diagnostic logging (issue #5.3)", () => {
5253
expect(cookies).toEqual([]);
5354
expect(capturedErrors.some((c) => c.includes("'cookies' key is absent"))).toBe(true);
5455
});
56+
57+
it("wasLastVaultLocked() is false when there is no vault (not locked — just not logged in)", async () => {
58+
const { getSavedCookies, wasLastVaultLocked } = await import("../src/config.js");
59+
await getSavedCookies();
60+
expect(wasLastVaultLocked()).toBe(false);
61+
});
62+
63+
it("wasLastVaultLocked() is true when vault.enc exists but cannot be unsealed", async () => {
64+
const { createProfile } = await import("../src/profiles.js");
65+
const { Vault, __resetKeyCache } = await import("../src/vault.js");
66+
createProfile("default");
67+
// Write a cookies blob encrypted with a passphrase (keychain disabled so
68+
// the blob is purely passphrase-protected).
69+
process.env.PERPLEXITY_DISABLE_KEYCHAIN = "1";
70+
process.env.PERPLEXITY_VAULT_PASSPHRASE = "secret";
71+
__resetKeyCache();
72+
const v = new Vault();
73+
await v.set("default", "cookies", JSON.stringify([{ name: "x", value: "y" }]));
74+
75+
// Now drop the passphrase (and keychain) — the daemon-style "Vault locked"
76+
// path: vault.enc exists but there is no unseal material.
77+
delete process.env.PERPLEXITY_VAULT_PASSPHRASE;
78+
__resetKeyCache();
79+
80+
vi.resetModules();
81+
const { getSavedCookies, wasLastVaultLocked } = await import("../src/config.js");
82+
const cookies = await getSavedCookies();
83+
expect(cookies).toEqual([]);
84+
expect(wasLastVaultLocked()).toBe(true);
85+
});
5586
});

0 commit comments

Comments
 (0)