codegraph ui couldn't show a file whose name contains a %: 100%.ts was
refused as an unusable path, and a%20b.ts was served as changed on disk
with no lines. resolveProjectFile, the chokepoint for every file the
viewer reads, percent-decoded its path. But that path is either one the
index stored or a query value the URL parser had already decoded once,
so this was a second decode. It threw on 100%.ts and turned a%20b.ts into
a b.ts, while resolveRequestedFile looked the file up in the index by the
name as given: the bytes of a b.ts were hashed against a%20b.ts's record.
The chokepoint no longer decodes. It refuses raw control bytes, and on
Windows a backslash, then checks containment as before. Raw request
paths (the viewer's own assets) are still decoded where they are read.
The File view's /api/file/<path>, whose path the HTTP layer also decodes
once, had the same double decode and is fixed by the same change.
The POSIX half of PR #2009 (backslashes kept in names on POSIX) is left
out, as it needs a wider change and a POSIX run.
Validation on Windows 11, with a fixture holding src/a%20b.ts,
src/a b.ts and src/100%.ts:
- codegraph ui over HTTP, /api/source with the query a browser sends:
main serves a%20b.ts as drift=true with no lines and answers 403 for
100%.ts; the fix serves all three files as themselves, drift=false.
- Tests: 2 new tests (the chokepoint, and /api/source plus
/api/file/<path> over HTTP) fail on main's source and pass with the
fix. Two
existing cases that fed an encoded ..%2f and %00 straight to the
chokepoint now go through the URL parser's single decode, as requests
do, and are still refused.
Reported in PR #2009 by @shrankr; this reimplements the viewer half of
the fix independently.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Save trail on the trail bar writes the walk to .codegraph/ui/trails/ as one
JSON file, listed on the empty screen and on Entry points above the derived
suggestions, reopened at the symbol you left with the whole path restored.
A hop is stored by qualified name, kind and file — never by node id, which
contains a start line and so changes the first time anybody edits above the
symbol. Every hop is re-resolved against the current index on the way out and
each row says what became of it: still here, moved to another file, now
ambiguous, or gone. A hole is never stitched over: the row opens the longest
run of CONSECUTIVE resolved hops and says which ones those are, because the
trail is a path and a skipped hop would draw a call that does not exist.
This is the first write the viewer makes, and the boundary moved with it:
POST/DELETE answer under /api/ only, must carry X-CodeGraph-UI and
application/json (neither of which a cross-origin form can produce without a
preflight this server answers none of), and --read-only refuses both while
still listing what is there. The blanket "read-only" claim is retired from the
banner, the README, the CLI help and the docs site in favour of the narrower
true one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>