diff --git a/gitnexus-factory-plugin/hooks/gitnexus-hook.js b/gitnexus-factory-plugin/hooks/gitnexus-hook.js index 05aa4368e..248680e86 100644 --- a/gitnexus-factory-plugin/hooks/gitnexus-hook.js +++ b/gitnexus-factory-plugin/hooks/gitnexus-hook.js @@ -2,15 +2,29 @@ /** * GitNexus Factory AI (Droid) Plugin Hook * - * PostToolUse handler — augments Grep/Glob/Execute searches with graph - * context from the GitNexus index. Self-contained: reads the tool payload from - * stdin, resolves a search pattern, and appends `gitnexus augment` output via - * hookSpecificOutput.additionalContext. No external dependencies (no jq). + * PostToolUse — augments Grep/Glob/Execute searches with graph context from + * the GitNexus index and returns it via hookSpecificOutput.additionalContext. + * + * Reuses the same guards as the Claude/Codex adapter (bundled byte-identical, + * kept in lockstep by test/unit/factory-plugin.test.ts): + * - acquireHookSlot — per-repo cap on concurrent augment children so + * parallel sessions can't fan out unbounded `gitnexus augment` spawns + * (#1486). + * - LadybugDB owner probe — skips the CLI augment when a GitNexus MCP/serve + * process already holds the single-writer DB lock, avoiding contention + * (#2396); the in-session MCP tools cover augmentation instead. + * + * The augment CLI child is NOT wrapped in the coreutils `timeout` + * orphan-containment guard the full Claude adapter uses (#2163) — same scope + * as the Cursor integration. Add it (resolveUnixGuardTimeout is exported by + * the bundled probe) if orphaned augment children become a problem here. */ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); +const { acquireHookSlot } = require('./hook-lock.js'); +const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs'); /** * Read JSON input from stdin synchronously. @@ -25,23 +39,42 @@ function readInput() { } /** - * Check if a directory (or ancestor) has a .gitnexus index. + * A `.gitnexus/` that holds `registry.json`/`repos` (and no per-repo index + * metadata) is the global registry, not a repo index — never augment against + * it. Mirrors the Claude adapter's guard. */ -function findGitNexusIndex(startDir) { +function isGlobalRegistryDir(candidate) { + if ( + fs.existsSync(path.join(candidate, 'gitnexus.json')) || + fs.existsSync(path.join(candidate, 'meta.json')) + ) { + return false; + } + return ( + fs.existsSync(path.join(candidate, 'registry.json')) || + fs.existsSync(path.join(candidate, 'repos')) + ); +} + +/** + * Walk up from startDir looking for a non-registry `.gitnexus/` folder. Returns + * the path to `.gitnexus/` or null if not found within 5 levels. + */ +function findGitNexusDir(startDir) { let dir = startDir || process.cwd(); for (let i = 0; i < 5; i++) { - if (fs.existsSync(path.join(dir, '.gitnexus'))) { - return true; - } + const candidate = path.join(dir, '.gitnexus'); + if (fs.existsSync(candidate) && !isGlobalRegistryDir(candidate)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } - return false; + return null; } /** - * Extract search pattern from tool input. + * Extract a search pattern from a Factory tool payload. Factory's shell tool is + * `Execute` (Claude's is `Bash`); Grep/Glob match Claude's. */ function extractPattern(toolName, toolInput) { if (toolName === 'Grep') { @@ -98,62 +131,87 @@ function extractPattern(toolName, toolInput) { return null; } +/** + * Run `gitnexus augment` for `pattern` and return its stderr (the augment CLI + * writes results to stderr; LadybugDB's native module captures stdout at the OS + * fd level, making it unusable in subprocess contexts). Tries a PATH-installed + * binary first, then falls back to npx. + * + * SECURITY: `pattern` is passed after the `--` end-of-options marker and never + * through a shell — the Windows npx fallback invokes `npx.cmd` directly rather + * than `shell: true`, so a pattern like `-rf` or `$(...)` is inert. + */ +function runAugment(pattern, cwd) { + const isWin = process.platform === 'win32'; + const spawnOpts = { + encoding: 'utf-8', + timeout: 8000, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }; + + try { + const child = spawnSync( + isWin ? 'gitnexus.cmd' : 'gitnexus', + ['augment', '--', pattern], + spawnOpts, + ); + if (!child.error && child.status === 0 && child.stderr && child.stderr.trim()) { + return child.stderr; + } + } catch { + /* not on PATH — fall through to npx */ + } + + try { + const child = spawnSync( + isWin ? 'npx.cmd' : 'npx', + ['-y', 'gitnexus', 'augment', '--', pattern], + spawnOpts, + ); + if (!child.error && child.status === 0 && child.stderr && child.stderr.trim()) { + return child.stderr; + } + } catch { + /* graceful failure */ + } + + return ''; +} + function main() { try { const input = readInput(); - const hookEvent = input.hook_event_name || ''; - - if (hookEvent !== 'PostToolUse') return; + if ((input.hook_event_name || '') !== 'PostToolUse') return; const cwd = input.cwd || process.cwd(); - if (!findGitNexusIndex(cwd)) return; + if (!path.isAbsolute(cwd)) return; + const gitNexusDir = findGitNexusDir(cwd); + if (!gitNexusDir) return; const toolName = input.tool_name || ''; - const toolInput = input.tool_input || {}; - if (toolName !== 'Grep' && toolName !== 'Glob' && toolName !== 'Execute') return; - const pattern = extractPattern(toolName, toolInput); + const pattern = extractPattern(toolName, input.tool_input || {}); if (!pattern || pattern.length < 3) return; - // augment CLI writes result to stderr (KuzuDB's native module captures - // stdout fd at OS level, making it unusable in subprocess contexts). + const release = acquireHookSlot(gitNexusDir); + if (!release) return; // all per-repo augment slots held by concurrent sessions + let result = ''; - - const isWin = process.platform === 'win32'; - - // Try direct gitnexus binary first (faster if globally installed) try { - const child = spawnSync('gitnexus', ['augment', pattern], { - encoding: 'utf-8', - timeout: 8000, - cwd, - stdio: ['pipe', 'pipe', 'pipe'], - shell: isWin, - }); - if (child.status === 0 && child.stderr && child.stderr.trim()) { - result = child.stderr; + if (hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid)) { + // #2396: a GitNexus MCP/serve process owns the single-writer DB, so a + // competing CLI augment would only contend on the lock. The session's + // MCP tools cover augmentation instead — skip silently. + return; } + result = runAugment(pattern, cwd); } catch { - /* not on PATH */ - } - - // Fallback to npx if direct binary didn't produce output - if (!result || !result.trim()) { - try { - const child = spawnSync('npx', ['-y', 'gitnexus', 'augment', pattern], { - encoding: 'utf-8', - timeout: 8000, - cwd, - stdio: ['pipe', 'pipe', 'pipe'], - shell: isWin, - }); - if (child.status === 0 && child.stderr && child.stderr.trim()) { - result = child.stderr; - } - } catch { - /* graceful failure */ - } + /* graceful failure */ + } finally { + release(); } if (result && result.trim()) { @@ -167,7 +225,7 @@ function main() { ); } } catch { - // Graceful failure + /* never let the hook break the tool call */ } } diff --git a/gitnexus-factory-plugin/hooks/hook-db-lock-probe.cjs b/gitnexus-factory-plugin/hooks/hook-db-lock-probe.cjs new file mode 100644 index 000000000..4376795e2 --- /dev/null +++ b/gitnexus-factory-plugin/hooks/hook-db-lock-probe.cjs @@ -0,0 +1,728 @@ +/** + * Cross-platform best-effort probe: does another process hold dbPath open + * with a command line that looks like a GitNexus MCP/serve server? + * + * Backends (no user-installed Sysinternals): + * - Linux: cmdline-first procfs scan under /proc, no lsof at all (#2180). Three + * phases, cheapest first: (0) read /proc//comm — a tiny task->comm read + * that never touches the target's mm — and keep only PIDs whose comm is a + * plausible node/gitnexus server; (1) read up to GITNEXUS_HOOK_PROC_CMDLINE_MAX + * bytes of /proc//cmdline via openSync+readSync (bounded, so a D-state + * holder stuck on mmap_lock or a giant argv can't wedge the hook) and prefilter + * with isGitNexusServerCommand; (2) only for the 0..N survivors, stat their + * /proc//fd/* and compare dev+inode against the target lbug. The lbug + * handle is fd-visible (a @ladybugdb/core property), so this finds every real + * owner without scanning every fd of every process. + * - macOS / *BSD / etc.: trusted lsof + ps (absolute paths first). + * - Windows: Restart Manager (rstrtmgr) via bundled PowerShell script + + * Win32_Process for command lines; trusted powershell.exe under %SystemRoot%. + * + * Fail matrix: + * - Linux proc scan: owner found -> fail-closed (skip augment); budget exhausted + * (GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS) -> fail-CLOSED (#2180). This is a + * deliberate change from the old "timeout -> fail-open then try lsof" path. + * End-to-end the busy-host outcome is unchanged: the old code's lsof fallback + * ETIMEDOUT'd on the very hosts where the scan ran out of budget and ALSO + * failed closed there — the lsof leg only ever added 1-2s of dead work plus + * the orphan-storm risk it caused (#2163). What changes is that an overloaded + * host now self-throttles immediately (the throttle the incident needed) + * instead of paying for a doomed lsof. Mid-load hosts that used to fall + * through to a successful lsof now answer from the scan directly (faster) or, + * if even the scan can't finish in budget, fail closed (self-throttle) — a + * bounded, documented tradeoff, never an orphan. + * - macOS / other Unix: fail-open on most errors; fail-closed only on lsof + * ETIMEDOUT, matching the hook contract. + * - Windows: fail-closed only on PowerShell ETIMEDOUT. + * + * Unix subprocess containment contract (#2163): + * - lsof/ps are wrapped in coreutils `timeout`/`gtimeout` when a working + * wrapper is found (`timeout -k 1 lsof ...`). If this hook process + * is itself SIGKILLed (e.g. by the runner's 10s hook timeout) the wrapper + * survives, SIGTERMs its child at the budget (2s lsof / 1s ps) and SIGKILLs + * it 1s later — orphan lifetime is bounded at ~3s instead of unbounded. + * - GITNEXUS_HOOK_TIMEOUT_PATH: the sentinel value `disabled` switches the + * wrapper off deterministically; any other value is adopted only when it + * exists AND passes a one-shot `-k` exit-propagation self-test — otherwise + * resolution FALLS THROUGH to the built-in candidate list (first self-test + * pass wins), so no malformed value of any shape can silently disable + * orphan containment. + * - The gitnexus server is lazy-open + sticky-hold: an idle MCP server holds + * ZERO lbug fds until the repo's first MCP query, then keeps the fd open. + * A probe before that first query is therefore always false — a known, + * pre-existing race, not a bug in this probe. + * - resolveUnixGuardTimeout is exported so the hook adapters can wrap the + * `gitnexus augment` CLI child — the longest-lived hook subprocess (7s + * local / 12s npx inner budgets) — in the same guard; see runGitNexusCli + * in the adapters (#2163 follow-up). + */ + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +function isGitNexusServerCommand(command) { + const hasServerMode = /(?:^|\s)(mcp|serve)(?:\s|$)/.test(command); + const hasGitNexus = + /(?:^|[/\\\s])gitnexus(?:\.cmd)?(?:\s|$)/.test(command) || + /node_modules[/\\]gitnexus[/\\]/.test(command); + return hasServerMode && hasGitNexus; +} + +// GITNEXUS_DEBUG-gated stderr diagnostics. Reuses the exact gating predicate the +// Windows ps1-load warning already uses (===' 1' / ==='true') so there is one +// debug convention in this file, and writes via process.stderr.write (NOT a +// spawn) so it never perturbs the windowsHide spawn-count invariant. +function debugLog(msg) { + if (process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true') { + process.stderr.write(`[GitNexus hook] ${msg}\n`); + } +} + +function resolveHookBinary(tool) { + const envKey = tool === 'lsof' ? 'GITNEXUS_HOOK_LSOF_PATH' : 'GITNEXUS_HOOK_PS_PATH'; + const fromEnv = process.env[envKey]; + if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv))) { + return String(fromEnv); + } + const candidates = + tool === 'lsof' + ? ['/usr/bin/lsof', '/usr/sbin/lsof', '/sbin/lsof', tool] + : ['/bin/ps', '/usr/bin/ps', tool]; + for (const candidate of candidates) { + if (candidate === tool) return tool; + try { + if (fs.existsSync(candidate)) return candidate; + } catch { + /* ignore */ + } + } + return tool; +} + +function hasMissingHookBinaryOverride(tool) { + const envKey = tool === 'lsof' ? 'GITNEXUS_HOOK_LSOF_PATH' : 'GITNEXUS_HOOK_PS_PATH'; + const fromEnv = process.env[envKey]; + if (!fromEnv || !String(fromEnv).trim()) return false; + try { + return !fs.existsSync(String(fromEnv).trim()); + } catch { + return true; + } +} + +// Sentinel: +// undefined = not resolved yet (resolve lazily, on first lsof/ps fallback) +// string = self-tested coreutils timeout/gtimeout path (use as wrapper) +// null = no usable wrapper (disabled, none found, or self-test failed) +let unixGuardTimeoutCache; + +/** + * Resolve a coreutils `timeout`/`gtimeout` binary to wrap lsof/ps with + * (#2163). Unix-only by contract: the probe's win32 dispatch returns before + * reaching it, and the exported callers (the adapters' runGitNexusCli, + * #2163 follow-up) must check the platform first — the self-test below + * spawns /bin/sh. The memoized result is module-wide, so probe and adapter + * share one lazy self-test per hook process. + * + * GITNEXUS_HOOK_TIMEOUT_PATH semantics: the sentinel `disabled` turns the + * wrapper off; any other value is only a CANDIDATE — an existing file path + * is tried first, but it must pass the `-k` exit-propagation self-test to + * be adopted. On any failure (non-existent path, directory, non-executable + * file, wrapper without `-k` support, always-exit-0 stub, …) resolution + * falls through to the built-in candidates below, tried in order, first + * self-test pass wins. This is strictly stronger than the sibling + * GITNEXUS_HOOK_LSOF_PATH / GITNEXUS_HOOK_PS_PATH overrides (which only + * check existence): no bad env value of ANY shape can silently disable + * orphan containment. + * + * Lazy self-test: candidates are probed only when the lsof/ps fallback is + * first reached, and the result is memoized. A candidate is adopted only + * when `timeout -k 1 1 /bin/sh -c 'exit 42'` exits 42 — i.e. it must RUN + * the wrapped command AND PROPAGATE its exit status. This rejects two + * failure shapes: wrappers without the coreutils `-k` flag — busybox <1.34, + * toybox, broken symlinks — which would exit with a usage error without + * ever running lsof, silently converting the lsof-ETIMEDOUT fail-closed + * contract into fail-open (#1492 regression); and always-exit-0 stubs + * (/bin/true shapes), which would otherwise be adopted and "succeed" every + * wrapped spawn instantly without running it — a constant no-owner probe + * answer and, worse, a silently dead augment (status 0, empty stderr passes + * the adapters' success check with no context; #2163 follow-up review). + * Only when EVERY candidate fails does the probe fall back to the unwrapped + * status quo (memoized null). busybox ≥1.34 passes the test and is fully + * usable for everything THIS file spawns (lsof/ps are the guard's direct + * children) and for the adapters' direct-exec arm. The adapters' npx arm + * additionally relies on coreutils' process-GROUP signalling for its + * `-s KILL` grandchild reaping; busybox signals only its direct child, and + * this self-test deliberately does not probe that capability — see the + * adapter docblocks for the residual-gap statement. + */ +function passesGuardSelfTest(guard) { + try { + const selfTest = spawnSync(guard, ['-k', '1', '1', '/bin/sh', '-c', 'exit 42'], { + encoding: 'utf-8', + timeout: 3000, + stdio: ['ignore', 'ignore', 'ignore'], + windowsHide: true, + }); + return !selfTest.error && selfTest.status === 42; + } catch { + return false; + } +} + +function resolveUnixGuardTimeout() { + if (unixGuardTimeoutCache !== undefined) return unixGuardTimeoutCache; + unixGuardTimeoutCache = null; + const fromEnv = process.env.GITNEXUS_HOOK_TIMEOUT_PATH; + const trimmed = fromEnv ? String(fromEnv).trim() : ''; + if (trimmed === 'disabled') return unixGuardTimeoutCache; + const candidates = []; + if (trimmed && fs.existsSync(trimmed)) candidates.push(trimmed); + for (const builtin of [ + '/usr/bin/timeout', + '/bin/timeout', + '/opt/homebrew/bin/gtimeout', + '/usr/local/bin/gtimeout', + ]) { + try { + if (fs.existsSync(builtin)) candidates.push(builtin); + } catch { + /* ignore */ + } + } + for (const candidate of candidates) { + if (passesGuardSelfTest(candidate)) { + unixGuardTimeoutCache = candidate; + break; + } + } + return unixGuardTimeoutCache; +} + +function resolveWindowsPowerShellPath() { + const fromEnv = process.env.GITNEXUS_HOOK_POWERSHELL_PATH; + if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv).trim())) { + return String(fromEnv).trim(); + } + const root = process.env.SystemRoot || 'C:\\Windows'; + const ps = path.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + if (fs.existsSync(ps)) return ps; + const psWow = path.join(root, 'SysWOW64', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + if (fs.existsSync(psWow)) return psWow; + return 'powershell.exe'; +} + +// Sentinel: +// undefined = not loaded yet (try the read) +// string = encoded PowerShell command (successful load) +// null = load attempted and failed (do not retry; warning already emitted) +let windowsRmListPsEncodedCommandCache; +let windowsRmListPsLoadFailureWarned = false; +function getWindowsRmListEncodedCommand() { + if (windowsRmListPsEncodedCommandCache !== undefined) { + return windowsRmListPsEncodedCommandCache; + } + try { + const ps1Path = path.join(__dirname, 'win-rm-list-json.ps1'); + const src = fs + .readFileSync(ps1Path, 'utf8') + .replace(/^\uFEFF/, '') + .replace(/\r\n/g, '\n'); + windowsRmListPsEncodedCommandCache = Buffer.from(src, 'utf16le').toString('base64'); + } catch (err) { + windowsRmListPsEncodedCommandCache = null; + if ( + !windowsRmListPsLoadFailureWarned && + (process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true') + ) { + windowsRmListPsLoadFailureWarned = true; + const msg = err && err.message ? String(err.message).slice(0, 200) : 'unknown'; + process.stderr.write(`[GitNexus hook] win-rm-list-json.ps1 load failed: ${msg}\n`); + } + } + return windowsRmListPsEncodedCommandCache; +} + +function hasGitNexusServerOwnerWindows(dbPathAbs, myPid) { + const encoded = getWindowsRmListEncodedCommand(); + if (!encoded) return false; + const psExe = resolveWindowsPowerShellPath(); + const r = spawnSync( + psExe, + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-STA', + '-EncodedCommand', + encoded, + ], + { + encoding: 'utf-8', + timeout: 6000, + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + env: { ...process.env, GITNEXUS_HOOK_RM_TARGET: dbPathAbs }, + }, + ); + // ETIMEDOUT means the PowerShell probe didn't return in time; treat as 'unresponsive process holds DB' → fail-closed (skip augment). + if (r.error) return r.error.code === 'ETIMEDOUT'; + if (r.status !== 0) return false; + let rows; + try { + rows = JSON.parse(String(r.stdout || '').trim() || '[]'); + } catch { + return false; + } + if (!Array.isArray(rows)) return false; + for (const row of rows) { + const procId = Number(row.pid); + const cmd = String(row.cmd || ''); + if (!Number.isFinite(procId) || procId === myPid) continue; + if (isGitNexusServerCommand(cmd)) return true; + } + return false; +} + +// The procfs root every Linux scan path reads from. Production is always /proc; +// GITNEXUS_HOOK_PROC_ROOT only exists so unit tests can inject a fixture tree +// (comm + cmdline + fd symlinks) and assert the three-phase logic without +// scanning the real, ~hundreds-of-process /proc of the test host. +// +// Test-only gate (F4): the override is honored ONLY under a test runner — +// vitest injects VITEST="true" and NODE_ENV="test" into every worker (verified; +// a production hook is `node .cjs` with neither set). Without the gate, a +// production env that accidentally leaked GITNEXUS_HOOK_PROC_ROOT (pointing at an +// empty/bad tree) would make readdirSync find no pids -> 'not-owned' -> Linux +// owner detection silently OFF (fail-OPEN: augment races the real server for the +// lbug, the #1492 class). Gating to the test signal makes that leak inert in +// production (always /proc) while the fake-procfs unit tests, which run under +// vitest, still inject freely. Unset env (or non-test context) => /proc, so the +// production path is byte-for-byte the historical behavior. +function isTestContext() { + return ( + process.env.VITEST === 'true' || process.env.VITEST === '1' || process.env.NODE_ENV === 'test' + ); +} +function getProcRoot() { + if (!isTestContext()) return '/proc'; + const raw = process.env.GITNEXUS_HOOK_PROC_ROOT; + return raw && String(raw).trim() ? String(raw) : '/proc'; +} + +// Max bytes read from /proc//cmdline in Phase 1. Bounded by default so a +// D-state holder wedged on mmap_lock, or a process with a pathological multi-MB +// argv, can't stall the hook. 16 KiB comfortably clears a realistic +// `node mcp` line +// (the `mcp`/`serve` mode token lives at the very tail, so the cap must be large +// enough to reach it — see PROC_CMDLINE_FLOOR escalation below). Overridable for +// tests; never goes below PROC_CMDLINE_FLOOR. +const PROC_CMDLINE_FLOOR = 4096; +function getCmdlineMaxBytes() { + const raw = process.env.GITNEXUS_HOOK_PROC_CMDLINE_MAX; + // Number() (not parseInt) so "8e3" reads as 8000, not 8 (parseInt stops at + // 'e'). The `raw && String(raw).trim()` guard keeps empty/whitespace on the + // default; trailing garbage ("8abc") now -> NaN -> default (stricter). + const n = raw && String(raw).trim() ? Number(String(raw).trim()) : NaN; + if (Number.isFinite(n) && n >= PROC_CMDLINE_FLOOR) return n; + return 16384; +} + +// Phase 0 comm prefilter. /proc//comm is the kernel task->comm string, +// capped at 16 bytes INCLUDING the trailing NUL — i.e. at most 15 visible +// chars, truncated by the kernel with no marker. So a process whose real name +// is longer than 15 chars shows a 15-char prefix here. The match below is +// therefore truncation-safe in BOTH directions (a whitelist name that is a +// prefix of comm, or comm that is a prefix of a whitelist name, both count) to +// guarantee we never drop a real owner at this cheap stage — Phase 2's dev+ino +// fd check is the real authority; Phase 0/1 only exist to skip the overwhelming +// majority (kernel threads, shells, editors) cheaply. +// +// The whitelist is calibrated against what a real `gitnexus mcp`/`serve` server +// actually reports for comm. Observed on production hosts: the server renames +// its main thread, so comm reads `MainThread` (via @ladybugdb/core's +// worker_threads setup), NOT `node` — omitting it would blind the probe to +// every real server (#1492-class owner miss). We also keep the plausible +// launcher/runtime basenames in case a future build does not rename the thread. +// Conservative by design: over-collecting a few extra candidates only costs a +// bounded number of Phase 1 cmdline reads. +const COMM_CANDIDATES = ['node', 'gitnexus', 'bun', 'deno', 'npm', 'npx', 'MainThread']; +function commLooksLikeServer(comm) { + const c = comm.trim(); + if (!c) return false; + for (const name of COMM_CANDIDATES) { + if (name === c || name.startsWith(c) || c.startsWith(name)) return true; + } + return false; +} + +function readProcComm(procRoot, pidStr) { + try { + return fs + .readFileSync(path.join(procRoot, pidStr, 'comm'), 'utf8') + .replace(/\0+/g, '') + .trim(); + } catch { + return ''; + } +} + +// Timeout sentinel for readLinuxCmdline (F3). MUST be distinct from the +// "unreadable/empty" return value (''): '' flows through isGitNexusServerCommand +// as a NON-candidate (both regexes are false on ''), so the Phase 1 caller +// `continue`s past it — correct for a raced/openSync-failed pid, but a FAIL-OPEN +// bug if it ever meant "I ran out of budget mid-read" (a real owner whose +// escalation timed out would be silently dropped, racing the lbug -> #1492). A +// unique Symbol can never collide with any cmdline string, so the caller can +// branch on it explicitly and map a mid-read timeout to the tri-state 'timeout' +// (fail-CLOSED) instead of swallowing it as a non-candidate. +const CMDLINE_TIMEOUT = Symbol('gitnexus.cmdline.timeout'); + +// Bounded /proc//cmdline read for Phase 1. openSync+readSync (not +// readFileSync) so a D-state holder cannot stall the hook on a huge or +// never-EOF argv: we read at most `cap` bytes and stop. cmdline separates argv +// with NULs; convert to spaces for isGitNexusServerCommand. +// +// Owner-miss guard for the 4 KB cap: the `gitnexus` token usually sits in the +// first path component while the `mcp`/`serve` mode token is the LAST argv, so +// a naive 4 KB read could clip the mode token off a server launched with a very +// long interpreter path and silently miss a real owner. We mitigate two ways: +// (a) the default cap (16 KiB) already clears realistic lines; (b) if the first +// read fills the cap AND already contains the `gitnexus` token but no mode +// token yet, we keep reading in bounded chunks (up to a hard ceiling) until the +// mode token appears or the file ends — so a genuine server is never missed for +// want of a few more bytes, while non-candidates still pay only the initial +// bounded read. +// +// Budget (F3): the escalation loop above is the one place a SINGLE pathological +// candidate could read up to HARD_CEIL (256 KiB) before the next scan-level +// budget check, weakening the timeout contract. `outOfBudget` (the scan's shared +// deadline callback) is checked once per escalation iteration; on expiry we +// return CMDLINE_TIMEOUT (NOT '') so the caller can fail-closed honestly rather +// than mistake the partial read for a non-candidate. Reads that simply can't +// open / error out still return '' (genuinely "not a readable candidate"). +function readLinuxCmdline(procRoot, pidStr, cap, outOfBudget) { + const file = path.join(procRoot, pidStr, 'cmdline'); + let fd; + try { + fd = fs.openSync(file, 'r'); + } catch { + return ''; + } + try { + const HARD_CEIL = 262144; // 256 KiB absolute ceiling for the escalation path + let collected = Buffer.alloc(0); + let offset = 0; + let chunkCap = cap; + for (;;) { + // allocUnsafe is safe here: readSync fills exactly [0, bytes), only + // buf.subarray(0, bytes) is consumed, and Buffer.concat deep-copies that + // slice into `collected`, so the uninitialized tail never reaches decode. + const buf = Buffer.allocUnsafe(chunkCap); + const bytes = fs.readSync(fd, buf, 0, chunkCap, offset); + if (bytes <= 0) break; + collected = Buffer.concat([collected, buf.subarray(0, bytes)]); + offset += bytes; + const text = collected.toString('utf8').replace(/\0+/g, ' '); + // Stop early when we can already decide "owner": has both the gitnexus + // token and a mode token. Keep going only when gitnexus is present but + // the mode token might be just past the boundary. + const hasGitNexus = + /(?:^|[/\\\s])gitnexus(?:\.cmd)?(?:\s|$)/.test(text) || + /node_modules[/\\]gitnexus[/\\]/.test(text); + const hasMode = /(?:^|\s)(mcp|serve)(?:\s|$)/.test(text); + if (hasMode) break; // decided (positive); isGitNexusServerCommand re-checks below + if (bytes < chunkCap) break; // EOF: full cmdline read, definitive + if (!hasGitNexus) break; // not a candidate; do not escalate the read + if (offset >= HARD_CEIL) break; // bounded escalation only + // Budget gate the escalation: a single huge-argv candidate must not burn + // the whole scan deadline before we re-check. Return the timeout sentinel + // (never '') so the caller fails closed instead of treating us as a + // non-candidate. The sole caller (linuxProcScanFindGitNexusServer) always + // passes outOfBudget, so no presence guard is needed. + if (outOfBudget()) return CMDLINE_TIMEOUT; + chunkCap = cap; // keep reading more in cap-sized chunks + } + return collected.toString('utf8').replace(/\0+/g, ' ').trim(); + } catch { + return ''; + } finally { + try { + fs.closeSync(fd); + } catch { + /* ignore */ + } + } +} + +function resolveLinuxProcBudgetMs() { + const raw = process.env.GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS; + // Gate on the STRING's emptiness, NOT the parsed number's truthiness — the + // old `Number(raw && trim()) ? ... : 1200` form treated "0" as falsy and + // silently fell back to 1200 (#2180). Use Number() (not parseInt) so "16e3" + // reads as 16000, not 16 (parseInt stops at 'e'). The `&& String(raw).trim()` + // guard is load-bearing: without it a set-but-empty/whitespace value would be + // `Number("")===0` => budget 0 => immediate fail-CLOSED timeout (augment + // permanently skipped). With it, ''/whitespace => NaN => 1200 default, while a + // finite "0" still parses to an explicit, deterministic "no budget" => + // immediate timeout. Non-numeric / unset => default 1200. + const n = raw != null && String(raw).trim() ? Number(String(raw).trim()) : NaN; + if (!Number.isFinite(n)) return 1200; + return n; // may be <= 0, meaning "out of budget on the first check" +} + +// Returns one of: 'owned' (a non-self process with a GitNexus-server cmdline +// holds the target lbug fd), 'not-owned' (scan completed, no such owner), or +// 'timeout' (the per-scan budget was exhausted before a verdict). The name is +// pinned by a source-contract test; only the return TYPE changed (#2180: +// boolean -> tri-state, so the dispatcher can fail-closed on 'timeout'). +function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) { + const budget = resolveLinuxProcBudgetMs(); + // A non-positive budget is an explicit, deterministic "no time to scan" => + // immediate timeout (the #2180 test vector, and the only correct reading of + // the fixed parse: "0" must NOT mean 1200). Returning before any procfs read + // keeps it instantaneous regardless of host load. + if (budget <= 0) return 'timeout'; + const procRoot = getProcRoot(); + const cmdlineCap = getCmdlineMaxBytes(); + const start = Date.now(); + const outOfBudget = () => Date.now() - start > budget; + + let targetStat; + try { + targetStat = fs.statSync(dbPathAbs); + } catch { + // Caller already existsSync'd the path; a stat failure here is a transient + // race, treat as no owner (historical semantics). + return 'not-owned'; + } + + let procEntries; + try { + procEntries = fs.readdirSync(procRoot, { withFileTypes: true }); + } catch { + return 'not-owned'; + } + + // Phase 0 + Phase 1: collect the few PIDs whose comm AND cmdline look like a + // GitNexus server, without touching any fd yet. + const candidates = []; + for (const ent of procEntries) { + if (outOfBudget()) return 'timeout'; + if (!ent.isDirectory() || !/^\d+$/.test(ent.name)) continue; + const pid = Number.parseInt(ent.name, 10); + if (!Number.isFinite(pid) || pid === myPid) continue; + + // Phase 0: cheap comm prefilter. + const comm = readProcComm(procRoot, ent.name); + if (!comm) continue; // unreadable comm (kernel thread, raced exit) -> skip + if (!commLooksLikeServer(comm)) continue; + + // Phase 1: bounded cmdline read + isGitNexusServerCommand prefilter. + if (outOfBudget()) return 'timeout'; + const cmdline = readLinuxCmdline(procRoot, ent.name, cmdlineCap, outOfBudget); + // F3: a mid-read budget timeout returns the CMDLINE_TIMEOUT sentinel (a + // Symbol, never a string). Fail CLOSED on it rather than letting it fall + // through isGitNexusServerCommand as a non-candidate — a real owner whose + // escalation timed out must not be silently dropped (would fail-OPEN). + if (cmdline === CMDLINE_TIMEOUT) return 'timeout'; + if (!isGitNexusServerCommand(cmdline)) continue; + candidates.push(ent.name); + } + + // Phase 2: only now stat the fds of the (typically 0-2) survivors. + for (const pidStr of candidates) { + if (outOfBudget()) return 'timeout'; + const fdDir = path.join(procRoot, pidStr, 'fd'); + let fds; + try { + fds = fs.readdirSync(fdDir); + } catch (err) { + // F1: the old code returned 'owned' for EVERY non-ENOENT error. That was + // a correctness bug: /proc//fd is owner-only (mode 0500), so a + // cross-user/root `gitnexus mcp` serving a DIFFERENT repo passes Phase 0+1 + // (its cmdline matches) and then EACCES'es here — yet its dev+ino was + // NEVER compared against THIS lbug. Claiming 'owned' lets it permanently, + // silently suppress augment for a repo it does not actually lock. We now + // distinguish the failure shapes (all still fail-closed where we can't + // prove non-ownership, but 'timeout' is the HONEST verdict for + // "inconclusive", not the false-positive 'owned'): + const code = err && err.code; + if (code === 'ENOENT') { + // Process raced away between the candidate scan and now -> genuinely no + // longer an owner. Move on. + continue; + } + if (code === 'EACCES' || code === 'EPERM') { + // Permission-denied fd dir: cannot read fds, so ownership is + // UNVERIFIABLE. Fail closed honestly via 'timeout' (the dispatcher maps + // timeout -> true, same protective skip as before) WITHOUT lying that we + // confirmed ownership. Do NOT degrade to not-owned/fail-open: if this + // really is the owner, fail-open re-opens the #1492 lbug race; augment + // is optional context, so a conservative skip costs little. + debugLog( + `fd dir unreadable for candidate pid ${pidStr} (${code}); ownership ` + + `unverifiable, probe inconclusive -> fail-closed (timeout)`, + ); + return 'timeout'; + } + if (code === 'EIO' || code === 'ESTALE') { + // Genuine transient I/O against this candidate's fd dir — not evidence + // it does NOT hold the lbug. Treat as inconclusive and fail closed + // (timeout) rather than continue, so a real owner mid-I/O-blip is not + // dropped (would fail-open). + debugLog( + `fd dir transient I/O error for candidate pid ${pidStr} (${code}); ` + + `probe inconclusive -> fail-closed (timeout)`, + ); + return 'timeout'; + } + // Any other shape (ENOTDIR — fd path is not a directory at all, so this + // is not a plausible live-procfs owner — and the long tail) is treated as + // "this candidate is not an owner": move to the next candidate instead of + // the old blanket 'owned'. If no other candidate owns the lbug the scan + // ends not-owned (dispatcher fail-open) — acceptable because ENOTDIR means + // the fd entry is structurally not a real /proc//fd. + debugLog( + `fd dir not a readable directory for candidate pid ${pidStr} ` + + `(${code || 'unknown'}); treating candidate as non-owner -> continue`, + ); + continue; + } + for (const fd of fds) { + if (outOfBudget()) return 'timeout'; + try { + const st = fs.statSync(path.join(fdDir, fd)); + if (st.dev === targetStat.dev && st.ino === targetStat.ino) { + return 'owned'; + } + } catch { + /* fd raced closed; ignore */ + } + } + } + + return 'not-owned'; +} + +function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) { + const guard = resolveUnixGuardTimeout(); + // An explicit missing override models ENOENT and must fail open instead of + // falling through to a host binary with different process-table visibility. + if (hasMissingHookBinaryOverride('lsof')) return false; + const lsofPath = resolveHookBinary('lsof'); + // The spawnSync timeouts below (lsof 1000ms / ps 500ms) are deliberately + // SHORTER than the wrapper budgets (2s / 1s): on the supervised path Node's + // SIGTERM always fires first, so `error.code === 'ETIMEDOUT'` and the + // fail-closed contract are untouched. The wrapper only matters once this + // hook process has been SIGKILLed and can no longer deliver that SIGTERM. + const [lsofCmd, lsofArgs] = guard + ? [guard, ['-k', '1', '2', lsofPath, '-nP', '-t', '--', dbPathAbs]] + : [lsofPath, ['-nP', '-t', '--', dbPathAbs]]; + const lsof = spawnSync(lsofCmd, lsofArgs, { + encoding: 'utf-8', + timeout: 1000, + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + }); + if (lsof.error) return lsof.error.code === 'ETIMEDOUT'; + // Guard-mediated deaths map to "unresponsive holder" (fail-closed). Three + // result shapes, verified against coreutils 9.1: + // - signal-death: when `-k` escalates to SIGKILL, coreutils timeout + // SELF-RAISES the signal, so spawnSync reports {status: null, signal} + // with no .error (spawnSync's own ETIMEDOUT was handled above). The + // same shape appears when this hook is frozen >2s (SIGSTOP, laptop + // suspend) and the guard expires while it sleeps. By construction, a + // guard-wrapped probe that died by signal without spawnSync ETIMEDOUT + // is a budget/kill outcome. + // - 124: budget expired and the child exited after the plain SIGTERM. + // - 137: NOT the coreutils -k path — only exit-code-propagating wrappers, + // or a child SIGKILLed externally (e.g. the OOM killer). + if (guard && lsof.status === null && lsof.signal) return true; + if (guard && (lsof.status === 124 || lsof.status === 137)) return true; + + const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean); + const psMissing = hasMissingHookBinaryOverride('ps'); + const psPath = resolveHookBinary('ps'); + for (const pid of pids) { + if (Number(pid) === myPid) continue; + // Missing ps means we cannot verify that this pid is a GitNexus server. + if (psMissing) continue; + const [psCmd, psArgs] = guard + ? [guard, ['-k', '1', '1', psPath, '-p', pid, '-o', 'command=']] + : [psPath, ['-p', pid, '-o', 'command=']]; + const ps = spawnSync(psCmd, psArgs, { + encoding: 'utf-8', + timeout: 500, + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + }); + if (ps.error) { + if (ps.error.code === 'ETIMEDOUT') return true; + continue; + } + // Same guard-mediated-death mapping as the lsof call above (signal-death + // from the -k escalation or a frozen hook; 124 budget expiry; 137 only + // for exit-code-propagating wrappers / external SIGKILL). + if (guard && ps.status === null && ps.signal) return true; + if (guard && (ps.status === 124 || ps.status === 137)) return true; + if (isGitNexusServerCommand(ps.stdout || '')) return true; + } + return false; +} + +/** + * @param {string} dbPath Absolute or relative path to the DB file (e.g. .../lbug). + * @param {number} myPid Current process PID (hook runner), excluded from matches. + */ +function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) { + if (!fs.existsSync(dbPath)) return false; + const dbPathAbs = path.resolve(dbPath); + + if (process.platform === 'win32') { + return hasGitNexusServerOwnerWindows(dbPathAbs, myPid); + } + + if (process.platform === 'linux') { + // #2180: cmdline-first procfs scan, no lsof. 'timeout' fails CLOSED + // (overloaded host self-throttles — the throttle the orphan-storm incident + // needed; the old lsof fallback ETIMEDOUT'd and failed closed on these same + // hosts anyway, only slower and with the orphan risk). 'not-owned' is the + // only false. See the fail matrix in the file header. + const verdict = linuxProcScanFindGitNexusServer(dbPathAbs, myPid); + return verdict !== 'not-owned'; + } + + return unixLsofPsFindGitNexusServer(dbPathAbs, myPid); +} + +module.exports = { + hasGitNexusDbLockedByGitNexusServer, + // Exported for white-box unit tests that must assert the tri-state verdict + // ('owned' | 'not-owned' | 'timeout') directly — the dispatcher collapses + // timeout and owned to the same boolean true, so the boolean API alone cannot + // distinguish the F1 EACCES->timeout fix from the old EACCES->owned bug. The + // Probe interface already declares this optional. Linux-only by contract; the + // name is pinned by a source-contract test. + linuxProcScanFindGitNexusServer, + // #2163 follow-up: the hook adapters wrap the augment CLI in the same + // guard. Returns a self-tested wrapper path — the built-in candidates are + // always absolute; a GITNEXUS_HOOK_TIMEOUT_PATH override is adopted as the + // exact string that passed the self-test. Same string is also the same + // RESOLUTION for absolute paths and for slashless names (PATH lookup is + // cwd-independent); a slash-containing RELATIVE override, however, is + // existsSync-checked and self-tested against this process's cwd while the + // adapters spawn the CLI with a `cwd` option (chdir-before-exec), so such + // a value can pass here yet ENOENT at the augment call site — set the + // override to an absolute path. Returns null when the wrapper is + // disabled/unavailable. Never call on win32 (see its JSDoc). + resolveUnixGuardTimeout, + // Exported for white-box unit tests of the numeric-env parsing (#2183 review): + // Number()-not-parseInt so "16e3" reads as 16000, plus the empty/whitespace + // guard that keeps a set-but-empty budget on the 1200 default instead of an + // immediate fail-closed timeout. Tested directly because the values are + // otherwise only observable indirectly through scan timing/escalation. + getCmdlineMaxBytes, + resolveLinuxProcBudgetMs, +}; diff --git a/gitnexus-factory-plugin/hooks/hook-lock.js b/gitnexus-factory-plugin/hooks/hook-lock.js new file mode 100644 index 000000000..759856384 --- /dev/null +++ b/gitnexus-factory-plugin/hooks/hook-lock.js @@ -0,0 +1,119 @@ +const fs = require('fs'); +const path = require('path'); + +const HOOK_LOCK_SUBDIR = '.hook-locks'; +const HOOK_LOCK_MAX_INFLIGHT = 3; +const HOOK_LOCK_STALE_MS = 30000; + +function acquireHookSlot(gitNexusDir) { + const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR); + try { + fs.mkdirSync(lockDir, { recursive: true }); + } catch { + // Cannot create lock dir (read-only fs, cross-user perm denial, out of + // inodes, etc.) — fail closed by returning null. Caller skips augment. + // Fail-open here would let N concurrent hooks all proceed unguarded and + // reintroduce the #1486 fan-out the guard exists to prevent. + return null; + } + + const myPidStr = String(process.pid); + + for (let slot = 0; slot < HOOK_LOCK_MAX_INFLIGHT; slot++) { + const slotPath = path.join(lockDir, `slot-${slot}.lock`); + for (let attempt = 0; attempt < 2; attempt++) { + try { + fs.writeFileSync(slotPath, myPidStr, { flag: 'wx' }); + let released = false; + const release = () => { + if (released) return; + released = true; + try { + // Only unlink if we still own the slot. If we appeared stale and + // another hook took over, the file now belongs to it — leave alone. + const content = fs.readFileSync(slotPath, 'utf-8').trim(); + if (content === myPidStr) fs.unlinkSync(slotPath); + } catch { + /* already removed or unreadable */ + } + }; + process.on('exit', release); + return release; + } catch { + // Slot exists. Decide whether to take it over. + // Open once and inspect mtime + content via the same fd so there's + // no TOCTOU between the metadata check and the content read + // (codeql js/file-system-race). + let fd; + try { + fd = fs.openSync(slotPath, 'r'); + } catch { + continue; // Vanished between EEXIST and open — retry this slot. + } + let isLive = false; + let mtimeMs = Date.now(); + try { + mtimeMs = fs.fstatSync(fd).mtimeMs; + const buf = Buffer.alloc(32); + const n = fs.readSync(fd, buf, 0, 32, 0); + const ownerStr = buf.slice(0, n).toString('utf-8').trim(); + if (ownerStr === '') { + // Owner created the file but hasn't written its PID yet. The + // wx open+write window is microseconds; give it the benefit + // of the doubt and treat as live. + isLive = true; + } else { + const owner = Number.parseInt(ownerStr, 10); + if (Number.isFinite(owner) && owner > 0) { + try { + process.kill(owner, 0); + isLive = true; + } catch (e) { + // ESRCH = process gone → treat as dead. EPERM = process exists + // but owned by another user (cross-user lock dir) → still alive, + // keep the slot. Anything else: be conservative, assume alive. + if (e && e.code === 'ESRCH') { + isLive = false; + } else { + isLive = true; + } + } + } + } + } catch { + /* unreadable — treat as dead */ + } finally { + try { + fs.closeSync(fd); + } catch { + /* already closed */ + } + } + // For slots younger than HOOK_LOCK_STALE_MS, PID-liveness wins — + // a slow-but-alive hook is never wrongly evicted. For older slots, + // age is the final arbiter as a defense against PID reuse on long- + // abandoned slots. 30s >> the 7s augment timeout, so a healthy run + // never crosses this threshold. + if (isLive && Date.now() - mtimeMs > HOOK_LOCK_STALE_MS) { + isLive = false; + } + if (isLive) break; // Try the next slot. + try { + fs.unlinkSync(slotPath); + } catch { + /* another hook beat us to it — retry will hit EEXIST */ + } + // Loop and retry this slot. + } + } + } + + return null; +} + +module.exports = { + HOOK_LOCK_SUBDIR, + HOOK_LOCK_MAX_INFLIGHT, + HOOK_LOCK_STALE_MS, + acquireHookSlot, +}; diff --git a/gitnexus-factory-plugin/hooks/win-rm-list-json.ps1 b/gitnexus-factory-plugin/hooks/win-rm-list-json.ps1 new file mode 100644 index 000000000..5c1564e30 --- /dev/null +++ b/gitnexus-factory-plugin/hooks/win-rm-list-json.ps1 @@ -0,0 +1,76 @@ +$ErrorActionPreference = 'Stop' +$target = $env:GITNEXUS_HOOK_RM_TARGET +if ([string]::IsNullOrWhiteSpace($target)) { Write-Output '[]'; exit 0 } +$target = (Resolve-Path -LiteralPath $target).ProviderPath + +if (-not ([Management.Automation.PSTypeName]'GitNexusHookRm.Native').Type) { +Add-Type @' +using System; +using System.Runtime.InteropServices; +namespace GitNexusHookRm { + public static class Native { + public const int ErrorMoreData = 234; + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public struct RM_UNIQUE_PROCESS { + public int dwProcessId; + public long ProcessStartTime; + } + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct RM_PROCESS_INFO { + public RM_UNIQUE_PROCESS Process; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] + public string strAppName; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] + public string strServiceShortName; + public uint ApplicationType; + public uint AppStatus; + public uint TSSessionId; + public uint bRestartable; + } + [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] + public static extern int RmStartSession(out uint pSessionHandle, uint dwSessionFlags, string strSessionKey); + [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] + public static extern int RmRegisterResources(uint pSessionHandle, uint nFiles, string[] rgsFileNames, uint nApplications, IntPtr rgApplications, uint nServices, string[] rgsServiceNames); + [DllImport("rstrtmgr.dll")] + public static extern int RmGetList(uint dwSessionHandle, out uint pnProcInfoNeeded, ref uint pnProcInfo, [In, Out] RM_PROCESS_INFO[] rgAffectedApps, ref uint lpdwRebootReasons); + [DllImport("rstrtmgr.dll")] + public static extern int RmEndSession(uint pSessionHandle); + } +} +'@ +} + +$h = [uint32]0 +$key = [guid]::NewGuid().ToString('N') +$rmErr = [GitNexusHookRm.Native]::RmStartSession([ref]$h, 0, $key) +if ($rmErr -ne 0) { Write-Output '[]'; exit 0 } +$files = @($target) +$err = [GitNexusHookRm.Native]::RmRegisterResources($h, 1, $files, 0, [IntPtr]::Zero, 0, $null) +if ($err -ne 0) { + [void][GitNexusHookRm.Native]::RmEndSession($h) + Write-Output '[]' + exit 0 +} +$need = [uint32]0 +$n = [uint32]0 +$reboot = [uint32]0 +$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $null, [ref]$reboot) +if ($err -ne [GitNexusHookRm.Native]::ErrorMoreData) { + [void][GitNexusHookRm.Native]::RmEndSession($h) + Write-Output '[]' + exit 0 +} +$n = $need +$buf = New-Object GitNexusHookRm.Native+RM_PROCESS_INFO[] ([int]$n) +$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $buf, [ref]$reboot) +[void][GitNexusHookRm.Native]::RmEndSession($h) +if ($err -ne 0) { Write-Output '[]'; exit 0 } + +$out = @() +for ($i = 0; $i -lt [int]$n; $i++) { + $procId = $buf[$i].Process.dwProcessId + $p = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$procId" -ErrorAction SilentlyContinue + $cmd = if ($p) { $p.CommandLine } else { '' } + $out += [PSCustomObject]@{ pid = [int]$procId; cmd = $cmd } +} +ConvertTo-Json -InputObject @($out) -Compress diff --git a/gitnexus/test/unit/factory-plugin.test.ts b/gitnexus/test/unit/factory-plugin.test.ts new file mode 100644 index 000000000..4d6f836e3 --- /dev/null +++ b/gitnexus/test/unit/factory-plugin.test.ts @@ -0,0 +1,336 @@ +/** + * Tests: GitNexus Factory AI (Droid) plugin + * + * Covers the standalone `gitnexus-factory-plugin/` used by `droid plugin + * install`: + * - manifest + hook wiring (plugin.json / mcp.json / hooks.json) + * - the PostToolUse search-augment hook's guard reuse and early-exit behavior + * - a drift guard proving the bundled guard modules are byte-identical to the + * canonical Claude-adapter copies (so a fix to one can't silently skip the + * other) + * + * The augment fan-out guard (acquireHookSlot) and the LadybugDB owner probe are + * the exact modules the Claude/Codex adapter ships; their internals are covered + * by hooks.test.ts and hook-db-lock-probe.test.ts. Here we assert the Factory + * hook WIRES them and behaves correctly on the Factory-specific paths. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { + runHook, + parseHookOutput, + createHookToolDir, + hookEnv, +} from '../utils/hook-test-helpers.js'; + +const REPO_ROOT = path.resolve(__dirname, '..', '..', '..'); +const PLUGIN_DIR = path.join(REPO_ROOT, 'gitnexus-factory-plugin'); +const HOOK = path.join(PLUGIN_DIR, 'hooks', 'gitnexus-hook.js'); +const HOOKS_JSON = path.join(PLUGIN_DIR, 'hooks', 'hooks.json'); +const PLUGIN_JSON = path.join(PLUGIN_DIR, '.factory-plugin', 'plugin.json'); +const MCP_JSON = path.join(PLUGIN_DIR, 'mcp.json'); +const CLAUDE_HOOKS = path.join(REPO_ROOT, 'gitnexus-claude-plugin', 'hooks'); + +// Guard modules bundled into the Factory plugin, kept byte-identical to the +// canonical Claude-adapter copies. +const BUNDLED_GUARDS = ['hook-lock.js', 'hook-db-lock-probe.cjs', 'win-rm-list-json.ps1'] as const; + +// ─── Manifest / file presence ─────────────────────────────────────── + +describe('Factory plugin files', () => { + it('ships the hook, its guards, and both manifests', () => { + for (const p of [ + HOOK, + HOOKS_JSON, + PLUGIN_JSON, + MCP_JSON, + ...BUNDLED_GUARDS.map((f) => path.join(PLUGIN_DIR, 'hooks', f)), + ]) { + expect(fs.existsSync(p), `${p} should exist`).toBe(true); + } + }); + + it('.factory-plugin holds only plugin.json (Factory manifest contract)', () => { + expect(fs.readdirSync(path.join(PLUGIN_DIR, '.factory-plugin'))).toEqual(['plugin.json']); + }); +}); + +// ─── Drift guard: bundled guards === canonical Claude copies ───────── + +describe('Factory plugin bundled guards stay in lockstep with the Claude adapter', () => { + for (const f of BUNDLED_GUARDS) { + it(`${f} is byte-identical to the canonical copy`, () => { + const bundled = fs.readFileSync(path.join(PLUGIN_DIR, 'hooks', f)); + const canonical = fs.readFileSync(path.join(CLAUDE_HOOKS, f)); + expect(bundled.equals(canonical)).toBe(true); + }); + } +}); + +// ─── plugin.json ──────────────────────────────────────────────────── + +describe('Factory plugin.json', () => { + const manifest = JSON.parse(fs.readFileSync(PLUGIN_JSON, 'utf-8')); + + it('is named gitnexus', () => { + expect(manifest.name).toBe('gitnexus'); + }); + + it('version matches gitnexus/package.json (single source of truth)', () => { + const pkg = JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, 'gitnexus', 'package.json'), 'utf-8'), + ); + expect(manifest.version).toBe(pkg.version); + }); +}); + +// ─── mcp.json ─────────────────────────────────────────────────────── + +describe('Factory mcp.json', () => { + const mcp = JSON.parse(fs.readFileSync(MCP_JSON, 'utf-8')); + + it('registers the gitnexus MCP server under mcpServers', () => { + expect(mcp.mcpServers?.gitnexus?.command).toBe('npx'); + expect(mcp.mcpServers.gitnexus.args).toContain('mcp'); + }); +}); + +// ─── hooks.json wiring ────────────────────────────────────────────── + +describe('Factory hooks.json wiring', () => { + const manifest = JSON.parse(fs.readFileSync(HOOKS_JSON, 'utf-8')); + const entry = manifest.PostToolUse[0]; + + it('registers a PostToolUse hook', () => { + expect(Array.isArray(manifest.PostToolUse)).toBe(true); + }); + + it('matches Factory search tools (Grep, Glob, Execute — not Bash)', () => { + expect(entry.matcher).toBe('Grep|Glob|Execute'); + expect(entry.matcher).not.toMatch(/\bBash\b/); + }); + + it('invokes the hook via the ${DROID_PLUGIN_ROOT} plugin-root variable', () => { + const command: string = entry.hooks[0].command; + expect(command).toContain('${DROID_PLUGIN_ROOT}'); + expect(command).toContain('hooks/gitnexus-hook.js'); + }); + + it('declares timeout in seconds (not milliseconds)', () => { + const timeout: number = entry.hooks[0].timeout; + expect(typeof timeout).toBe('number'); + expect(timeout).toBeGreaterThan(0); + expect(timeout).toBeLessThan(120); + }); +}); + +// ─── Source regressions ───────────────────────────────────────────── + +describe('Factory hook source regressions', () => { + const source = fs.readFileSync(HOOK, 'utf-8'); + + it('wires the augment fan-out guard (acquireHookSlot + finally release)', () => { + expect(source).toContain("require('./hook-lock.js')"); + expect(source).toContain('acquireHookSlot('); + expect(source).toMatch(/finally\s*\{[^}]*release\(\)/s); + }); + + it('wires the LadybugDB owner probe before running augment', () => { + expect(source).toContain("require('./hook-db-lock-probe.cjs')"); + expect(source).toContain('hasGitNexusDbLockedByGitNexusServer('); + }); + + it('never passes shell: true / shell: isWin to spawnSync (injection risk)', () => { + for (const line of source.split('\n')) { + const t = line.trim(); + if (t.startsWith('//') || t.startsWith('*')) continue; + expect(/shell:\s*(true|isWin)/.test(line), `injection risk: ${t}`).toBe(false); + } + }); + + it('invokes npx.cmd directly on Windows instead of a shell', () => { + expect(source).toContain('npx.cmd'); + }); + + it('passes the pattern after the -- end-of-options marker', () => { + expect(source).toMatch(/'augment',\s*'--',\s*pattern/); + }); + + it('validates cwd is absolute and gates on a non-registry .gitnexus dir', () => { + expect(source).toMatch(/path\.isAbsolute\(cwd\)/); + expect(source).toContain('isGlobalRegistryDir'); + }); + + it('emits Factory-shape hookSpecificOutput.additionalContext', () => { + expect(source).toContain('hookSpecificOutput'); + expect(source).toContain('additionalContext'); + }); + + it('rejects patterns shorter than 3 chars', () => { + expect(source).toMatch(/length\s*<\s*3/); + }); +}); + +// ─── Behavior: early-exit paths (no augment spawned) ──────────────── + +describe('Factory hook behavior — early exits', () => { + let tmpDir: string; + + beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-factory-early-')); + }); + afterAll(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + + it('exits cleanly on empty stdin', () => { + const r = spawnSync(process.execPath, [HOOK], { + input: '', + encoding: 'utf-8', + timeout: 10000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe(''); + }); + + it('exits cleanly on invalid JSON stdin', () => { + const r = spawnSync(process.execPath, [HOOK], { + input: 'not json', + encoding: 'utf-8', + timeout: 10000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe(''); + }); + + it('ignores non-PostToolUse events', () => { + const r = runHook(HOOK, { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }); + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe(''); + }); + + it('produces no output when cwd is relative', () => { + const r = runHook(HOOK, { + hook_event_name: 'PostToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: 'relative/path', + }); + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe(''); + }); + + it('produces no output when cwd has no .gitnexus index', () => { + const r = runHook(HOOK, { + hook_event_name: 'PostToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }); + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe(''); + }); + + it('produces no output for unmatched tools or Execute without rg/grep', () => { + for (const input of [ + { tool_name: 'MadeUpTool', tool_input: { foo: 'bar' } }, + { tool_name: 'Execute', tool_input: { command: 'ls -la' } }, + { tool_name: 'Grep', tool_input: { pattern: 'is' } }, // < 3 chars + ]) { + const r = runHook(HOOK, { hook_event_name: 'PostToolUse', cwd: tmpDir, ...input }); + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe(''); + } + }); +}); + +// ─── Behavior: happy path (augment via a fake gitnexus on PATH) ───── + +describe('Factory hook behavior — augment', () => { + let repoDir: string; + let binDir: string; + + beforeAll(() => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-factory-repo-')); + // Bare .gitnexus/ (no registry.json/repos) → treated as a repo index; no + // `lbug` file → the DB-owner probe short-circuits false and augment runs. + fs.mkdirSync(path.join(repoDir, '.gitnexus'), { recursive: true }); + binDir = createHookToolDir({ gitnexusStderr: '[GitNexus] graph context for validateUser' }); + }); + afterAll(() => { + fs.rmSync(repoDir, { recursive: true, force: true }); + fs.rmSync(binDir, { recursive: true, force: true }); + }); + + it('emits augment stderr as additionalContext for a Grep search', () => { + const r = runHook( + HOOK, + { + hook_event_name: 'PostToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: repoDir, + }, + undefined, + { env: hookEnv(binDir) }, + ); + expect(r.status).toBe(0); + const out = parseHookOutput(r.stdout); + expect(out?.hookEventName).toBe('PostToolUse'); + expect(out?.additionalContext).toContain('graph context for validateUser'); + }); +}); + +// ─── Behavior: fan-out guard skips when all slots are held ────────── + +describe('Factory hook behavior — augment fan-out guard', () => { + it('exits silently when all MAX_INFLIGHT slots hold live pids', async () => { + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-factory-slots-')); + const lockDir = path.join(repoDir, '.gitnexus', '.hook-locks'); + fs.mkdirSync(lockDir, { recursive: true }); + const binDir = createHookToolDir({ gitnexusStderr: 'should never be emitted' }); + + const { spawn } = await import('child_process'); + const sleepers = [0, 1, 2].map(() => + spawn(process.execPath, ['-e', 'setTimeout(()=>{},60000)'], { stdio: 'ignore' }), + ); + try { + sleepers.forEach((s, i) => + fs.writeFileSync(path.join(lockDir, `slot-${i}.lock`), String(s.pid)), + ); + + const r = runHook( + HOOK, + { + hook_event_name: 'PostToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: repoDir, + }, + undefined, + { env: hookEnv(binDir) }, + ); + + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe(''); + } finally { + for (const s of sleepers) { + try { + s.kill(); + } catch { + /* ignore */ + } + } + fs.rmSync(repoDir, { recursive: true, force: true }); + fs.rmSync(binDir, { recursive: true, force: true }); + } + }); +});