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/.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/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/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/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 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. 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 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 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/packages/extension/package.json b/packages/extension/package.json index f88bf23..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.", @@ -35,7 +35,7 @@ ], "engines": { "vscode": "^1.100.0", - "node": ">=20" + "node": "^22.0.0 || ^24.0.0" }, "categories": [ "AI", 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/src/daemon/runtime.ts b/packages/extension/src/daemon/runtime.ts index f6ae788..217cdb3 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; @@ -104,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 } : {}), }); } @@ -420,11 +433,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/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 { 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/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); + }); +}); 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(); + }); +}); 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..f34b187 --- /dev/null +++ b/packages/extension/tests/daemon-runtime-spawn.test.ts @@ -0,0 +1,123 @@ +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 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(() => { + 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({ 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"); + }); + + 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({ startTimeoutMs: FAST_DEADLINE_MS }); } catch { /* expected */ } + const opts = spawnMock.mock.calls[0]?.[2] as { env: Record }; + expect(opts.env.PERPLEXITY_VAULT_PASSPHRASE).toBeUndefined(); + }); + + 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({ 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"); + }); + + 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({ 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"); + }); + + 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({ startTimeoutMs: FAST_DEADLINE_MS }); } catch { /* expected */ } + expect(process.env.PERPLEXITY_VAULT_PASSPHRASE).toBeUndefined(); + }); +}); 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/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: . diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 62af4ad..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.", @@ -33,7 +33,7 @@ "provenance": true }, "engines": { - "node": ">=20" + "node": "^22.0.0 || ^24.0.0" }, "keywords": [ "perplexity", 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/src/daemon/attach.ts b/packages/mcp-server/src/daemon/attach.ts index ed57b5d..a0f58f6 100644 --- a/packages/mcp-server/src/daemon/attach.ts +++ b/packages/mcp-server/src/daemon/attach.ts @@ -3,6 +3,24 @@ 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; + } +} + +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; @@ -46,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); @@ -111,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/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"); + }); +}); 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); + }); +}); 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(); + }); +}); 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); }); }); 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/); + }); +}); 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. +}