/** * MCPX workspace registration bridge. * * Registration is dynamic (lease-based): `ensureMcpxWorkspace` registers the * current project root with a TTL lease (`mcpx workspace register --ttl …`). * While a window is alive it renews the lease every minute * (`startWorkspaceLease`); when the window goes offline the heartbeat stops * and MCPX drops the workspace once the lease expires. Registration is * opt-in: the /mcpx panel's e key toggles the lease for the current window * (default: not registered). * * Opt out with PI_MCPX_BRIDGE=0. Override the binary with MCPX_BIN. */ import { randomUUID } from "node:crypto"; import { spawn, spawnSync } from "node:child_process"; import { closeSync, existsSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { isIP } from "node:net"; import { homedir } from "node:os"; import { isAbsolute, join, resolve } from "node:path"; // buildChangesYaml lives in the wizard module and uses only pure YAML helpers // (splitSections/parseListItems/patchYamlScalar) with no bridge dependency at // call time, so the circular import is safe under ESM lazy binding. import { buildChangesYaml, type McpxConfigChanges } from "./tui/mcpx-wizard.ts"; const bridgeDisabled = () => process.env.PI_MCPX_BRIDGE === "0"; const registeredPaths = new Set(); const workspaceRegistrationPromises = new Map>(); export const LEASE_TTL_SECONDS = 300; // lease length; heartbeat renews every minute const HEARTBEAT_MS = 60_000; let leaseTimer: NodeJS.Timeout | undefined; let leaseCwd: string | undefined; let leaseGeneration = 0; // PERF-RV-003: cache locateMcpx() result across calls within a process. null = // not-yet-probed; string = resolved path; undefined = probed-but-not-found. let cachedMcpx: string | undefined | null = null; // PERF-RV-003: cache detectMcpxForPmf() result across calls within a process. // null = not-yet-probed. let cachedMcpxForPmf: { installed: boolean; version?: string } | null = null; export function locateMcpx(): string | undefined { // PERF-RV-003: return the cached result if we have already probed. if (cachedMcpx !== null) return cachedMcpx ?? undefined; const configured = process.env.MCPX_BIN; if (configured && existsSync(configured)) { cachedMcpx = configured; return configured; } // Default install location: ~/.mcpx/bin/mcpx(.exe) — the panel's s/x // controls and the startup bridge find it here without PATH changes. const homeBin = join(homedir(), ".mcpx", "bin", process.platform === "win32" ? "mcpx.exe" : "mcpx"); if (existsSync(homeBin)) { cachedMcpx = homeBin; return homeBin; } const probe = spawnSync(process.platform === "win32" ? "where.exe" : "which", ["mcpx"], { encoding: "utf8", shell: false, timeout: 10_000, }); if (probe.status === 0) { const line = String(probe.stdout || "").split(/\r?\n/).find(Boolean); if (line) { cachedMcpx = line; return line; } } cachedMcpx = undefined; return undefined; } function absoluteRoot(root: string | undefined): string { const rawRoot = root ?? process.cwd(); return isAbsolute(rawRoot) ? resolve(rawRoot) : resolve(process.cwd(), rawRoot); } function resolveMcpxSpawn(args: string[]): { binary: string; useShell: boolean } | { error: string } { const mcpx = locateMcpx(); if (!mcpx) return { error: "mcpx binary not found" }; const useShell = process.platform === "win32" && /\.(cmd|bat)$/i.test(mcpx); // npm shims require cmd.exe, but never pass shell metacharacters through a // shim. The normal installed mcpx.exe path uses shell:false and accepts all // valid Windows paths. if (useShell && args.some((arg) => /[\u0000\r\n&|<>^%!()]/.test(arg))) { return { error: "unsafe Windows command argument" }; } return { binary: mcpx, useShell }; } function runMcpx(args: string[]): { status: number | null; stderr: string } { const plan = resolveMcpxSpawn(args); if ("error" in plan) return { status: null, stderr: plan.error }; const result = spawnSync(plan.binary, args, { encoding: "utf8", timeout: 15_000, shell: plan.useShell, }); return { status: result.status, stderr: String(result.stderr || result.stdout || "").trim() }; } /** Async variant for the lease heartbeat: spawnSync would block the TUI event * loop for up to 15s whenever the mcpx binary hangs or responds slowly. */ function runMcpxAsync(args: string[]): Promise<{ status: number | null; stderr: string }> { const plan = resolveMcpxSpawn(args); if ("error" in plan) return Promise.resolve({ status: null, stderr: plan.error }); return new Promise((resolve) => { const child = spawn(plan.binary, args, { shell: plan.useShell, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }); let output = ""; const capture = (chunk: Buffer) => { output = (output + chunk.toString()).slice(-16_384); }; child.stdout?.on("data", capture); child.stderr?.on("data", capture); const timer = setTimeout(() => { try { child.kill(); } catch { /* already dead */ } }, 15_000); const finish = (status: number | null) => { clearTimeout(timer); resolve({ status, stderr: output.trim() }); }; child.on("error", () => finish(null)); child.on("close", (code) => finish(code)); }); } /** * Register `root` (defaults to process.cwd()) with the local MCPX runtime * under a lease. The CLI call is deferred by one event-loop turn, but the * returned promise settles after the config write completes. Deduplicated per * path so bursty callers only spawn one registration. */ export async function ensureMcpxWorkspace( root?: string, ttlSeconds: number = LEASE_TTL_SECONDS, generation?: number, ): Promise { if (bridgeDisabled()) return false; const absRoot = absoluteRoot(root); if (generation !== undefined && generation !== leaseGeneration) return false; if (registeredPaths.has(absRoot)) return true; const existing = workspaceRegistrationPromises.get(absRoot); if (existing) return existing; const registration = (async () => { registeredPaths.add(absRoot); const registered = await renewWorkspaceLease(absRoot, ttlSeconds, generation); if (!registered) registeredPaths.delete(absRoot); return registered; })(); workspaceRegistrationPromises.set(absRoot, registration); try { return await registration; } finally { if (workspaceRegistrationPromises.get(absRoot) === registration) { workspaceRegistrationPromises.delete(absRoot); } } } /** * Renew the lease for an already-registered root. This bypasses the * per-path dedupe set: the heartbeat must actually re-register so the * TTL is extended while the window stays alive. */ function renewWorkspaceLease(absRoot: string, ttlSeconds: number, generation?: number): Promise { return new Promise((resolve) => { setImmediate(async () => { // A heartbeat renewal enqueued before stop() must not fire after stop. // generation is only supplied by the lease heartbeat; an explicit // ensureMcpxWorkspace call (no generation) always runs. if (generation !== undefined && generation !== leaseGeneration) { resolve(false); return; } try { const args = ttlSeconds > 0 ? ["workspace", "register", "--ttl", `${ttlSeconds}s`, absRoot] : ["workspace", "register", absRoot]; const { status, stderr } = await runMcpxAsync(args); const registered = status === 0; if (!registered) { console.warn(`[pi-maestro-flow] MCPX workspace registration failed (${status ?? "spawn error"}): ${stderr}`); } resolve(registered); } catch (error) { console.warn(`[pi-maestro-flow] MCPX workspace registration skipped: ${error instanceof Error ? error.message : String(error)}`); resolve(false); } }); }); } /** Unregister `root` (defaults to process.cwd()) from the local MCPX runtime. */ export function removeMcpxWorkspace(root?: string): void { if (bridgeDisabled()) return; const absRoot = absoluteRoot(root); registeredPaths.delete(absRoot); try { const { status, stderr } = runMcpx(["workspace", "remove", absRoot]); if (status !== 0) { console.warn(`[pi-maestro-flow] MCPX workspace removal failed (${status ?? "spawn error"}): ${stderr}`); } } catch (error) { console.warn(`[pi-maestro-flow] MCPX workspace removal skipped: ${error instanceof Error ? error.message : String(error)}`); } } /** * Start a lease for `cwd`: register immediately, then renew every minute so * the workspace stays alive while this window is running. When the window * goes offline the heartbeat stops and MCPX drops the workspace at expiry. * Resolves after the initial registration command has completed. */ export async function startWorkspaceLease(cwd: string, ttlSeconds: number = LEASE_TTL_SECONDS): Promise { stopWorkspaceLease(); leaseCwd = cwd; leaseGeneration++; const generation = leaseGeneration; const absRoot = absoluteRoot(cwd); registeredPaths.delete(absRoot); const registered = await ensureMcpxWorkspace(cwd, ttlSeconds, generation); if (generation === leaseGeneration && !bridgeDisabled()) { leaseTimer = setInterval(() => { void renewWorkspaceLease(absoluteRoot(cwd), ttlSeconds, generation); }, HEARTBEAT_MS); leaseTimer.unref?.(); // never keep the process alive on shutdown console.log(`[pi-maestro-flow] MCPX workspace lease started (ttl ${ttlSeconds}s, heartbeat ${HEARTBEAT_MS / 1000}s): ${cwd}`); } return registered; } /** Stop the lease heartbeat (used when the window is unregistered or closed). */ export function stopWorkspaceLease(): void { leaseGeneration++; // invalidate any heartbeat renewal already enqueued via setImmediate if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = undefined; } leaseCwd = undefined; } /** * Register `cwd` permanently (no TTL lease): the entry has no expires_at and * survives window close until explicitly removed. Any active lease heartbeat * for the previous registration is stopped first. Resolves after the register * command completes. */ export async function registerMcpxWorkspacePermanent(cwd: string): Promise { stopWorkspaceLease(); const absRoot = absoluteRoot(cwd); registeredPaths.delete(absRoot); // force a real re-register even if a lease entry exists return ensureMcpxWorkspace(cwd, 0); } /** Test hook: reset the in-process deduplication state. */ export function _resetMcpxBridgeState(): void { registeredPaths.clear(); workspaceRegistrationPromises.clear(); stopWorkspaceLease(); // Also reset the caches so tests that set up a fresh MCPX_BIN in a temp dir // re-probe instead of reusing a stale cached path from a prior test. cachedMcpx = null; cachedMcpxForPmf = null; } /** Test hook: reset the locateMcpx cache (PERF-RV-003). */ export function _resetMcpxCache(): void { cachedMcpx = null; } /** Test hook: reset the detectMcpxForPmf cache (PERF-RV-003). */ export function _resetMcpxForPmfCache(): void { cachedMcpxForPmf = null; } // --- Tunnel health & config detection (shared by overlay + wizard + extension) --- const MCPX_PID_FILE = () => process.env.MCPX_PID_FILE ?? join(homedir(), ".mcpx", "mcpx-server.pid"); const MCPX_TUNNEL_PID_FILE = () => process.env.MCPX_TUNNEL_PID_FILE ?? join(homedir(), ".mcpx", "cloudflared.pid"); const MCPX_CONFIG_PATH = () => join(homedir(), ".mcpx", "config.yaml"); export type TunnelHealth = "ok" | "auth" | "dead" | "unknown"; const CLOUDFLARE_1033_ATTEMPTS = 3; const CLOUDFLARE_1033_RETRY_MS = 1_000; function parseProbeURL(raw: string): URL | undefined { let parsed: URL; try { parsed = new URL(raw); } catch { return undefined; } if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return undefined; if (parsed.username || parsed.password || parsed.search || parsed.hash) return undefined; const hostname = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase(); if (!hostname || hostname === "localhost" || hostname.endsWith(".localhost") || hostname.endsWith(".local") || hostname === "metadata.google.internal") { return undefined; } // Plain HTTP is only accepted for the loopback development endpoint used by // local MCPX instances. Public tunnel URLs must use HTTPS. if (parsed.protocol === "http:" && hostname !== "127.0.0.1") return undefined; const addressType = isIP(hostname); if (addressType === 4) { const octets = hostname.split(".").map(Number); const privateAddress = octets[0] === 10 || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || (octets[0] === 192 && octets[1] === 168) || octets[0] === 169 && octets[1] === 254; const loopbackAddress = octets[0] === 127; if ((privateAddress || loopbackAddress) && hostname !== "127.0.0.1") return undefined; } else if (addressType === 6 && (/^(::1|fc|fd|fe8|fe9|fea|feb)/i.test(hostname))) { return undefined; } return parsed; } export interface TunnelState { pid?: number; url?: string; alive: boolean; health: TunnelHealth; } function isProcessAlive(pid: number): boolean { try { if (process.platform === "win32") { const result = spawnSync("tasklist", ["/FI", `PID eq ${pid}`, "/NH", "/FO", "CSV"], { encoding: "utf8", timeout: 5_000, }); if (result.status !== 0) return false; // SEC-RV-008: parse the CSV output and compare the PID column exactly, // avoiding substring matches (PID 5 matching PID 50). The CSV format is // "Image Name","PID","Session Name","Session#","Mem Usage" — the PID is // column 1, but compare every column for exact equality so a locale or // column-order change cannot silently break liveness detection. If no // column matches, return false (do NOT fall back to a substring match). const pidStr = String(pid); for (const line of String(result.stdout || "").split(/\r?\n/)) { if (!line.trim()) continue; for (const col of line.split(",")) { if (col.trim().replace(/^["']|["']$/g, "") === pidStr) return true; } } return false; } process.kill(pid, 0); return true; } catch (error) { // EPERM: the process exists but is owned by another user — report alive. if ((error as NodeJS.ErrnoException).code === "EPERM") return true; return false; } } /** Parse the public tunnel URL (auth.oauth.server_url) from ~/.mcpx/config.yaml. */ function readConfigServerURL(): string | undefined { try { const raw = readFileSync(MCPX_CONFIG_PATH(), "utf8"); // Match `server_url:` under auth.oauth. mcpx's yaml.v3 output and the wizard // both indent it at 8 spaces, but a hand-edited file may use 2+; accept any // indentation. Tolerate an optional trailing comment by excluding `#` from // the value capture (a URL never contains a bare `#`). const match = raw.match(/^\s{2,}server_url:\s*"?([^"\n#]+)"?/m); const url = match?.[1]?.trim(); return url && /^https?:\/\//.test(url) ? url : undefined; } catch { return undefined; } } /** * Read the tunnel state from the PID file (written by the wizard) + config. * `health` is "unknown" until {@link probeTunnelHealth} fills it in; the * overlay calls them together so the board shows a complete row per refresh. */ export function readTunnelState(): TunnelState { let pid: number | undefined; try { const raw = readFileSync(MCPX_TUNNEL_PID_FILE(), "utf8").trim(); const parsed = Number(raw); if (Number.isInteger(parsed) && parsed > 0) pid = parsed; } catch { // no PID file — tunnel was never started via the wizard in this profile } const url = readConfigServerURL(); const alive = pid !== undefined && isProcessAlive(pid); return { pid, url, alive, health: "unknown" }; } /** * Probe the public tunnel URL end-to-end via an MCP `initialize` over HTTPS. * Mirrors the overlay's probeEndpoint judgment: * 200 → "ok" (open mode; public exposure should not use this) * 401 + WWW-Authenticate → "auth" (OAuth handshake reachable — healthy) * 403 / 404 → "dead" (tunnel up but mcpx config not reloaded, e.g. the * "invalid Host header" / missing OAuth routes case) * 530 + Cloudflare Error 1033 → retry while the edge connector registers, * then "dead" if the outage persists * timeout/refuse → "dead" (tunnel broken or mcpx down) */ export async function probeTunnelHealth(url: string): Promise { const parsed = parseProbeURL(url); if (!parsed) return "dead"; parsed.pathname = `${parsed.pathname.replace(/\/$/, "")}/mcp`; const endpoint = parsed.href; for (let attempt = 0; attempt < CLOUDFLARE_1033_ATTEMPTS; attempt++) { try { const response = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-06-18", }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "mcpx-tui", version: "1.0.0" }, }, }), signal: AbortSignal.timeout(4_000), redirect: "manual", }); if (response.status === 200) return "ok"; if (response.status === 401) { // mcpx's wrapMCP always sets WWW-Authenticate with resource_metadata on 401 // (http_gateway.go). A 401 without it is not mcpx's OAuth handshake start — // treat as dead (e.g. an upstream proxy's own 401, not the tunnel to mcpx). const wwwAuth = response.headers.get("WWW-Authenticate") ?? ""; return wwwAuth.toLowerCase().includes("resource_metadata") ? "auth" : "dead"; } if (response.status === 530) { const body = (await response.text().catch(() => "")).slice(0, 32_768); const edgeConnectorPending = /\b1033\b/.test(body); if (edgeConnectorPending && attempt + 1 < CLOUDFLARE_1033_ATTEMPTS) { await new Promise((resolve) => setTimeout(resolve, CLOUDFLARE_1033_RETRY_MS)); continue; } } return "dead"; } catch { return "dead"; } } return "dead"; } /** * Whether the initial wizard configuration has been completed: ~/.mcpx/config.yaml * exists with a concrete auth.mode (bearer/oauth/dual). Used by the overlay's * register-window action to decide whether to auto-open the wizard. * * Mirrors mcpx's EffectiveAuthMode: an empty mode with a non-empty token is * bearer (a valid, usable configuration), so accept that as configured too. * `open` is intentionally excluded — it is not safe for window registration on * a runtime that may be exposed, so an open-mode user is still routed to the * wizard to pick bearer/oauth. */ export function isMcpxConfigured(): boolean { let raw: string; try { raw = readFileSync(MCPX_CONFIG_PATH(), "utf8"); } catch { return false; } // Accept 2+ spaces of indentation for hand-edited YAML (mcpx/wizard use 4). const modeMatch = raw.match(/^\s{2,}mode:\s*([A-Za-z]+)/m); const mode = modeMatch?.[1]?.trim().toLowerCase(); if (mode === "bearer" || mode === "dual") return true; if (mode === "oauth") return Boolean(readConfigServerURL()); if (mode === "open") return false; // No explicit mode: mcpx treats a non-empty token as bearer (EffectiveAuthMode). const tokenMatch = raw.match(/^\s{2,}token:\s*"?([^"\n#]+)"?/m); const token = tokenMatch?.[1]?.trim(); return Boolean(token); } /** * Read the OAuth ops password (运维口令) from ~/.mcpx/config.yaml. * mcpx auto-generates one at startup when this is empty (kept in memory + the * startup log only). Persisting it here makes it stable across restarts and * lets the board show it for the authorize page. * Returns undefined when not set in config (the runtime still has an in-memory one). */ export function readOpsPassword(): string | undefined { try { const raw = readFileSync(MCPX_CONFIG_PATH(), "utf8"); const match = raw.match(/^\s{2,}password:\s*"?([^"\n#]+)"?/m); const pw = match?.[1]?.trim(); return pw || undefined; } catch { return undefined; } } /** Read a configured bearer token for local Runtime calls. OAuth passwords are not tokens. */ export function readMcpxBearerToken(): string | undefined { try { const raw = readFileSync(MCPX_CONFIG_PATH(), "utf8"); const mode = raw.match(/^\s{2,}mode:\s*([A-Za-z]+)/m)?.[1]?.toLowerCase(); if (mode && mode !== "bearer" && mode !== "dual") return undefined; const token = raw.match(/^\s{2,}token:\s*"?([^"\n#]+)"?/m)?.[1]?.trim(); return token || undefined; } catch { return undefined; } } /** Structured view of the editable mcpx config fields, for the inline /mcpx * config editor. Reads ~/.mcpx/config.yaml once and surfaces every field the * overlay can edit; missing fields default to mcpx's documented defaults so * the editor shows a complete form even on a hand-trimmed config. */ export interface McpxConfigView { server: { host: string; port: number; disableLocalhostProtection: boolean; trustProxyHeaders: boolean }; auth: { mode: "open" | "bearer" | "oauth" | string; token: string; oauthPassword: string; oauthServerURL: string }; commands: { default: "allow" | "confirm" | "deny" | string; allow: string[]; confirm: string[]; deny: string[]; autoAllowReadonly: boolean | null; }; files: { maxReadBytes: number; maxPatchFiles: number; allow: string[]; confirm: string[]; deny: string[]; }; } /** Read the editable fields of ~/.mcpx/config.yaml into a structured view. */ export function readMcpxConfigView(): McpxConfigView | undefined { let raw: string; try { raw = readFileSync(MCPX_CONFIG_PATH(), "utf8"); } catch { return undefined; } const scalar = (re: RegExp) => raw.match(re)?.[1]?.trim(); const bool = (re: RegExp, fallback: boolean) => { const v = scalar(re); return v === undefined ? fallback : v === "true"; }; const listUnder = (parentKey: string, listKey: string): string[] => { // Find the `parentKey:` line, capture its indent, then collect `- item` // lines that sit under the matching `listKey:` child — stopping at any // sibling key at the parent's indent (e.g. `files:` ends `commands.allow`) // or a shallower/top-level key. const parentMatch = raw.match(new RegExp(`^(\\s{2,})${parentKey}:\\s*$`, "m")); if (!parentMatch) return []; const parentIndent = parentMatch[1]!.length; const childIndent = parentIndent + 4; const start = parentMatch.index! + parentMatch[0].length; const rest = raw.slice(start).split(/\r?\n/); const items: string[] = []; let inList = false; const listKeyRe = new RegExp(`^\\s{${childIndent},}${listKey}:\\s*(\\[\\])?$`); for (const line of rest) { if (line === "") continue; if (/^\S/.test(line)) break; // top-level key (column 0) const indent = line.match(/^\s*/)?.[0].length ?? 0; if (indent <= parentIndent) break; // sibling/parent-level key ends this block if (indent >= childIndent && /[A-Za-z_][A-Za-z0-9_]*:\s*$/.test(line.trim())) { inList = listKeyRe.test(line); continue; } if (inList) { const m = line.match(/^\s*-\s+(.+?)\s*$/); if (m) items.push(m[1]!.trim()); } } return items; }; const host = scalar(/^\s{2,}host:\s*"?([^"\n#]+)"?/m) ?? "127.0.0.1"; const portRaw = scalar(/^\s{2,}port:\s*(\d+)/m); const port = Number.isInteger(Number(portRaw)) ? Number(portRaw) : 9090; const mode = scalar(/^\s{2,}mode:\s*([A-Za-z]+)/m) ?? "open"; const token = scalar(/^\s{2,}token:\s*"?([^"\n#]+)"?/m) ?? ""; const oauthPassword = scalar(/^\s{8,}password:\s*"?([^"\n#]+)"?/m) ?? ""; const oauthServerURL = scalar(/^\s{2,}server_url:\s*"?([^"\n#]+)"?/m) ?? ""; const commandsDefault = scalar(/^\s{8}default:\s*([A-Za-z]+)/m) ?? "allow"; const autoReadonlyRaw = scalar(/^\s{8}auto_allow_readonly:\s*(\S+)/m); const autoAllowReadonly = autoReadonlyRaw === undefined || autoReadonlyRaw === "null" ? null : autoReadonlyRaw === "true"; const maxReadRaw = scalar(/^\s{8}max_read_bytes:\s*(\d+)/m); const maxPatchRaw = scalar(/^\s{8}max_patch_files:\s*(\d+)/m); return { server: { host, port, disableLocalhostProtection: bool(/^\s{2,}disable_localhost_protection:\s*(\S+)/m, false), trustProxyHeaders: bool(/^\s{2,}trust_proxy_headers:\s*(\S+)/m, false), }, auth: { mode, token, oauthPassword, oauthServerURL }, commands: { default: commandsDefault, allow: listUnder("commands", "allow"), confirm: listUnder("commands", "confirm"), deny: listUnder("commands", "deny"), autoAllowReadonly, }, files: { maxReadBytes: Number.isInteger(Number(maxReadRaw)) ? Number(maxReadRaw) : 1_048_576, maxPatchFiles: Number.isInteger(Number(maxPatchRaw)) ? Number(maxPatchRaw) : 20, allow: listUnder("files", "allow"), confirm: listUnder("files", "confirm"), deny: listUnder("files", "deny"), }, }; } /** Apply a partial set of config changes to ~/.mcpx/config.yaml using the * section-preserving merger from the wizard. Only the supplied fields are * rewritten; every other section is preserved verbatim. Returns the new YAML * text and a human-readable summary of what changed. */ export function writeMcpxConfigChanges(changes: McpxConfigChanges): { yaml: string; summary: string[] } { let existing = ""; try { existing = readFileSync(MCPX_CONFIG_PATH(), "utf8"); } catch { // first-run: buildChangesYaml handles an empty base. } const { yaml, summary } = buildChangesYaml(existing, changes, process.cwd()); replaceConfigAtomically(yaml); return { yaml, summary }; } /** * Detect whether the `mcpx-for-pmf` fork is installed as a global npm package. * The fork's postinstall drops the platform binary at ~/.mcpx/bin; the npm * package name is the reliable signal that distinguishes the fork from the * upstream `mcpx` package. Returns { installed, version }. */ export function detectMcpxForPmf(): { installed: boolean; version?: string } { // PERF-RV-003: return the cached result if we have already probed. if (cachedMcpxForPmf !== null) return cachedMcpxForPmf; try { // Resolve the global node_modules root: `npm root -g` prints the path. const probe = spawnSync("npm", ["root", "-g"], { encoding: "utf8", timeout: 8_000, shell: process.platform === "win32", }); if (probe.status !== 0) { cachedMcpxForPmf = { installed: false }; return cachedMcpxForPmf; } const root = String(probe.stdout || "").split(/\r?\n/).find((l) => l.trim()); if (!root) { cachedMcpxForPmf = { installed: false }; return cachedMcpxForPmf; } const pkgPath = join(root.trim(), "mcpx-for-pmf", "package.json"); const raw = readFileSync(pkgPath, "utf8"); const pkg = JSON.parse(raw) as { name?: string; version?: string }; if (pkg.name === "mcpx-for-pmf") { cachedMcpxForPmf = { installed: true, version: pkg.version }; return cachedMcpxForPmf; } cachedMcpxForPmf = { installed: false }; return cachedMcpxForPmf; } catch { cachedMcpxForPmf = { installed: false }; return cachedMcpxForPmf; } } /** * Remove a registered workspace via `mcpx workspace remove `. * Writes config.yaml only — the running mcpx rebuilds its in-memory registry * at the next ~5min lease sweep, so the removed workspace stays live until * then. Returns { ok, message }. */ export function removeWorkspaceByPath(path: string): { ok: boolean; message: string } { const mcpx = locateMcpx(); if (!mcpx) return { ok: false, message: "未找到 mcpx 二进制" }; const { status, stderr } = runMcpx(["workspace", "remove", path]); if (status === 0) return { ok: true, message: "已移除(mcpx ≤5min 内清理)" }; return { ok: false, message: stderr || "workspace remove 失败" }; } // --- Delegated task registry (Phase 4: board display) --- /** Mirrors mcpx internal/tasks DelegatedTask JSON ({taskID}.json registry entry). */ export interface DelegatedTask { task_id: string; remote_session_id: string; workspace: string; target_owner_id?: string; spawn_pid?: number; action: string; message: string; purpose: string; status: string; result?: string; result_summary?: string[]; created_at: string; delivered_at?: string; completed_at?: string; error?: string; } /** Shape of the {taskID}.result.json companion file pi writes on completion. */ interface DelegatedTaskResult { task_id: string; status?: string; result?: string; result_summary?: string[]; completed_at?: string; error?: string; } /** Root of the delegated-task registry: {home}/.mcpx/tasks/delegated/{sessionID}/. */ function delegatedTaskDir(sessionId: string): string { return join(homedir(), ".mcpx", "tasks", "delegated", sessionId); }/** * Read delegated tasks from the mcpx file registry. * Each {taskID}.json is a registry entry; a {taskID}.result.json companion (written * by the pi agent after the task settles) is merged in, promoting status to * the result's status and folding in result/result_summary. * Pass sessionId to scope to one Remote Session; omit to scan all sessions. * Returns undefined when no registry dir exists. */ export function readDelegatedTasks(sessionId?: string): DelegatedTask[] | undefined { const root = join(homedir(), ".mcpx", "tasks", "delegated"); let sessionDirs: string[]; try { if (sessionId) { sessionDirs = [join(root, sessionId)]; } else { // scan all session subdirs sessionDirs = readdirSync(root, { withFileTypes: true }) .filter((e) => e.isDirectory()) .map((e) => join(root, e.name)); } } catch { return undefined; } const tasks: DelegatedTask[] = []; for (const dir of sessionDirs) { let entries: string[]; try { entries = readdirSync(dir); } catch { continue; } for (const name of entries) { // registry entries are {taskID}.json; skip companion result files. if (!name.endsWith(".json") || name.endsWith(".result.json")) continue; try { const task = JSON.parse(readFileSync(join(dir, name), "utf8")) as DelegatedTask; // merge companion result file if present const resultPath = join(dir, task.task_id + ".result.json"); if (existsSync(resultPath)) { try { const res = JSON.parse(readFileSync(resultPath, "utf8")) as DelegatedTaskResult; if (res.status) task.status = res.status; if (typeof res.result === "string") task.result = res.result; if (Array.isArray(res.result_summary)) task.result_summary = res.result_summary; if (res.completed_at) task.completed_at = res.completed_at; if (res.error) task.error = res.error; } catch { // malformed result file — keep registry entry as-is } } tasks.push(task); } catch { // malformed registry entry — skip } } } // newest first tasks.sort((a, b) => (b.created_at || "").localeCompare(a.created_at || "")); return tasks; } // --- Quick tunnel restart + config sync (one-click URL refresh) --- /** Resolve the cloudflared executable path (mirrors the wizard's logic). */ function resolveCloudflared(): string | undefined { const probe = spawnSync(process.platform === "win32" ? "where.exe" : "which", ["cloudflared"], { encoding: "utf8", timeout: 10_000, shell: false, }); if (probe.status === 0) { const line = String(probe.stdout || "").split(/\r?\n/).find(Boolean); if (line) return line.trim(); } return undefined; } /** A process whose command line is an exact Cloudflare Quick Tunnel command. */ export interface QuickTunnelProcess { pid: number; commandLine: string; } type QuickTunnelDiscovery = (localPort: number) => QuickTunnelProcess[] | undefined; let quickTunnelDiscoveryOverride: QuickTunnelDiscovery | undefined; /** Test hook for exercising adoption/start guards without touching real processes. */ export function setQuickTunnelDiscoveryForTest(discovery: QuickTunnelDiscovery | undefined): () => void { const previous = quickTunnelDiscoveryOverride; quickTunnelDiscoveryOverride = discovery; return () => { quickTunnelDiscoveryOverride = previous; }; } /** Keep tunnel discovery and spawning bounded to a valid TCP port. */ export function isValidTunnelPort(port: number): boolean { return Number.isInteger(port) && port >= 1 && port <= 65_535; } const WINDOWS_QUICK_TUNNEL_QUERY = "$ErrorActionPreference = 'Stop'; @(Get-CimInstance Win32_Process -Filter \"Name = 'cloudflared.exe'\" | Select-Object Name,ProcessId,CommandLine) | ConvertTo-Json -Compress"; function quickTunnelCommandMatches(argv: string[], port: number): boolean { if (argv.length !== 4) return false; const executable = argv[0]!.split(/[\\/]/).pop()!.toLowerCase(); return (executable === "cloudflared" || executable === "cloudflared.exe") && argv[1] === "tunnel" && argv[2] === "--url" && argv[3] === `http://127.0.0.1:${port}`; } function quickTunnelCommandLine(argv: string[]): string { return argv.join(" "); } /** Return whether a command line is exactly a Quick Tunnel for `port`. */ export function isQuickTunnelCommandLine(commandLine: string, port: number): boolean { const trimmed = commandLine.trim(); const executableMatch = trimmed.match(/^(?:"([^"]+)"|(\S+))(?:\s+)(.*)$/); if (!executableMatch) return false; const executable = executableMatch[1] ?? executableMatch[2]; const args = executableMatch[3]!.trim().split(/\s+/); return quickTunnelCommandMatches([executable!, ...args], port); } function parseWindowsProcessOutput(raw: string, port: number): QuickTunnelProcess[] | undefined { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return undefined; } if (parsed === null) return []; const entries: unknown[] = Array.isArray(parsed) ? parsed : [parsed]; const matches: QuickTunnelProcess[] = []; for (const entry of entries) { if (typeof entry !== "object" || entry === null) return undefined; const record = entry as { Name?: unknown; ProcessId?: unknown; CommandLine?: unknown }; if (typeof record.Name !== "string") return undefined; if (record.Name.toLowerCase() !== "cloudflared.exe") continue; if (typeof record.ProcessId !== "number") return undefined; // A cloudflared process with no readable command line cannot be confirmed // as this Quick Tunnel, so refuse to continue rather than spawn another. if (record.CommandLine === null || record.CommandLine === undefined) return undefined; if (typeof record.CommandLine !== "string") return undefined; if (!isQuickTunnelCommandLine(record.CommandLine, port)) continue; if (Number.isInteger(record.ProcessId) && record.ProcessId > 0) { matches.push({ pid: record.ProcessId, commandLine: record.CommandLine }); } } return matches; } function discoverWindowsQuickTunnels(port: number): QuickTunnelProcess[] | undefined { const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_QUICK_TUNNEL_QUERY], { encoding: "utf8", timeout: 5_000, shell: false, windowsHide: true, }); if (result.status !== 0 || result.error) return undefined; return parseWindowsProcessOutput(String(result.stdout || "[]"), port); } function discoverProcQuickTunnels(port: number): QuickTunnelProcess[] | undefined { let entries: string[]; try { entries = readdirSync("/proc"); } catch { return undefined; } const matches: QuickTunnelProcess[] = []; for (const entry of entries) { if (!/^\d+$/.test(entry)) continue; let raw: string; try { raw = readFileSync(`/proc/${entry}/cmdline`, "utf8"); } catch (error) { // A process can disappear between readdir and read; other errors mean // discovery is incomplete and must not be followed by a spawn. if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; return undefined; } if (!raw) continue; const argv = raw.split("\0").filter((arg) => arg.length > 0); if (quickTunnelCommandMatches(argv, port)) { matches.push({ pid: Number(entry), commandLine: quickTunnelCommandLine(argv) }); } } return matches; } function discoverPsQuickTunnels(port: number): QuickTunnelProcess[] | undefined { const result = spawnSync("ps", ["-wwaxo", "pid=,command="], { encoding: "utf8", timeout: 5_000, shell: false, }); if (result.status !== 0 || result.error) return undefined; const matches: QuickTunnelProcess[] = []; for (const line of String(result.stdout || "").split(/\r?\n/)) { const match = line.match(/^\s*(\d+)\s+(.+?)\s*$/); if (!match) continue; const pid = Number(match[1]); const commandLine = match[2]!; if (Number.isInteger(pid) && pid > 0 && isQuickTunnelCommandLine(commandLine, port)) { matches.push({ pid, commandLine }); } } return matches; } /** * Discover exact Quick Tunnel processes. `undefined` means the process query * failed or was incomplete; callers must fail closed and never spawn then. */ export function discoverQuickTunnelProcesses(localPort: number): QuickTunnelProcess[] | undefined { if (quickTunnelDiscoveryOverride) return quickTunnelDiscoveryOverride(localPort); if (!isValidTunnelPort(localPort)) return undefined; return process.platform === "win32" ? discoverWindowsQuickTunnels(localPort) : process.platform === "darwin" ? discoverPsQuickTunnels(localPort) : discoverProcQuickTunnels(localPort); } /** Strict identity check used before adopting or stopping a Quick Tunnel PID. */ export function isQuickTunnelProcess(pid: number, localPort: number): boolean | undefined { if (!Number.isInteger(pid) || pid <= 0 || !isValidTunnelPort(localPort)) return false; const processes = discoverQuickTunnelProcesses(localPort); if (!processes) return undefined; return processes.some((process) => process.pid === pid); } /** * SEC-RV-006: verify the process at `pid` matches `expectedName` before * killing it, so a stale PID file cannot point at an unrelated process that * was later reused. `expectedName` is a lowercase substring to match against * the process command line / image name (e.g. "cloudflared", "mcpx"). * * - POSIX: read `/proc//cmdline` and check it contains `expectedName` * (case-insensitive). ENOENT or any read error → false (process gone or not * ours). * - Windows: `tasklist /FI "PID eq "` and compare the image name column; * match if it ends with `.exe`. If tasklist is unavailable or * returns nothing parseable, fail closed and refuse the kill. */ export function isProcessOwnedBy(pid: number, expectedName: string): boolean { return processMatches(pid, expectedName); } function processMatches(pid: number, expectedName: string): boolean { const needle = expectedName.toLowerCase(); try { if (process.platform !== "win32") { const cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf8"); // Exact argv[0] basename identity. A substring match over the whole // command line would also pass for unrelated processes (e.g. an editor // or pager holding ~/.mcpx/config.yaml) when a PID was reused. const argv0 = cmdline.split("\0").find((arg) => arg.length > 0) ?? ""; const base = argv0.split(/[\\/]/).pop()!.toLowerCase().replace(/\.exe$/, ""); return base === needle || base.startsWith(`${needle}-`) || base.startsWith(`${needle}_`); } // Windows: tasklist CSV, image name is the first column. const result = spawnSync("tasklist", ["/FI", `PID eq ${pid}`, "/NH", "/FO", "CSV"], { encoding: "utf8", timeout: 3_000, }); const stdout = String(result.stdout || ""); if (result.status !== 0 || !stdout.trim()) return false; const expected = `${needle}.exe`; for (const line of stdout.split(/\r?\n/)) { if (!line.trim()) continue; const first = (line.split(",")[0] ?? "").trim().replace(/^["']|["']$/g, "").toLowerCase(); if (!first) continue; return first.endsWith(expected); } return false; // no parseable image name — fail closed } catch { return false; // POSIX /proc unreadable (ENOENT) — process gone or not ours } } const TERM_GRACE_MS = 2_000; const TERM_POLL_MS = 100; /** * POSIX kill with SIGKILL escalation: SIGTERM first, then poll until the * process is gone within the grace window, else SIGKILL. Without escalation a * process that ignores SIGTERM would survive its own PID-file cleanup as an * untracked orphan. */ export async function killProcessWithEscalation(pid: number): Promise { try { process.kill(pid, "SIGTERM"); } catch { return; } // ESRCH — already dead const deadline = Date.now() + TERM_GRACE_MS; while (Date.now() < deadline) { if (!isProcessAlive(pid)) return; await new Promise((resolve) => setTimeout(resolve, TERM_POLL_MS)); } try { process.kill(pid, "SIGKILL"); } catch { /* already dead */ } } /** Kill a cloudflared tunnel process by PID (process tree on Windows). */ async function killTunnel(pid: number): Promise { // SEC-RV-006: verify identity before killing so a reused PID cannot target an // unrelated process. On mismatch, just clean the stale PID file and return. if (pid && !processMatches(pid, "cloudflared")) { try { rmSync(MCPX_TUNNEL_PID_FILE(), { force: true }); } catch { /* best-effort */ } return; } try { if (process.platform === "win32") { spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" }); } else { await killProcessWithEscalation(pid); } } catch { // already dead } } /** Stop a Cloudflare quick tunnel tracked by the PID file (best-effort). */ export async function stopQuickTunnel(): Promise { let pid: number | undefined; try { const raw = readFileSync(MCPX_TUNNEL_PID_FILE(), "utf8").trim(); const parsed = Number(raw); if (Number.isInteger(parsed) && parsed > 0) pid = parsed; } catch { /* no PID file */ } if (pid) await killTunnel(pid); try { rmSync(MCPX_TUNNEL_PID_FILE(), { force: true }); } catch { /* best-effort */ } } /** * Restart the Cloudflare quick tunnel bound to 127.0.0.1: and parse the * freshly generated trycloudflare.com URL. Every exact Quick Tunnel currently * bound to this port is stopped first, including processes with no PID file. */ export async function restartQuickTunnel(localPort: number, timeoutMs = 30_000): Promise { if (!isValidTunnelPort(localPort)) throw new Error("无效的隧道端口(必须为 1-65535)"); const binary = resolveCloudflared(); if (!binary) throw new Error("未找到 cloudflared — 安装后重试"); const existing = discoverQuickTunnelProcesses(localPort); if (!existing) throw new Error("无法确认现有 Quick Tunnel 进程,已停止启动以避免重复"); const oldPids = new Set(existing.map((process) => process.pid)); for (const process of existing) await killTunnel(process.pid); try { const pidFile = readFileSync(MCPX_TUNNEL_PID_FILE(), "utf8").trim(); const pid = Number(pidFile); if (Number.isInteger(pid) && oldPids.has(pid)) rmSync(MCPX_TUNNEL_PID_FILE(), { force: true }); } catch { /* no PID file */ } if (existing.length > 0) { await new Promise((resolve) => setTimeout(resolve, 500)); const remaining = discoverQuickTunnelProcesses(localPort); if (!remaining) throw new Error("无法确认 Quick Tunnel 已停止,已停止启动以避免重复"); if (remaining.length > 0) throw new Error(`仍有 ${remaining.length} 个 Quick Tunnel 进程未停止,已停止启动`); } const isShim = process.platform === "win32" && /\.(cmd|bat)$/i.test(binary); // Redirect child output to a log file instead of pipes: the tunnel is meant // to outlive this process (detached + unref). Held pipes would kill it via // SIGPIPE/EPIPE once this process exits and would leak the unbounded capture // buffer; the child owns its inherited fd, so closing ours is safe. const logPath = `${MCPX_TUNNEL_PID_FILE()}.log`; let logFd: number | undefined; try { logFd = openSync(logPath, "w"); } catch { /* no capture — URL parse will time out */ } const child = spawn(binary, ["tunnel", "--url", `http://127.0.0.1:${localPort}`], { detached: !isShim, stdio: ["ignore", logFd ?? "ignore", logFd ?? "ignore"], shell: isShim, windowsHide: true, }); child.unref(); try { writeFileSync(MCPX_TUNNEL_PID_FILE(), String(child.pid ?? ""), "utf8"); } catch { /* best-effort */ } const readLog = (): string => { if (logFd === undefined) return ""; try { return readFileSync(logPath, "utf8"); } catch { return ""; } }; const deadline = Date.now() + timeoutMs; try { while (Date.now() < deadline) { const match = readLog().match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/); if (match) return match[0]; // Any child exit without a URL match is a hard failure — a clean exit // (code 0) without a URL also means cloudflared is gone and the loop must // not keep polling for the full timeout. if (child.exitCode !== null) { throw new Error(`cloudflared 退出未取得 URL (exit=${child.exitCode}): ${readLog().slice(-400)}`); } await new Promise((resolve) => setTimeout(resolve, 400)); } throw new Error(`cloudflared 启动超时未取得 URL: ${readLog().slice(-400)}`); } catch (error) { // Failure: kill the orphaned child and clean the PID file before throwing. try { child.kill(); } catch { /* already dead */ } try { rmSync(MCPX_TUNNEL_PID_FILE(), { force: true }); } catch { /* best-effort */ } throw error; } finally { if (logFd !== undefined) { try { closeSync(logFd); } catch { /* best-effort */ } } } } function replaceConfigAtomically(next: string): void { const path = MCPX_CONFIG_PATH(); const temp = `${path}.mcpx-${process.pid}-${randomUUID()}.tmp`; try { writeFileSync(temp, next, { encoding: "utf8", flag: "wx", mode: 0o600 }); renameSync(temp, path); } finally { try { rmSync(temp, { force: true }); } catch { /* best-effort */ } } } /** Restore a previously-read config snapshot after a failed tunnel transaction. */ export function restoreMcpxConfig(raw: string): void { if (!raw) throw new Error("config.yaml 快照为空"); replaceConfigAtomically(raw); } /** Update auth.oauth.server_url in ~/.mcpx/config.yaml (in place, section-preserving). */ export function updateConfigServerURL(url: string): void { let parsed: URL; try { parsed = new URL(url); } catch { throw new Error("无效的隧道 URL: " + url); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("隧道 URL 必须是 http/https: " + url); const path = MCPX_CONFIG_PATH(); const raw = readFileSync(path, "utf8"); const pattern = /^(\s{2,}server_url:\s*).*/m; if (!pattern.test(raw)) throw new Error("config.yaml 无 server_url 字段"); const next = raw.replace(pattern, (_match, prefix: string) => `${prefix}${parsed.href.replace(/\/$/, "")}`); replaceConfigAtomically(next); } /** Stop the mcpx process tracked by ~/.mcpx/mcpx-server.pid (best-effort). */ export async function stopMcpx(): Promise { let pid: number | undefined; const pidFile = MCPX_PID_FILE(); try { const raw = readFileSync(pidFile, "utf8").trim(); const parsed = Number(raw); if (Number.isInteger(parsed) && parsed > 0) pid = parsed; } catch { /* no pid file */ } if (!pid) return; // SEC-RV-006: verify identity before killing so a reused PID cannot target an // unrelated process. On mismatch, just clean the stale PID file and return. if (pid && !processMatches(pid, "mcpx")) { try { rmSync(pidFile, { force: true }); } catch { /* best-effort */ } return; } try { if (process.platform === "win32") { spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" }); } else { await killProcessWithEscalation(pid); } } catch { /* already dead */ } try { rmSync(pidFile, { force: true }); } catch { /* best-effort */ } }