mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
fix(eval): address auth configuration feedback
This commit is contained in:
parent
e8697075c4
commit
cb34f797f9
6 changed files with 151 additions and 13 deletions
16
README.md
16
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.
|
||||
|
||||
<details>
|
||||
<summary><strong>Authenticated <code>eval-server</code> binding</strong></summary>
|
||||
|
||||
`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 <token>` 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.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>All <code>analyze</code> flags</strong></summary>
|
||||
|
||||
|
|
@ -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 <n>`. 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. |
|
||||
|
|
|
|||
|
|
@ -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:<host>:<port>
|
||||
|
|
@ -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<string>;
|
||||
|
||||
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<string | null> {
|
||||
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<vo
|
|||
const idleTimeoutSec = parseInt(options?.idleTimeout || '0');
|
||||
|
||||
const rawHost = options?.host ?? '127.0.0.1';
|
||||
const host = validateHost(rawHost);
|
||||
const host = await resolveEvalServerBindHost(rawHost);
|
||||
if (!host) {
|
||||
cliError(
|
||||
`Invalid --host value "${rawHost}":\n` +
|
||||
` Must be an IP address or "localhost".\n\n` +
|
||||
` Must be an IP address or a hostname that resolves to a local IPv4 address.\n\n` +
|
||||
` Examples:\n` +
|
||||
` gitnexus eval-server --host 127.0.0.1 (loopback only, default)\n` +
|
||||
` GITNEXUS_AUTH_TOKEN=... gitnexus eval-server --host 0.0.0.0\n` +
|
||||
` GITNEXUS_AUTH_TOKEN=... gitnexus eval-server --host 192.168.1.5\n` +
|
||||
` gitnexus eval-server --host localhost (OS-resolved loopback)\n`,
|
||||
` gitnexus eval-server --host localhost (resolved IPv4 loopback)\n` +
|
||||
` GITNEXUS_AUTH_TOKEN=... gitnexus eval-server --host devbox.local\n`,
|
||||
{ flag: '--host', value: rawHost },
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const authToken = resolveEvalServerAuthToken(process.env);
|
||||
let authToken: string | undefined;
|
||||
try {
|
||||
authToken = resolveEvalServerAuthToken(process.env);
|
||||
} catch (error) {
|
||||
cliError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Unable to read eval-server authentication configuration.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
assertSecureEvalServerBinding(host, authToken);
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -273,7 +273,7 @@ export const en = {
|
|||
'help.option.cypher.limit': 'Max result rows to return',
|
||||
'help.option.check.cycles': 'Detect circular imports and fail when any are found',
|
||||
'help.option.evalServer.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)',
|
||||
'help.option.evalServer.idleTimeout': 'Auto-shutdown after N seconds idle (0 = disabled)',
|
||||
'help.option.embeddings.install.cuda':
|
||||
"Also download the CUDA GPU binaries (runs onnxruntime-node's NuGet postinstall; set GLOBAL_AGENT_HTTPS_PROXY behind a proxy)",
|
||||
|
|
|
|||
|
|
@ -255,7 +255,8 @@ export const zhCN = {
|
|||
'help.option.detectChanges.limit': '最多返回的已变更符号数',
|
||||
'help.option.cypher.limit': '最多返回的结果行数',
|
||||
'help.option.check.cycles': '检测循环导入,并在发现循环时失败',
|
||||
'help.option.evalServer.host': '绑定地址(默认:127.0.0.1;非回环绑定需要 GITNEXUS_AUTH_TOKEN)',
|
||||
'help.option.evalServer.host':
|
||||
'绑定地址或可解析的主机名(默认:127.0.0.1;非回环绑定需要 GITNEXUS_AUTH_TOKEN)',
|
||||
'help.option.evalServer.idleTimeout': '空闲 N 秒后自动关闭(0 = 禁用)',
|
||||
'help.option.embeddings.install.cuda':
|
||||
'同时下载 CUDA GPU 二进制文件(运行 onnxruntime-node 的 NuGet postinstall;代理后请设置 GLOBAL_AGENT_HTTPS_PROXY)',
|
||||
|
|
|
|||
|
|
@ -436,7 +436,7 @@ program
|
|||
.option('-p, --port <port>', 'Port number', '4848')
|
||||
.option(
|
||||
'--host <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 <seconds>', 'Auto-shutdown after N seconds idle (0 = disabled)', '0')
|
||||
.action(createLbugLazyAction(() => import('./eval-server.js'), 'evalServerCommand'));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue