feat(ui): serve the viewer from codegraph ui, loopback-only and read-only (CG-41)

Adds the `codegraph ui [path]` command (alias `web`) and `src/ui-server/`, a
`node:http` server with no framework and no new dependency.

The command reads an index that already exists — it never creates one, so a
missing index prints the same friendly guidance the MCP tools give instead of
a stack trace, and a sensitive system directory is refused up front.

Security is the substance here, not the routing. The server binds 127.0.0.1
only, answers GET and HEAD only, and sends no CORS headers ever. The realistic
attack on a process that serves your source code from a local port is DNS
rebinding, so every request must carry a loopback `Host` (on our port) and, if
it carries an `Origin` at all, a loopback one — anything else is 403 before
the filesystem is touched. Every path resolves through the engine's existing
`validatePathWithinRoot` chokepoint, which already handles `../` traversal and
in-tree symlinks pointing out of the root (#527); `..` segments are refused
outright so a traversal attempt gets a 404 rather than the SPA shell.

`PathRefusalError` moves from `mcp/tools.ts` into the dependency-free
`errors.ts` (re-exported from its old home, so class identity and every
`instanceof` check are unchanged) — that is what lets a non-MCP read sink
enforce the same refusal without importing the MCP tool graph.

Assets come from `dist/viewer/` resolved relative to `__dirname`, the way
`db/index.ts` finds `schema.sql`. Hashed assets are cached immutably,
`index.html` never. Port 4747, or the next free one — an explicit `--port`
stays explicit rather than silently moving. `--no-open` skips the browser, and
`CODEGRAPH_BROWSER` picks one (or `none` to suppress it), which is also what
makes "did it open a browser" testable end to end.

`resolveProjectFile` and the `/api/` handler seam are the boundary CG-42's
JSON API plugs into; `/api/*` 404s as JSON so a typo'd endpoint never returns
the app shell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-08-26 16:17:09 -05:00
co-authored by Claude Opus 5
parent a72f22a6d3
commit 0196c2e53a
11 changed files with 2032 additions and 1 deletions
+358
View File
@@ -0,0 +1,358 @@
/**
* `codegraph ui` — the CLI face of the viewer server (CG-41).
*
* Exercised end-to-end against the built binary, because the things worth
* pinning here are the ones that only exist once commander, the project
* resolver and the server are wired together: the help text, the friendly
* "not indexed" guidance, the sensitive-directory refusal, and whether
* `--no-open` actually stops a browser from being launched.
*
* The browser check works by pointing `CODEGRAPH_BROWSER` at a script that
* touches a marker file — so "did it try to open a browser" becomes an
* observable fact rather than a promise.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFileSync, spawn, type ChildProcess } from 'child_process';
import * as fs from 'fs';
import * as http from 'http';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { DEFAULT_UI_PORT as DEFAULT_PORT } from '../src/ui-server/constants';
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
const BASE_ENV = {
...process.env,
CODEGRAPH_NO_DAEMON: '1',
CODEGRAPH_WASM_RELAUNCHED: '1',
NO_COLOR: '1',
};
/** Run the CLI to completion, capturing stdout+stderr and the exit code. */
function runCli(args: string[], env: Record<string, string> = {}): { code: number; output: string } {
try {
const output = execFileSync(process.execPath, [BIN, ...args], {
encoding: 'utf-8',
env: { ...BASE_ENV, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
return { code: 0, output };
} catch (err) {
const e = err as { status?: number; stdout?: string; stderr?: string };
return { code: e.status ?? 1, output: `${e.stdout ?? ''}${e.stderr ?? ''}` };
}
}
/** GET a path from a running viewer, with a valid loopback Host. */
function get(port: number, requestPath: string): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
const req = http.request(
{ host: '127.0.0.1', port, path: requestPath, method: 'GET' },
(res) => {
const chunks: Buffer[] = [];
res.on('data', (c: Buffer) => chunks.push(c));
res.on('end', () =>
resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') })
);
}
);
req.on('error', reject);
req.end();
});
}
/**
* Start `codegraph ui` and wait for the URL it prints.
*
* The banner IS the readiness signal: the server is bound before the URL is
* printed, so anything the test does after this line is talking to a live
* socket.
*/
function startViewer(
args: string[],
env: Record<string, string>
): Promise<{ child: ChildProcess; port: number; output: () => string }> {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [BIN, 'ui', ...args], {
env: { ...BASE_ENV, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
const timer = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error(`codegraph ui never printed a URL. Output:\n${output}`));
}, 30_000);
const onChunk = (chunk: Buffer): void => {
output += chunk.toString('utf-8');
const match = output.match(/http:\/\/127\.0\.0\.1:(\d+)/);
if (match?.[1]) {
clearTimeout(timer);
resolve({ child, port: Number(match[1]), output: () => output });
}
};
child.stdout?.on('data', onChunk);
child.stderr?.on('data', onChunk);
child.on('error', (err) => {
clearTimeout(timer);
reject(err);
});
child.on('exit', (code) => {
clearTimeout(timer);
reject(new Error(`codegraph ui exited with ${code} before serving. Output:\n${output}`));
});
});
}
async function stopViewer(child: ChildProcess): Promise<void> {
if (child.exitCode !== null) return;
await new Promise<void>((resolve) => {
child.once('exit', () => resolve());
child.kill('SIGTERM');
// A viewer that ignores SIGTERM must not hang the suite.
setTimeout(() => {
child.kill('SIGKILL');
resolve();
}, 5_000).unref();
});
}
describe('codegraph ui — help', () => {
it('reads well and documents the flags', () => {
const { code, output } = runCli(['ui', '--help']);
expect(code).toBe(0);
expect(output).toContain('--port');
expect(output).toContain('--no-open');
expect(output).toContain('4747');
expect(output).toContain('127.0.0.1');
expect(output).toContain('read-only');
expect(output).toContain('Examples:');
expect(output).toContain('CODEGRAPH_BROWSER');
});
it('works through `codegraph help ui`', () => {
const viaHelpCommand = runCli(['help', 'ui']);
const viaFlag = runCli(['ui', '--help']);
expect(viaHelpCommand.code).toBe(0);
expect(viaHelpCommand.output).toBe(viaFlag.output);
});
it('is listed in the top-level help, and `web` is an alias', () => {
const top = runCli(['--help']);
expect(top.output).toContain('ui|web [options] [path]');
const viaAlias = runCli(['help', 'web']);
expect(viaAlias.code).toBe(0);
expect(viaAlias.output).toContain('--no-open');
});
it('rejects a nonsense --port with a plain message, not a stack trace', () => {
const { code, output } = runCli(['ui', '--port', 'banana']);
expect(code).toBe(1);
expect(output).toContain('--port must be a whole number');
expect(output).not.toContain('at Object.');
});
});
describe('codegraph ui — refusals', () => {
let unindexed: string;
beforeAll(() => {
unindexed = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-unindexed-'));
fs.writeFileSync(path.join(unindexed, 'a.ts'), 'export const a = 1;\n');
});
afterAll(() => {
fs.rmSync(unindexed, { recursive: true, force: true });
});
it('gives friendly guidance — never a stack trace — when there is no index', () => {
const { code, output } = runCli(['ui', unindexed]);
expect(code).toBe(1);
expect(output).toContain('No CodeGraph index found');
expect(output).toContain('codegraph init');
expect(output).not.toContain('at Object.');
expect(output).not.toContain('Error:');
});
// `/etc` is only sensitive on POSIX; on Windows it resolves to a
// non-existent `C:\etc` and the "no index" path handles it instead.
it.runIf(process.platform !== 'win32')('refuses a sensitive system directory', () => {
const { code, output } = runCli(['ui', '/etc']);
expect(code).toBe(1);
expect(output).toContain('Refusing to operate on sensitive');
});
});
describe('codegraph ui — serving', () => {
let projectDir: string;
let markerDir: string;
let opener: string;
beforeAll(async () => {
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-cli-'));
fs.mkdirSync(path.join(projectDir, 'src'));
fs.writeFileSync(
path.join(projectDir, 'src', 'auth.ts'),
'export function parseToken(t: string){ return t.trim(); }\n'
);
const cg = CodeGraph.initSync(projectDir);
await cg.indexAll();
cg.close();
// A stand-in browser: records that it was launched, and with what.
markerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-open-'));
const markerFile = path.join(markerDir, 'opened.txt');
if (process.platform === 'win32') {
opener = path.join(markerDir, 'open.cmd');
fs.writeFileSync(opener, `@echo %1 > "${markerFile}"\r\n`);
} else {
opener = path.join(markerDir, 'open.sh');
fs.writeFileSync(opener, `#!/bin/sh\nprintf '%s' "$1" > "${markerFile}"\n`);
fs.chmodSync(opener, 0o755);
}
}, 120_000);
afterAll(() => {
fs.rmSync(projectDir, { recursive: true, force: true });
fs.rmSync(markerDir, { recursive: true, force: true });
});
const markerFile = (): string => path.join(markerDir, 'opened.txt');
/** The opener is async (detached); give it a moment before concluding. */
async function waitForMarker(timeoutMs: number): Promise<string | null> {
const deadline = Date.now() + timeoutMs;
for (;;) {
if (fs.existsSync(markerFile())) return fs.readFileSync(markerFile(), 'utf-8');
if (Date.now() > deadline) return null;
await new Promise((r) => setTimeout(r, 50));
}
}
it('serves the viewer and prints where it is', async () => {
const viewer = await startViewer(['--no-open', '--port', '0', projectDir], {});
try {
const res = await get(viewer.port, '/');
expect(res.status).toBe(200);
expect(res.body).toContain('<div id="app">');
const banner = viewer.output();
expect(banner).toContain('CodeGraph viewer');
expect(banner).toContain(projectDir);
expect(banner).toContain('this machine only');
} finally {
await stopViewer(viewer.child);
}
}, 60_000);
it('honours --no-open: no browser is launched', async () => {
fs.rmSync(markerFile(), { force: true });
const viewer = await startViewer(['--no-open', '--port', '0', projectDir], {
CODEGRAPH_BROWSER: opener,
});
try {
// Confirm the server is genuinely up before concluding "nothing opened" —
// otherwise this passes for the wrong reason.
expect((await get(viewer.port, '/')).status).toBe(200);
expect(await waitForMarker(1_500)).toBeNull();
expect(viewer.output()).toContain('Open that URL in a browser');
expect(viewer.output()).not.toContain('Opening your browser');
} finally {
await stopViewer(viewer.child);
}
}, 60_000);
it('opens the browser at the served URL when --no-open is absent', async () => {
fs.rmSync(markerFile(), { force: true });
const viewer = await startViewer(['--port', '0', projectDir], { CODEGRAPH_BROWSER: opener });
try {
const opened = await waitForMarker(10_000);
expect(opened).not.toBeNull();
expect(opened?.trim()).toContain(`http://127.0.0.1:${viewer.port}`);
expect(viewer.output()).toContain('Opening your browser');
} finally {
await stopViewer(viewer.child);
}
}, 60_000);
it('CODEGRAPH_BROWSER=none suppresses the launch like --no-open', async () => {
fs.rmSync(markerFile(), { force: true });
const viewer = await startViewer(['--port', '0', projectDir], { CODEGRAPH_BROWSER: 'none' });
try {
expect((await get(viewer.port, '/')).status).toBe(200);
expect(await waitForMarker(1_000)).toBeNull();
} finally {
await stopViewer(viewer.child);
}
}, 60_000);
it('moves off the default port when it is busy', async () => {
// Occupy 4747 so the fallback has something to fall back FROM. If a
// developer's own viewer already holds it, the bind fails and the
// assertion below is still exactly the right one: the new viewer must not
// be on 4747 either way.
const blocker = http.createServer(() => {});
const bound = await new Promise<boolean>((resolve) => {
blocker.once('error', () => resolve(false));
blocker.listen(DEFAULT_PORT, '127.0.0.1', () => resolve(true));
});
try {
const viewer = await startViewer(['--no-open', projectDir], {});
try {
expect(viewer.port).not.toBe(DEFAULT_PORT);
expect((await get(viewer.port, '/')).status).toBe(200);
} finally {
await stopViewer(viewer.child);
}
} finally {
if (bound) await new Promise<void>((resolve) => blocker.close(() => resolve()));
}
}, 60_000);
it('refuses to move off a port the user pinned with --port', async () => {
const blocker = http.createServer(() => {});
await new Promise<void>((resolve) => blocker.listen(0, '127.0.0.1', resolve));
const taken = (blocker.address() as { port: number }).port;
try {
const { code, output } = runCli(['ui', '--no-open', '--port', String(taken), projectDir]);
expect(code).toBe(1);
expect(output).toContain('already in use');
expect(output).not.toContain('at Object.');
} finally {
await new Promise<void>((resolve) => blocker.close(() => resolve()));
}
}, 60_000);
it('refuses a foreign Host end-to-end', async () => {
const viewer = await startViewer(['--no-open', '--port', '0', projectDir], {});
try {
const res = await new Promise<{ status: number; body: string }>((resolve, reject) => {
const req = http.request(
{
host: '127.0.0.1',
port: viewer.port,
path: '/',
headers: { Host: 'evil.example' },
setHost: false,
},
(r) => {
const chunks: Buffer[] = [];
r.on('data', (c: Buffer) => chunks.push(c));
r.on('end', () =>
resolve({ status: r.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') })
);
}
);
req.on('error', reject);
req.end();
});
expect(res.status).toBe(403);
expect(res.body).not.toContain('<div id="app">');
} finally {
await stopViewer(viewer.child);
}
}, 60_000);
});