From cb34f797f993ceaff06577e76cb0bf2a53e33e36 Mon Sep 17 00:00:00 2001 From: Eva Date: Thu, 16 Jul 2026 09:13:34 +0700 Subject: [PATCH] fix(eval): address auth configuration feedback --- README.md | 16 ++++ gitnexus/src/cli/eval-server.ts | 84 ++++++++++++++++++--- gitnexus/src/cli/i18n/en.ts | 2 +- gitnexus/src/cli/i18n/zh-CN.ts | 3 +- gitnexus/src/cli/index.ts | 2 +- gitnexus/test/unit/eval-server-auth.test.ts | 57 +++++++++++++- 6 files changed, 151 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 907bc2984..c8681ef2d 100644 --- a/README.md +++ b/README.md @@ -328,6 +328,7 @@ gitnexus setup # Configure MCP for detected editors (one-time; gitnexus analyze [path] # Index a repository (or update a stale index) gitnexus mcp # Start MCP server (stdio) — serves all indexed repos gitnexus serve # Start local HTTP server (multi-repo) for web UI connection +gitnexus eval-server # Start lightweight evaluation HTTP tools (loopback by default) gitnexus list # List all indexed repositories gitnexus status # Show index status for current repo gitnexus clean # Delete index for current repo @@ -337,6 +338,20 @@ gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks You can also query the graph directly from the terminal — `gitnexus query`, `context`, `impact`, `trace`, `cypher`, `detect-changes`, and `check` mirror the MCP tools of the same names, and `gitnexus doctor` prints runtime platform capabilities. +
+Authenticated eval-server binding + +`gitnexus eval-server` binds to `127.0.0.1` by default. Loopback bindings do not require authentication. Any non-loopback bind, including `0.0.0.0`, a LAN address, or a hostname that resolves to a LAN IPv4 address, requires `GITNEXUS_AUTH_TOKEN`. Every endpoint then requires an exact `Authorization: Bearer ` header. + +```bash +GITNEXUS_AUTH_TOKEN='replace-me' gitnexus eval-server --host 0.0.0.0 +curl -H 'Authorization: Bearer replace-me' http://127.0.0.1:4848/health +``` + +The token may be set in the shell, `.env.local`, or `.env` in the working directory. Precedence is shell > `.env.local` > `.env`. Only `GITNEXUS_AUTH_TOKEN` is read from those files; their other values are not added to the process environment. Keep token files uncommitted. + +
+
All analyze flags @@ -434,6 +449,7 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max | `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size (must be ≥ 1). Equivalent to `--workers `. The worker pool is the sole parse path — there is no sequential parser, so `0` is rejected with an actionable error (the pool self-heals via quarantine + respawn). | Constrained containers (cgroup CPU limits) or CI runners with explicit quotas. To narrow down a worker crash set `1` for a single-worker pool — not `0`. | | `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. | | `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. | +| `GITNEXUS_AUTH_TOKEN` | unset | Bearer token required when `eval-server` binds beyond loopback. May also be read from `.env.local` or `.env`; shell values take precedence. | Exposing the evaluation HTTP tools to a container, VM, or LAN. | | `GITNEXUS_PROFILE_DEFERRED` | unset | When `1`, emits `[deferred-profile]` timing/progress logs for the post-chunk deferred resolution band (imports → heritage → buildHeritageMap → legacy call resolution). Implied by `GITNEXUS_VERBOSE`. | Diagnosing analyze stalls in "Resolving calls (all chunks)" on large Java/Kotlin repos (issue #1741) without the full verbose ingestion noise. | | `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. | | `PROF_LBUG_LOAD` | unset | When `1`, emits one `[lbug-load prof]` summary line per `loadGraphToLbug` call breaking the graph-DB persistence wall into stages (`csv-emit` / `copy-nodes` / `copy-rels` / `fallback` / `total`) plus node & edge counts. Zero-cost when unset. | Attributing large-repo analyze wall time across CSV generation vs. LadybugDB `COPY` (issue #2203) — the analyze "emit" timing is the scope-resolution bucket, not this DB-write path. | diff --git a/gitnexus/src/cli/eval-server.ts b/gitnexus/src/cli/eval-server.ts index 0dc00588d..8c1a8daab 100644 --- a/gitnexus/src/cli/eval-server.ts +++ b/gitnexus/src/cli/eval-server.ts @@ -17,6 +17,7 @@ * gitnexus eval-server # default port 4848, binds 127.0.0.1 * gitnexus eval-server --port 4848 # explicit port * GITNEXUS_AUTH_TOKEN=... gitnexus eval-server --host 0.0.0.0 + * GITNEXUS_AUTH_TOKEN=... gitnexus eval-server --host devbox.local * gitnexus eval-server --idle-timeout 300 # auto-shutdown after 300s idle * * READY signal format: GITNEXUS_EVAL_SERVER_READY:: @@ -31,8 +32,11 @@ import http from 'http'; import crypto from 'node:crypto'; +import { lookup } from 'node:dns/promises'; import { isIPv4, isIPv6 } from 'node:net'; -import { writeSync } from 'node:fs'; +import { readFileSync, writeSync } from 'node:fs'; +import path from 'node:path'; +import { parseEnv } from 'node:util'; import { LocalBackend, type RepoListing, @@ -62,12 +66,62 @@ export function validateHost(raw: string): string | null { return null; } -/** Resolve the eval-server bearer token without retaining surrounding shell whitespace. */ -export function resolveEvalServerAuthToken(env: NodeJS.ProcessEnv): string | undefined { - return env.GITNEXUS_AUTH_TOKEN?.trim() || undefined; +type EvalServerHostLookup = (hostname: string) => Promise; + +function isHostname(raw: string): boolean { + if (!raw || raw.length > 253 || /^[\d.]+$/.test(raw)) return false; + return raw + .split('.') + .every( + (label) => + label.length > 0 && + label.length <= 63 && + /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(label), + ); } -/** True only for bind hosts that are local to this machine. */ +/** Resolve a DNS bind name once so validation and listen() use the same concrete address. */ +export async function resolveEvalServerBindHost( + raw: string, + resolveHostname: EvalServerHostLookup = async (hostname) => + (await lookup(hostname, { family: 4 })).address, +): Promise { + const directHost = validateHost(raw); + if (directHost && directHost !== 'localhost') return directHost; + if (directHost !== 'localhost' && !isHostname(raw)) return null; + + try { + const address = await resolveHostname(raw); + return isIPv4(address) ? address : null; + } catch { + return null; + } +} + +function readAuthTokenFile(filePath: string): string | undefined { + try { + return parseEnv(readFileSync(filePath, 'utf8')).GITNEXUS_AUTH_TOKEN?.trim() || undefined; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw new Error(`Unable to read eval-server authentication from ${filePath}`, { cause: error }); + } +} + +/** Resolve the bearer token from the shell, then .env.local, then .env. */ +export function resolveEvalServerAuthToken( + env: NodeJS.ProcessEnv, + cwd: string = process.cwd(), +): string | undefined { + if (Object.hasOwn(env, 'GITNEXUS_AUTH_TOKEN')) { + return env.GITNEXUS_AUTH_TOKEN?.trim() || undefined; + } + + return ( + readAuthTokenFile(path.join(cwd, '.env.local')) ?? readAuthTokenFile(path.join(cwd, '.env')) + ); +} + +/** True only for literal loopback addresses; DNS names are resolved before this check. */ export function isEvalServerLoopbackHost(host: string): boolean { return host === 'localhost' || host === '::1' || (isIPv4(host) && host.startsWith('127.')); } @@ -684,22 +738,34 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise', 'Port number', '4848') .option( '--host ', - 'Bind address (default: 127.0.0.1; non-loopback requires GITNEXUS_AUTH_TOKEN)', + 'Bind address or resolvable hostname (default: 127.0.0.1; non-loopback requires GITNEXUS_AUTH_TOKEN)', ) .option('--idle-timeout ', 'Auto-shutdown after N seconds idle (0 = disabled)', '0') .action(createLbugLazyAction(() => import('./eval-server.js'), 'evalServerCommand')); diff --git a/gitnexus/test/unit/eval-server-auth.test.ts b/gitnexus/test/unit/eval-server-auth.test.ts index a3c1336cc..fca33cc5f 100644 --- a/gitnexus/test/unit/eval-server-auth.test.ts +++ b/gitnexus/test/unit/eval-server-auth.test.ts @@ -1,12 +1,22 @@ -import { describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; import { assertSecureEvalServerBinding, isEvalServerBearerAuthorized, isEvalServerLoopbackHost, resolveEvalServerAuthToken, + resolveEvalServerBindHost, } from '../../src/cli/eval-server.js'; describe('eval-server bearer authentication', () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); + it('resolves a trimmed token and treats blank values as absent', () => { expect(resolveEvalServerAuthToken({ GITNEXUS_AUTH_TOKEN: ' secret-value ' })).toBe( 'secret-value', @@ -15,6 +25,51 @@ describe('eval-server bearer authentication', () => { expect(resolveEvalServerAuthToken({ GITNEXUS_AUTH_TOKEN: ' ' })).toBeUndefined(); }); + it('loads .env.local before .env while preserving explicit shell values', () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'gitnexus-eval-auth-')); + tempDirs.push(cwd); + writeFileSync(path.join(cwd, '.env'), 'GITNEXUS_AUTH_TOKEN=from-env\n'); + writeFileSync(path.join(cwd, '.env.local'), 'GITNEXUS_AUTH_TOKEN=from-local\n'); + + expect(resolveEvalServerAuthToken({}, cwd)).toBe('from-local'); + expect(resolveEvalServerAuthToken({ GITNEXUS_AUTH_TOKEN: 'from-shell' }, cwd)).toBe( + 'from-shell', + ); + expect(resolveEvalServerAuthToken({ GITNEXUS_AUTH_TOKEN: '' }, cwd)).toBeUndefined(); + }); + + it('falls back to .env when .env.local is absent', () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'gitnexus-eval-auth-')); + tempDirs.push(cwd); + writeFileSync(path.join(cwd, '.env'), 'GITNEXUS_AUTH_TOKEN="from env"\n'); + + expect(resolveEvalServerAuthToken({}, cwd)).toBe('from env'); + }); + + it('resolves DNS bind names to the concrete IPv4 used for the security decision', async () => { + const resolveHostname = async (hostname: string) => { + expect(hostname).toBe('devbox.local'); + return '192.168.1.50'; + }; + + await expect(resolveEvalServerBindHost('devbox.local', resolveHostname)).resolves.toBe( + '192.168.1.50', + ); + await expect(resolveEvalServerBindHost('devbox.local', async () => '::1')).resolves.toBeNull(); + await expect(resolveEvalServerBindHost('not a hostname', resolveHostname)).resolves.toBeNull(); + }); + + it('preserves literal IP addresses without a DNS lookup', async () => { + let lookupCalled = false; + await expect( + resolveEvalServerBindHost('10.0.0.2', async () => { + lookupCalled = true; + return '127.0.0.1'; + }), + ).resolves.toBe('10.0.0.2'); + expect(lookupCalled).toBe(false); + }); + it.each(['127.0.0.1', '127.0.0.2', 'localhost', '::1'])('classifies %s as loopback', (host) => { expect(isEvalServerLoopbackHost(host)).toBe(true); });