GitNexus/gitnexus/test/unit/hook-db-lock-probe.test.ts
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

912 lines
40 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Direct unit tests for the Linux cmdline-first DB-owner scan (#2180).
*
* These exercise linuxProcScanFindGitNexusServer / hasGitNexusDbLockedByGitNexusServer
* against a FAKE /proc tree (GITNEXUS_HOOK_PROC_ROOT) so the three-phase logic
* (comm -> cmdline -> fd dev+ino) is asserted deterministically, without
* scanning the test host's real /proc. One live e2e at the bottom uses the REAL
* /proc to protect the "lbug handle is fd-visible" property the scan relies on.
*
* The probe is a CJS module; we require it through createRequire and toggle env
* per-test. resetModules-style isolation is unnecessary because the only
* module-level cache (unixGuardTimeoutCache) is on the macOS/Unix path, which
* these Linux tests never reach.
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import { createRequire } from 'node:module';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { spawn, spawnSync } from 'child_process';
import { createFakeProcRoot, type FakeProcEntry } from '../utils/hook-test-helpers.js';
const PROBE_PATH = path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'hook-db-lock-probe.cjs');
type Probe = {
hasGitNexusDbLockedByGitNexusServer: (dbPath: string, myPid: number) => boolean;
linuxProcScanFindGitNexusServer?: (dbPathAbs: string, myPid: number) => string;
getCmdlineMaxBytes?: () => number;
resolveLinuxProcBudgetMs?: () => number;
resolveHookBinary?: (tool: 'lsof' | 'ps') => string;
hasMissingHookBinaryOverride?: (tool: 'lsof' | 'ps') => boolean;
};
const probe = createRequire(import.meta.url)(PROBE_PATH) as Probe;
// The probe now exports linuxProcScanFindGitNexusServer unconditionally (F1
// white-box verdict assertions). Narrow it once here to a non-optional typed
// fn so the per-test call sites stay assertion-free; a dedicated test below
// pins that the export really is a function.
type ScanVerdictFn = (dbPathAbs: string, myPid: number) => string;
const scanVerdictFn = probe.linuxProcScanFindGitNexusServer as ScanVerdictFn;
const isLinux = process.platform === 'linux';
// ── env scoping helpers ────────────────────────────────────────────
const ENV_KEYS = [
'GITNEXUS_HOOK_PROC_ROOT',
'GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS',
'GITNEXUS_HOOK_PROC_CMDLINE_MAX',
'GITNEXUS_HOOK_LSOF_PATH',
'GITNEXUS_HOOK_PS_PATH',
] as const;
const savedEnv: Record<string, string | undefined> = {};
function setEnv(overrides: Record<string, string | undefined>) {
for (const k of ENV_KEYS) {
if (!(k in savedEnv)) savedEnv[k] = process.env[k];
}
for (const [k, v] of Object.entries(overrides)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
}
const cleanups: Array<() => void> = [];
afterEach(() => {
for (const k of Object.keys(savedEnv)) {
const v = savedEnv[k];
if (v === undefined) delete process.env[k];
else process.env[k] = v;
delete savedEnv[k];
}
while (cleanups.length) {
try {
cleanups.pop()!();
} catch {
/* best-effort */
}
}
});
/**
* Build a temp lbug + a fake /proc root, run the dispatcher with the fake root,
* and return the boolean owner verdict. The lbug is the dev+ino the fake fd
* symlinks point at, so a holder whose fdTargets include `lbug` is a true owner.
*/
function runScan(
entries: (lbugPath: string) => FakeProcEntry[],
env: Record<string, string | undefined> = {},
): { owned: boolean; lbugPath: string } {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-probe-'));
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
const lbugPath = path.join(dir, 'lbug');
fs.writeFileSync(lbugPath, '');
const procRoot = createFakeProcRoot(entries(lbugPath));
cleanups.push(() => fs.rmSync(procRoot, { recursive: true, force: true }));
setEnv({ GITNEXUS_HOOK_PROC_ROOT: procRoot, ...env });
const owned = probe.hasGitNexusDbLockedByGitNexusServer(lbugPath, 1);
return { owned, lbugPath };
}
const GITNEXUS_MCP_ARGV = (script: string) => ['node', script, 'mcp'];
// ── Hook binary override: check and lookup agree on trimming (#2543 review) ──
//
// unixLsofPsFindGitNexusServer calls hasMissingHookBinaryOverride (trims) and
// then resolveHookBinary. If the lookup did NOT trim, a padded-but-valid
// override such as " /tmp/lsof " passed the missing-check yet fell through to
// the built-in/PATH binary. Both helpers must see the same trimmed path.
describe('hook binary override trimming (white-box, #2543 review)', () => {
const resolveBin = probe.resolveHookBinary as (tool: 'lsof' | 'ps') => string;
const missing = probe.hasMissingHookBinaryOverride as (tool: 'lsof' | 'ps') => boolean;
function makeFakeBinary(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hookbin-'));
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
const bin = path.join(dir, 'fake-tool');
fs.writeFileSync(bin, '');
return bin;
}
it.each(['lsof', 'ps'] as const)(
'%s: whitespace-padded override to an existing file resolves to the trimmed path',
(tool) => {
const bin = makeFakeBinary();
const envKey = tool === 'lsof' ? 'GITNEXUS_HOOK_LSOF_PATH' : 'GITNEXUS_HOOK_PS_PATH';
setEnv({ [envKey]: ` ${bin}\t ` });
expect(missing(tool)).toBe(false);
expect(resolveBin(tool)).toBe(bin);
},
);
it.each(['', ' '])('empty/whitespace override %j is ignored by both helpers', (value) => {
setEnv({ GITNEXUS_HOOK_LSOF_PATH: value });
expect(missing('lsof')).toBe(false);
expect(resolveBin('lsof').trim()).not.toBe('');
});
it('missing-path override is reported missing and never returned by the lookup', () => {
const absent = path.join(os.tmpdir(), 'gitnexus-hookbin-does-not-exist', 'lsof');
setEnv({ GITNEXUS_HOOK_LSOF_PATH: ` ${absent} ` });
expect(missing('lsof')).toBe(true);
expect(resolveBin('lsof')).not.toBe(absent);
});
});
// ── Numeric env parsing (white-box, #2183 review) ──────────────────────
//
// getCmdlineMaxBytes / resolveLinuxProcBudgetMs switched from parseInt(.,10) to
// Number() so scientific notation ("16e3") parses as 16000 instead of 16. These
// are platform-independent (pure string->number), so they run on every OS, not
// just Linux. The load-bearing case is the EMPTY-STRING budget regression guard:
// a naive parseInt->Number swap would make a set-but-empty
// GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS="" resolve to Number("")===0 => budget 0 =>
// immediate fail-CLOSED timeout (augment permanently skipped). The added
// `&& String(raw).trim()` guard keeps ''/whitespace on the 1200 default.
// GITNEXUS_HOOK_TIMEOUT_PATH relative override (#2543 review): the adapters
// spawn the returned wrapper with the tool request's `cwd`, so the probe must
// hand back an ABSOLUTE path resolved against the directory its existsSync
// check and self-test ran in. The resolution is memoized per module instance,
// so each case runs in a fresh `node` child whose cwd holds a self-testing
// guard script (`-k <k> <budget> cmd…` -> exec cmd…).
describe.skipIf(process.platform === 'win32')(
'GITNEXUS_HOOK_TIMEOUT_PATH relative override resolves to absolute (#2543 review)',
() => {
it.each(['./fake-guard', 'fake-guard'])('override %s -> path.resolve(value)', (value) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-probe-relguard-'));
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
fs.writeFileSync(path.join(dir, 'fake-guard'), '#!/bin/sh\nshift 3\nexec "$@"\n', {
mode: 0o755,
});
const script =
`const p=require(${JSON.stringify(PROBE_PATH)});` +
`process.stdout.write(JSON.stringify({cwd:process.cwd(),guard:p.resolveUnixGuardTimeout()}));`;
const child = spawnSync(process.execPath, ['-e', script], {
cwd: dir,
encoding: 'utf-8',
env: { ...process.env, GITNEXUS_HOOK_TIMEOUT_PATH: value },
timeout: 15000,
});
expect(child.status).toBe(0);
const out = JSON.parse(child.stdout) as { cwd: string; guard: string | null };
expect(out.guard).toBe(path.resolve(out.cwd, value));
expect(path.isAbsolute(out.guard ?? '')).toBe(true);
});
},
);
describe('numeric env parsing (white-box, #2183 review)', () => {
const budget = probe.resolveLinuxProcBudgetMs as () => number;
const cmdlineMax = probe.getCmdlineMaxBytes as () => number;
it('exports the two parse helpers as functions', () => {
expect(typeof probe.resolveLinuxProcBudgetMs).toBe('function');
expect(typeof probe.getCmdlineMaxBytes).toBe('function');
});
it('budget: "16e3" parses as 16000 (scientific notation), not 16', () => {
// parseInt('16e3',10) === 16 (stops at 'e'); Number('16e3') === 16000.
setEnv({ GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '16e3' });
expect(budget()).toBe(16000);
});
it('budget: set-but-empty "" and whitespace fall back to 1200, NOT 0 (regression guard)', () => {
// The deepening catch: without the `&& String(raw).trim()` guard these would
// be Number('')===0 => an immediate fail-closed timeout on every hook call.
for (const empty of ['', ' ', '\t']) {
setEnv({ GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: empty });
expect(budget()).toBe(1200);
}
});
it('budget: "0" still parses to 0 (the deliberate #2180 immediate-timeout vector)', () => {
setEnv({ GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '0' });
expect(budget()).toBe(0);
});
it('budget: trailing garbage "123abc" and unset fall back to 1200', () => {
setEnv({ GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '123abc' });
expect(budget()).toBe(1200);
setEnv({ GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: undefined });
expect(budget()).toBe(1200);
});
it('cmdline max: "8e3" parses as 8000 (>= floor); "2e3" (=2000, below floor) and ""/unset -> 16384', () => {
setEnv({ GITNEXUS_HOOK_PROC_CMDLINE_MAX: '8e3' });
expect(cmdlineMax()).toBe(8000);
setEnv({ GITNEXUS_HOOK_PROC_CMDLINE_MAX: '2e3' });
expect(cmdlineMax()).toBe(16384);
setEnv({ GITNEXUS_HOOK_PROC_CMDLINE_MAX: '' });
expect(cmdlineMax()).toBe(16384);
setEnv({ GITNEXUS_HOOK_PROC_CMDLINE_MAX: undefined });
expect(cmdlineMax()).toBe(16384);
});
// #2543 review: an oversized-but-finite override used to reach
// Buffer.allocUnsafe unchanged; the allocation threw, readLinuxCmdline's catch
// returned '' (non-candidate) and a real owner was silently missed. Oversized
// integers now clamp to the 256 KiB ceiling (the escalation's HARD_CEIL).
it.each([
['262145', 262144],
[String(2 ** 40), 262144],
[String(Number.MAX_SAFE_INTEGER), 262144],
['1e300', 262144],
])('cmdline max: oversized %s clamps to the 256 KiB ceiling', (raw, expected) => {
setEnv({ GITNEXUS_HOOK_PROC_CMDLINE_MAX: raw });
expect(cmdlineMax()).toBe(expected);
});
it.each(['4096', '8000', '16384', '262144'])(
'cmdline max: in-range integer %s is returned unchanged',
(raw) => {
setEnv({ GITNEXUS_HOOK_PROC_CMDLINE_MAX: raw });
expect(cmdlineMax()).toBe(Number(raw));
},
);
it.each(['Infinity', '-Infinity', 'NaN', '8192.5', '8abc', '4095', '-1'])(
'cmdline max: non-integer / garbage / below-floor %s falls back to the 16384 default',
(raw) => {
setEnv({ GITNEXUS_HOOK_PROC_CMDLINE_MAX: raw });
expect(cmdlineMax()).toBe(16384);
},
);
});
describe.skipIf(!isLinux)('Linux cmdline-first DB-owner scan (#2180)', () => {
// ── D1: three-phase correctness ──────────────────────────────────
it('owned: a gitnexus mcp process holding the lbug fd is detected', () => {
const { owned } = runScan((lbug) => [
{
pid: 4242,
comm: 'MainThread', // real gitnexus servers report this on modern Node
cmdline: GITNEXUS_MCP_ARGV('/opt/app/node_modules/gitnexus/dist/cli/index.js'),
fdTargets: ['/dev/null', lbug],
},
]);
expect(owned).toBe(true);
});
it('owned: an oversized GITNEXUS_HOOK_PROC_CMDLINE_MAX (2**40) does not blind the scan (#2543 review)', () => {
// Pre-fix, the 2**40 cap reached Buffer.allocUnsafe, threw, and the catch
// mapped it to '' (non-candidate) => a real owner silently reported false.
const { owned } = runScan(
(lbug) => [
{
pid: 4243,
comm: 'MainThread',
cmdline: GITNEXUS_MCP_ARGV('/opt/app/node_modules/gitnexus/dist/cli/index.js'),
fdTargets: [lbug],
},
],
{ GITNEXUS_HOOK_PROC_CMDLINE_MAX: String(2 ** 40) },
);
expect(owned).toBe(true);
});
it('not-owned: a node process that is not a gitnexus server (even holding the lbug) is ignored', () => {
const { owned } = runScan((lbug) => [
{
pid: 5555,
comm: 'node',
cmdline: ['node', '/some/app/server.js'],
fdTargets: [lbug], // holds the fd, but cmdline is not a gitnexus server
},
]);
expect(owned).toBe(false);
});
it('not-owned: a gitnexus mcp process that does NOT hold the lbug fd is not an owner', () => {
const { owned } = runScan((lbug) => [
{
pid: 6001,
comm: 'MainThread',
cmdline: GITNEXUS_MCP_ARGV('/x/node_modules/gitnexus/dist/cli/index.js'),
fdTargets: ['/dev/null'], // server, but holds some OTHER fd, not this lbug
},
// a decoy that holds the lbug but is not a server
{
pid: 6002,
comm: 'vim',
cmdline: ['vim', '/etc/hosts'],
fdTargets: [lbug],
},
]);
expect(owned).toBe(false);
});
it('Phase 0 trap: cmdline LOOKS like gitnexus but comm is non-candidate → filtered out before fd check', () => {
// The fd symlink points at the lbug, so if Phase 0 did NOT filter on comm
// the cmdline prefilter would match and the fd check would say "owned".
// Because comm is a non-candidate ('postgres'), Phase 0 drops it first.
const { owned } = runScan((lbug) => [
{
pid: 7007,
comm: 'postgres', // not in COMM_CANDIDATES, not a prefix of any
cmdline: GITNEXUS_MCP_ARGV('/x/node_modules/gitnexus/dist/cli/index.js'),
fdTargets: [lbug],
},
]);
expect(owned).toBe(false);
});
it('Phase 0 truncation-safe: a 15-char-truncated comm prefix of a candidate still matches', () => {
// Kernel comm cap is 15 visible chars; a candidate name truncated to a
// prefix must NOT be dropped. We use a comm that is a strict prefix of a
// whitelist entry ('MainThr' ⊂ 'MainThread').
const { owned } = runScan((lbug) => [
{
pid: 8008,
comm: 'MainThr',
cmdline: GITNEXUS_MCP_ARGV('/x/node_modules/gitnexus/dist/cli/index.js'),
fdTargets: [lbug],
},
]);
expect(owned).toBe(true);
});
// ── D2: budget / timeout → fail-closed ───────────────────────────
it('budget <= 0 → immediate timeout → dispatcher fails CLOSED (owner=true)', () => {
// Even though NO process is a gitnexus server, budget 0 yields 'timeout'
// which the dispatcher maps to true (self-throttle). This also pins the
// #2180 budget-parse fix: "0" must NOT fall back to 1200.
const { owned } = runScan(
() => [{ pid: 9001, comm: 'bash', cmdline: ['bash'], fdTargets: [] }],
{ GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '0' },
);
expect(owned).toBe(true);
});
it('budget "0" is not silently treated as 1200 (regression for the parse bug)', () => {
// With a healthy non-owner fake proc and budget '0', the OLD code (which
// coerced "0" to 1200) would have completed the scan and returned
// not-owned (false). The fixed code returns timeout → true.
const { owned } = runScan(
() => [{ pid: 9100, comm: 'node', cmdline: ['node', '/app/x.js'], fdTargets: [] }],
{ GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '0' },
);
expect(owned).toBe(true);
});
it('a generous budget over a non-owner tree completes and returns not-owned', () => {
const { owned } = runScan(
() => [
{ pid: 9200, comm: 'node', cmdline: ['node', '/app/x.js'], fdTargets: [] },
{ pid: 9201, comm: 'bash', cmdline: ['bash', '-l'], fdTargets: [] },
],
{ GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '5000' },
);
expect(owned).toBe(false);
});
// ── D3: 4 KB+ cmdline cap — escalation must really iterate (F2) ────
//
// The cmdline shape here is deliberate (Codex): the `gitnexus` token sits in
// the SECOND argv (a SHORT node_modules/gitnexus path, well inside the first
// 4 KB chunk); a ~9 KB
// pad argv then pushes the trailing `mcp` mode token PAST 4096, so the first
// 4 KB chunk has gitnexus-but-no-mode and the loop MUST escalate to a second
// read to find `mcp`. Setting GITNEXUS_HOOK_PROC_CMDLINE_MAX=4096 makes the
// chunk size 4 KB so escalation actually happens (the 16 KB default would read
// the whole line in one shot and the loop would never iterate — the old test's
// latent no-op).
// gitnexus token early (well under 4 KB), mode token forced past 4 KB by pad.
const GITNEXUS_SHORT = '/nm/node_modules/gitnexus/dist/cli/index.js';
const PAD_PAST_4K = 'x'.repeat(9000); // pushes the trailing `mcp` well past 4096
it('owned even when the mode token sits far past 4 KB → escalation iterates and finds it', () => {
const readSyncSpy = vi.spyOn(fs, 'readSync');
cleanups.push(() => readSyncSpy.mockRestore());
const { owned } = runScan(
(lbug) => [
{
pid: 10001,
comm: 'MainThread',
// node | SHORT gitnexus path (<4KB) | 9KB pad | mcp → mcp lands >4096
cmdline: ['node', GITNEXUS_SHORT, PAD_PAST_4K, 'mcp'],
fdTargets: [lbug],
},
],
{ GITNEXUS_HOOK_PROC_CMDLINE_MAX: '4096' },
);
expect(owned).toBe(true);
// White-box proof the escalation actually re-read: with a 4 KB chunk over a
// >4 KB cmdline, readSync must have been called more than once for this pid.
// (A "just bump the cap" pseudo-fix that read everything in one go would
// leave this at 1 and fail.)
expect(readSyncSpy.mock.calls.length).toBeGreaterThan(1);
});
it('discrimination: same gitnexus cmdline but mode token past HARD_CEIL → not-owned', () => {
// Negative control proving the escalation has a real upper bound (HARD_CEIL
// = 256 KiB) and the positive test above is not just "always escalates". The
// gitnexus token is early so escalation runs, but a >256 KiB pad keeps the
// `mcp` token beyond the ceiling, so the bounded read stops before reaching
// it → isGitNexusServerCommand sees no mode token → not a candidate →
// not-owned. (A broken "escalate forever" impl would wrongly read to `mcp`
// and report owned, failing this assertion.)
const padPastCeil = 'x'.repeat(300000); // > HARD_CEIL (262144)
const { owned } = runScan(
(lbug) => [
{
pid: 10002,
comm: 'MainThread',
cmdline: ['node', GITNEXUS_SHORT, padPastCeil, 'mcp'],
fdTargets: [lbug],
},
],
{ GITNEXUS_HOOK_PROC_CMDLINE_MAX: '4096' },
);
expect(owned).toBe(false);
});
// ── D3c: a partial read stops early only when DECIDED (#2543 review) ──
//
// The escalation loop used to stop as soon as the chunk held a mode word, or
// as soon as it lacked the gitnexus token. Neither decides ownership: a Node
// preload flag can put `mcp` in the first chunk while the gitnexus CLI path
// lies past it, and a long interpreter prefix can push BOTH tokens past it.
// Either way the old loop returned an incomplete cmdline, Phase 1 rejected it,
// and Phase 2 never compared the holder's fd (a fail-OPEN owner miss, #1492).
// Now only "both tokens present", EOF, the ceiling, or the budget stop it.
it('owned: a mode word in the first chunk (`--require mcp`) with the gitnexus path past it', () => {
const { owned } = runScan(
(lbug) => [
{
pid: 10050,
comm: 'MainThread',
// node | --require mcp | 9KB pad | gitnexus path (>4096) | serve
cmdline: ['node', '--require', 'mcp', PAD_PAST_4K, GITNEXUS_SHORT, 'serve'],
fdTargets: [lbug],
},
],
{ GITNEXUS_HOOK_PROC_CMDLINE_MAX: '4096' },
);
expect(owned).toBe(true);
});
it('owned: neither token in the first chunk, both past it (long interpreter prefix)', () => {
const { owned } = runScan(
(lbug) => [
{
pid: 10051,
comm: 'MainThread',
cmdline: ['node', PAD_PAST_4K, GITNEXUS_SHORT, 'mcp'],
fdTargets: [lbug],
},
],
{ GITNEXUS_HOOK_PROC_CMDLINE_MAX: '4096' },
);
expect(owned).toBe(true);
});
it('not-owned: a mode word early but no gitnexus token anywhere is read to EOF and rejected', () => {
const { owned } = runScan(
(lbug) => [
{
pid: 10052,
comm: 'node',
cmdline: ['node', '--require', 'mcp', PAD_PAST_4K, '/app/other-server.js', 'serve'],
fdTargets: [lbug],
},
],
{ GITNEXUS_HOOK_PROC_CMDLINE_MAX: '4096' },
);
expect(owned).toBe(false);
});
it('does not over-read: a giant non-gitnexus cmdline is bounded and yields not-owned', () => {
const giant = 'x'.repeat(500000); // 500 KB single arg, no gitnexus token
const { owned } = runScan((lbug) => [
{
pid: 10100,
comm: 'node',
cmdline: ['node', `/app/${giant}.js`],
fdTargets: [lbug],
},
]);
expect(owned).toBe(false);
});
// ── D3b: cmdline escalation respects the scan budget (F3) ─────────
//
// A single pathological candidate whose `mcp` token sits far past the chunk
// size used to be able to read up to HARD_CEIL (256 KiB) inside one
// readLinuxCmdline call before the scan-level budget was re-checked. F3 wires
// outOfBudget into the escalation loop: when the deadline trips mid-read it
// returns the CMDLINE_TIMEOUT *Symbol* (NOT '' — '' would flow through
// isGitNexusServerCommand as a non-candidate and silently drop a possible
// owner, a fail-OPEN), and the Phase 1 caller maps that Symbol to the 'timeout'
// verdict (fail-CLOSED). We drive the deadline deterministically by advancing
// a Date.now spy after the first escalation read.
it('escalation that exceeds the budget mid-read → verdict timeout (sentinel, not silent drop)', () => {
const scan = scanVerdictFn;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-probe-f3-'));
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
const lbugPath = path.join(dir, 'lbug');
fs.writeFileSync(lbugPath, '');
// gitnexus token early (escalation will start), mode token pushed past 4 KB
// so a SECOND read is required — between those reads we trip the clock.
const procRoot = createFakeProcRoot([
{
pid: 10200,
comm: 'MainThread',
cmdline: ['node', GITNEXUS_SHORT, 'x'.repeat(9000), 'mcp'],
fdTargets: [lbugPath],
},
]);
cleanups.push(() => fs.rmSync(procRoot, { recursive: true, force: true }));
setEnv({
GITNEXUS_HOOK_PROC_ROOT: procRoot,
GITNEXUS_HOOK_PROC_CMDLINE_MAX: '4096',
GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '1000', // positive, so the scan starts
});
// Deterministic clock: the scan captures `start` (call #1) and runs its
// Phase-0/1 entry budget checks in-budget; once the escalation loop is under
// way we jump Date.now() past the 1000 ms budget so the loop's in-read
// outOfBudget() returns the CMDLINE_TIMEOUT sentinel. The threshold (>4) is
// chosen so the early checks (start capture, per-entry + Phase-1 pre-read
// checks) stay at base and only the escalation's mid-loop check trips.
const base = Date.now();
let nowCalls = 0;
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => {
nowCalls += 1;
return nowCalls > 4 ? base + 5000 : base;
});
cleanups.push(() => nowSpy.mockRestore());
const verdict = scan(lbugPath, 1);
// The load-bearing assertion: a mid-escalation budget trip yields the
// 'timeout' verdict (via the Symbol sentinel) — NOT a silent non-candidate
// drop (which would be 'not-owned' here and a fail-OPEN if this were a real
// cross-budget owner).
expect(verdict).toBe('timeout');
nowSpy.mockRestore();
});
// ── D5: GITNEXUS_HOOK_PROC_ROOT is honored ONLY under a test runner (F4) ──
//
// Production hooks run as `node <probe>.cjs` with neither VITEST nor
// NODE_ENV=test set; vitest injects both into every worker (verified). F4
// gates getProcRoot() on that signal so a production env that leaked
// GITNEXUS_HOOK_PROC_ROOT (pointing at an empty/bad tree) cannot turn Linux
// owner detection OFF (no pids -> not-owned -> fail-OPEN, the #1492 class).
// These tests run inside vitest, so the gate is OPEN and injection works (the
// entire fake-procfs suite above already depends on that). Here we prove the
// gate is load-bearing: with the test signals stripped, the override is
// ignored and the scan falls back to the real /proc (so our fake lbug is NOT
// found there -> not-owned), and with them present the override is honored.
it('honors GITNEXUS_HOOK_PROC_ROOT under the vitest test signal (gate open)', () => {
// Sanity: in this vitest worker VITEST/NODE_ENV are set, so the fake root is
// honored and a fake owner is detected — same mechanism the whole suite uses.
const { owned } = runScan((lbug) => [
{
pid: 10300,
comm: 'MainThread',
cmdline: GITNEXUS_MCP_ARGV('/x/node_modules/gitnexus/dist/cli/index.js'),
fdTargets: [lbug],
},
]);
expect(owned).toBe(true);
});
it('ignores GITNEXUS_HOOK_PROC_ROOT when the test signal is absent (gate closed → real /proc)', () => {
// Strip BOTH test signals so getProcRoot() falls back to /proc even though
// GITNEXUS_HOOK_PROC_ROOT points at our fake tree. The fake lbug is not an
// fd under the real /proc, so the scan returns not-owned: proof the override
// is inert in a non-test (production-shaped) context.
const savedVitest = process.env.VITEST;
const savedNodeEnv = process.env.NODE_ENV;
cleanups.push(() => {
if (savedVitest === undefined) delete process.env.VITEST;
else process.env.VITEST = savedVitest;
if (savedNodeEnv === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = savedNodeEnv;
});
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-probe-f4-'));
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
const lbugPath = path.join(dir, 'lbug');
fs.writeFileSync(lbugPath, '');
const procRoot = createFakeProcRoot([
{
pid: 10400,
comm: 'MainThread',
cmdline: GITNEXUS_MCP_ARGV('/x/node_modules/gitnexus/dist/cli/index.js'),
fdTargets: [lbugPath],
},
]);
cleanups.push(() => fs.rmSync(procRoot, { recursive: true, force: true }));
setEnv({ GITNEXUS_HOOK_PROC_ROOT: procRoot });
// Now drop the test signals — must happen AFTER setEnv so the gate sees them gone.
delete process.env.VITEST;
delete process.env.NODE_ENV;
const verdict = scanVerdictFn(lbugPath, 1);
// Gate closed -> getProcRoot() returns '/proc'; our fake lbug fd is not in
// the real /proc, so no owner is found.
expect(verdict).toBe('not-owned');
expect(probe.hasGitNexusDbLockedByGitNexusServer(lbugPath, 1)).toBe(false);
});
// ── D4: unreadable candidate fd dir → honest tri-state verdict (F1) ──
//
// /proc/<pid>/fd is owner-only (mode 0500). A cross-user/root `gitnexus mcp`
// serving a DIFFERENT repo clears Phase 0+1 (cmdline matches) and then EACCES
// here — but its dev+ino was never compared against THIS lbug. The OLD code
// returned 'owned' for every non-ENOENT readdir error, falsely claiming
// ownership and permanently suppressing augment for a repo that process does
// not lock. F1 splits the failure shapes:
// - EACCES / EPERM -> 'timeout' (unverifiable; fail-closed HONESTLY)
// - EIO / ESTALE -> 'timeout' (transient I/O; fail-closed)
// - ENOTDIR -> continue (not a real fd dir; treat as non-owner)
// - any other errno -> 'timeout' (EMFILE/ENFILE/ENOMEM/…: inconclusive)
// The dispatcher collapses owned+timeout to boolean true, so these assert the
// exported tri-state verdict directly — a boolean check could not tell the F1
// fix from the old bug.
it('exports linuxProcScanFindGitNexusServer for white-box verdict assertions', () => {
expect(typeof probe.linuxProcScanFindGitNexusServer).toBe('function');
});
it('candidate fd dir EACCES → verdict timeout (honest fail-closed, NOT owned)', () => {
if (process.getuid && process.getuid() === 0) {
// root bypasses chmod 000, so a real EACCES is not reproducible on this
// host. This disk-based test no-ops under root; the uid-agnostic spy
// tests below cover every F1 errno branch (EACCES/EPERM/EIO/ESTALE/
// ENOTDIR) regardless of who runs the suite.
return;
}
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-probe-eacces-'));
cleanups.push(() => {
try {
fs.chmodSync(path.join(dir, 'proc', '11001', 'fd'), 0o755);
} catch {
/* ignore */
}
fs.rmSync(dir, { recursive: true, force: true });
});
const lbugPath = path.join(dir, 'lbug');
fs.writeFileSync(lbugPath, '');
const procRoot = path.join(dir, 'proc');
const fdDir = path.join(procRoot, '11001', 'fd');
fs.mkdirSync(fdDir, { recursive: true });
fs.writeFileSync(path.join(procRoot, '11001', 'comm'), 'MainThread\n');
fs.writeFileSync(
path.join(procRoot, '11001', 'cmdline'),
['node', '/x/node_modules/gitnexus/dist/cli/index.js', 'mcp'].join('\0') + '\0',
);
fs.chmodSync(fdDir, 0o000); // EACCES on readdir
setEnv({ GITNEXUS_HOOK_PROC_ROOT: procRoot });
// White-box: assert the verdict is 'timeout' (NOT 'owned' — the F1 point).
const verdict = scanVerdictFn(lbugPath, 1);
expect(verdict).toBe('timeout');
// And the dispatcher still fails closed (boolean true) on that timeout.
const owned = probe.hasGitNexusDbLockedByGitNexusServer(lbugPath, 1);
expect(owned).toBe(true);
});
// uid-agnostic coverage of every F1 fd-readdir errno branch. chmod 000 yields
// no EACCES for root, so the disk-based tests above no-op there — these spy
// fs.readdirSync to throw a chosen errno only for the candidate's fd dir (the
// procRoot enumeration calls through), pinning the F1 split in CI regardless
// of the runner's uid.
for (const { code, expected } of [
{ code: 'EACCES', expected: 'timeout' },
{ code: 'EPERM', expected: 'timeout' },
{ code: 'EIO', expected: 'timeout' },
{ code: 'ESTALE', expected: 'timeout' },
{ code: 'ENOTDIR', expected: 'not-owned' },
// Resource/interruption failures say nothing about ownership of an
// already-identified server candidate: fail closed, never not-owned.
{ code: 'EMFILE', expected: 'timeout' },
{ code: 'ENFILE', expected: 'timeout' },
{ code: 'ENOMEM', expected: 'timeout' },
{ code: 'EINTR', expected: 'timeout' },
] as const) {
it(`candidate fd readdir ${code} → verdict ${expected} (uid-agnostic spy)`, () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-probe-fderr-'));
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
const lbugPath = path.join(dir, 'lbug');
fs.writeFileSync(lbugPath, '');
const procRoot = path.join(dir, 'proc');
const fdDir = path.join(procRoot, '11001', 'fd');
fs.mkdirSync(fdDir, { recursive: true });
fs.writeFileSync(path.join(procRoot, '11001', 'comm'), 'MainThread\n');
fs.writeFileSync(
path.join(procRoot, '11001', 'cmdline'),
['node', '/x/node_modules/gitnexus/dist/cli/index.js', 'mcp'].join('\0') + '\0',
);
setEnv({ GITNEXUS_HOOK_PROC_ROOT: procRoot });
const realReaddir = fs.readdirSync.bind(fs);
const spy = vi.spyOn(fs, 'readdirSync').mockImplementation((p, ...rest) => {
if (typeof p === 'string' && p.endsWith(`${path.sep}fd`)) {
const err = new Error(`mock ${code}`) as NodeJS.ErrnoException;
err.code = code;
throw err;
}
return (realReaddir as (...a: unknown[]) => unknown)(p, ...rest);
});
cleanups.push(() => spy.mockRestore());
// White-box: assert the exported tri-state verdict directly (the
// dispatcher would collapse timeout+owned to the same boolean).
expect(scanVerdictFn(lbugPath, 1)).toBe(expected);
spy.mockRestore();
});
}
it('candidate fd path is a FILE (ENOTDIR) → treated as non-owner → not-owned', () => {
// ENOTDIR means the fd entry is not a real /proc/<pid>/fd directory at all,
// so it is not a plausible live owner. The candidate is skipped (continue);
// with no other candidate the scan ends not-owned (the OLD code wrongly
// returned 'owned' here). Runs on every OS incl. root.
const { verdict, owned } = runScanFdEnotdir();
expect(verdict).toBe('not-owned');
expect(owned).toBe(false);
});
it('EACCES candidate then a REAL owner later → still detects the real owner', () => {
// Regression guard for the F1 continue/return choice: an EACCES candidate
// must NOT short-circuit the scan in a way that hides a genuine owner. Here
// the EACCES dir yields timeout BEFORE reaching the true owner — timeout is
// the protective (fail-closed) verdict, so dispatcher returns true either
// way. (Ordering in /proc readdir is numeric-string; 11001 < 11050.)
if (process.getuid && process.getuid() === 0) return; // EACCES needs non-root
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-probe-mixed-'));
cleanups.push(() => {
try {
fs.chmodSync(path.join(dir, 'proc', '11001', 'fd'), 0o755);
} catch {
/* ignore */
}
fs.rmSync(dir, { recursive: true, force: true });
});
const lbugPath = path.join(dir, 'lbug');
fs.writeFileSync(lbugPath, '');
const procRoot = path.join(dir, 'proc');
// Candidate A: EACCES fd dir.
const fdDirA = path.join(procRoot, '11001', 'fd');
fs.mkdirSync(fdDirA, { recursive: true });
fs.writeFileSync(path.join(procRoot, '11001', 'comm'), 'MainThread\n');
fs.writeFileSync(
path.join(procRoot, '11001', 'cmdline'),
['node', '/x/node_modules/gitnexus/dist/cli/index.js', 'mcp'].join('\0') + '\0',
);
fs.chmodSync(fdDirA, 0o000);
setEnv({ GITNEXUS_HOOK_PROC_ROOT: procRoot });
const verdict = scanVerdictFn(lbugPath, 1);
// EACCES is hit first and fails closed (timeout) — the protective outcome.
expect(verdict).toBe('timeout');
expect(probe.hasGitNexusDbLockedByGitNexusServer(lbugPath, 1)).toBe(true);
});
});
// Helper for the ENOTDIR branch: fd is a FILE not a dir, so readdir throws
// ENOTDIR. F1: this candidate is treated as a non-owner (continue) → not-owned.
function runScanFdEnotdir(): { verdict: string; owned: boolean } {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-probe-enotdir-'));
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
const lbugPath = path.join(dir, 'lbug');
fs.writeFileSync(lbugPath, '');
const procRoot = path.join(dir, 'proc');
const pidDir = path.join(procRoot, '11002');
fs.mkdirSync(pidDir, { recursive: true });
fs.writeFileSync(path.join(pidDir, 'comm'), 'MainThread\n');
fs.writeFileSync(
path.join(pidDir, 'cmdline'),
['node', '/x/node_modules/gitnexus/dist/cli/index.js', 'mcp'].join('\0') + '\0',
);
fs.writeFileSync(path.join(pidDir, 'fd'), 'not a dir'); // readdir -> ENOTDIR
setEnv({ GITNEXUS_HOOK_PROC_ROOT: procRoot });
const verdict = scanVerdictFn(lbugPath, 1);
const owned = probe.hasGitNexusDbLockedByGitNexusServer(lbugPath, 1);
return { verdict, owned };
}
// ── D6: live e2e against the REAL /proc ─────────────────────────────
//
// Protects the load-bearing assumption that a real lbug handle is fd-visible in
// /proc/<pid>/fd (a @ladybugdb/core property; a future move to mmap-only would
// silently regress #1492 with no other test going red). We spawn a child that
// opens an fd on a real temp lbug AND wears a gitnexus-mcp cmdline, then assert
// the scan reports owned. Crucially we assert against OUR holder's identity, not
// "any owner" — this host runs background gitnexus servers, so a bare
// truthiness check could be a false positive.
describe.skipIf(!isLinux)('Linux DB-owner scan — live /proc e2e (#2180)', () => {
it('detects a real fd-visible gitnexus-mcp-shaped lbug holder', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-e2e-'));
const lbugPath = path.join(dir, 'lbug');
fs.writeFileSync(lbugPath, '');
// Give the holder a gitnexus-server cmdline by running it from a
// node_modules/gitnexus/dist/cli/index.js path with an `mcp` arg.
const scriptDir = path.join(dir, 'node_modules', 'gitnexus', 'dist', 'cli');
fs.mkdirSync(scriptDir, { recursive: true });
const script = path.join(scriptDir, 'index.js');
const pidFile = path.join(dir, 'holder.pid');
fs.writeFileSync(
script,
`const fs=require('fs');` +
`const fd=fs.openSync(${JSON.stringify(lbugPath)},'r');` +
`fs.writeFileSync(${JSON.stringify(pidFile)},String(process.pid));` +
`process.on('SIGTERM',()=>{try{fs.closeSync(fd);}catch{}process.exit(0);});` +
`setInterval(()=>{},1<<30);`,
);
const holder = spawn(process.execPath, [script, 'mcp'], { stdio: 'ignore' });
try {
// Wait for the holder to report ready (pid file written). Widened to ~10s
// (was 5s): a loaded CI runner can be slow to spawn the child, and this is
// the one genuine false-FAIL path in the e2e (the budget timeout below
// merely hollows the assertion rather than failing it).
let holderPid = 0;
for (let i = 0; i < 400; i++) {
try {
const raw = fs.readFileSync(pidFile, 'utf8').trim();
if (raw) {
holderPid = Number.parseInt(raw, 10);
break;
}
} catch {
/* not ready yet */
}
await new Promise((r) => setTimeout(r, 25));
}
expect(holderPid).toBeGreaterThan(0);
// Confirm the holder really is fd-visible (the property under test).
const fdDir = `/proc/${holderPid}/fd`;
const targetStat = fs.statSync(lbugPath);
const fdVisible = fs.readdirSync(fdDir).some((fd) => {
try {
const st = fs.statSync(path.join(fdDir, fd));
return st.dev === targetStat.dev && st.ino === targetStat.ino;
} catch {
return false;
}
});
expect(fdVisible).toBe(true);
// Real /proc, generous explicit budget. Clear PROC_ROOT (-> real /proc)
// and raise the scan budget via setEnv so the module afterEach restores
// BOTH (no raw process.env mutation leaking to sibling tests). The
// generous budget is load-bearing: this dispatcher maps a budget 'timeout'
// to owned=TRUE, so on a busy host the default 1200ms could be exhausted
// before reaching the holder and the assertion would still pass for the
// WRONG reason (a hollow timeout, not real fd-visible detection). 10s
// keeps the assertion honest. Use a PID we are NOT so the holder is not
// excluded, and assert owned for OUR lbug specifically.
setEnv({
GITNEXUS_HOOK_PROC_ROOT: undefined,
GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '10000',
});
const t0 = Date.now();
const owned = probe.hasGitNexusDbLockedByGitNexusServer(lbugPath, process.pid);
const ms = Date.now() - t0;
expect(owned).toBe(true);
// Coarse regression guard against the old O(procs×fds)+lsof path (~1.2s+).
// The bound sits ABOVE the 10s budget so a legitimately-slow-but-correct
// scan can't trip it — a regression guard, not a tight perf SLA.
expect(ms).toBeLessThan(15000);
} finally {
try {
holder.kill('SIGKILL');
} catch {
/* ignore */
}
fs.rmSync(dir, { recursive: true, force: true });
}
}, 40000);
});