diff --git a/README.md b/README.md
index 907bc29843..c8681ef2d4 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 0dc00588d8..8c1a8daab8 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 a3c1336cc5..fca33cc5f1 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);
});