|
| 1 | +/** |
| 2 | + * Read-only diagnostic tool: report what the server knows about its config |
| 3 | + * without revealing any secret values. Intended for the future agent-guided |
| 4 | + * `/setup` skill (separate repo) but immediately useful for a user |
| 5 | + * diagnosing "is my server configured the way I think it is?". |
| 6 | + * |
| 7 | + * Strict no-secrets contract: |
| 8 | + * - Never echoes raw API keys, RPC URLs (which may carry keys in the path), |
| 9 | + * WC session symkeys, or paired-account private material. |
| 10 | + * - WC session topic surfaces only as the last 8 chars (matches the |
| 11 | + * existing `get_ledger_status` convention — enough to cross-check |
| 12 | + * against Ledger Live's connected-apps list). |
| 13 | + * - Per-key fields are reduced to `{ set: boolean; source: "env-var" | |
| 14 | + * "config" | "unset" }`. |
| 15 | + * |
| 16 | + * Pure local I/O: reads `~/.vaultpilot-mcp/config.json` and inspects |
| 17 | + * `process.env`. No RPC calls, no network. Cheap to invoke on every |
| 18 | + * `/setup` step. |
| 19 | + */ |
| 20 | +import { existsSync, readFileSync } from "node:fs"; |
| 21 | +import { homedir } from "node:os"; |
| 22 | +import { join } from "node:path"; |
| 23 | +import { fileURLToPath } from "node:url"; |
| 24 | +import { readUserConfig, getConfigPath } from "../../config/user-config.js"; |
| 25 | +import { SUPPORTED_CHAINS, type SupportedChain } from "../../types/index.js"; |
| 26 | + |
| 27 | +type EvmRpcSource = |
| 28 | + | "env-var" |
| 29 | + | "provider-key-env" |
| 30 | + | "provider-key-config" |
| 31 | + | "custom-url-config" |
| 32 | + | "public-fallback"; |
| 33 | + |
| 34 | +type SolanaRpcSource = "env-var" | "config-url" | "public-fallback"; |
| 35 | + |
| 36 | +type ApiKeySource = "env-var" | "config" | "unset"; |
| 37 | + |
| 38 | +const ENV_URL_VAR: Record<SupportedChain, string> = { |
| 39 | + ethereum: "ETHEREUM_RPC_URL", |
| 40 | + arbitrum: "ARBITRUM_RPC_URL", |
| 41 | + polygon: "POLYGON_RPC_URL", |
| 42 | + base: "BASE_RPC_URL", |
| 43 | + optimism: "OPTIMISM_RPC_URL", |
| 44 | +}; |
| 45 | + |
| 46 | +/** |
| 47 | + * Determine the source of the EVM RPC URL for a given chain. Mirrors the |
| 48 | + * priority order in `src/config/chains.ts:resolveRpcUrlRaw`. Replicated |
| 49 | + * deliberately rather than refactored-and-shared so a refactor of the |
| 50 | + * resolver doesn't accidentally change diagnostic output. |
| 51 | + */ |
| 52 | +function classifyEvmRpcSource(chain: SupportedChain): EvmRpcSource { |
| 53 | + if (process.env[ENV_URL_VAR[chain]]) return "env-var"; |
| 54 | + const envProvider = process.env.RPC_PROVIDER?.toLowerCase(); |
| 55 | + if ( |
| 56 | + (envProvider === "infura" || envProvider === "alchemy") && |
| 57 | + process.env.RPC_API_KEY |
| 58 | + ) { |
| 59 | + return "provider-key-env"; |
| 60 | + } |
| 61 | + const cfg = readUserConfig(); |
| 62 | + if (cfg) { |
| 63 | + if (cfg.rpc.provider === "custom" && cfg.rpc.customUrls?.[chain]) { |
| 64 | + return "custom-url-config"; |
| 65 | + } |
| 66 | + if ( |
| 67 | + (cfg.rpc.provider === "infura" || cfg.rpc.provider === "alchemy") && |
| 68 | + cfg.rpc.apiKey |
| 69 | + ) { |
| 70 | + return "provider-key-config"; |
| 71 | + } |
| 72 | + } |
| 73 | + return "public-fallback"; |
| 74 | +} |
| 75 | + |
| 76 | +function classifySolanaRpcSource(): SolanaRpcSource { |
| 77 | + if (process.env.SOLANA_RPC_URL) return "env-var"; |
| 78 | + if (readUserConfig()?.solanaRpcUrl) return "config-url"; |
| 79 | + return "public-fallback"; |
| 80 | +} |
| 81 | + |
| 82 | +function classifyApiKey(envName: string, configValue: unknown): { set: boolean; source: ApiKeySource } { |
| 83 | + if (process.env[envName]) return { set: true, source: "env-var" }; |
| 84 | + if (typeof configValue === "string" && configValue.length > 0) { |
| 85 | + return { set: true, source: "config" }; |
| 86 | + } |
| 87 | + return { set: false, source: "unset" }; |
| 88 | +} |
| 89 | + |
| 90 | +interface VaultPilotConfigStatus { |
| 91 | + /** Where this server expects to read / write its config file. */ |
| 92 | + configPath: string; |
| 93 | + /** Whether the config file exists on disk right now. */ |
| 94 | + configFileExists: boolean; |
| 95 | + /** vaultpilot-mcp version (read from package.json at process start). */ |
| 96 | + serverVersion: string; |
| 97 | + /** Per-chain RPC URL source classification (no URLs leaked). */ |
| 98 | + rpc: Record<SupportedChain | "solana", { source: EvmRpcSource | SolanaRpcSource }>; |
| 99 | + /** Per-service API key presence + source. Boolean-only — values never leak. */ |
| 100 | + apiKeys: { |
| 101 | + etherscan: { set: boolean; source: ApiKeySource }; |
| 102 | + oneInch: { set: boolean; source: ApiKeySource }; |
| 103 | + tronGrid: { set: boolean; source: ApiKeySource }; |
| 104 | + walletConnectProjectId: { set: boolean; source: ApiKeySource }; |
| 105 | + }; |
| 106 | + /** Counts of paired Ledger accounts + WC session-topic suffix (last 8 chars). */ |
| 107 | + pairings: { |
| 108 | + walletConnect: { sessionTopicSuffix?: string }; |
| 109 | + solana: { count: number }; |
| 110 | + tron: { count: number }; |
| 111 | + }; |
| 112 | + /** |
| 113 | + * Agent-side preflight skill state — checked by path, no content read. |
| 114 | + * Override path via VAULTPILOT_SKILL_MARKER_PATH env var (read-only sniff — |
| 115 | + * we don't validate the skill content here). |
| 116 | + */ |
| 117 | + preflightSkill: { |
| 118 | + expectedPath: string; |
| 119 | + installed: boolean; |
| 120 | + }; |
| 121 | +} |
| 122 | + |
| 123 | +/** |
| 124 | + * Resolve the server version by reading `package.json` relative to this |
| 125 | + * file's compiled location. Falls back to `"unknown"` if the file isn't |
| 126 | + * found (e.g. unusual install layouts) — diagnostic output, not load-bearing. |
| 127 | + */ |
| 128 | +function readServerVersion(): string { |
| 129 | + try { |
| 130 | + const here = fileURLToPath(import.meta.url); |
| 131 | + // Compiled location: dist/modules/diagnostics/index.js → ../../../package.json |
| 132 | + const pkgPath = join(here, "..", "..", "..", "..", "package.json"); |
| 133 | + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { version?: string }; |
| 134 | + return pkg.version ?? "unknown"; |
| 135 | + } catch { |
| 136 | + return "unknown"; |
| 137 | + } |
| 138 | +} |
| 139 | + |
| 140 | +const DEFAULT_SKILL_MARKER = join( |
| 141 | + homedir(), |
| 142 | + ".claude", |
| 143 | + "skills", |
| 144 | + "vaultpilot-preflight", |
| 145 | + "SKILL.md", |
| 146 | +); |
| 147 | + |
| 148 | +function skillMarkerPath(): string { |
| 149 | + return process.env.VAULTPILOT_SKILL_MARKER_PATH ?? DEFAULT_SKILL_MARKER; |
| 150 | +} |
| 151 | + |
| 152 | +export function getVaultPilotConfigStatus(_args: Record<string, never> = {}): VaultPilotConfigStatus { |
| 153 | + const cfg = readUserConfig(); |
| 154 | + const configPath = getConfigPath(); |
| 155 | + |
| 156 | + const rpc = {} as VaultPilotConfigStatus["rpc"]; |
| 157 | + for (const chain of SUPPORTED_CHAINS) { |
| 158 | + rpc[chain] = { source: classifyEvmRpcSource(chain) }; |
| 159 | + } |
| 160 | + rpc.solana = { source: classifySolanaRpcSource() }; |
| 161 | + |
| 162 | + // WC session-topic last-8-chars suffix only (mirrors `get_ledger_status`). |
| 163 | + const sessionTopic = cfg?.walletConnect?.sessionTopic; |
| 164 | + const sessionTopicSuffix = |
| 165 | + typeof sessionTopic === "string" && sessionTopic.length >= 8 |
| 166 | + ? sessionTopic.slice(-8) |
| 167 | + : undefined; |
| 168 | + |
| 169 | + const skillPath = skillMarkerPath(); |
| 170 | + return { |
| 171 | + configPath, |
| 172 | + configFileExists: existsSync(configPath), |
| 173 | + serverVersion: readServerVersion(), |
| 174 | + rpc, |
| 175 | + apiKeys: { |
| 176 | + etherscan: classifyApiKey("ETHERSCAN_API_KEY", cfg?.etherscanApiKey), |
| 177 | + oneInch: classifyApiKey("ONEINCH_API_KEY", cfg?.oneInchApiKey), |
| 178 | + tronGrid: classifyApiKey("TRON_API_KEY", cfg?.tronApiKey), |
| 179 | + walletConnectProjectId: classifyApiKey( |
| 180 | + "WALLETCONNECT_PROJECT_ID", |
| 181 | + cfg?.walletConnect?.projectId, |
| 182 | + ), |
| 183 | + }, |
| 184 | + pairings: { |
| 185 | + walletConnect: sessionTopicSuffix ? { sessionTopicSuffix } : {}, |
| 186 | + solana: { count: cfg?.pairings?.solana?.length ?? 0 }, |
| 187 | + tron: { count: cfg?.pairings?.tron?.length ?? 0 }, |
| 188 | + }, |
| 189 | + preflightSkill: { |
| 190 | + expectedPath: skillPath, |
| 191 | + installed: existsSync(skillPath), |
| 192 | + }, |
| 193 | + }; |
| 194 | +} |
0 commit comments