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
+74
View File
@@ -0,0 +1,74 @@
/**
* Opening the user's browser at the viewer URL.
*
* No dependency: the three platform openers are one-liners, and pulling in a
* package to shell out to `open` would be the only runtime dependency the
* viewer adds to a CLI that currently has ten.
*/
import { spawn } from 'child_process';
import { BROWSER_ENV } from './constants';
export { BROWSER_ENV };
const SUPPRESS_VALUES: ReadonlySet<string> = new Set(['', 'none', '0', 'false', 'off']);
export interface OpenCommand {
command: string;
args: string[];
}
/**
* The command that would open `url`, or `null` when opening is suppressed.
*
* Split out from {@link openBrowser} so the platform mapping is testable
* without launching anything.
*/
export function browserOpenCommand(
url: string,
platform: NodeJS.Platform,
override?: string
): OpenCommand | null {
if (override !== undefined) {
const trimmed = override.trim();
if (SUPPRESS_VALUES.has(trimmed.toLowerCase())) return null;
return { command: trimmed, args: [url] };
}
if (platform === 'darwin') return { command: 'open', args: [url] };
if (platform === 'win32') {
// `start` is a cmd builtin, not an executable. The empty string is the
// window title — without it `start` treats a quoted URL as the title and
// opens a blank console instead.
return { command: 'cmd', args: ['/c', 'start', '', url] };
}
return { command: 'xdg-open', args: [url] };
}
/**
* Open `url` in the user's default browser, best effort.
*
* Never throws and never keeps the CLI alive: the child is detached and
* unref'd, and a missing opener (a headless Linux box with no `xdg-open`) is
* swallowed — the URL is already printed, which is the part that matters.
*
* @returns `true` if a launch was attempted.
*/
export function openBrowser(url: string, platform: NodeJS.Platform = process.platform): boolean {
const open = browserOpenCommand(url, platform, process.env[BROWSER_ENV]);
if (!open) return false;
try {
const child = spawn(open.command, open.args, {
detached: true,
stdio: 'ignore',
// `start` is a shell builtin reached through `cmd /c`, so no shell here.
shell: false,
});
child.on('error', () => {
/* no opener installed — the printed URL is the fallback */
});
child.unref();
return true;
} catch {
return false;
}
}