gitnexus 1.6.8-rc.21 → 1.6.8-rc.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,7 +24,10 @@ const fs = require('fs');
24
24
  const path = require('path');
25
25
  const { spawnSync } = require('child_process');
26
26
  const { acquireHookSlot } = require('./hook-lock.cjs');
27
- const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
27
+ const {
28
+ hasGitNexusDbLockedByGitNexusServer,
29
+ resolveUnixGuardTimeout,
30
+ } = require('./hook-db-lock-probe.cjs');
28
31
  const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs');
29
32
 
30
33
  function readInput() {
@@ -198,10 +201,73 @@ function resolveCliPath() {
198
201
  return cliPath;
199
202
  }
200
203
 
204
+ // Debounce for the unguarded-CLI diagnostic below (#2163 follow-up review):
205
+ // at most one line per (short-lived) hook process, even if a future change
206
+ // runs the CLI more than once.
207
+ let unguardedCliWarned = false;
208
+
209
+ /**
210
+ * Unix orphan containment (#2163 follow-up): the augment CLI is the
211
+ * longest-lived hook child (inner spawnSync timeout 7s locally, 12s via
212
+ * npx), so on Unix it gets the same SIGKILL-surviving coreutils `timeout`
213
+ * wrapper as the probe's lsof/ps. The wrapper budget is ceil(inner/1000)+1
214
+ * seconds — STRICTLY greater than the inner spawnSync timeout, so on the
215
+ * supervised path Node's SIGTERM always fires first and the existing
216
+ * error/status contract is untouched. Once the hook itself has been
217
+ * SIGKILLed (exactly the orphan case the wrapper exists for), the guard
218
+ * semantics differ per branch:
219
+ * - direct exec (the CLI is the guard's CHILD): `-k 1` TERM-first — a
220
+ * SIGTERM-immune CLI can hold the guard ~1s past the inner timeout
221
+ * before the `-k` SIGKILL escalation reaps it.
222
+ * - npx (the CLI is a GRANDCHILD: guard → npx → CLI): `-s KILL` — the
223
+ * budget expiry SIGKILLs the whole process group outright. TERM-first
224
+ * would kill only the obedient npx parent, making `timeout` reap it and
225
+ * return before the `-k` escalation ever fires, stranding a
226
+ * SIGTERM-immune CLI grandchild unbounded (reproduced on coreutils
227
+ * 9.x). `-k 1` is retained alongside `-s KILL` as a harmless belt: with
228
+ * `-s KILL` the `-k` escalation signal is also KILL. Two residual gaps
229
+ * on this branch, both bounded by "no worse than pre-fix" (where the
230
+ * grandchild received no signal at all): the group-wide SIGKILL is
231
+ * coreutils semantics — a busybox `timeout` passes the self-test (it
232
+ * has `-k` and propagates exit status) but signals only its direct
233
+ * child, so a busybox guard cannot reach the grandchild; and on the
234
+ * SUPERVISED path (hook alive, inner spawnSync timeout SIGTERMs the
235
+ * guard) coreutils forwards TERM rather than the `-s` signal, npx dies,
236
+ * and the guard exits before any KILL fires — so a SIGTERM-immune CLI
237
+ * grandchild still escapes in those two cases.
238
+ * If the sibling probe predates the resolveUnixGuardTimeout export (version
239
+ * skew), the adapter degrades to the unwrapped invocation instead of
240
+ * throwing. Windows is deliberately NOT wrapped — there is no coreutils
241
+ * timeout to resolve there and the resolver's self-test spawns /bin/sh — so
242
+ * on win32 (the npx.cmd path) and whenever the guard resolves to null (e.g.
243
+ * macOS without Homebrew coreutils — reported once under GITNEXUS_DEBUG)
244
+ * the argv stays byte-identical to the pre-wrap invocation.
245
+ */
201
246
  function runGitNexusCli(cliPath, args, cwd, timeout) {
202
247
  const isWin = process.platform === 'win32';
248
+ // Version-skew guard (#2163 follow-up review): an older sibling probe
249
+ // without the resolveUnixGuardTimeout export must degrade to the unwrapped
250
+ // invocation — a TypeError here would be swallowed by the caller's catch
251
+ // and silently kill the augment.
252
+ const guard =
253
+ isWin || typeof resolveUnixGuardTimeout !== 'function' ? null : resolveUnixGuardTimeout();
254
+ if (!isWin && !guard && !unguardedCliWarned && isDebugEnabled()) {
255
+ // Diagnose the "stays unwrapped" Unix paths once per hook process: no
256
+ // usable coreutils timeout/gtimeout (e.g. macOS without Homebrew
257
+ // coreutils), GITNEXUS_HOOK_TIMEOUT_PATH=disabled, or probe skew above.
258
+ unguardedCliWarned = true;
259
+ process.stderr.write(
260
+ '[GitNexus hook] no usable timeout/gtimeout guard; augment CLI child runs unguarded\n',
261
+ );
262
+ }
203
263
  if (cliPath) {
204
- return spawnSync(process.execPath, [cliPath, ...args], {
264
+ const [cmd, cmdArgs] = guard
265
+ ? [
266
+ guard,
267
+ ['-k', '1', String(Math.ceil(timeout / 1000) + 1), process.execPath, cliPath, ...args],
268
+ ]
269
+ : [process.execPath, [cliPath, ...args]];
270
+ return spawnSync(cmd, cmdArgs, {
205
271
  encoding: 'utf-8',
206
272
  timeout,
207
273
  cwd,
@@ -209,7 +275,27 @@ function runGitNexusCli(cliPath, args, cwd, timeout) {
209
275
  windowsHide: true,
210
276
  });
211
277
  }
212
- return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
278
+ // A non-null guard implies non-Windows, so the wrapped arm can hardcode
279
+ // plain `npx`. The wrapped arm leads with `-s KILL` (NOT TERM-first like
280
+ // the direct branch above): the CLI here is a grandchild behind npx — see
281
+ // the docblock.
282
+ const [cmd, cmdArgs] = guard
283
+ ? [
284
+ guard,
285
+ [
286
+ '-s',
287
+ 'KILL',
288
+ '-k',
289
+ '1',
290
+ String(Math.ceil((timeout + 5000) / 1000) + 1),
291
+ 'npx',
292
+ '-y',
293
+ 'gitnexus',
294
+ ...args,
295
+ ],
296
+ ]
297
+ : [isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args]];
298
+ return spawnSync(cmd, cmdArgs, {
213
299
  encoding: 'utf-8',
214
300
  timeout: timeout + 5000,
215
301
  cwd,
@@ -15,7 +15,10 @@ const fs = require('fs');
15
15
  const path = require('path');
16
16
  const { spawnSync } = require('child_process');
17
17
  const { acquireHookSlot } = require('./hook-lock.cjs');
18
- const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
18
+ const {
19
+ hasGitNexusDbLockedByGitNexusServer,
20
+ resolveUnixGuardTimeout,
21
+ } = require('./hook-db-lock-probe.cjs');
19
22
  const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs');
20
23
 
21
24
  /**
@@ -218,14 +221,76 @@ function resolveCliPath() {
218
221
  return cliPath;
219
222
  }
220
223
 
224
+ // Debounce for the unguarded-CLI diagnostic below (#2163 follow-up review):
225
+ // at most one line per (short-lived) hook process, even if a future change
226
+ // runs the CLI more than once.
227
+ let unguardedCliWarned = false;
228
+
221
229
  /**
222
230
  * Spawn a gitnexus CLI command synchronously.
223
231
  * Returns the stderr output (KuzuDB captures stdout at OS level).
232
+ *
233
+ * Unix orphan containment (#2163 follow-up): the augment CLI is the
234
+ * longest-lived hook child (inner spawnSync timeout 7s locally, 12s via
235
+ * npx), so on Unix it gets the same SIGKILL-surviving coreutils `timeout`
236
+ * wrapper as the probe's lsof/ps. The wrapper budget is ceil(inner/1000)+1
237
+ * seconds — STRICTLY greater than the inner spawnSync timeout, so on the
238
+ * supervised path Node's SIGTERM always fires first and the existing
239
+ * error/status contract is untouched. Once the hook itself has been
240
+ * SIGKILLed (exactly the orphan case the wrapper exists for), the guard
241
+ * semantics differ per branch:
242
+ * - direct exec (the CLI is the guard's CHILD): `-k 1` TERM-first — a
243
+ * SIGTERM-immune CLI can hold the guard ~1s past the inner timeout
244
+ * before the `-k` SIGKILL escalation reaps it.
245
+ * - npx (the CLI is a GRANDCHILD: guard → npx → CLI): `-s KILL` — the
246
+ * budget expiry SIGKILLs the whole process group outright. TERM-first
247
+ * would kill only the obedient npx parent, making `timeout` reap it and
248
+ * return before the `-k` escalation ever fires, stranding a
249
+ * SIGTERM-immune CLI grandchild unbounded (reproduced on coreutils
250
+ * 9.x). `-k 1` is retained alongside `-s KILL` as a harmless belt: with
251
+ * `-s KILL` the `-k` escalation signal is also KILL. Two residual gaps
252
+ * on this branch, both bounded by "no worse than pre-fix" (where the
253
+ * grandchild received no signal at all): the group-wide SIGKILL is
254
+ * coreutils semantics — a busybox `timeout` passes the self-test (it
255
+ * has `-k` and propagates exit status) but signals only its direct
256
+ * child, so a busybox guard cannot reach the grandchild; and on the
257
+ * SUPERVISED path (hook alive, inner spawnSync timeout SIGTERMs the
258
+ * guard) coreutils forwards TERM rather than the `-s` signal, npx dies,
259
+ * and the guard exits before any KILL fires — so a SIGTERM-immune CLI
260
+ * grandchild still escapes in those two cases.
261
+ * If the sibling probe predates the resolveUnixGuardTimeout export (version
262
+ * skew), the adapter degrades to the unwrapped invocation instead of
263
+ * throwing. Windows is deliberately NOT wrapped — there is no coreutils
264
+ * timeout to resolve there and the resolver's self-test spawns /bin/sh — so
265
+ * on win32 (the npx.cmd path) and whenever the guard resolves to null (e.g.
266
+ * macOS without Homebrew coreutils — reported once under GITNEXUS_DEBUG)
267
+ * the argv stays byte-identical to the pre-wrap invocation.
224
268
  */
225
269
  function runGitNexusCli(cliPath, args, cwd, timeout) {
226
270
  const isWin = process.platform === 'win32';
271
+ // Version-skew guard (#2163 follow-up review): an older sibling probe
272
+ // without the resolveUnixGuardTimeout export must degrade to the unwrapped
273
+ // invocation — a TypeError here would be swallowed by the caller's catch
274
+ // and silently kill the augment.
275
+ const guard =
276
+ isWin || typeof resolveUnixGuardTimeout !== 'function' ? null : resolveUnixGuardTimeout();
277
+ if (!isWin && !guard && !unguardedCliWarned && isDebugEnabled()) {
278
+ // Diagnose the "stays unwrapped" Unix paths once per hook process: no
279
+ // usable coreutils timeout/gtimeout (e.g. macOS without Homebrew
280
+ // coreutils), GITNEXUS_HOOK_TIMEOUT_PATH=disabled, or probe skew above.
281
+ unguardedCliWarned = true;
282
+ process.stderr.write(
283
+ '[GitNexus hook] no usable timeout/gtimeout guard; augment CLI child runs unguarded\n',
284
+ );
285
+ }
227
286
  if (cliPath) {
228
- return spawnSync(process.execPath, [cliPath, ...args], {
287
+ const [cmd, cmdArgs] = guard
288
+ ? [
289
+ guard,
290
+ ['-k', '1', String(Math.ceil(timeout / 1000) + 1), process.execPath, cliPath, ...args],
291
+ ]
292
+ : [process.execPath, [cliPath, ...args]];
293
+ return spawnSync(cmd, cmdArgs, {
229
294
  encoding: 'utf-8',
230
295
  timeout,
231
296
  cwd,
@@ -233,8 +298,27 @@ function runGitNexusCli(cliPath, args, cwd, timeout) {
233
298
  windowsHide: true,
234
299
  });
235
300
  }
236
- // On Windows, invoke npx.cmd directly (no shell needed)
237
- return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
301
+ // On Windows, invoke npx.cmd directly (no shell needed). A non-null guard
302
+ // implies non-Windows, so the wrapped arm can hardcode plain `npx`. The
303
+ // wrapped arm leads with `-s KILL` (NOT TERM-first like the direct branch
304
+ // above): the CLI here is a grandchild behind npx — see the docblock.
305
+ const [cmd, cmdArgs] = guard
306
+ ? [
307
+ guard,
308
+ [
309
+ '-s',
310
+ 'KILL',
311
+ '-k',
312
+ '1',
313
+ String(Math.ceil((timeout + 5000) / 1000) + 1),
314
+ 'npx',
315
+ '-y',
316
+ 'gitnexus',
317
+ ...args,
318
+ ],
319
+ ]
320
+ : [isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args]];
321
+ return spawnSync(cmd, cmdArgs, {
238
322
  encoding: 'utf-8',
239
323
  timeout: timeout + 5000,
240
324
  cwd,
@@ -20,13 +20,18 @@
20
20
  * it 1s later — orphan lifetime is bounded at ~3s instead of unbounded.
21
21
  * - GITNEXUS_HOOK_TIMEOUT_PATH: the sentinel value `disabled` switches the
22
22
  * wrapper off deterministically; any other value is adopted only when it
23
- * exists AND passes a one-shot `-k` self-test — otherwise resolution FALLS
24
- * THROUGH to the built-in candidate list (first self-test pass wins), so
25
- * no malformed value of any shape can silently disable orphan containment.
23
+ * exists AND passes a one-shot `-k` exit-propagation self-test — otherwise
24
+ * resolution FALLS THROUGH to the built-in candidate list (first self-test
25
+ * pass wins), so no malformed value of any shape can silently disable
26
+ * orphan containment.
26
27
  * - The gitnexus server is lazy-open + sticky-hold: an idle MCP server holds
27
28
  * ZERO lbug fds until the repo's first MCP query, then keeps the fd open.
28
29
  * A probe before that first query is therefore always false — a known,
29
30
  * pre-existing race, not a bug in this probe.
31
+ * - resolveUnixGuardTimeout is exported so the hook adapters can wrap the
32
+ * `gitnexus augment` CLI child — the longest-lived hook subprocess (7s
33
+ * local / 12s npx inner budgets) — in the same guard; see runGitNexusCli
34
+ * in the adapters (#2163 follow-up).
30
35
  */
31
36
 
32
37
  const fs = require('fs');
@@ -70,38 +75,53 @@ let unixGuardTimeoutCache;
70
75
 
71
76
  /**
72
77
  * Resolve a coreutils `timeout`/`gtimeout` binary to wrap lsof/ps with
73
- * (#2163). Dead code on Windows (the win32 dispatch returns earlier).
78
+ * (#2163). Unix-only by contract: the probe's win32 dispatch returns before
79
+ * reaching it, and the exported callers (the adapters' runGitNexusCli,
80
+ * #2163 follow-up) must check the platform first — the self-test below
81
+ * spawns /bin/sh. The memoized result is module-wide, so probe and adapter
82
+ * share one lazy self-test per hook process.
74
83
  *
75
84
  * GITNEXUS_HOOK_TIMEOUT_PATH semantics: the sentinel `disabled` turns the
76
85
  * wrapper off; any other value is only a CANDIDATE — an existing file path
77
- * is tried first, but it must pass the `-k` self-test to be adopted. On any
78
- * failure (non-existent path, directory, non-executable file, wrapper
79
- * without `-k` support, …) resolution falls through to the built-in
80
- * candidates below, tried in order, first self-test pass wins. This is
81
- * strictly stronger than the sibling GITNEXUS_HOOK_LSOF_PATH /
82
- * GITNEXUS_HOOK_PS_PATH overrides (which only check existence): no bad env
83
- * value of ANY shape can silently disable orphan containment.
86
+ * is tried first, but it must pass the `-k` exit-propagation self-test to
87
+ * be adopted. On any failure (non-existent path, directory, non-executable
88
+ * file, wrapper without `-k` support, always-exit-0 stub, …) resolution
89
+ * falls through to the built-in candidates below, tried in order, first
90
+ * self-test pass wins. This is strictly stronger than the sibling
91
+ * GITNEXUS_HOOK_LSOF_PATH / GITNEXUS_HOOK_PS_PATH overrides (which only
92
+ * check existence): no bad env value of ANY shape can silently disable
93
+ * orphan containment.
84
94
  *
85
95
  * Lazy self-test: candidates are probed only when the lsof/ps fallback is
86
96
  * first reached, and the result is memoized. A candidate is adopted only
87
- * when `timeout -k 1 1 /bin/sh -c :` exits 0. This rejects wrappers that do
88
- * not support the coreutils `-k` flag busybox <1.34, toybox, broken
89
- * symlinks which would otherwise exit with a usage error without ever
90
- * running lsof, silently converting the lsof-ETIMEDOUT fail-closed contract
91
- * into fail-open (#1492 regression). Only when EVERY candidate fails does
92
- * the probe fall back to the unwrapped status quo (memoized null).
93
- * busybox ≥1.34 passes the test and is fully usable (capability, not
94
- * identity, decides).
97
+ * when `timeout -k 1 1 /bin/sh -c 'exit 42'` exits 42 i.e. it must RUN
98
+ * the wrapped command AND PROPAGATE its exit status. This rejects two
99
+ * failure shapes: wrappers without the coreutils `-k` flag busybox <1.34,
100
+ * toybox, broken symlinks which would exit with a usage error without
101
+ * ever running lsof, silently converting the lsof-ETIMEDOUT fail-closed
102
+ * contract into fail-open (#1492 regression); and always-exit-0 stubs
103
+ * (/bin/true shapes), which would otherwise be adopted and "succeed" every
104
+ * wrapped spawn instantly without running it — a constant no-owner probe
105
+ * answer and, worse, a silently dead augment (status 0, empty stderr passes
106
+ * the adapters' success check with no context; #2163 follow-up review).
107
+ * Only when EVERY candidate fails does the probe fall back to the unwrapped
108
+ * status quo (memoized null). busybox ≥1.34 passes the test and is fully
109
+ * usable for everything THIS file spawns (lsof/ps are the guard's direct
110
+ * children) and for the adapters' direct-exec arm. The adapters' npx arm
111
+ * additionally relies on coreutils' process-GROUP signalling for its
112
+ * `-s KILL` grandchild reaping; busybox signals only its direct child, and
113
+ * this self-test deliberately does not probe that capability — see the
114
+ * adapter docblocks for the residual-gap statement.
95
115
  */
96
116
  function passesGuardSelfTest(guard) {
97
117
  try {
98
- const selfTest = spawnSync(guard, ['-k', '1', '1', '/bin/sh', '-c', ':'], {
118
+ const selfTest = spawnSync(guard, ['-k', '1', '1', '/bin/sh', '-c', 'exit 42'], {
99
119
  encoding: 'utf-8',
100
120
  timeout: 3000,
101
121
  stdio: ['ignore', 'ignore', 'ignore'],
102
122
  windowsHide: true,
103
123
  });
104
- return !selfTest.error && selfTest.status === 0;
124
+ return !selfTest.error && selfTest.status === 42;
105
125
  } catch {
106
126
  return false;
107
127
  }
@@ -359,4 +379,16 @@ function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) {
359
379
 
360
380
  module.exports = {
361
381
  hasGitNexusDbLockedByGitNexusServer,
382
+ // #2163 follow-up: the hook adapters wrap the augment CLI in the same
383
+ // guard. Returns a self-tested wrapper path — the built-in candidates are
384
+ // always absolute; a GITNEXUS_HOOK_TIMEOUT_PATH override is adopted as the
385
+ // exact string that passed the self-test. Same string is also the same
386
+ // RESOLUTION for absolute paths and for slashless names (PATH lookup is
387
+ // cwd-independent); a slash-containing RELATIVE override, however, is
388
+ // existsSync-checked and self-tested against this process's cwd while the
389
+ // adapters spawn the CLI with a `cwd` option (chdir-before-exec), so such
390
+ // a value can pass here yet ENOENT at the augment call site — set the
391
+ // override to an absolute path. Returns null when the wrapper is
392
+ // disabled/unavailable. Never call on win32 (see its JSDoc).
393
+ resolveUnixGuardTimeout,
362
394
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.8-rc.21",
3
+ "version": "1.6.8-rc.22",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",