From 7e290355abbed13fd76bf9af39bd0bce6a0ab3c5 Mon Sep 17 00:00:00 2001 From: "A.R." Date: Sun, 10 May 2026 02:28:34 +0300 Subject: [PATCH 01/18] chore(repo): pre-push hook refuses to publish private planning artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds enforcement for the existing whitelist .gitignore policy that hides docs/superpowers/. Three pieces: scripts/git-hooks/pre-push — bash hook that scans the to-push range for docs/superpowers/ paths and aborts with a clear remediation message. Override per-push with PERP_ALLOW_PRIVATE_PLAN_PUSH=1 git push scripts/install-git-hooks.mjs — postinstall helper that runs git config core.hooksPath scripts/git-hooks on this clone. Silent no-op when not in a git repo (consumed-as-dependency case). package.json — wires the helper as postinstall. .gitignore — adds a screaming comment block above the whitelist so future contributors (and agents) see the policy before reaching for git add -f. Why now: an earlier session inadvertently force-added planning artifacts under docs/superpowers/plans/ to the public remote. The branch was scrubbed (force-pushed back to main); this hook prevents the same accident from re-reaching the remote. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 9 ++++ package.json | 3 +- scripts/git-hooks/pre-push | 76 +++++++++++++++++++++++++++++++ scripts/install-git-hooks.mjs | 84 +++++++++++++++++++++++++++++++++++ 4 files changed, 171 insertions(+), 1 deletion(-) create mode 100755 scripts/git-hooks/pre-push create mode 100644 scripts/install-git-hooks.mjs diff --git a/.gitignore b/.gitignore index caf886a..1c2b7b2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,15 @@ # New files are HIDDEN unless explicitly whitelisted. # Only production-relevant files are public. # ═══════════════════════════════════════════════════════ +# +# DO NOT `git add -f` files under docs/superpowers/ unless you have a clear, +# considered reason to publish them. Planning artifacts under that path are +# private AI-development content. The pre-push hook in +# scripts/git-hooks/pre-push will refuse pushes that contain them — set +# PERP_ALLOW_PRIVATE_PLAN_PUSH=1 to override for a single push if you really +# mean it. Activate the hook once per clone with: +# git config core.hooksPath scripts/git-hooks +# (or run `npm install` — postinstall does it for you). # 1. Ignore everything * diff --git a/package.json b/package.json index ac206b0..caa8d09 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "package:vsix": "npm run bump && npm run package:vsix -w perplexity-vscode", "test:coverage": "npm run build -w @perplexity-user-mcp/shared && vitest run --coverage", "bump": "node scripts/bump-version.mjs", - "bump:dry": "node scripts/bump-version.mjs --dry-run" + "bump:dry": "node scripts/bump-version.mjs --dry-run", + "postinstall": "node scripts/install-git-hooks.mjs" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push new file mode 100755 index 0000000..5eaa269 --- /dev/null +++ b/scripts/git-hooks/pre-push @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# pre-push: refuse pushes that contain private planning artifacts. +# +# Repo policy: docs/superpowers/ is private (gitignored by default). Force-adding +# a planning doc with `git add -f` is occasionally legitimate, but pushing one +# to the public remote is almost always an accident. This hook catches the push +# step rather than the add step so that local force-adds (e.g. for cross-clone +# sync) still work. +# +# Override (single push only): +# PERP_ALLOW_PRIVATE_PLAN_PUSH=1 git push +# +# Activation per clone (one-time): +# git config core.hooksPath scripts/git-hooks +# (Or run `npm install` once — the postinstall script does this for you.) + +set -euo pipefail + +if [[ "${PERP_ALLOW_PRIVATE_PLAN_PUSH:-}" == "1" ]]; then + exit 0 +fi + +readonly NULL_SHA="0000000000000000000000000000000000000000" +readonly PROTECTED_PATH="docs/superpowers/" + +# stdin format (per `man githooks`): one ref-pair per line — +# +violations="" + +while read -r local_ref local_sha remote_ref remote_sha; do + # Branch deletion (local_sha is all-zeros): nothing to inspect. + if [[ "$local_sha" == "$NULL_SHA" ]]; then + continue + fi + + if [[ "$remote_sha" == "$NULL_SHA" ]]; then + # New branch on the remote. Compare against main if we have it; otherwise + # inspect every commit reachable from local_sha (best effort). + if git rev-parse --verify --quiet main >/dev/null 2>&1; then + range="main..${local_sha}" + else + range="$local_sha" + fi + else + # Existing remote branch: inspect only the commits we're adding. + range="${remote_sha}..${local_sha}" + fi + + # `git diff --name-only RANGE -- PATH` lists files under PATH that changed + # in the range, in any commit. Empty output = no violation. + offending=$(git diff --name-only "$range" -- "$PROTECTED_PATH" 2>/dev/null || true) + if [[ -n "$offending" ]]; then + violations+="${offending}"$'\n' + fi +done + +if [[ -n "$violations" ]]; then + { + echo "" + echo "ERROR: refusing to push private planning artifacts." + echo "" + echo "The following files under '${PROTECTED_PATH}' would be published:" + echo "" + echo "$violations" | sed '/^$/d' | sort -u | sed 's/^/ /' + echo "" + echo "These are gitignored by repo policy — internal AI planning, not meant" + echo "for the public remote. If you really need to publish this push, run" + echo "once with the explicit override:" + echo "" + echo " PERP_ALLOW_PRIVATE_PLAN_PUSH=1 git push" + echo "" + } >&2 + exit 1 +fi + +exit 0 diff --git a/scripts/install-git-hooks.mjs b/scripts/install-git-hooks.mjs new file mode 100644 index 0000000..343061e --- /dev/null +++ b/scripts/install-git-hooks.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +// install-git-hooks: configure this clone to use scripts/git-hooks/. +// +// Run via `npm install`'s postinstall (silent) or directly: +// node scripts/install-git-hooks.mjs +// +// Why: the pre-push hook in scripts/git-hooks/pre-push refuses to publish +// private planning artifacts under docs/superpowers/. The hook only fires +// after `git config core.hooksPath scripts/git-hooks` runs once per clone; +// this script does that automatically. +// +// Safe to run when not in a git repo (e.g. when this package is consumed +// as a dependency by something else): the script no-ops with a quiet log. + +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +const REPO_ROOT = resolve(import.meta.dirname, ".."); +const HOOKS_DIR = "scripts/git-hooks"; + +function log(msg) { + // Single-line stderr output; doesn't disrupt npm-install progress meters. + process.stderr.write(`[install-git-hooks] ${msg}\n`); +} + +function isGitRepo() { + try { + execFileSync("git", ["rev-parse", "--git-dir"], { + cwd: REPO_ROOT, + stdio: ["ignore", "ignore", "ignore"], + }); + return true; + } catch { + return false; + } +} + +function currentHooksPath() { + try { + return execFileSync("git", ["config", "--get", "core.hooksPath"], { + cwd: REPO_ROOT, + encoding: "utf8", + }).trim(); + } catch { + return ""; + } +} + +function setHooksPath() { + execFileSync("git", ["config", "core.hooksPath", HOOKS_DIR], { + cwd: REPO_ROOT, + stdio: ["ignore", "ignore", "inherit"], + }); +} + +function main() { + if (!isGitRepo()) { + log("not a git repo; skipping (this is normal when consumed as a dependency)."); + return; + } + if (!existsSync(resolve(REPO_ROOT, HOOKS_DIR, "pre-push"))) { + log(`hook directory '${HOOKS_DIR}' missing pre-push script; skipping.`); + return; + } + const current = currentHooksPath(); + if (current === HOOKS_DIR) { + return; // already configured; silent no-op. + } + if (current && current !== HOOKS_DIR) { + log(`core.hooksPath is currently '${current}', not '${HOOKS_DIR}'.`); + log(`leaving alone — set manually if you want repo hooks: git config core.hooksPath ${HOOKS_DIR}`); + return; + } + setHooksPath(); + log(`activated repo hooks (core.hooksPath = ${HOOKS_DIR}).`); +} + +try { + main(); +} catch (err) { + log(`error: ${err.message}; continuing.`); + // Never fail npm install over a hook setup hiccup. +} From 65bd1aa768863a68b1852198326b19a88c77d911 Mon Sep 17 00:00:00 2001 From: "A.R." Date: Sun, 10 May 2026 02:50:13 +0300 Subject: [PATCH 02/18] chore(ci): bump CI matrix and engines from Node 20 to Node 22/24 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node 20 reached End-of-Life on 2026-04-30 (last month). Node 22 and 24 are the two currently-supported LTS lines (Node 24 promoted to Active LTS in October 2025). Node 26 became "Current" in May 2026 and is optional canary territory until it hits LTS in October 2026. Three changes, single commit: 1. .github/workflows/ci.yml — matrix: [22, 24] (was [20, 22]). This also fixes two pre-existing CI failures that were Node-20- specific: a. ubuntu-latest: tsup DTS worker OOM during mcp-server build. b. windows-latest: leaked FSWatcher in launcher.test.js firing post-teardown with EPERM. Both are tsup/test bugs with Node-20-specific manifestation; both stop reproducing on 22/24 due to better worker GC semantics. The existing NODE_OPTIONS=--max-old-space-size=4096 stays as defense-in-depth. 2. packages/mcp-server/package.json — engines.node: explicit LTS union "^22.0.0 || ^24.0.0" matching what we test. Pattern lifted from Vite/Vitest's engines style: honest contract that doesn't promise unknown future majors. 3. packages/extension/package.json — engines.node: same pattern. Note: VS Code's extension host still ships Node 20.x internally (Electron-bundled, see microsoft/vscode .nvmrc), so the extension SOURCE remains Node-20-syntax-compatible (already enforced by tsup's target: node20). This engines bump describes our test surface and standalone-CLI consumer requirement. Source for compatibility verification: @modelcontextprotocol/sdk explicitly requires Node 24+ per its CONTRIBUTING.md as of May 2026, which is the strongest single nudge to ensure 24 is in our matrix. tsup, vitest, vite, keytar, patchright all verified compatible with Node 22/24 with no version-specific regressions. Out of scope (separate follow-ups): - Node 26 canary job (continue-on-error) — wait for Oct 2026 LTS. - actions/setup-node@v4 / actions/checkout@v4 Node 20 deprecation — GitHub forces migration June 2nd, 2026. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 2 +- packages/extension/package.json | 2 +- packages/mcp-server/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05c6814..1851ae9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] - node-version: [20, 22] + node-version: [22, 24] defaults: run: shell: bash diff --git a/packages/extension/package.json b/packages/extension/package.json index f88bf23..847641e 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -35,7 +35,7 @@ ], "engines": { "vscode": "^1.100.0", - "node": ">=20" + "node": "^22.0.0 || ^24.0.0" }, "categories": [ "AI", diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 62af4ad..b528594 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -33,7 +33,7 @@ "provenance": true }, "engines": { - "node": ">=20" + "node": "^22.0.0 || ^24.0.0" }, "keywords": [ "perplexity", From 2d1233ab060bb0e106e29ea30b34eca466a67562 Mon Sep 17 00:00:00 2001 From: "A.R." Date: Sun, 10 May 2026 03:27:41 +0300 Subject: [PATCH 03/18] feat(ext): build-daemon-env helper reads SecretStorage passphrase Reads peekStoredVaultPassphrase(context); returns { PERPLEXITY_VAULT_PASSPHRASE } when present, {} otherwise. Never mutates process.env. Empty-string SecretStorage value is treated as absent. Test scaffold mocks the "vscode" module (vitest's Node env has none) per the existing pattern in vault-passphrase.test.ts. This helper exists for the daemon-runtime wiring in a follow-up task; nothing in the codebase calls it yet. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../extension/src/auth/build-daemon-env.ts | 22 ++++++++ .../extension/tests/build-daemon-env.test.ts | 51 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 packages/extension/src/auth/build-daemon-env.ts create mode 100644 packages/extension/tests/build-daemon-env.test.ts diff --git a/packages/extension/src/auth/build-daemon-env.ts b/packages/extension/src/auth/build-daemon-env.ts new file mode 100644 index 0000000..01e85e9 --- /dev/null +++ b/packages/extension/src/auth/build-daemon-env.ts @@ -0,0 +1,22 @@ +import * as vscode from "vscode"; +import { peekStoredVaultPassphrase } from "./vault-passphrase.js"; + +/** + * Build the env-var overlay that `spawnBundledDaemon` merges into the daemon's + * spawn environment. Today this is just the SecretStorage vault passphrase + * (when one exists); future overlays can extend this without touching + * `daemon/runtime.ts`. Called once per daemon spawn, never cached. + * + * Invariant: never mutates `process.env`. The returned object is consumed + * by the spawn() call site and discarded. + */ +export async function buildDaemonEnv( + context: vscode.ExtensionContext, +): Promise> { + const env: Record = {}; + const passphrase = await peekStoredVaultPassphrase(context); + if (passphrase && passphrase.length > 0) { + env.PERPLEXITY_VAULT_PASSPHRASE = passphrase; + } + return env; +} diff --git a/packages/extension/tests/build-daemon-env.test.ts b/packages/extension/tests/build-daemon-env.test.ts new file mode 100644 index 0000000..bd02a74 --- /dev/null +++ b/packages/extension/tests/build-daemon-env.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi } from "vitest"; + +// vitest's Node environment has no real "vscode" module — every extension test +// in this repo mocks it. We don't call any vscode runtime API here (the fake +// context's `.secrets` is wired by the test), but vault-passphrase.ts does +// `import * as vscode from "vscode"` and we transitively load it through +// build-daemon-env.ts, so the mock must exist before the import below. +vi.mock("vscode", () => ({ + window: { + showInputBox: async () => undefined, + }, +})); + +import * as vscode from "vscode"; +import { buildDaemonEnv } from "../src/auth/build-daemon-env"; + +function fakeContext(stored: string | undefined): vscode.ExtensionContext { + return { + secrets: { + get: vi.fn(async (key: string) => + key === "perplexity.vault.passphrase" ? stored : undefined, + ), + store: vi.fn(), + delete: vi.fn(), + onDidChange: vi.fn(), + }, + } as unknown as vscode.ExtensionContext; +} + +describe("buildDaemonEnv", () => { + it("returns empty object when SecretStorage is empty", async () => { + const env = await buildDaemonEnv(fakeContext(undefined)); + expect(env).toEqual({}); + }); + + it("returns PERPLEXITY_VAULT_PASSPHRASE when SecretStorage has a value", async () => { + const env = await buildDaemonEnv(fakeContext("hunter2-correct-horse")); + expect(env).toEqual({ PERPLEXITY_VAULT_PASSPHRASE: "hunter2-correct-horse" }); + }); + + it("ignores empty-string passphrase (treats as absent)", async () => { + const env = await buildDaemonEnv(fakeContext("")); + expect(env).toEqual({}); + }); + + it("does not mutate process.env", async () => { + const before = process.env.PERPLEXITY_VAULT_PASSPHRASE; + await buildDaemonEnv(fakeContext("never-leak-this")); + expect(process.env.PERPLEXITY_VAULT_PASSPHRASE).toBe(before); + }); +}); From 4dbe3fcc1e95429b9097b53a1f72a9a34140a0a6 Mon Sep 17 00:00:00 2001 From: "A.R." Date: Sun, 10 May 2026 03:34:04 +0300 Subject: [PATCH 04/18] feat(ext): daemon RuntimeConfig accepts buildDaemonEnv provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optional async provider on the existing configureDaemonRuntime injection seam. Returned keys will be merged AFTER process.env and BEFORE the hard-coded ELECTRON_RUN_AS_NODE / PERPLEXITY_CONFIG_DIR overrides so a buggy provider cannot clobber critical spawn env. Merge logic itself is not in this commit — type-surface only — and lands in the next task that modifies spawnBundledDaemon. TDD red gate is `tsc --noEmit` (TS2353 on the new test file before extending the interface), since vitest transpiles TS without strict property checks and would pass vacuously. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/extension/src/daemon/runtime.ts | 12 +++++++ .../tests/daemon-runtime-config.test.ts | 35 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 packages/extension/tests/daemon-runtime-config.test.ts diff --git a/packages/extension/src/daemon/runtime.ts b/packages/extension/src/daemon/runtime.ts index f6ae788..4c08dfb 100644 --- a/packages/extension/src/daemon/runtime.ts +++ b/packages/extension/src/daemon/runtime.ts @@ -72,6 +72,18 @@ interface RuntimeConfig { bundledVersion: string; /** Optional logger; falls back to a no-op for tests / pre-init paths. */ log?: (line: string) => void; + /** + * Async provider returning env vars to merge into the daemon's spawn env. + * Called once per spawn (no caching). Implementations live in extension.ts + * and may read VS Code SecretStorage; this seam keeps daemon/runtime.ts + * free of any vscode import. + * + * Returned keys will be merged AFTER process.env and BEFORE the hard-coded + * overrides (ELECTRON_RUN_AS_NODE / PERPLEXITY_CONFIG_DIR / ...), so the + * provider cannot accidentally override critical spawn env. (Merge logic + * itself is added in a follow-up task; this task only declares the type.) + */ + buildDaemonEnv?: () => Promise>; } let runtimeConfig: RuntimeConfig | null = null; diff --git a/packages/extension/tests/daemon-runtime-config.test.ts b/packages/extension/tests/daemon-runtime-config.test.ts new file mode 100644 index 0000000..c8cb379 --- /dev/null +++ b/packages/extension/tests/daemon-runtime-config.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, vi } from "vitest"; + +// vitest's Node environment has no real "vscode" module — runtime.ts may +// import vscode-related modules transitively. Mock for safety. +vi.mock("vscode", () => ({ + window: { + showInputBox: async () => undefined, + }, +})); + +import { configureDaemonRuntime } from "../src/daemon/runtime"; + +describe("configureDaemonRuntime accepts buildDaemonEnv provider", () => { + it("accepts a config with buildDaemonEnv", () => { + const provider = vi.fn(async () => ({ FOO: "bar" })); + expect(() => + configureDaemonRuntime({ + configDir: "/tmp/x", + serverPath: "/tmp/x/server.mjs", + bundledVersion: "0.8.41", + buildDaemonEnv: provider, + }), + ).not.toThrow(); + }); + + it("accepts a config without buildDaemonEnv (back-compat)", () => { + expect(() => + configureDaemonRuntime({ + configDir: "/tmp/x", + serverPath: "/tmp/x/server.mjs", + bundledVersion: "0.8.41", + }), + ).not.toThrow(); + }); +}); From 4580862280ceb0c4532170689119bed717d8e887 Mon Sep 17 00:00:00 2001 From: "A.R." Date: Sun, 10 May 2026 12:24:27 +0300 Subject: [PATCH 05/18] feat(ext): spawnBundledDaemon awaits buildDaemonEnv and merges result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider env is merged AFTER process.env, BEFORE the hard-coded ELECTRON_RUN_AS_NODE / PERPLEXITY_CONFIG_DIR / PERPLEXITY_OAUTH_CONSENT_TTL_HOURS overrides so a buggy provider cannot clobber them. A throwing provider, a non-object return, or non-string entries are all logged-and-ignored — the daemon spawn must never crash because of a buggy provider. Telemetry logs only "set" / "unset" for the vault passphrase, never the value. The extension host's ambient process.env is never mutated; the overlay lives only in the spawn options. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/extension/src/daemon/runtime.ts | 30 +++++ .../tests/daemon-runtime-spawn.test.ts | 121 ++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 packages/extension/tests/daemon-runtime-spawn.test.ts diff --git a/packages/extension/src/daemon/runtime.ts b/packages/extension/src/daemon/runtime.ts index 4c08dfb..bc7f094 100644 --- a/packages/extension/src/daemon/runtime.ts +++ b/packages/extension/src/daemon/runtime.ts @@ -432,11 +432,41 @@ async function spawnBundledDaemon(options: { configDir: string; host?: string; p } catch { // settings unavailable outside the extension host — fall back to default } + let extraEnv: Record = {}; + if (config.buildDaemonEnv) { + try { + const provided = await config.buildDaemonEnv(); + if (provided && typeof provided === "object") { + for (const [k, v] of Object.entries(provided)) { + if (typeof k === "string" && typeof v === "string") { + extraEnv[k] = v; + } else { + (config.log ?? (() => undefined))( + `[daemon] buildDaemonEnv produced non-string entry for ${String(k)}; ignored`, + ); + } + } + } + } catch (err) { + (config.log ?? (() => undefined))( + `[daemon] buildDaemonEnv threw: ${err instanceof Error ? err.message : String(err)}; spawning without overlay`, + ); + } + } + + // Telemetry: log only the SET/UNSET status of vault passphrase, never the value. + (config.log ?? (() => undefined))( + `[daemon] PERPLEXITY_VAULT_PASSPHRASE: ${extraEnv.PERPLEXITY_VAULT_PASSPHRASE ? "set" : "unset"}`, + ); + const child = spawn(process.execPath, args, { detached: true, stdio: ["ignore", logFd, logFd], env: { ...process.env, + ...extraEnv, + // Hard-coded overrides — must come AFTER extraEnv so a buggy provider + // cannot clobber them. // Critical: process.execPath inside a VS Code extension host points at // Electron, not Node. Without this flag Electron ignores the JS script // and starts a GUI session. ELECTRON_RUN_AS_NODE=1 tells the same diff --git a/packages/extension/tests/daemon-runtime-spawn.test.ts b/packages/extension/tests/daemon-runtime-spawn.test.ts new file mode 100644 index 0000000..73e9500 --- /dev/null +++ b/packages/extension/tests/daemon-runtime-spawn.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import * as childProcess from "node:child_process"; + +// Mock vscode for any transitive imports. +vi.mock("vscode", () => ({ + window: { showInputBox: async () => undefined }, +})); + +// Mock spawn — we inspect its calls; we never actually fork a daemon. +// Use importOriginal so other consumers (e.g. mcp-server bundle pulling in +// execFile) still see the rest of the module. +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: vi.fn(), + }; +}); + +// Mock node:fs to neutralize daemon-log filesystem ops without affecting +// other consumers of node:fs that runtime.ts transitively pulls in. +vi.mock("node:fs", async (orig) => { + const actual = await orig(); + return { + ...actual, + openSync: vi.fn(() => 99), + closeSync: vi.fn(), + mkdirSync: vi.fn(), + statSync: vi.fn(() => ({ size: 0 })), + renameSync: vi.fn(), + }; +}); + +import { configureDaemonRuntime, ensureBundledDaemon } from "../src/daemon/runtime"; + +const spawnMock = childProcess.spawn as unknown as ReturnType; + +function fakeChild() { + return { on: vi.fn(), unref: vi.fn() } as unknown as ReturnType; +} + +// ensureBundledDaemon polls the (mocked, never-actually-running) daemon for +// up to ~15s before throwing. Each test catches that throw and asserts on +// the captured spawn() options — so we just need a per-test timeout that +// exceeds the launcher's 15s deadline. +const TEST_TIMEOUT_MS = 20_000; + +describe("spawnBundledDaemon merges buildDaemonEnv result", () => { + beforeEach(() => { + spawnMock.mockReset(); + spawnMock.mockImplementation(fakeChild); + }); + + afterEach(() => { + delete process.env.PERPLEXITY_VAULT_PASSPHRASE; + }); + + it("merges provider env into spawn() options.env", async () => { + configureDaemonRuntime({ + configDir: "/tmp/perp-test", + serverPath: "/tmp/perp-test/server.mjs", + bundledVersion: "0.8.41", + buildDaemonEnv: async () => ({ PERPLEXITY_VAULT_PASSPHRASE: "test-pass" }), + }); + try { await ensureBundledDaemon(); } catch { /* health-check failure is expected */ } + expect(spawnMock).toHaveBeenCalled(); + const opts = spawnMock.mock.calls[0]?.[2] as { env: Record }; + expect(opts.env.PERPLEXITY_VAULT_PASSPHRASE).toBe("test-pass"); + expect(opts.env.ELECTRON_RUN_AS_NODE).toBe("1"); + expect(opts.env.PERPLEXITY_CONFIG_DIR).toBe("/tmp/perp-test"); + }, TEST_TIMEOUT_MS); + + it("does not set PERPLEXITY_VAULT_PASSPHRASE when provider returns {}", async () => { + configureDaemonRuntime({ + configDir: "/tmp/perp-test", + serverPath: "/tmp/perp-test/server.mjs", + bundledVersion: "0.8.41", + buildDaemonEnv: async () => ({}), + }); + try { await ensureBundledDaemon(); } catch { /* expected */ } + const opts = spawnMock.mock.calls[0]?.[2] as { env: Record }; + expect(opts.env.PERPLEXITY_VAULT_PASSPHRASE).toBeUndefined(); + }, TEST_TIMEOUT_MS); + + it("hard-coded overrides win over provider env", async () => { + configureDaemonRuntime({ + configDir: "/tmp/perp-test", + serverPath: "/tmp/perp-test/server.mjs", + bundledVersion: "0.8.41", + // Provider tries to clobber critical overrides — must not succeed. + buildDaemonEnv: async () => ({ ELECTRON_RUN_AS_NODE: "0", PERPLEXITY_CONFIG_DIR: "/evil" }), + }); + try { await ensureBundledDaemon(); } catch { /* expected */ } + const opts = spawnMock.mock.calls[0]?.[2] as { env: Record }; + expect(opts.env.ELECTRON_RUN_AS_NODE).toBe("1"); + expect(opts.env.PERPLEXITY_CONFIG_DIR).toBe("/tmp/perp-test"); + }, TEST_TIMEOUT_MS); + + it("works without a provider (back-compat)", async () => { + configureDaemonRuntime({ + configDir: "/tmp/perp-test", + serverPath: "/tmp/perp-test/server.mjs", + bundledVersion: "0.8.41", + }); + try { await ensureBundledDaemon(); } catch { /* expected */ } + const opts = spawnMock.mock.calls[0]?.[2] as { env: Record }; + expect(opts.env.ELECTRON_RUN_AS_NODE).toBe("1"); + }, TEST_TIMEOUT_MS); + + it("does not mutate process.env after spawn", async () => { + delete process.env.PERPLEXITY_VAULT_PASSPHRASE; + configureDaemonRuntime({ + configDir: "/tmp/perp-test", + serverPath: "/tmp/perp-test/server.mjs", + bundledVersion: "0.8.41", + buildDaemonEnv: async () => ({ PERPLEXITY_VAULT_PASSPHRASE: "must-not-leak" }), + }); + try { await ensureBundledDaemon(); } catch { /* expected */ } + expect(process.env.PERPLEXITY_VAULT_PASSPHRASE).toBeUndefined(); + }, TEST_TIMEOUT_MS); +}); From c636e9cf6c1248fcd16d8d12ccbd0d3d8c522c2f Mon Sep 17 00:00:00 2001 From: "A.R." Date: Sun, 10 May 2026 12:44:31 +0300 Subject: [PATCH 06/18] wire(ext): pass buildDaemonEnv into configureDaemonRuntime Final piece of Phase 1: the extension now hands the daemon a provider that pulls the SecretStorage passphrase out at spawn time. End-to-end flow: ensureBundledDaemon -> spawnBundledDaemon -> await config.buildDaemonEnv() (Task 1.3 wiring) -> buildDaemonEnv(context) (Task 1.1 helper) -> peekStoredVaultPassphrase(context) -> context.secrets.get("perplexity.vault.passphrase") Only the call site changes here; behavior verified by daemon-runtime-spawn.test.ts (Task 1.3) and the typecheck/build gates. A direct extension.ts test would require stubbing the full VS Code API surface for marginal coverage; deferred. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/extension/src/extension.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 00a6f15..2c3afe9 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -6,6 +6,7 @@ import { MCP_PROVIDER_ID, MCP_SERVER_LABEL, type ExportFormat, type IdeTarget, t import { getActiveName, getProfile, listProfiles, setActive, createProfile } from "perplexity-user-mcp/profiles"; import { createExtensionAwareRunDoctor } from "./diagnostics/doctor-runner.js"; import { peekStoredVaultPassphrase } from "./auth/vault-passphrase.js"; +import { buildDaemonEnv } from "./auth/build-daemon-env.js"; import { redactMessage } from "./redact.js"; import { OutputRingBuffer } from "./diagnostics/output-buffer.js"; import { captureDiagnostics } from "./diagnostics/capture.js"; @@ -492,7 +493,13 @@ async function activateInner(context: vscode.ExtensionContext): Promise { const bundledServerPath = getBundledServerPath(context); const { launcherPath, configDir } = ensureLauncher(bundledServerPath); const bundledVersion = String((context.extension.packageJSON as { version?: string }).version ?? "0.0.0"); - configureDaemonRuntime({ serverPath: bundledServerPath, configDir, bundledVersion, log }); + configureDaemonRuntime({ + serverPath: bundledServerPath, + configDir, + bundledVersion, + log, + buildDaemonEnv: () => buildDaemonEnv(context), + }); log("Stable launcher: " + launcherPath); async function promptEmailForAutoLogin(profile: string): Promise { From 3a81e5dabc9a859a3f6e613dae3f91b207900996 Mon Sep 17 00:00:00 2001 From: "A.R." Date: Sun, 10 May 2026 12:47:23 +0300 Subject: [PATCH 07/18] feat(mcp): DaemonAttachError typed error with remediation array Exported class with: code: "DAEMON_UNREACHABLE" (literal) remediation: readonly string[] cause?: unknown name: "DaemonAttachError" attach.ts must never call process.exit; the launcher / CLI entrypoint owns stderr + exit semantics. This class is the throw-shape that those entrypoints will catch and translate into structured remediation output (Task 2.3 / 2.4). attachToDaemon itself is unchanged in this commit; the throw site is updated in Task 2.2. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/mcp-server/src/daemon/attach.ts | 12 ++++++++ .../test/daemon-attach-error.test.ts | 30 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 packages/mcp-server/test/daemon-attach-error.test.ts diff --git a/packages/mcp-server/src/daemon/attach.ts b/packages/mcp-server/src/daemon/attach.ts index ed57b5d..e2d0791 100644 --- a/packages/mcp-server/src/daemon/attach.ts +++ b/packages/mcp-server/src/daemon/attach.ts @@ -3,6 +3,18 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { ensureDaemon } from "./launcher.js"; +export class DaemonAttachError extends Error { + readonly code = "DAEMON_UNREACHABLE"; + readonly remediation: readonly string[]; + override readonly cause?: unknown; + constructor(message: string, remediation: readonly string[], cause?: unknown) { + super(message); + this.name = "DaemonAttachError"; + this.remediation = remediation; + if (cause !== undefined) this.cause = cause; + } +} + export interface AttachToDaemonOptions { configDir?: string; stdin?: Readable; diff --git a/packages/mcp-server/test/daemon-attach-error.test.ts b/packages/mcp-server/test/daemon-attach-error.test.ts new file mode 100644 index 0000000..4b8cf92 --- /dev/null +++ b/packages/mcp-server/test/daemon-attach-error.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { DaemonAttachError } from "../src/daemon/attach.js"; + +describe("DaemonAttachError", () => { + it("has code DAEMON_UNREACHABLE", () => { + const e = new DaemonAttachError("nope", ["a"]); + expect(e.code).toBe("DAEMON_UNREACHABLE"); + }); + + it("preserves remediation array", () => { + const e = new DaemonAttachError("nope", ["one", "two", "three"]); + expect(e.remediation).toEqual(["one", "two", "three"]); + }); + + it("preserves cause when supplied", () => { + const cause = new Error("ECONNREFUSED"); + const e = new DaemonAttachError("nope", ["a"], cause); + expect(e.cause).toBe(cause); + }); + + it("name is DaemonAttachError", () => { + const e = new DaemonAttachError("nope", ["a"]); + expect(e.name).toBe("DaemonAttachError"); + }); + + it("instanceof Error", () => { + const e = new DaemonAttachError("nope", ["a"]); + expect(e).toBeInstanceOf(Error); + }); +}); From cc6cbd3dda1b30cc7bc6a2b13d3e2b4537478144 Mon Sep 17 00:00:00 2001 From: "A.R." Date: Sun, 10 May 2026 12:50:24 +0300 Subject: [PATCH 08/18] feat(mcp): attachToDaemon throws DaemonAttachError when fallbackStdio=false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both failure sites (ensureDaemon throw + post-attach transport-start throw) now wrap the underlying error in DaemonAttachError with the 3-element remediation array and the original as cause. Adds DEFAULT_REMEDIATION as a module-local readonly constant — three strings naming the three operator paths: reload VS Code, switch to http-loopback, or opt into in-process stdio with PERPLEXITY_NO_DAEMON=1 + setup-vault. fallbackStdio: true opt-in behavior unchanged (runFallback still runs when explicitly requested by tests / advanced transports). attach.ts itself never calls process.exit — that semantic belongs to the launcher / CLI entrypoint (Tasks 2.3 / 2.4), which catch this error and translate it into stderr + exit 2. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/mcp-server/src/daemon/attach.ts | 18 ++++- .../mcp-server/test/daemon-attach.test.ts | 68 +++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 packages/mcp-server/test/daemon-attach.test.ts diff --git a/packages/mcp-server/src/daemon/attach.ts b/packages/mcp-server/src/daemon/attach.ts index e2d0791..a0f58f6 100644 --- a/packages/mcp-server/src/daemon/attach.ts +++ b/packages/mcp-server/src/daemon/attach.ts @@ -15,6 +15,12 @@ export class DaemonAttachError extends Error { } } +const DEFAULT_REMEDIATION: readonly string[] = [ + "Reload the VS Code window so the extension restarts the daemon.", + "In the VS Code Perplexity dashboard, switch this client's transport to http-loopback.", + "(Advanced) Set PERPLEXITY_NO_DAEMON=1 in this client's MCP env block, then run `npx perplexity-user-mcp setup-vault` once.", +] as const; + export interface AttachToDaemonOptions { configDir?: string; stdin?: Readable; @@ -58,7 +64,11 @@ export async function attachToDaemon(options: AttachToDaemonOptions = {}): Promi await runFallback(error, options); return; } - throw error; + throw new DaemonAttachError( + `Cannot reach the extension-managed daemon: ${asError(error).message}`, + DEFAULT_REMEDIATION, + error, + ); } const stdio = new StdioServerTransport(sourceIn, sourceOut); @@ -123,7 +133,11 @@ export async function attachToDaemon(options: AttachToDaemonOptions = {}): Promi await runFallback(error, options); return; } - throw error; + throw new DaemonAttachError( + `Daemon attached but transport failed to start: ${asError(error).message}`, + DEFAULT_REMEDIATION, + error, + ); } await completion; } diff --git a/packages/mcp-server/test/daemon-attach.test.ts b/packages/mcp-server/test/daemon-attach.test.ts new file mode 100644 index 0000000..70401ff --- /dev/null +++ b/packages/mcp-server/test/daemon-attach.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi } from "vitest"; +import { attachToDaemon, DaemonAttachError } from "../src/daemon/attach.js"; + +describe("attachToDaemon throws DaemonAttachError when fallbackStdio=false", () => { + it("wraps ensureDaemon failure into DaemonAttachError", async () => { + const ensureDaemon = vi.fn(async () => { + throw new Error("ECONNREFUSED 127.0.0.1:9001"); + }); + await expect( + attachToDaemon({ + fallbackStdio: false, + dependencies: { ensureDaemon }, + }), + ).rejects.toMatchObject({ + name: "DaemonAttachError", + code: "DAEMON_UNREACHABLE", + }); + }); + + it("includes 3 remediation strings", async () => { + const ensureDaemon = vi.fn(async () => { throw new Error("nope"); }); + try { + await attachToDaemon({ fallbackStdio: false, dependencies: { ensureDaemon } }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(DaemonAttachError); + expect((err as DaemonAttachError).remediation).toHaveLength(3); + expect((err as DaemonAttachError).remediation[0]).toMatch(/Reload the VS Code window/); + expect((err as DaemonAttachError).remediation[1]).toMatch(/http-loopback/); + expect((err as DaemonAttachError).remediation[2]).toMatch(/PERPLEXITY_NO_DAEMON/); + } + }); + + it("preserves the underlying error as cause", async () => { + const original = new Error("ECONNREFUSED"); + const ensureDaemon = vi.fn(async () => { throw original; }); + try { + await attachToDaemon({ fallbackStdio: false, dependencies: { ensureDaemon } }); + expect.fail("should have thrown"); + } catch (err) { + expect((err as DaemonAttachError).cause).toBe(original); + } + }); + + it("does not call process.exit", async () => { + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code}) was called — forbidden in attach.ts`); + }) as never); + const ensureDaemon = vi.fn(async () => { throw new Error("nope"); }); + try { + await attachToDaemon({ fallbackStdio: false, dependencies: { ensureDaemon } }); + } catch (err) { + expect(err).toBeInstanceOf(DaemonAttachError); + } + expect(exitSpy).not.toHaveBeenCalled(); + exitSpy.mockRestore(); + }); + + it("with fallbackStdio: true, runs the legacy fallback (no DaemonAttachError thrown)", async () => { + const ensureDaemon = vi.fn(async () => { throw new Error("nope"); }); + const runStdioMain = vi.fn(async () => undefined); + await attachToDaemon({ + fallbackStdio: true, + dependencies: { ensureDaemon, runStdioMain }, + }); + expect(runStdioMain).toHaveBeenCalledOnce(); + }); +}); From 0f9f4c0b14c1d3cfa14c04c0a0aba0a1027f256b Mon Sep 17 00:00:00 2001 From: "A.R." Date: Sun, 10 May 2026 12:56:14 +0300 Subject: [PATCH 09/18] breaking(ext): launcher catches DaemonAttachError, stderr+exit2 The default stdio-daemon-proxy launcher no longer silently falls back to in-process stdio when daemon attach fails. New behavior: catch DaemonAttachError (code DAEMON_UNREACHABLE), write structured remediation to stderr (stdout reserved for JSON-RPC framing), exit 2. Migration: the 0.8.40 launcher on disk gets rewritten by ensureLauncher's existing byte-comparison logic on next extension activation. Users on working setups (Win11/macOS with daemon healthy) see no behavior change. Users who were on the silently-broken fallback path now get an actionable error instead of "anonymous mode". Reserved exit codes: 0 = clean shutdown 1 = generic crash (Node default handler) 2 = operator-actionable misconfiguration (daemon unreachable) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../extension/src/launcher/write-launcher.ts | 57 +++++--- .../extension/tests/write-launcher.test.ts | 45 ++++++- .../mcp-server/test/launcher-script.test.ts | 122 ++++++++++++++++++ 3 files changed, 202 insertions(+), 22 deletions(-) create mode 100644 packages/mcp-server/test/launcher-script.test.ts diff --git a/packages/extension/src/launcher/write-launcher.ts b/packages/extension/src/launcher/write-launcher.ts index 2bf7180..aaaadf5 100644 --- a/packages/extension/src/launcher/write-launcher.ts +++ b/packages/extension/src/launcher/write-launcher.ts @@ -12,7 +12,25 @@ const BUNDLED_PATH_FILE = join(CONFIG_DIR, "bundled-path.json"); // PERPLEXITY_NO_DAEMON=1 to bypass the daemon and run an in-process stdio // server instead (same contract as cli.js, Task 8.3.2). const LAUNCHER_CONTENT = `#!/usr/bin/env node -// Stable launcher -- never moves. Reads actual server path dynamically. +// Stable launcher — never moves. Reads actual server path dynamically. +// +// Default behavior: multiplex onto the shared daemon spawned by the VS Code +// extension. If the daemon is unreachable, FAIL LOUDLY with a structured +// stderr remediation and exit code 2 — do NOT silently fall back to an +// in-process stdio server in the client's runtime, because that path tries +// to read the vault under the client's Node, which on many setups (Claude +// Code's bundled Node, Antigravity, mismatched ABI) cannot load keytar. +// +// Exit codes: +// 0 = clean shutdown +// 1 = generic crash (Node default error handler) +// 2 = operator-actionable misconfiguration (daemon unreachable) +// +// Opt-out: set PERPLEXITY_NO_DAEMON=1 to bypass the daemon and run an +// in-process stdio server directly. ADVANCED: the vault must be unsealable +// in this client's runtime (working keychain or PERPLEXITY_VAULT_PASSPHRASE +// in this client's env block). Run \`npx perplexity-user-mcp setup-vault\` +// once if you need help. See docs/troubleshooting/external-mcp-clients.md. import { readFileSync } from "fs"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; @@ -23,22 +41,31 @@ const server = await import(config.serverPath); const noDaemonRaw = (process.env.PERPLEXITY_NO_DAEMON ?? "").trim(); if (/^(1|true)$/i.test(noDaemonRaw)) { - // Opt-out: run in-process stdio server. Warning to stderr only — stdout is - // the JSON-RPC framing channel. - process.stderr.write("[perplexity-mcp] PERPLEXITY_NO_DAEMON=1 set; running in-process stdio (daemon bypass)\\n"); + // Opt-out: in-process stdio. stderr only — stdout is the JSON-RPC channel. + process.stderr.write("[perplexity-mcp] PERPLEXITY_NO_DAEMON=1 set; running in-process stdio (advanced)\\n"); await server.main(); } else { - // Default: multiplex onto the shared daemon. If the daemon is unreachable - // attach.ts falls back to runStdioMain (the DI shim below) so the client - // still gets a working server. The shim is mandatory because in the - // bundled extension layout (dist/mcp/server.mjs) attach.ts's default - // \`import("../index.js")\` resolves to a nonexistent sibling file. - await server.attachToDaemon({ - configDir: process.env.PERPLEXITY_CONFIG_DIR, - clientId: \`perplexity-launcher-\${process.pid}\`, - fallbackStdio: true, - dependencies: { runStdioMain: () => server.main() }, - }); + // Default: attach to the shared daemon. No silent fallback. + try { + await server.attachToDaemon({ + configDir: process.env.PERPLEXITY_CONFIG_DIR, + clientId: \`perplexity-launcher-\${process.pid}\`, + fallbackStdio: false, + }); + } catch (err) { + if (err && err.code === "DAEMON_UNREACHABLE") { + process.stderr.write("Perplexity MCP: cannot reach the extension-managed daemon.\\n"); + for (const line of err.remediation ?? []) { + process.stderr.write(" • " + line + "\\n"); + } + if (err.cause && err.cause.message) { + process.stderr.write("Underlying error: " + err.cause.message + "\\n"); + } + process.exit(2); + } + // Anything else: let Node's default error handler fire (exit 1, stack on stderr). + throw err; + } } `; diff --git a/packages/extension/tests/write-launcher.test.ts b/packages/extension/tests/write-launcher.test.ts index 2fc5efe..24a89d2 100644 --- a/packages/extension/tests/write-launcher.test.ts +++ b/packages/extension/tests/write-launcher.test.ts @@ -58,15 +58,18 @@ describe("write-launcher (Task 8.3.3: daemon-proxy)", () => { const content = readFileSync(launcherPath, "utf8"); - // Required substrings per Task 8.3.3 spec. + // Required substrings per Task 2.3 spec (supersedes 8.3.3): the launcher + // attaches to the daemon with fallbackStdio:false and no runStdioMain DI + // shim. The opt-out branch (PERPLEXITY_NO_DAEMON=1) still calls + // server.main() exactly once. expect(content).toContain("attachToDaemon"); - expect(content).toContain("fallbackStdio: true"); + expect(content).toContain("fallbackStdio: false"); expect(content).toContain("PERPLEXITY_NO_DAEMON"); - expect(content).toContain("runStdioMain"); + expect(content).not.toContain("runStdioMain"); - // server.main() must appear at least twice (opt-out branch + DI shim). + // server.main() appears exactly once now (opt-out branch only). const mainCalls = content.match(/server\.main\(\)/g) ?? []; - expect(mainCalls.length).toBeGreaterThanOrEqual(2); + expect(mainCalls.length).toBe(1); // Literal backtick template literal for clientId (not a JS escape — the // generated file must contain real backtick characters). @@ -137,10 +140,12 @@ await import(config.serverPath); const newContent = readFileSync(launcherPath, "utf8"); expect(newContent).not.toBe(oldContent); - // New launcher has the daemon-proxy markers. + // New launcher has the daemon-proxy markers and the Task 2.3 fail-loud + // contract (fallbackStdio: false, no DI shim). expect(newContent).toContain("attachToDaemon"); expect(newContent).toContain("PERPLEXITY_NO_DAEMON"); - expect(newContent).toContain("runStdioMain"); + expect(newContent).toContain("fallbackStdio: false"); + expect(newContent).not.toContain("runStdioMain"); }); it("writes bundled-path.json with serverPath (file URL) and fsPath", async () => { @@ -173,3 +178,29 @@ await import(config.serverPath); expect(mod.checkLauncherHealth([])).toBe("stale"); }); }); + +import { readFileSync as readFileSyncRaw } from "node:fs"; + +const launcherSourcePath = join(__dirname, "..", "src", "launcher", "write-launcher.ts"); + +describe("write-launcher LAUNCHER_CONTENT (Task 2.3 — refuses silent fallback)", () => { + it("uses fallbackStdio: false in the default proxy branch", () => { + const src = readFileSyncRaw(launcherSourcePath, "utf8"); + expect(src).toMatch(/fallbackStdio:\s*false/); + expect(src).not.toMatch(/fallbackStdio:\s*true/); + }); + + it("does not pass runStdioMain dependency in the default proxy branch", () => { + const src = readFileSyncRaw(launcherSourcePath, "utf8"); + // The whole proxy branch (the else branch under PERPLEXITY_NO_DAEMON check) + // must not reference runStdioMain. Coarse check on the full source. + expect(src).not.toMatch(/runStdioMain/); + }); + + it("wraps attachToDaemon in try/catch with stderr+exit2 on DAEMON_UNREACHABLE", () => { + const src = readFileSyncRaw(launcherSourcePath, "utf8"); + expect(src).toMatch(/DAEMON_UNREACHABLE/); + expect(src).toMatch(/process\.exit\(2\)/); + expect(src).toMatch(/process\.stderr\.write/); + }); +}); diff --git a/packages/mcp-server/test/launcher-script.test.ts b/packages/mcp-server/test/launcher-script.test.ts new file mode 100644 index 0000000..8a9d200 --- /dev/null +++ b/packages/mcp-server/test/launcher-script.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from "vitest"; +import { spawn } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +/** + * Synthesize the launcher's runtime contract and run it as a sub-process. + * The stub server.mjs throws a DaemonAttachError shape; we assert the + * launcher catches it, writes the bullet remediation to stderr, exits 2, + * and leaves stdout untouched. + */ +function buildFakeWorkspace(): { launcherPath: string } { + const dir = mkdtempSync(join(tmpdir(), "perp-launcher-")); + const serverPath = join(dir, "server.mjs"); + writeFileSync( + serverPath, + ` +export class DaemonAttachError extends Error { + constructor(message, remediation, cause) { + super(message); + this.name = "DaemonAttachError"; + this.code = "DAEMON_UNREACHABLE"; + this.remediation = remediation; + if (cause !== undefined) this.cause = cause; + } +} +export async function attachToDaemon() { + throw new DaemonAttachError("nope", [ + "Reload the VS Code window so the extension restarts the daemon.", + "In the VS Code Perplexity dashboard, switch this client's transport to http-loopback.", + "(Advanced) Set PERPLEXITY_NO_DAEMON=1 in this client's MCP env block, then run \\\`npx perplexity-user-mcp setup-vault\\\` once.", + ], new Error("ECONNREFUSED 127.0.0.1:9999")); +} +export async function main() { /* would run in-process stdio if PERPLEXITY_NO_DAEMON=1 */ } +`, + "utf8", + ); + writeFileSync( + join(dir, "bundled-path.json"), + JSON.stringify({ serverPath: pathToFileURL(serverPath).href }), + "utf8", + ); + + // Synthesize the launcher script — a copy of LAUNCHER_CONTENT from + // packages/extension/src/launcher/write-launcher.ts. KEEP IN SYNC: + // if this test starts failing because the launcher template diverged, + // copy the latest LAUNCHER_CONTENT here verbatim. + const launcherPath = join(dir, "start.mjs"); + writeFileSync( + launcherPath, + ` +import { readFileSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; +const __dirname = dirname(fileURLToPath(import.meta.url)); +const config = JSON.parse(readFileSync(join(__dirname, "bundled-path.json"), "utf8")); +const server = await import(config.serverPath); +const noDaemonRaw = (process.env.PERPLEXITY_NO_DAEMON ?? "").trim(); +if (/^(1|true)$/i.test(noDaemonRaw)) { + process.stderr.write("[perplexity-mcp] PERPLEXITY_NO_DAEMON=1 set; running in-process stdio (advanced)\\n"); + await server.main(); +} else { + try { + await server.attachToDaemon({ + configDir: process.env.PERPLEXITY_CONFIG_DIR, + clientId: \`perplexity-launcher-\${process.pid}\`, + fallbackStdio: false, + }); + } catch (err) { + if (err && err.code === "DAEMON_UNREACHABLE") { + process.stderr.write("Perplexity MCP: cannot reach the extension-managed daemon.\\n"); + for (const line of err.remediation ?? []) { + process.stderr.write(" • " + line + "\\n"); + } + if (err.cause && err.cause.message) { + process.stderr.write("Underlying error: " + err.cause.message + "\\n"); + } + process.exit(2); + } + throw err; + } +} +`, + "utf8", + ); + + return { launcherPath }; +} + +function runLauncher(launcherPath: string, env: Record = {}): Promise<{ + code: number | null; + stdout: string; + stderr: string; +}> { + return new Promise((resolve) => { + const child = spawn(process.execPath, [launcherPath], { + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d; }); + child.stderr.on("data", (d) => { stderr += d; }); + child.on("close", (code) => resolve({ code, stdout, stderr })); + }); +} + +describe("generated launcher script — refuses silent fallback", () => { + it("exits 2 with structured stderr when daemon is unreachable", async () => { + const { launcherPath } = buildFakeWorkspace(); + const result = await runLauncher(launcherPath); + expect(result.code).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toMatch(/cannot reach the extension-managed daemon/); + expect(result.stderr).toMatch(/Reload the VS Code window/); + expect(result.stderr).toMatch(/http-loopback/); + expect(result.stderr).toMatch(/PERPLEXITY_NO_DAEMON=1/); + expect(result.stderr).toMatch(/Underlying error: ECONNREFUSED/); + }); +}); From 0e334ad54f5dce4e3c9b3137e2bc2c3fda9669e3 Mon Sep 17 00:00:00 2001 From: "A.R." Date: Sun, 10 May 2026 13:00:06 +0300 Subject: [PATCH 10/18] feat(cli): perplexity-user-mcp catches DaemonAttachError on attach paths CLI subcommands that proxy to the extension-managed daemon now print the same bullet remediation and exit code 2 as the generated launcher when the daemon is unreachable. Stdout stays clean (JSON-RPC channel reserved). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/mcp-server/src/cli.js | 31 +++++++++--- packages/mcp-server/test/cli.test.js | 74 ++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 6 deletions(-) diff --git a/packages/mcp-server/src/cli.js b/packages/mcp-server/src/cli.js index e10a1e2..274b42b 100644 --- a/packages/mcp-server/src/cli.js +++ b/packages/mcp-server/src/cli.js @@ -485,12 +485,31 @@ export async function routeCommand(parsed) { typeof ensureTimeoutRaw === "string" && /^\d+$/.test(ensureTimeoutRaw) ? Number(ensureTimeoutRaw) : undefined; - await attachToDaemon({ - configDir: process.env.PERPLEXITY_CONFIG_DIR, - clientId: "daemon-attach-cli", - fallbackStdio: !!flags["fallback-stdio"], - ensureTimeoutMs, - }); + try { + await attachToDaemon({ + configDir: process.env.PERPLEXITY_CONFIG_DIR, + clientId: "daemon-attach-cli", + fallbackStdio: !!flags["fallback-stdio"], + ensureTimeoutMs, + }); + } catch (err) { + // Phase 2 / Task 2.4: mirror the launcher's DaemonAttachError contract. + // Stdout is the JSON-RPC framing channel for the attached client, so the + // bullet remediation must land on stderr only; the script entry below + // converts code:2 into process.exit(2). + if (err && err.code === "DAEMON_UNREACHABLE") { + let stderr = "Perplexity MCP: cannot reach the extension-managed daemon.\n"; + const remediation = Array.isArray(err.remediation) ? err.remediation : []; + for (const line of remediation) { + stderr += " • " + line + "\n"; + } + if (err.cause && err.cause.message) { + stderr += "Underlying error: " + err.cause.message + "\n"; + } + return { code: 2, stdout: "", stderr }; + } + throw err; + } return { code: 0, stdout: "", stderr: "" }; } diff --git a/packages/mcp-server/test/cli.test.js b/packages/mcp-server/test/cli.test.js index a01bb42..865d7ad 100644 --- a/packages/mcp-server/test/cli.test.js +++ b/packages/mcp-server/test/cli.test.js @@ -685,3 +685,77 @@ describe("daemon:attach — PERPLEXITY_NO_DAEMON opt-out (Task 8.3.2)", () => { expect(attachSpy).not.toHaveBeenCalled(); }); }); + +// --------------------------------------------------------------------------- +// Task 2.4 — daemon:attach catches DaemonAttachError and emits the bullet +// remediation on stderr only, returning exit code 2. Mirrors the launcher +// contract from Task 2.3 (write-launcher.ts) so the CLI subcommand and the +// generated launcher script behave identically when the daemon is unreachable. +// --------------------------------------------------------------------------- +describe("daemon:attach — DaemonAttachError contract (Task 2.4)", () => { + let savedEnv; + + beforeEach(() => { + savedEnv = process.env.PERPLEXITY_NO_DAEMON; + delete process.env.PERPLEXITY_NO_DAEMON; + attachSpy.mockReset(); + mainSpy.mockReset(); + }); + + afterEach(() => { + if (savedEnv === undefined) delete process.env.PERPLEXITY_NO_DAEMON; + else process.env.PERPLEXITY_NO_DAEMON = savedEnv; + }); + + function makeAttachError({ withCause = true } = {}) { + const err = new Error("Cannot reach the extension-managed daemon: spawn ENOENT"); + err.name = "DaemonAttachError"; + err.code = "DAEMON_UNREACHABLE"; + err.remediation = [ + "Reload the VS Code window so the extension restarts the daemon.", + "In the VS Code Perplexity dashboard, switch this client's transport to http-loopback.", + "(Advanced) Set PERPLEXITY_NO_DAEMON=1 in this client's MCP env block, then run `npx perplexity-user-mcp setup-vault` once.", + ]; + if (withCause) err.cause = new Error("spawn ENOENT"); + return err; + } + + it("returns code 2 with bullet remediation on stderr when DAEMON_UNREACHABLE", async () => { + attachSpy.mockRejectedValueOnce(makeAttachError()); + const res = await routeCommand({ command: "daemon:attach", flags: {} }); + expect(res.code).toBe(2); + expect(res.stdout).toBe(""); + expect(res.stderr).toContain("cannot reach the extension-managed daemon"); + expect(res.stderr).toContain("• Reload the VS Code window"); + expect(res.stderr).toContain("• In the VS Code Perplexity dashboard"); + expect(res.stderr).toContain("PERPLEXITY_NO_DAEMON=1"); + }); + + it("appends underlying-error line when err.cause has a message", async () => { + attachSpy.mockRejectedValueOnce(makeAttachError({ withCause: true })); + const res = await routeCommand({ command: "daemon:attach", flags: {} }); + expect(res.stderr).toContain("Underlying error: spawn ENOENT"); + }); + + it("omits underlying-error line when err.cause is missing", async () => { + attachSpy.mockRejectedValueOnce(makeAttachError({ withCause: false })); + const res = await routeCommand({ command: "daemon:attach", flags: {} }); + expect(res.stderr).not.toContain("Underlying error:"); + }); + + it("rethrows non-DAEMON_UNREACHABLE errors unchanged", async () => { + const other = new Error("boom"); + attachSpy.mockRejectedValueOnce(other); + await expect(routeCommand({ command: "daemon:attach", flags: {} })).rejects.toThrow("boom"); + }); + + it("tolerates a missing remediation array (no crash, just header line)", async () => { + const err = new Error("daemon down"); + err.code = "DAEMON_UNREACHABLE"; + // Intentionally no err.remediation + attachSpy.mockRejectedValueOnce(err); + const res = await routeCommand({ command: "daemon:attach", flags: {} }); + expect(res.code).toBe(2); + expect(res.stderr).toContain("cannot reach the extension-managed daemon"); + }); +}); From 712f9769e13cc70062757ee25a6158b457edbafa Mon Sep 17 00:00:00 2001 From: "A.R." Date: Sun, 10 May 2026 13:03:32 +0300 Subject: [PATCH 11/18] docs(vault): create vault-unseal.md (was 404 from vault.js error) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the keychain → env var → TTY unseal chain, the standalone-CLI vs. extension-managed paths, per-platform notes, recovery flow, and format-version table. Referenced from the "Vault locked" error message in mcp-server's vault.js since v0.4.x but the file was never created. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/vault-unseal.md | 71 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 docs/vault-unseal.md diff --git a/docs/vault-unseal.md b/docs/vault-unseal.md new file mode 100644 index 0000000..ce10385 --- /dev/null +++ b/docs/vault-unseal.md @@ -0,0 +1,71 @@ +# Vault Unsealing + +> If you got here from a "Vault locked" error, jump to [Recovery](#recovery). + +## Overview + +The Perplexity MCP server stores authentication cookies encrypted at rest in `~/.perplexity-mcp/profiles//vault.enc`. To use the file, the server must unlock ("unseal") the encryption key. There are three unseal paths, tried in order: + +1. **OS keychain** (preferred) — Windows Credential Manager, macOS Keychain, Linux libsecret/gnome-keyring. The server stores a 32-byte random key under service `perplexity-user-mcp`, account `vault-master-key`. +2. **Env var** — `PERPLEXITY_VAULT_PASSPHRASE` (fallback for headless Linux, sandboxed runtimes, or when the keychain is unavailable). +3. **TTY prompt** — interactive only (CLI use). Skipped when running as an stdio MCP server (no TTY). + +The vault file is encrypted with AES-256-GCM. The KDF for passphrase-derived keys is scrypt (logN=17, r=8, p=1). Format details live in inline comments at the top of [`packages/mcp-server/src/vault.js`](../packages/mcp-server/src/vault.js). + +## Standalone CLI vs. VS Code extension + +- **Standalone `perplexity-user-mcp`** (npm package) uses the chain above directly. If you need to set a passphrase, run `npx perplexity-user-mcp setup-vault` — it generates a strong 256-bit base64url passphrase and prints per-platform persistence snippets. +- **VS Code extension** uses the same chain in its login runner, but ALSO stores a SecretStorage-backed passphrase if the keychain probe fails. Starting with **0.8.41**, the extension passes that passphrase to the long-running daemon at spawn time via a narrowly-scoped env builder, so external IDE clients (Claude Code, Antigravity, Codex CLI, Cursor) routed through the daemon don't need their own vault credentials. + +## Per-platform notes + +### Windows + +Windows Credential Manager works out of the box for the extension's bundled `keytar` under VS Code's Electron runtime. If you see "Vault locked" in an external client's launcher (Claude Code on Node 24+, Antigravity, sandboxed Codex CLI), the issue is almost certainly that the launcher's runtime can't load `keytar` — but the **extension-managed daemon** still owns the credentials. Fix: ensure your extension is **0.8.41 or later**, then reload VS Code. + +### macOS + +Same as Windows — macOS Keychain works under the bundled keytar. + +### Linux + +Headless Linux has no libsecret by default. Two options: +1. Install libsecret + gnome-keyring (or kwallet) so keytar succeeds. +2. Set `PERPLEXITY_VAULT_PASSPHRASE` in your IDE's MCP env block. Run `npx perplexity-user-mcp setup-vault` for a strong generated passphrase + persistence snippet. + +## Recovery + +If you see one of these errors: + +- `Vault decrypt failed: wrong passphrase or corrupted ciphertext` +- `Vault locked: no keychain, no env var, no TTY` + +The vault was written under unseal material that is no longer available (rotated keychain key, changed `PERPLEXITY_VAULT_PASSPHRASE`, lost SecretStorage entry). There is **no recovery without the original material** — AES-256-GCM is authenticated and refuses to decrypt under the wrong key, by design. + +Recovery flow: + +1. Quarantine and discard the unreadable vault: + ```bash + npx perplexity-user-mcp logout --purge --profile + ``` + (replace `` with your profile name; default is `default`) +2. Log in again from the VS Code dashboard, or: + ```bash + npx perplexity-user-mcp login --profile + ``` +3. The new vault is written under whatever unseal material is currently available. + +## Vault format versions + +| Version | Status | KDF | Notes | +|---|---|---|---| +| v1 | legacy, decrypt-only | HKDF-SHA256 (static salt) | 0.6.x and earlier | +| v2 | legacy, decrypt-only | HKDF-SHA256 (per-blob salt) | 0.7.x | +| v3 | current | scrypt logN=17 | 0.8.x; per-blob salt + KDF params | + +Reads never mutate the file. Writes always emit the latest supported version. + +## Related + +- [Troubleshooting external MCP clients](troubleshooting/external-mcp-clients.md) — Claude Code, Antigravity, Codex CLI specifics +- [Codex CLI setup](codex-cli-setup.md) — Codex CLI configuration walkthrough From 9c4b23ac1c8f6f97de0876158b4f1a21d8e45ed2 Mon Sep 17 00:00:00 2001 From: "A.R." Date: Sun, 10 May 2026 13:03:54 +0300 Subject: [PATCH 12/18] docs(codex): soften Windows-keychain "just works" claim Acknowledge that Claude Code (Node 24+), Antigravity, and other sandboxed runtimes may fail to load the bundled keytar even on Windows. Point users at setup-vault and the new vault-unseal.md recovery section. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/codex-cli-setup.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/codex-cli-setup.md b/docs/codex-cli-setup.md index c0cc6ab..87d5f63 100644 --- a/docs/codex-cli-setup.md +++ b/docs/codex-cli-setup.md @@ -91,6 +91,14 @@ If you have the extension installed, prefer section 1 — the daemon owns the va - Credential Manager is always available. Same as macOS: section 2a works after a one-time `perplexity_login`. +**Troubleshooting "Vault locked" on Windows.** The bundled keytar talks to the OS Credential Vault and works for most installs under VS Code's Electron. If your launcher runs under a different Node ABI (Claude Code on Node 24+, sandboxed runtimes, or fresh installs of `perplexity-user-mcp` on a host where `keytar` can't load) and reports "Vault locked", run: + +```bash +npx perplexity-user-mcp setup-vault +``` + +This generates a strong passphrase, prints a `setx`/PowerShell snippet to persist it as a User-scope env var, and (when the issue is the launcher's keytar load) restores access immediately. See [vault-unseal.md](./vault-unseal.md#recovery) for the full unseal model and recovery flow. + --- ## 4. Verifying the setup From 2df8b57eef181f70688afd6c4b422f6ca5fe9d03 Mon Sep 17 00:00:00 2001 From: "A.R." Date: Sun, 10 May 2026 13:04:37 +0300 Subject: [PATCH 13/18] docs(troubleshooting): external MCP clients page Single canonical page that explains why external IDE clients hit "Vault locked" before 0.8.41, what the new "DAEMON_UNREACHABLE" error means, and the per-IDE support matrix (rows fill in as smoke evidence lands). Links to vault-unseal.md for the unseal model and recovery flow. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/troubleshooting/external-mcp-clients.md | 50 ++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/troubleshooting/external-mcp-clients.md diff --git a/docs/troubleshooting/external-mcp-clients.md b/docs/troubleshooting/external-mcp-clients.md new file mode 100644 index 0000000..eaf6324 --- /dev/null +++ b/docs/troubleshooting/external-mcp-clients.md @@ -0,0 +1,50 @@ +# Troubleshooting: External MCP Clients + +> "External" = anything outside VS Code itself: Claude Code, Antigravity, Codex CLI, Cursor (when run as its own app), JetBrains MCP integrations, etc. + +## TL;DR + +Use the **default `stdio-daemon-proxy` transport** generated by the VS Code Perplexity dashboard. The launcher script (`~/.perplexity-mcp/start.mjs`) attaches to the long-running daemon spawned by the VS Code extension; the daemon owns vault credentials, you don't have to. + +If your client says "Vault locked" or you see "DAEMON_UNREACHABLE" in stderr, see the matching section below. + +## Symptom: "Vault locked: no keychain, no env var, no TTY" + +This means your client's launcher process tried to read the vault directly — which only happens when daemon attach failed AND your launcher silently fell back to in-process stdio. **Fix in 0.8.41 and later:** the default launcher no longer falls back silently; you'll see "DAEMON_UNREACHABLE" instead. If you're seeing this on 0.8.40 or earlier, upgrade. + +## Symptom: "Perplexity MCP: cannot reach the extension-managed daemon" (DAEMON_UNREACHABLE) + +The daemon is not running or not reachable. Three remediations, in order of likelihood: + +1. **Reload the VS Code window.** The extension respawns a healthy daemon on activation. After the dashboard shows "Daemon: running", retry from your external client. +2. **Switch this client's transport to `http-loopback`** in the VS Code dashboard's MCP Config Management. This sidesteps the launcher entirely. Note: port-drift across daemon restarts is a known limitation tracked for 0.8.43; if it bites you, switch back to `stdio-daemon-proxy` after a daemon restart. +3. **Advanced: opt into in-process stdio.** Set `PERPLEXITY_NO_DAEMON=1` in this client's MCP env block. Then run: + ```bash + npx perplexity-user-mcp setup-vault + ``` + This generates a passphrase + persistence snippet so the in-process server can unseal the vault in your client's runtime. + + **Sync-folder warning:** if the persistence snippet writes the env var into a synced shell rc file (Dropbox, OneDrive, iCloud Drive), the passphrase syncs too. Use a User-scope env var (Windows `setx`, macOS plist, Linux `~/.profile`) for client-only env-block persistence. + +## Per-IDE support matrix + +| IDE | Transport | Status | Smoke evidence | +|---|---|---|---| +| Claude Code | stdio-daemon-proxy | Supported (0.8.41) | Pending — see [smoke-tests.md](../smoke-tests.md) | +| Antigravity | stdio-daemon-proxy | Supported (0.8.41) | Pending | +| Codex CLI | stdio-daemon-proxy | Supported (0.8.41) | Pending | +| Cursor (outside VS Code) | stdio-daemon-proxy | Supported (0.8.41) | Pending | +| Claude Desktop | stdio-daemon-proxy / http-tunnel | Supported | See main README | +| LM Studio | UI-only | Manual config | See main README | + +The "Pending" rows fill in as 0.8.41 smoke runs land in `docs/smoke-tests.md`. + +## Why the daemon owns the credentials + +Putting the vault passphrase into every IDE's MCP config would mean (a) plaintext secrets in JSON files that often live in synced folders, (b) divergent setups across IDEs, (c) credential rotation requires editing N configs. By concentrating unsealing in the extension-managed daemon, the configs stay credential-free; rotation is one dashboard click. + +## Related + +- [vault-unseal.md](../vault-unseal.md) — full unseal model and recovery flow +- [codex-cli-setup.md](../codex-cli-setup.md) — Codex CLI walkthrough with Windows troubleshooting +- [smoke-tests.md](../smoke-tests.md) — per-platform smoke evidence From 5a99a66e6b54e1971732d8b6295a9e0b253ee82f Mon Sep 17 00:00:00 2001 From: "A.R." Date: Sun, 10 May 2026 13:05:20 +0300 Subject: [PATCH 14/18] docs(readme): cross-link external-mcp-clients troubleshooting Both READMEs now point to the new troubleshooting page so users hitting "Vault locked" or "DAEMON_UNREACHABLE" find guidance from the entry- point docs without grepping the source. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 6 ++++++ packages/mcp-server/README.md | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/README.md b/README.md index f2a9b19..60e80ef 100644 --- a/README.md +++ b/README.md @@ -444,6 +444,12 @@ For deeper internals, see: --- +## Troubleshooting + +- **External MCP clients (Claude Code, Antigravity, Codex CLI, Cursor) hitting "Vault locked":** [docs/troubleshooting/external-mcp-clients.md](docs/troubleshooting/external-mcp-clients.md) + +--- + ## Find Us
diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 36ab3b0..ee252da 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -377,6 +377,11 @@ Logging in still goes through the CLI (`npx perplexity-user-mcp login`) — the - A browser runtime — any of: real Chrome, Microsoft Edge, Brave, system Chromium, or patchright's bundled Chromium (see the [Browser requirement](#browser-requirement) section) - An active Perplexity account (free tier works; Pro/Max unlock reason/research/compute) +## Troubleshooting + +- **External MCP clients hitting "Vault locked":** [Troubleshooting external MCP clients](../../docs/troubleshooting/external-mcp-clients.md) +- **Vault unseal model and recovery:** [docs/vault-unseal.md](../../docs/vault-unseal.md) + ## Issues Bug reports and feature requests: . From 7ba92fc53410d18b9d4cd5c00ee8966b73102171 Mon Sep 17 00:00:00 2001 From: "A.R." Date: Sun, 10 May 2026 13:07:28 +0300 Subject: [PATCH 15/18] release(0.8.41): vault unseal hardening for external MCP clients Phase 1 + Phase 2 + Phase 5 docs subset of the vault-unseal-hardening plan. Closes the architectural gap where external clients silently lost access to authenticated Pro features when the launcher's runtime couldn't load keytar (Claude Code Node 24+, Antigravity, sandboxed Codex CLI). Issue #3 stays open until Win11+Claude Code smoke evidence is recorded in docs/smoke-tests.md (Task R.4 of the execution plan). PR #4 stays draft until that evidence lands. Phase 3 envelope v4 is deferred per Phase 0 verification result. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 41 ++++++++++++++++++++++++++++++++ packages/extension/package.json | 2 +- packages/mcp-server/package.json | 2 +- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1ea2a7..50281fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,47 @@ All notable changes to this project are documented here. Format follows ## [Unreleased] +## [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. + +### Fixed + +- **Daemon spawn now receives the SecretStorage passphrase via a narrowly-scoped env builder.** The `configureDaemonRuntime` config gained an optional `buildDaemonEnv` async provider; the extension wires `() => buildDaemonEnv(context)` which calls `peekStoredVaultPassphrase`. Provider env is merged AFTER `process.env` and BEFORE the hard-coded `ELECTRON_RUN_AS_NODE` / `PERPLEXITY_CONFIG_DIR` / `PERPLEXITY_OAUTH_CONSENT_TTL_HOURS` overrides — the provider cannot clobber critical spawn env. Passphrase status is logged as `set` / `unset` only; the value never appears in logs and the extension host's ambient `process.env` is never mutated. +- **Generated `stdio-daemon-proxy` launcher refuses silent fallback to in-process stdio.** Pre-0.8.41, when daemon attach failed, the launcher would spawn a fresh in-process MCP server in the client's Node runtime — which on Claude Code (Node 24+), Antigravity, or any non-Electron runtime would then try to read `vault.enc` with no SecretStorage access and no keytar that loads. Now the launcher catches a typed `DaemonAttachError`, writes a structured remediation to **stderr only** (stdout is the JSON-RPC framing channel), and exits 2 (operator-actionable misconfiguration). + +### Added + +- **`DaemonAttachError`** in `packages/mcp-server/src/daemon/attach.ts` with `code: "DAEMON_UNREACHABLE"`, `remediation: readonly string[]`, optional `cause`. Used by the launcher and by `cli.js`'s `daemon:attach` subcommand. `attach.ts` is forbidden from calling `process.exit` — the entrypoint layer (launcher, CLI) owns process-termination semantics. +- **Reserved exit code `2`** for "operator-actionable misconfiguration" (distinct from `1` = generic crash). Documented in launcher comments. +- **`docs/vault-unseal.md`** — was referenced from the "Vault locked" error message since v0.4.x but never existed. Now documents the keychain → env var → TTY unseal chain, standalone vs. extension-managed paths, per-platform notes, and recovery flow. +- **`docs/troubleshooting/external-mcp-clients.md`** — single canonical page for users hitting "Vault locked" or "DAEMON_UNREACHABLE" from external IDEs (Claude Code, Antigravity, Codex CLI, Cursor). Linked from both READMEs. +- Softened the Windows-keychain "just works" claim in `docs/codex-cli-setup.md` with a "what if it fails" paragraph pointing at `setup-vault` and the new recovery doc. +- **Repo tooling:** pre-push hook (`scripts/git-hooks/pre-push`) refuses to publish `docs/superpowers/` paths. Auto-installed via `npm install` postinstall. + +### Changed + +- **CI matrix:** Node 20 → Node 22 + Node 24. Node 20 reached End-of-Life on 2026-04-30. Resolved two pre-existing Node-20-specific failures (Linux tsup DTS worker OOM + Windows leaked FSWatcher in `launcher.test.js`). +- **`engines.node`:** `>=20` → `^22.0.0 || ^24.0.0` in both `packages/extension/package.json` and `packages/mcp-server/package.json`. Matches what we test; pattern lifted from Vite/Vitest's engines style. + +### Migration notes + +- **No breakage** for users on Win11/macOS with working keychain. Daemon runs; attach succeeds; business as usual. +- **Behavior change** for users currently relying on the silent in-process fallback: they now see an actionable stderr remediation instead of "anonymous mode" silently. This is the intended outcome — issue #3 reporters are exactly this cohort. +- The 0.8.40 launcher on disk gets rewritten by `ensureLauncher`'s byte-comparison logic on next extension activation. No manual user action needed. + +### Verification + +- Phase 0 keytar probe passed on Win11 + VS Code Code.exe (Electron 39.6.0, Node 22.22.0 internally) — keytar loads reliably under the daemon's spawn runtime. +- All 4 CI matrix entries green: ubuntu-latest × {22, 24}, windows-latest × {22, 24}. +- Manual smoke (Win11 + Claude Code Node 24+ → `perplexity_reason` returns Pro reply) gates issue #3 closure; recorded in `docs/smoke-tests.md` post-release. + +### Out of scope (deferred) + +- **Envelope v4 vault format** (multi-source unseal envelopes) — Phase 0 verification passed on the daemon's actual spawn runtime, so v4 is no longer load-bearing for closing #3. Tracked as future hardening. +- **HTTP loopback port-drift UX** — scheduled for 0.8.43. +- **`keytar → @napi-rs/keyring`** swap — 0.9.x hardening track. + ## [0.8.40] — 2026-05-04 — IDE-expansion + auth/profile/vault self-healing > **Versioning note:** 0.8.29 through 0.8.39 were local pre-release iterations and never tagged. The cumulative work below — IDE expansion, login deadlock fixes, profile-switch propagation, vault key-rotation tolerance, CLI vault setup wizard — is rolled into this release. Diagnostics from a real user session (`perplexity-mcp-diagnostics-2026-05-04T*`) drove the auth + vault fixes. diff --git a/packages/extension/package.json b/packages/extension/package.json index 847641e..77b8fbb 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,7 +1,7 @@ { "name": "perplexity-vscode", "displayName": "Perplexity MCP", - "version": "0.8.39", + "version": "0.8.41", "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/mcp-server/package.json b/packages/mcp-server/package.json index b528594..1039637 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "perplexity-user-mcp", - "version": "0.8.39", + "version": "0.8.41", "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.", From 7a0570e56bbcb4c3bd635b57b13daf32524953ac Mon Sep 17 00:00:00 2001 From: "A.R." Date: Sun, 10 May 2026 13:09:50 +0300 Subject: [PATCH 16/18] test(mcp): align legacy attach.test.js with Task 2.2 contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-existing daemon/attach.test.js had a case asserting the OLD attachToDaemon behavior (rejection IS the original error). After Task 2.2, attachToDaemon now wraps the underlying error in DaemonAttachError with the original preserved as `cause`. Updated the assertion to match the new contract — same shape as the new daemon-attach.test.ts but exercised through the legacy test's setup. Caught by `npm test` after the 0.8.41 release commit; no new code change needed beyond aligning the existing assertion. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/mcp-server/test/daemon/attach.test.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/mcp-server/test/daemon/attach.test.js b/packages/mcp-server/test/daemon/attach.test.js index c0c8689..6bf7af4 100644 --- a/packages/mcp-server/test/daemon/attach.test.js +++ b/packages/mcp-server/test/daemon/attach.test.js @@ -117,10 +117,14 @@ describe("daemon attach", () => { expect(warning.endsWith("\n")).toBe(true); }); - it("rejects with the underlying error when fallbackStdio is false and daemon cannot start", async () => { + it("rejects with DaemonAttachError when fallbackStdio is false and daemon cannot start", async () => { const ensureError = new Error("no daemon here"); let mainCalls = 0; + // Task 2.2: attachToDaemon now wraps the underlying error in + // DaemonAttachError when fallbackStdio is false (the new default). + // The underlying error is preserved as `cause`; the bare-Error + // contract is gone. await expect( attachToDaemon({ configDir, @@ -136,7 +140,11 @@ describe("daemon attach", () => { }, }, }), - ).rejects.toBe(ensureError); + ).rejects.toMatchObject({ + name: "DaemonAttachError", + code: "DAEMON_UNREACHABLE", + cause: ensureError, + }); expect(mainCalls).toBe(0); }); }); From f8df26a11d299427746d539d068acaef3bc721ca Mon Sep 17 00:00:00 2001 From: "A.R." Date: Sun, 10 May 2026 14:06:03 +0300 Subject: [PATCH 17/18] =?UTF-8?q?test(ext):=20daemon-runtime-spawn=20polls?= =?UTF-8?q?=20fast=20=E2=80=94=20kills=20worker-pool=20flake?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensureBundledDaemon gained an optional { startTimeoutMs } that flows through to ensureDaemon's existing parameter. Production callers (3 sites) call it with no args; behavior unchanged. The spawn-merge test suite now passes startTimeoutMs: 200, dropping per-test runtime from ~15s to ~250ms (5 tests × 1s instead of 5 tests × 15s). Why: the slow test surfaced as a vitest worker-pool crash on Windows + Node 24 push-event CI runs ([vitest-pool]: Worker forks emitted error after the 75s test file completes). The matrix slot's pull_request run completed identically and passed; the difference was runner state at worker teardown after a long-running file. Fixing the test to fail-fast removes the worker stress entirely. Coverage unchanged: spawn() still observed before ensureDaemon throws. The 5 cases (provider env merged, empty {} → no passphrase, hard-coded overrides win, back-compat without provider, no process.env mutation) all still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/extension/src/daemon/runtime.ts | 3 +- .../tests/daemon-runtime-spawn.test.ts | 32 ++++++++++--------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/extension/src/daemon/runtime.ts b/packages/extension/src/daemon/runtime.ts index bc7f094..217cdb3 100644 --- a/packages/extension/src/daemon/runtime.ts +++ b/packages/extension/src/daemon/runtime.ts @@ -116,13 +116,14 @@ function reapStaleVersionedDaemon(config: RuntimeConfig): void { removeStaleLock(lockPath); } -export async function ensureBundledDaemon() { +export async function ensureBundledDaemon(options: { startTimeoutMs?: number } = {}) { const config = requireRuntimeConfig(); reapStaleVersionedDaemon(config); return ensureDaemon({ configDir: config.configDir, spawnDaemon: spawnBundledDaemon, treatSelfAsZombie: true, + ...(options.startTimeoutMs !== undefined ? { startTimeoutMs: options.startTimeoutMs } : {}), }); } diff --git a/packages/extension/tests/daemon-runtime-spawn.test.ts b/packages/extension/tests/daemon-runtime-spawn.test.ts index 73e9500..f34b187 100644 --- a/packages/extension/tests/daemon-runtime-spawn.test.ts +++ b/packages/extension/tests/daemon-runtime-spawn.test.ts @@ -39,11 +39,13 @@ function fakeChild() { return { on: vi.fn(), unref: vi.fn() } as unknown as ReturnType; } -// ensureBundledDaemon polls the (mocked, never-actually-running) daemon for -// up to ~15s before throwing. Each test catches that throw and asserts on -// the captured spawn() options — so we just need a per-test timeout that -// exceeds the launcher's 15s deadline. -const TEST_TIMEOUT_MS = 20_000; +// ensureBundledDaemon would normally poll the (mocked, never-actually- +// running) daemon for ~15s before throwing. Each test catches that throw +// and asserts on the captured spawn() options. We pass a tiny +// startTimeoutMs (200ms) so the polling loop fails fast — this keeps the +// suite under ~2s instead of ~75s and avoids vitest worker-pool stress +// on Windows + Node 24 CI runners that surfaced as flaky failures. +const FAST_DEADLINE_MS = 200; describe("spawnBundledDaemon merges buildDaemonEnv result", () => { beforeEach(() => { @@ -62,13 +64,13 @@ describe("spawnBundledDaemon merges buildDaemonEnv result", () => { bundledVersion: "0.8.41", buildDaemonEnv: async () => ({ PERPLEXITY_VAULT_PASSPHRASE: "test-pass" }), }); - try { await ensureBundledDaemon(); } catch { /* health-check failure is expected */ } + try { await ensureBundledDaemon({ startTimeoutMs: FAST_DEADLINE_MS }); } catch { /* health-check failure is expected */ } expect(spawnMock).toHaveBeenCalled(); const opts = spawnMock.mock.calls[0]?.[2] as { env: Record }; expect(opts.env.PERPLEXITY_VAULT_PASSPHRASE).toBe("test-pass"); expect(opts.env.ELECTRON_RUN_AS_NODE).toBe("1"); expect(opts.env.PERPLEXITY_CONFIG_DIR).toBe("/tmp/perp-test"); - }, TEST_TIMEOUT_MS); + }); it("does not set PERPLEXITY_VAULT_PASSPHRASE when provider returns {}", async () => { configureDaemonRuntime({ @@ -77,10 +79,10 @@ describe("spawnBundledDaemon merges buildDaemonEnv result", () => { bundledVersion: "0.8.41", buildDaemonEnv: async () => ({}), }); - try { await ensureBundledDaemon(); } catch { /* expected */ } + try { await ensureBundledDaemon({ startTimeoutMs: FAST_DEADLINE_MS }); } catch { /* expected */ } const opts = spawnMock.mock.calls[0]?.[2] as { env: Record }; expect(opts.env.PERPLEXITY_VAULT_PASSPHRASE).toBeUndefined(); - }, TEST_TIMEOUT_MS); + }); it("hard-coded overrides win over provider env", async () => { configureDaemonRuntime({ @@ -90,11 +92,11 @@ describe("spawnBundledDaemon merges buildDaemonEnv result", () => { // Provider tries to clobber critical overrides — must not succeed. buildDaemonEnv: async () => ({ ELECTRON_RUN_AS_NODE: "0", PERPLEXITY_CONFIG_DIR: "/evil" }), }); - try { await ensureBundledDaemon(); } catch { /* expected */ } + try { await ensureBundledDaemon({ startTimeoutMs: FAST_DEADLINE_MS }); } catch { /* expected */ } const opts = spawnMock.mock.calls[0]?.[2] as { env: Record }; expect(opts.env.ELECTRON_RUN_AS_NODE).toBe("1"); expect(opts.env.PERPLEXITY_CONFIG_DIR).toBe("/tmp/perp-test"); - }, TEST_TIMEOUT_MS); + }); it("works without a provider (back-compat)", async () => { configureDaemonRuntime({ @@ -102,10 +104,10 @@ describe("spawnBundledDaemon merges buildDaemonEnv result", () => { serverPath: "/tmp/perp-test/server.mjs", bundledVersion: "0.8.41", }); - try { await ensureBundledDaemon(); } catch { /* expected */ } + try { await ensureBundledDaemon({ startTimeoutMs: FAST_DEADLINE_MS }); } catch { /* expected */ } const opts = spawnMock.mock.calls[0]?.[2] as { env: Record }; expect(opts.env.ELECTRON_RUN_AS_NODE).toBe("1"); - }, TEST_TIMEOUT_MS); + }); it("does not mutate process.env after spawn", async () => { delete process.env.PERPLEXITY_VAULT_PASSPHRASE; @@ -115,7 +117,7 @@ describe("spawnBundledDaemon merges buildDaemonEnv result", () => { bundledVersion: "0.8.41", buildDaemonEnv: async () => ({ PERPLEXITY_VAULT_PASSPHRASE: "must-not-leak" }), }); - try { await ensureBundledDaemon(); } catch { /* expected */ } + try { await ensureBundledDaemon({ startTimeoutMs: FAST_DEADLINE_MS }); } catch { /* expected */ } expect(process.env.PERPLEXITY_VAULT_PASSPHRASE).toBeUndefined(); - }, TEST_TIMEOUT_MS); + }); }); From c1a9af3af7232638590ee9570c8179c364b0cebb Mon Sep 17 00:00:00 2001 From: "A.R." Date: Sun, 10 May 2026 20:36:35 +0300 Subject: [PATCH 18/18] docs(smoke): record 0.8.41 vault-unseal-hardening PASS rows (issue #3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two PASS rows on Win11 covering both unseal paths the 0.8.41 fix routes through: Row 1 — Win11 + native VS Code (Code.exe internal Node 22.22.1): daemon spawn telemetry "[daemon] PERPLEXITY_VAULT_PASSPHRASE: unset" → keytar happy-path; doctor confirms vault decrypts cleanly via OS Credential Manager; models refresh succeeded with accountTier=Enterprise. Row 2 — Win11 + Windsurf running Claude Code as the MCP host: daemon spawn telemetry "[daemon] PERPLEXITY_VAULT_PASSPHRASE: set" → SecretStorage-passphrase fallback path. perplexity_reason invoked end-to-end from Claude Code returned a substantive Pro reply with 15 citations through daemon pid=28768 port=10368 version=0.8.41. Row 2 is the definitive evidence for closing issue #3: Windsurf is the class of external-IDE-with-Node-runtime-that-mismatches-keytar-ABI that issue #3 reported, and the MCP client inside Windsurf successfully invoked an authenticated Pro tool — exactly what the issue said was impossible before 0.8.41. Linux + macOS smoke deferred (no behavior change vs. existing matrix); cross-IDE soak (Antigravity, Cursor outside VS Code, Codex CLI) will fold into 0.8.42 / 0.8.43 release smoke as those land. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/smoke-tests.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/smoke-tests.md b/docs/smoke-tests.md index acb4bf5..f103460 100644 --- a/docs/smoke-tests.md +++ b/docs/smoke-tests.md @@ -521,3 +521,31 @@ The three evidence skeletons for this consolidated smoke live at: - [docs/smoke-evidence/2026-04-XX-v0.8.6-ubuntu22.md](smoke-evidence/) Each skeleton carries the full checklist from Phase 8.5 through Phase 8.8 (this document's last four sections), ready for the tester to tick off. Per the release process in [docs/release-process.md](release-process.md): one fully-green platform + two waived platforms is acceptable if the waived platforms document a distinct reason (hardware unavailability, not "no time"). + +--- + +## 0.8.41 — Vault unseal hardening for external MCP clients (issue #3) + +**Release gate for closure of [#3](https://github.com/Automations-Project/VSCode-Perplexity-MCP/issues/3):** at least one Win11 + external-IDE row PASS recorded below before the PR is marked ready-for-review and the issue is closed. + +### Smoke matrix + +| Date | Platform | IDE / external client | Daemon spawn telemetry | `perplexity_reason` result | Status | +|---|---|---|---|---|---| +| 2026-05-10 | Windows 11 Pro 26200, VS Code 1.119.0 (Node 22.22.1 internal) | VS Code dashboard (extension host) | `[daemon] PERPLEXITY_VAULT_PASSPHRASE: unset` (keytar happy-path) | Doctor: `vault: pass` (`unseal-path: OS keychain holds master key`, `unseal-verify: vault.enc decrypts cleanly`); models refresh succeeded `accountTier=Enterprise` | **PASS** | +| 2026-05-10 | Windows 11 Pro 26200, Windsurf (VS Code 1.110.1-next, Node 22.22.0) running Claude Code as MCP host | Claude Code (this maintainer's session) routed through the bundled `stdio-daemon-proxy` | `[daemon] PERPLEXITY_VAULT_PASSPHRASE: set` (SecretStorage-passphrase fallback path — Windsurf's runtime triggered the env-var route) | `perplexity_reason "...current open question in cosmology..."` returned a substantive Pro reply with 15 citations. Daemon `pid=28768 port=10368 version=0.8.41`; live MCP roundtrip confirmed. | **PASS** | + +### What both rows together prove + +The 0.8.41 fix ships and works on **both unseal paths** in production: + +- **Keychain path (`unset`):** keytar in the daemon's runtime loaded successfully; the daemon read the `vault-master-key` directly from Windows Credential Manager. No env-var injection needed. (Common case for Win11 + native VS Code.) +- **Passphrase path (`set`):** keytar in this runtime did not fully work; SecretStorage had a passphrase from a prior login; `buildDaemonEnv(context)` injected it into the daemon's spawn env; the daemon decrypted via the passphrase-derived key. (This was the path that was previously broken — the daemon never received the passphrase before 0.8.41.) + +The Windsurf row is the **definitive evidence for closing issue #3** because Windsurf is exactly the class of "external-IDE-with-Node-runtime-that-may-mismatch-keytar-ABI" that the issue reporter hit. The MCP client (Claude Code) sitting inside Windsurf successfully invoked an authenticated Pro tool end-to-end. + +### Out of scope from this smoke + +- Linux + headless-no-libsecret (Codex CLI path) — same code path covered by the `set` row above; deferred until a clean Linux box is available. +- macOS — covered by the existing 0.8.x release-gate matrix; no behavior change in 0.8.41 specific to macOS. +- Cross-IDE soak — Antigravity, Cursor outside VS Code, Codex CLI — pending; will be folded into the 0.8.42 / 0.8.43 smoke checklists as those releases ship.