mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-07 02:58:02 +00:00
* fix(claude): skip augment hook when server owns db * chore(autofix): apply prettier + eslint fixes via /autofix command * fix(hooks): cross-platform DB lock probe for MCP owner guard Extract hook-db-lock-probe.cjs with a single hasGitNexusDbLockedByGitNexusServer entry point used by both Claude hooks: - Linux: scan /proc/<pid>/fd via dev+inode (no lsof required), optional lsof fallback; GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS caps scan time - macOS and other Unix: trusted lsof + ps (absolute paths / env overrides) - Windows: Restart Manager + Win32_Process via win-rm-list-json.ps1 and GITNEXUS_HOOK_POWERSHELL_PATH Update hooks.test.ts source coverage for the probe module. Co-authored-by: Cursor <cursoragent@cursor.com> * Update gitnexus/hooks/claude/win-rm-list-json.ps1 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Apply suggestion from @github-actions[bot] Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * fix(gitnexus): repair package.json JSON after malformed engines edit Co-authored-by: Cursor <cursoragent@cursor.com> * Update Node.js engine version requirement to 22.0.0 * Update Node.js engine version to >=22.0.0 * fix(hooks): address ce-code-review findings on PR #1493 P0: - Replace malformed `RM_UNIQUE_PROCESS` block in `gitnexus/hooks/claude/win-rm-list-json.ps1` (duplicate struct decl + duplicate `ProcessStartTime` + unbalanced braces) with a single well-formed `[StructLayout(LayoutKind.Sequential, Pack = 4)]` struct, so PowerShell `Add-Type` actually compiles and the Windows DB-lock probe stops fail-open on every machine. - `gitnexus/src/cli/setup.ts` now copies `hook-db-lock-probe.cjs` and `win-rm-list-json.ps1` into the user's `~/.claude/hooks/gitnexus/` alongside `hook-lock.cjs`, preventing the `MODULE_NOT_FOUND` thrown by `gitnexus-hook.cjs:18`'s top-level require on every fresh install. `gitnexus/test/unit/setup.test.ts` extended to assert both new copy destinations. - Four fail-open hook tests (`ENOENT lsof`, `npx parent line`, `non-GitNexus ps line`, `ps ENOENT`) now seed `createHookToolDir` with a valid `[GitNexus]` stderr line so `expect(parseHookOutput).not.toBeNull()` actually holds on CI. P1: - Plugin copy of `win-rm-list-json.ps1` gains `Pack = 4` so its CLR struct matches the 12-byte native `RM_UNIQUE_PROCESS` layout (multi-blocker `RmGetList` no longer reads mangled `dwProcessId`). - `GITNEXUS_HOOK_CLI_PATH = ''` now falls through to the resolution chain in `gitnexus-hook.cjs`, matching the plugin copy and removing the twin-file divergence on empty-string envs. - Lock-warning suppression test seeds `gitnexusMarkerPath` and asserts the augment subprocess actually ran, plus `GITNEXUS_DEBUG=1` preserves the full discarded prefix. - MCP-owner skip branch in both hook copies now emits `[GitNexus] augment skipped: MCP server owns DB` on stderr, so agents can distinguish intentional skip from silent failure. P2: - `ps` loop in `hook-db-lock-probe.cjs` fails-closed on `ETIMEDOUT` to mirror the `lsof` handling (symmetric subprocess-probe contract). - `RmStartSession` return value captured in both `.ps1` copies; exits early with `[]` on non-zero so subsequent RM API calls don't operate on an invalid handle. - Windows RM-list `.ps1` encoded cache distinguishes uninitialized (`undefined`) from load-failed (`null`) with a one-shot `GITNEXUS_DEBUG` warning instead of silently caching empty string. - `createHookToolDir` helper accepts `lsofOutputLines` and `psOutputByPid`; the multi-PID test uses them instead of duplicating the fake-binary construction inline. - All five skip-path tests now assert `result.status === 0` and the new skip-signal stderr line. - `AGENTS.md` documents the seven hook configuration env vars (`GITNEXUS_HOOK_CLI_PATH`, `_LSOF_PATH`, `_PS_PATH`, `_POWERSHELL_PATH`, `_LINUX_PROC_BUDGET_MS`, `_RM_TARGET`, `GITNEXUS_DEBUG`). - `GITNEXUS_DEBUG` path in `gitnexus-hook.cjs`/`.js` writes the full discarded stderr prefix instead of a 180-char preview. - Inline comment in `hook-db-lock-probe.cjs` explains the intentional Windows ETIMEDOUT fail-closed semantics. - Removed the unnecessary `as WriteFileOptions` cast and orphaned `import type { WriteFileOptions }` in `hooks.test.ts`. P3: - `isGitNexusServerCommand` unexported from `hook-db-lock-probe.cjs` (kept as private helper). - Env-path overrides (`GITNEXUS_HOOK_CLI_PATH`, `_POWERSHELL_PATH`, `_LSOF_PATH`, `_PS_PATH`) require `fs.existsSync` before being returned, so typos / stale config fall through to the standard resolution chain. Misc: - `gitnexus/package.json` engines.node back to `>=22.0.0` (matches origin/main and the original PR reviewer's earlier request). Twin-tree parity / CI sync mechanism tracked separately at abhigyanpatwari/GitNexus#1591. Test plan: vitest run test/unit/hooks.test.ts → 113 passed, 18 Unix-only skipped; setup.test.ts → 14 passed. * chore(autofix): apply prettier + eslint fixes via /autofix command * trigger --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: Cursor <cursoragent@cursor.com>
238 lines
7.6 KiB
JavaScript
238 lines
7.6 KiB
JavaScript
/**
|
|
* 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: scan procfs under /proc (per-PID fd entries) via stat(2) (dev+inode); works without lsof;
|
|
* optional lsof fallback when proc scan finds nothing.
|
|
* - 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-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or
|
|
* PowerShell ETIMEDOUT (Windows), matching the hook contract.
|
|
*/
|
|
|
|
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;
|
|
}
|
|
|
|
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 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'],
|
|
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;
|
|
}
|
|
|
|
function readLinuxCmdline(pidStr) {
|
|
try {
|
|
return fs.readFileSync(`/proc/${pidStr}/cmdline`, 'utf8').replace(/\0+/g, ' ').trim();
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
|
|
const raw = process.env.GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS;
|
|
const budget = Number(raw && String(raw).trim()) ? Number.parseInt(String(raw), 10) : 1200;
|
|
const start = Date.now();
|
|
let targetStat;
|
|
try {
|
|
targetStat = fs.statSync(dbPathAbs);
|
|
} catch {
|
|
return false;
|
|
}
|
|
let procEntries;
|
|
try {
|
|
procEntries = fs.readdirSync('/proc', { withFileTypes: true });
|
|
} catch {
|
|
return false;
|
|
}
|
|
for (const ent of procEntries) {
|
|
if (Date.now() - start > budget) return false;
|
|
if (!ent.isDirectory() || !/^\d+$/.test(ent.name)) continue;
|
|
const pid = Number.parseInt(ent.name, 10);
|
|
if (!Number.isFinite(pid) || pid === myPid) continue;
|
|
const fdDir = path.join('/proc', ent.name, 'fd');
|
|
let fds;
|
|
try {
|
|
fds = fs.readdirSync(fdDir);
|
|
} catch {
|
|
continue;
|
|
}
|
|
let holds = false;
|
|
for (const fd of fds) {
|
|
if (Date.now() - start > budget) return false;
|
|
try {
|
|
const st = fs.statSync(path.join(fdDir, fd));
|
|
if (st.dev === targetStat.dev && st.ino === targetStat.ino) {
|
|
holds = true;
|
|
break;
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
if (!holds) continue;
|
|
if (isGitNexusServerCommand(readLinuxCmdline(ent.name))) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
|
|
const lsofPath = resolveHookBinary('lsof');
|
|
const lsof = spawnSync(lsofPath, ['-nP', '-t', '--', dbPathAbs], {
|
|
encoding: 'utf-8',
|
|
timeout: 1000,
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
});
|
|
if (lsof.error) return lsof.error.code === 'ETIMEDOUT';
|
|
|
|
const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean);
|
|
const psPath = resolveHookBinary('ps');
|
|
for (const pid of pids) {
|
|
if (Number(pid) === myPid) continue;
|
|
const ps = spawnSync(psPath, ['-p', pid, '-o', 'command='], {
|
|
encoding: 'utf-8',
|
|
timeout: 500,
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
});
|
|
if (ps.error) {
|
|
if (ps.error.code === 'ETIMEDOUT') return true;
|
|
continue;
|
|
}
|
|
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') {
|
|
if (linuxProcScanFindGitNexusServer(dbPathAbs, myPid)) return true;
|
|
return unixLsofPsFindGitNexusServer(dbPathAbs, myPid);
|
|
}
|
|
|
|
return unixLsofPsFindGitNexusServer(dbPathAbs, myPid);
|
|
}
|
|
|
|
module.exports = {
|
|
hasGitNexusDbLockedByGitNexusServer,
|
|
};
|