GitNexus/gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs
Joseph Yared b92c14cdd0
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
feat: add Factory AI (Droid) integration (#2543)
* feat(setup): add Factory Droid (MCP + skills) to gitnexus setup

Register 'droid' in the editor-targets abstraction so `gitnexus setup -c droid`
writes the MCP server to ~/.factory/mcp.json and installs skills to
~/.factory/skills/ from the single canonical skills/ source (no per-editor
copies). uninstall.ts is target-driven, so removal is covered automatically.
Adds unit + round-trip coverage.

* feat(plugin): add gitnexus-factory-plugin for droid plugin install

* docs: add Factory Droid to editor support table and setup docs

* fix(factory-plugin): guard augment hook against fan-out and DB contention

Reuse the Claude adapter's acquireHookSlot and LadybugDB owner probe
(bundled byte-identical, kept in lockstep by a drift test) instead of
running an unguarded augment. Add direct tests for the hook and manifests.

* docs: align Factory row in editor support table

* fix(factory-plugin): honor GITNEXUS_HOOK_CLI_PATH so augment runs on Windows

* docs(hooks): point bundled guard copies at their drift tests

* docs(factory-plugin): note the Execute tokenizer's quoting limit

* docs(readme): clarify the Full tier and group the Factory row

* docs(hooks): trim drift note to a single line

* test(ci): run factory-plugin tests on the windows cross-platform lane

* refactor(hooks): drop the drift-note comments, the tests already enforce it

* fix(factory-plugin): pin CLI version and parse quoted shell patterns

- Pin mcp.json and the hook's npx fallback to gitnexus@<version> from
  the plugin manifest, registered with the release sync script so a
  mutable @latest can never execute on MCP connect or augment fallback
- Port the #2938 shell tokenizer (tokenizeShellWords + parseRgGrepPattern)
  so quoted, backslash-escaped, --regexp=, -eVALUE, and -- patterns survive
- Add the #2938 regression matrix and pin assertions to factory-plugin.test.ts

* docs: add Factory Droid to published npm README

* fix(factory-plugin): wire marketplace so droid installs the Factory plugin

Add .factory-plugin/marketplace.json sourcing ./gitnexus-factory-plugin.
Droid reads it before .claude-plugin/marketplace.json, so
`droid plugin install` now delivers the Factory plugin (Execute matcher,
pinned mcp.json) instead of the translated Claude plugin (Bash matcher,
gitnexus@latest). Register the surface in the version-sync script and
cover the wiring in the factory and sync test suites.

* fix(factory-plugin): use registry lookup for index resolution

Bundle registry-query.cjs so external indexes resolve (#3060); re-pin to 1.6.12.

* fix(factory-plugin): sync Execute parser with Cursor hook

Fixes echo-rg and -f false positives; tighten test env isolation.

* fix(factory-plugin): stop no-match augment from re-running via npx

A PATH `gitnexus` that finds no match exits 0 with empty stderr, which
fell through to a second `npx -y gitnexus@<pin> augment` with its own 8s
timeout (16s worst case vs the 10s hook budget). Fall through to npx only
when the PATH launcher is missing (ENOENT); any launched PATH binary,
including a timeout or non-zero exit, now ends the augment.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(factory-plugin): filter augment stderr to the [GitNexus] block

runAugment returned raw child stderr, so npm/Node/LadybugDB warnings leaked
into additionalContext and noise-only stderr counted as success. Port the
Claude adapter's extractAugmentContext (verbatim, with isDebugEnabled) and
apply it on every launch tier before the success decision. Adds a drift test
against the Claude copy and PATH-tier noise/noise-only behavior tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(factory-plugin): quote DROID_PLUGIN_ROOT in hook command

An unquoted plugin root containing spaces (e.g. a Windows user profile
path) split into multiple argv words, so the PostToolUse hook silently
never ran. Quote it like the Claude plugin does, and pin the exact
quoted command in the hooks.json wiring test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(release): stage Factory plugin manifests in the release commit

The rc release job stages only the original four manifest surfaces in the
detached release commit, so the v<version> tag tree carried the Factory
plugin.json, mcp.json and marketplace.json at the previous version while
--check (working tree) passed. Stage them too, and guard the git add block
against the synced surfaces in sync-plugin-manifests.test.ts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(factory-plugin): simplify hook gates, spawn tiers and tests

- main(): resolve the repo only after the tool-name and pattern gates,
  matching the Claude/Cursor hook order (skips fs/git work on no-op calls).
- runAugment(): share one spawnAugment helper between the
  GITNEXUS_HOOK_CLI_PATH and npx tiers; PATH tier ENOENT logic unchanged.
- factory-plugin test: pre-filter comment lines instead of `continue`.
- sync-plugin-manifests test: hoist EXECUTABLE_MCP_FILES and derive
  TOTAL_SURFACES from its length.
- fnSource(): throw when the function or its closing brace is not found.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(cli): list Factory Droid in localized setup help

`localizeCliHelp` overwrites the `setup` command description with the
`help.command.setup.description` i18n key, so the literal edited in
index.ts never reached `gitnexus setup --help`. Add Factory Droid to the
en and zh-CN keys.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Address PR review feedback (#2543)

- factory hook: run every augment tier under the bundled Unix timeout
  guard (npx tier group-kills), keeping exactly-one-tier fall-through
- hook-db-lock-probe: trim GITNEXUS_HOOK_{LSOF,PS}_PATH once so a padded
  override is used, not silently replaced (all 3 copies)
- hook-lock: evict a stale slot via rename-to-tombstone + identity check,
  so a concurrently recreated fresh lock is never deleted (all 4 copies)
- registry-query: a set-but-invalid storage override resolves no repo
  instead of falling back to the registry storagePath (all 4 copies)
- publish.yml: stage the ten skill mcp.json manifests in the rc release
  commit; the staging test now requires every synced surface

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Address PR review feedback round 2 (#2543)

- hook-lock: replace rename-to-tombstone eviction with an O_EXCL per-slot
  `.evicting` marker plus an identity re-check before unlink, so a live
  lock is never moved, and a crashed evictor leaves only a self-expiring
  marker (all 4 copies)
- hook-db-lock-probe: clamp GITNEXUS_HOOK_PROC_CMDLINE_MAX to a named
  256 KiB ceiling and require an integer, so an oversized override can
  no longer fail the buffer allocation and miss a live owner (all 3 copies)
- registry-query: treat an empty GITNEXUS_STORAGE_PATH/ROOT as set but
  invalid, matching the CLI's `!== undefined` rule (all 4 copies); the
  factory test env now deletes those keys instead of blanking them

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Address PR review feedback round 3 (#2543)

- hook-db-lock-probe: a capped /proc cmdline read stops early only once
  both the GitNexus token and the mcp/serve mode are present (or at EOF,
  the ceiling, or the budget), so a mode word such as `--require mcp`
  before the GitNexus path no longer hides a live owner (all 3 copies)
- registry-query: correct the override comment; a filesystem root is
  invalid only for GITNEXUS_STORAGE_PATH, not GITNEXUS_STORAGE_ROOT
  (all 4 copies, comment only)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Address PR review feedback round 4 (#2543)

- hook-db-lock-probe: an fd-directory read error other than ENOENT or
  ENOTDIR on an identified server candidate now fails closed ('timeout')
  instead of reporting not-owned (EMFILE/ENFILE/ENOMEM/EINTR)
- hook-db-lock-probe: resolve GITNEXUS_HOOK_TIMEOUT_PATH to an absolute
  path before validating and caching it, so callers that spawn with a
  request cwd can still execute the guard
- hook-db-lock-probe: document the chunked cmdline read's actual stop
  conditions (all 3 copies)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Harden hook-lock eviction marker lifecycle (#2543)

Per the chosen option (B) for the stale-slot eviction race:
- `.evicting` markers carry a per-call owner token (pid + random hex)
- an evictor re-reads its token immediately before the slot identity
  check and unlink; a stalled evictor whose marker was broken backs off
- `finally` removes the marker only while it still holds our token
- an orphaned marker is broken only if, re-checked just before unlink,
  its bigint identity and token are unchanged from when judged stale
- doc comment states the two remaining two-syscall windows (slot
  lstat->unlink, marker token->unlink); POSIX has no conditional
  unlink, and the worst case is one extra concurrent augment

All four byte-identical hook-lock copies updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Fix CodeQL file-system race in hook-lock orphan-marker check (#2543)

breakOrphanedMarker stat'd the marker by path and then read it by path,
which CodeQL flags (js/file-system-race): the file could be replaced
between the two calls. Take the stat and the token from one open
descriptor (readMarkerSnapshot, O_NOFOLLOW where available) for both the
"judged stale" snapshot and the pre-unlink re-check. All four hook-lock
copies updated.

The replaced-marker test injected its swap via a readFileSync(path) spy,
which no longer fires; it now swaps the marker just before its second
open, counting opens of the marker path only (a per-path counter fired
early on slot-0 and let a mutant pass).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Unregister hook-lock exit listener on release (#2543)

Each acquireHookSlot registered `release` as a process 'exit' listener
that was never removed, so a long-lived process acquiring and releasing
slots repeatedly would accumulate listeners (MaxListenersExceededWarning)
and retain every closure. release() now removes itself. All four
hook-lock copies updated; a test asserts 12 acquire/release cycles leave
the 'exit' listener count unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 19:12:40 +01:00

759 lines
36 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: cmdline-first procfs scan under /proc, no lsof at all (#2180). Three
* phases, cheapest first: (0) read /proc/<pid>/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/<pid>/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/<pid>/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 <budget> 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';
// Trim once, exactly as hasMissingHookBinaryOverride does, so a padded but
// valid override (" /tmp/lsof ") is both accepted there and used here.
const fromEnv = process.env[envKey] ? String(process.env[envKey]).trim() : '';
if (fromEnv && fs.existsSync(fromEnv)) {
return 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 = [];
// Resolve the override against THIS process's cwd — the directory the
// existsSync check and the self-test run in — so the cached/returned path is
// always absolute. The adapters spawn the wrapper with a different `cwd`
// (the tool request's), where a relative value would resolve elsewhere
// (ENOENT), and a slashless name would switch to a PATH lookup.
const override = trimmed ? path.resolve(trimmed) : '';
if (override && fs.existsSync(override)) candidates.push(override);
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 <file>.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/<pid>/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 <abs path to .../node_modules/gitnexus/dist/cli/index.js> 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 via GITNEXUS_HOOK_PROC_CMDLINE_MAX: an integer in
// [PROC_CMDLINE_FLOOR, PROC_CMDLINE_CEIL] is used as-is; a larger integer is
// CLAMPED to PROC_CMDLINE_CEIL; anything else (below the floor, fractional,
// non-numeric, Infinity) falls back to the 16 KiB default.
const PROC_CMDLINE_FLOOR = 4096;
// Upper bound for a single cmdline read chunk, and the absolute ceiling of the
// escalation path in readLinuxCmdline (same 256 KiB — no single read may exceed
// what the whole escalation is allowed to collect). Without it, an oversized
// override (e.g. 2**40 — past buffer.constants.MAX_LENGTH on older Node lines
// and unallocatable in practice on any) made Buffer.allocUnsafe throw; readLinuxCmdline's catch turned that into '' (a
// NON-candidate), so a real server owner was silently missed (fail-OPEN, the
// #1492 race). Oversized values are clamped rather than defaulted: the operator
// asked for MORE bytes, and the ceiling is the most the read will ever collect
// anyway, so clamping honours the intent while keeping allocation bounded.
const PROC_CMDLINE_CEIL = 262144;
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;
// Number.isInteger rejects NaN, +/-Infinity and fractions (a fractional
// Buffer/readSync length is not a byte count).
if (Number.isInteger(n) && n >= PROC_CMDLINE_FLOOR) return Math.min(n, PROC_CMDLINE_CEIL);
return 16384;
}
// Phase 0 comm prefilter. /proc/<pid>/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/<pid>/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 in `cap`-sized chunks and stop as soon as the text
// holds both server tokens, at EOF, at PROC_CMDLINE_CEIL, or when the scan
// budget runs out (see below). 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, so almost every
// process is decided by the first read hitting EOF; (b) a read that fills the
// cap stops early ONLY once it holds BOTH tokens (decided owner). Holding one
// token, or neither, decides nothing: interpreter flags can put a mode-looking
// word first (`node --require mcp .../gitnexus/... serve`) or push the gitnexus
// path past the first chunk. So we keep reading in cap-sized chunks until both
// tokens appear, the file ends, or the hard ceiling is reached. Only processes
// that passed the Phase 0 comm prefilter AND have a cmdline longer than the cap
// ever escalate, and each escalation step is budget-gated (below).
//
// 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 = PROC_CMDLINE_CEIL; // 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 only when the partial read is DECIDED: both the gitnexus
// token and a mode token are present (isGitNexusServerCommand is exactly
// that conjunction). A partial read missing either token is undecided —
// the missing one may lie past the chunk boundary — so it keeps reading.
if (isGitNexusServerCommand(text)) break; // decided (positive)
if (bytes < chunkCap) break; // EOF: full cmdline read, definitive
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/<pid>/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';
}
if (code === 'ENOTDIR') {
// The fd path is not a directory at all, so this is structurally not a
// real /proc/<pid>/fd — not a plausible live owner. Move on.
debugLog(
`fd dir not a directory for candidate pid ${pidStr} (ENOTDIR); ` +
`treating candidate as non-owner -> continue`,
);
continue;
}
// Any other error (EMFILE, ENFILE, ENOMEM, EINTR, no code, …) says nothing
// about whether this already-identified server candidate holds the lbug.
// Only ENOENT/ENOTDIR above establish non-ownership; everything else is
// inconclusive and fails closed via 'timeout', same as EACCES/EIO.
debugLog(
`fd dir read failed for candidate pid ${pidStr} ` +
`(${code || 'unknown'}); probe inconclusive -> fail-closed (timeout)`,
);
return 'timeout';
}
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, always-ABSOLUTE wrapper path: the built-in
// candidates are absolute, and a GITNEXUS_HOOK_TIMEOUT_PATH override is
// path.resolve()d against this process's cwd before its existsSync check
// and self-test, so the adapters can spawn it under any `cwd` option.
// 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,
// Exported for white-box tests pinning that the override check and the
// override lookup agree on whitespace-padded GITNEXUS_HOOK_{LSOF,PS}_PATH.
resolveHookBinary,
hasMissingHookBinaryOverride,
};