diff --git a/src/server.ts b/src/server.ts index ac65203..2d243b4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -37,6 +37,7 @@ import { isPidAlive, loadSession, closeSession, + getOwnAncestorPids, } from "./storage/sessions.js"; import { logEvent } from "./storage/worklog.js"; import { spawnDetachedAuditWorker } from "./audit-spawner.js"; @@ -78,10 +79,21 @@ const defaultWorkspacePath = isWorkspace ? serverCwd : null; // the Claude session_id, which is the key for multi-window isolation. // // Instead of a single "currentSession", the server owns all AXME sessions -// created by hooks whose parent process id equals our own parent process -// id (i.e., the same Claude Code instance that spawned us). At disconnect, -// we close all of them. +// created by hooks of the same IDE instance that spawned us. Hooks record +// ownerPpid via getClaudeCodePid() (their grandparent above the sh wrapper). +// Under Claude Code that equals our PARENT — claude spawns hooks and the +// MCP server alike. Cursor adds a layer: hooks hang off the cursor-server +// main process while we are a child of the extension host, so the recorded +// owner is our GRANDparent and strict ppid equality never matched — every +// Cursor extension install had session close / audit / worklog silently +// dead ("No active AXME session found", QA 2026-06-11). Match against our +// ancestor chain instead; chain[0] is process.ppid, so the Claude Code +// behavior is unchanged. const OWN_PPID = process.ppid; +const OWN_ANCESTOR_PIDS = new Set(getOwnAncestorPids(4)); +function isOwnedMapping(ownerPpid: number | undefined): boolean { + return ownerPpid != null && OWN_ANCESTOR_PIDS.has(ownerPpid); +} // Track which context paths have been delivered in this MCP session. // Used by axme_oracle/decisions/memories to avoid duplicating workspace @@ -121,7 +133,7 @@ void sendStartupEvents(); */ function getOwnedSessionIdForLogging(): string | undefined { const all = listClaudeSessionMappings(defaultProjectPath); - const owned = all.filter(m => m.ownerPpid === OWN_PPID); + const owned = all.filter(m => isOwnedMapping(m.ownerPpid)); if (owned.length > 0) return owned[0].axmeSessionId; // No mapping for our PID — check for stale mappings from dead Claude Code @@ -173,7 +185,7 @@ async function cleanupAndExit(reason: string): Promise { // detached audit worker for each, clear the mapping, and exit. No awaiting. try { const mappings = listClaudeSessionMappings(defaultProjectPath); - const owned = mappings.filter(m => m.ownerPpid === OWN_PPID); + const owned = mappings.filter(m => isOwnedMapping(m.ownerPpid)); // Deduplicate: group AXME sessions by Claude session ID. // Multiple AXME sessions can share the same Claude session (race condition @@ -1197,7 +1209,7 @@ async function auditOrphansInBackground(): Promise { // currently owned by this MCP server via an active mapping file. const ownedAxmeIds = new Set( listClaudeSessionMappings(defaultProjectPath) - .filter(m => m.ownerPpid === OWN_PPID) + .filter(m => isOwnedMapping(m.ownerPpid)) .map(m => m.axmeSessionId), ); diff --git a/src/storage/sessions.ts b/src/storage/sessions.ts index 06e9a1f..8a66c56 100644 --- a/src/storage/sessions.ts +++ b/src/storage/sessions.ts @@ -18,6 +18,7 @@ import { join, resolve } from "node:path"; import { readdirSync, readFileSync, rmSync, openSync, closeSync, unlinkSync, statSync } from "node:fs"; +import { execSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { ensureDir, writeJson, readJson, pathExists, atomicWrite, removeFile, readSafe } from "./engine.js"; import { logSessionStart } from "./worklog.js"; @@ -104,20 +105,96 @@ export function isRetryableError(errMsg: string): boolean { * via ensureAxmeSessionForClaude). */ export function getClaudeCodePid(): number { + const parent = readParentPidLinux(process.ppid); + if (parent != null) return parent; + // /proc missing (macOS, Windows), or parent died before we could read + // its stat file. + return process.ppid; +} + +/** Parse the parent PID of `pid` from /proc (Linux only). Null elsewhere/on failure. */ +function readParentPidLinux(pid: number): number | null { try { - const stat = readFileSync(`/proc/${process.ppid}/stat`, "utf-8"); + const stat = readFileSync(`/proc/${pid}/stat`, "utf-8"); const closeParen = stat.lastIndexOf(")"); if (closeParen > 0) { // Fields after "(comm) " are space-separated: state ppid pgrp ... const parts = stat.slice(closeParen + 2).split(" "); - const grandparent = parseInt(parts[1], 10); - if (Number.isFinite(grandparent) && grandparent > 1) return grandparent; + const parent = parseInt(parts[1], 10); + if (Number.isFinite(parent) && parent > 1) return parent; } - } catch { - // /proc missing (macOS, Windows), or parent died before we could read - // its stat file. Fall through to fallback. + } catch { /* /proc missing or pid gone */ } + return null; +} + +/** Parent PID of `pid` via `ps` (macOS and other POSIX without /proc). */ +function readParentPidPosix(pid: number): number | null { + try { + const out = execSync(`ps -o ppid= -p ${pid}`, { encoding: "utf-8", timeout: 2_000, stdio: ["ignore", "pipe", "ignore"] }).trim(); + const parent = parseInt(out, 10); + if (Number.isFinite(parent) && parent > 1) return parent; + } catch { /* ps unavailable or pid gone */ } + return null; +} + +/** + * Walk this process's ancestor chain — parent, grandparent, … — up to + * `maxDepth` levels. The first element is always `process.ppid`. + * + * Why this exists: hooks record `ownerPpid` via getClaudeCodePid() (their + * grandparent — one step above the sh wrapper). Under Claude Code that PID + * equals the MCP server's PARENT, because the same claude process spawns + * both — so a strict `ownerPpid === process.ppid` equality worked. Cursor + * adds a layer: hooks are spawned by the cursor-server main process while + * the MCP server is a child of the EXTENSION HOST, so the hook-recorded + * owner is the server's GRANDparent and strict equality never matches. + * (QA 2026-06-11: `axme_begin_close` returned "No active AXME session + * found" on every Cursor extension install — session close, audit spawn + * and worklog were all dead.) Ownership checks must match against the + * ancestor chain, not a single ppid. + * + * Platform strategy: Linux walks /proc (microseconds); macOS walks `ps` + * (one small exec per level); Windows resolves the whole chain in a single + * PowerShell invocation (spawning powershell per level would cost seconds). + * Any failure stops the walk — the chain always contains at least + * `process.ppid`, so behavior degrades to the old strict equality. + */ +export function getOwnAncestorPids(maxDepth = 4): number[] { + const chain: number[] = []; + const seen = new Set(); + let current = process.ppid; + if (process.platform === "win32") { + return getOwnAncestorPidsWindows(maxDepth); } - return process.ppid; + for (let depth = 0; depth < maxDepth; depth++) { + if (!Number.isFinite(current) || current <= 1 || seen.has(current)) break; + chain.push(current); + seen.add(current); + const parent = process.platform === "linux" + ? readParentPidLinux(current) + : readParentPidPosix(current); + if (parent == null) break; + current = parent; + } + return chain.length > 0 ? chain : [process.ppid]; +} + +/** Windows ancestor chain in ONE PowerShell call (CIM walk). */ +function getOwnAncestorPidsWindows(maxDepth: number): number[] { + try { + const script = + `$p=${process.ppid};$out=@();for($i=0;$i -lt ${maxDepth} -and $p -gt 1;$i++){` + + `$out+=$p;$p=(Get-CimInstance Win32_Process -Filter \\"ProcessId=$p\\" -ErrorAction SilentlyContinue).ParentProcessId};` + + `$out -join ','`; + const out = execSync(`powershell -NoProfile -NonInteractive -Command "${script}"`, { + encoding: "utf-8", + timeout: 10_000, + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + const chain = out.split(",").map(s => parseInt(s.trim(), 10)).filter(n => Number.isFinite(n) && n > 1); + if (chain.length > 0) return chain; + } catch { /* powershell unavailable — fall back to parent only */ } + return [process.ppid]; } function sessionsRoot(projectPath: string): string { diff --git a/test/session-ownership.test.ts b/test/session-ownership.test.ts new file mode 100644 index 0000000..4cdfd53 --- /dev/null +++ b/test/session-ownership.test.ts @@ -0,0 +1,58 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { getOwnAncestorPids, getClaudeCodePid } from "../src/storage/sessions.js"; + +/** + * Ancestor-chain ownership matching (Cursor extension fix, 2026-06-11). + * + * Hooks record ownerPpid = their grandparent (getClaudeCodePid). Under + * Claude Code that equals the MCP server's parent; under Cursor it is the + * server's GRANDparent (cursor-server vs extension host). Ownership checks + * therefore match against getOwnAncestorPids(), whose first element must be + * process.ppid so the historical strict-equality behavior is a subset. + */ +describe("getOwnAncestorPids", () => { + it("starts with process.ppid", () => { + const chain = getOwnAncestorPids(); + assert.ok(chain.length >= 1, "chain must never be empty"); + assert.equal(chain[0], process.ppid, "chain[0] must be the direct parent"); + }); + + it("walks beyond the direct parent where the platform allows", () => { + const chain = getOwnAncestorPids(4); + // Test runners are always at least two levels deep (init → shell/runner + // → node). /proc (Linux) and ps (macOS) both support the walk; a + // single-element chain there would mean the walk silently broke. + if (process.platform === "linux" || process.platform === "darwin") { + assert.ok(chain.length >= 2, `expected >=2 ancestors, got: ${chain.join(",")}`); + } + }); + + it("returns finite pids > 1 with no duplicates", () => { + const chain = getOwnAncestorPids(4); + for (const pid of chain) { + assert.ok(Number.isFinite(pid) && pid > 1, `bad pid in chain: ${pid}`); + } + assert.equal(new Set(chain).size, chain.length, "chain must not contain duplicates"); + }); + + it("respects maxDepth", () => { + assert.ok(getOwnAncestorPids(1).length <= 1); + assert.ok(getOwnAncestorPids(2).length <= 2); + }); + + it("contains the hook-side owner pid for same-parent layouts (Claude Code invariant)", () => { + // In a Claude Code layout hooks and the server share one parent, and + // getClaudeCodePid() from THIS process resolves our grandparent — which + // must be inside our own ancestor chain. This is exactly the membership + // check isOwnedMapping() performs in server.ts. + const chain = new Set(getOwnAncestorPids(4)); + const hookStyleOwner = getClaudeCodePid(); + if (process.platform === "linux") { + assert.ok( + chain.has(hookStyleOwner), + `grandparent ${hookStyleOwner} not in ancestor chain ${[...chain].join(",")}`, + ); + } + }); +});