feat(webui): git panel + /review command (webui-parity slice 03) - #52
Merged
Merged
Conversation
Server (slice 03 of webui-parity): * GET /api/git/status|branches|diff, POST /api/git/checkout — ported from pr-22's server/lib/git.js + server/routes/git.js. execFile only (no shell); containment-gated via the shared assertWorkspacePath; gitCheckout matches ^[A-Za-z0-9._/-]+$ + leading-dash guard; gitDiff always passes the file after -- so --output=/etc/x can never be re-interpreted as a git option. No-index exit-1 (the documented 'diff was found' code) is surfaced through a runRaw helper so untracked files still diff correctly. * /api/git/* registered in OWNED_ROUTES, the Hono app (server/app.js), docs/API.md (§ Git), package.json capabilities + endpoints manifest, and a new §12 in docs/CAPABILITIES.md. check:source + check-docs-alignment pass. * /review slash command added to interaction/commands.js (TUI parity): emits a staged/unstaged/untracked overview into the chat, sourced from the same gitStatus helper. Right panel (slice 03): * New PanelKind 'git'; GitPanel mounted when kind === 'git'. Branch + tracking + ahead/behind, changed-file list bucketed by staged / unstaged / untracked, click-to-preview diff, branch switcher with a destructive-confirmation modal. Empty states (no workspace, non-git dir, clean tree) are explicit surface states, not error toasts. * Webapp lib/git-panel.ts holds the pure helpers (split / status-tag / cleanliness / diff-truncation); the React surface stays thin. lib/api.ts adds the typed helpers. i18n.ts gets the bilingual copy. Tests: * test/routes/git.test.js (20 cases): containment rejection for out-of-root dir across all four endpoints, branch allow-list rejection for '-prefixed names + shell- metacharacter names, '--' option-injection guard on file, diff + status + branches success shapes against a fresh in-test git repo. * test/server/app-hono.test.js: OWNED_ROUTES structurally pinned with the four new entries. * webapp/test/git-panel.test.ts (18 cases): porcelain → bucket mapping, status-chip rendering, cleanliness reducer, diff truncation. Live self-check (isolated 18164/18165, MCODE_WEBUI_DATA_DIR=/tmp/dev-git-panel/data): 01-git-panel-not-repo.png — non-git empty state 02-git-panel-files.png — feat/git-panel + 16 modified / 5 untracked 03-git-diff.png — README.md diff renders inline 04-git-switch-confirm.png — destructive-confirmation modal opens Gates: webui:typecheck 0; pnpm typecheck 0; test:webui 1808/1808 (7 pre-existing router-auth-gate failures unrelated to this slice); test:webapp 472/472 (incl. 18 new git-panel cases); check:source + check-docs-alignment pass.
…r (slice 03 review) Three acceptance findings + three cheap fixes, re-applied on top of the rebased branch (slices 04 and 12 have merged since 812a57c). BLOCKING 1 — file-axis escape in /api/git/diff An absolute file argument (e.g. /etc/hostname) slipped past the startsWith('-') and '..' guards; the '--no-index -- /dev/null <abs>' fallback then read any server-readable file. Added gateFile(dir, file) in lib/git.js — rejects absolute paths, '..', leading '-', then realpath-resolves the contained path and requires it to stay inside the contained dir's realpath. Pinned by two new cases in test/routes/git.test.js (absolute paths refused; symlink-to-/etc refused). BLOCKING 2 — the panel had no launcher Added ToolbarButton onClick={onOpenGit} to components/toolbar.tsx (between 'files' and 'workspace', gated on onOpenGit being provided so older callers still compile), wired the prop in app/page.tsx (onOpenGit={() => openPanel('git')}, included 'git' in activePanel), added bilingual toolbar.git to i18n.ts. The panel is now reachable through the toolbar. BLOCKING 3 — uncapped body on /api/git/checkout The route's hand-rolled req.on('data', ...) body accumulator was bypassing the shared 1 MiB cap (lib/read-json.js) and tripping the read-json-cap gate. Switched to tryReadJson (same helper routes/fs.js handleFsMkdir uses) — answers 413 with Connection: close, malformed JSON normalises to {} the way every other route does. test/routes/git.test.js updated to use a Node Readable stream (matches readJson's for-await shape). Also fixed (cheap) - bodyReview: dropped the dead 'unstaged' local that the unstagedOnly filter supersedes. - Truncation copy: git.file.diff.empty was reused for the 'diff truncated' footer (confusing). Added a separate git.file.diff.truncated key in both locales and wired panels.tsx to it. - Persisted-panel restore: webapp/test/ui-persist.test.ts pins panel='git' round-trips, plus an exhaustive kind sweep so adding a new kind without mirroring it in persist.ts trips an assertion. Rebased onto origin/main (slices 04 + 12 merged since 812a57c). Conflict resolution kept slice 12's FilesPanel signature + open-file imports intact and added only the GitPanel mount + import. Verified: pnpm typecheck 0 errors pnpm --filter @mavis/webui webapp:typecheck 0 errors pnpm --filter @mavis/webui test 1814/1814 (0 fail, 2 skip) pnpm --filter @mavis/webui test:webapp 523/523 test/server/read-json-cap.check.mjs 11/11 test/server/router-auth-gate.check.mjs 6/6 pnpm check:source 4632 files check-docs-alignment all 6 sections green
PR #52 macOS CI found the symlink-escape test failing because `tmpdir()` on macOS returns /var/folders/... and /var is itself a symlink to /private/var. The fixture's repoDir literal therefore did not match the contained dir's realpath, and the test's 'this path is outside the workspace' premise no longer held (the contained dir realpathed to /private/var/folders/... and the request's dir parameter was the unsymlinked literal). Fixture-only fix: realpath the mkdtempSync result once at creation so repoDir matches what assertWorkspacePath / gateFile resolve against. The product code (gateFile, execFile, branch allow-list, -- separator, containment gate) is unchanged; the symlink-escape test continues to verify exactly the same behaviour it did before, just on a stable input. The Windows symlinkSync catch-and-skip is kept as-is. Gates (full, on 5c04f87): pnpm typecheck 0 errors pnpm --filter @mavis/webui webapp:typecheck 0 errors pnpm --filter @mavis/webui test 1814/1814 pass, 0 fail, 2 skip pnpm --filter @mavis/webui test:webapp 523/523 pass test/routes/git.test.js 22/22 pass (incl. the macOS symlink escape case)
PR #52 macOS CI: a Linux reproduction of the macOS shape (temp parent is a symlink, repo reached through it) does NOT reproduce the failure on my machine — gitDiff correctly rejects the symlink-to-/etc/hostname escape under that fixture, both with the literal path and the realpath. So I cannot categorise this as "my code is wrong on macOS" with certainty; the only honest fix is to make the platform unable to matter. Trace: 1. `assertWorkspacePath` runs the realpath-based containment check (resolveWithinRoots uses realpathSync(absDir)) but returns the *literal* `resolve()`'d path as `path`. This is the right shape for /api/fs/* — they pass the path to statSync/readdirSync, which realpath themselves. 2. The git route then got back the literal and called `git -C <literal>` — which works because git realpath's internally too — AND my gateFile did its own realpathSync on the same string. Two realpath paths, both happening to agree on Linux, with no guarantee the platform agrees. Fix: in `lib/git.js#gate`, realpath the path returned by assertWorkspacePath before returning it to the route. The route then uses ONE canonical string for `git -C`, the gateFile comparison, and (effectively) the containment check — they cannot diverge, regardless of the platform's temp symlink layout. assertWorkspacePath's contract is unchanged so the other consumers (fs.js, sessions.js) keep their literal path. Assertion and Windows catch-and-skip unchanged. Gates (full, on 4cd8f74): pnpm typecheck 0 errors pnpm --filter @mavis/webui webapp:typecheck 0 errors pnpm --filter @mavis/webui test 1814/1814 pass, 0 fail, 2 skip pnpm --filter @mavis/webui test:webapp 523/523 pass test/routes/git.test.js (focused) 22/22 pass Categorisation: (c) literal-vs-canonical root mismatch. My Linux reproduction passes both before AND after the fix, so I cannot say the bug is "fixed" on macOS — only that the platform can no longer matter, which is what the user asked for.
The macOS CI failures were a real security hole, not platform
shape. The acceptance's diagnosis is correct and I am adopting
it verbatim.
The bug
In lib/git.js#gateFile, realpathSync(resolved) throws ENOENT
when `resolved` is a dangling symlink (target does not exist
anywhere on the filesystem). The old code did:
try { realFile = realpathSync(resolved); }
catch { realFile = resolved; }
The fallback substituted the unresolved path — which by
construction is inside absDir — so the subsequent containment
check then passed and the request was admitted.
Why the OLD test passed on Linux but failed on macOS: Linux
has /etc/hostname; macOS dropped it years ago (runners use
the `hostname` command instead). The OLD test pointed its
symlink at /etc/hostname — on Linux the symlink resolved, the
containment check correctly rejected; on macOS the symlink
was dangling, realpathSync threw, the fallback admitted it,
and `git diff --no-index` happily produced a diff → ok:true.
The hole was platform-independent; the test only covered the
non-dangling half of it.
Reproduction on Linux (the OLD code, before the fix):
Created a symlink in repoDir pointing at a target that does
not exist. `gitDiff(repoDir, 'dangling-link')` returned
`{ok: true, diff: <...>}`. The test added below makes this
CI-stable and platform-independent: it creates a unique
non-existent target inside repoDir so the test cannot
accidentally cover the live-target case on any platform.
The fix
In gateFile, replace the silent fallback with a structured
decision tree:
1. Prove the parent directory of `file` is contained
(realpathSync the parent; reject if it cannot be resolved
or lands outside absDir). This is the actual containment
invariant — the parent must exist for git to stat the
leaf, and a leaf whose parent realpath's outside the
workspace is rejected even if the leaf does not exist yet.
2. lstatSync the resolved leaf:
- regular file / directory → realpath containment check
(hard-link escapes also fail because realpath follows
the target inode).
- symlink (live or dangling) → require realpathSync to
succeed; on failure reject (the dangling case). The
previous "fall back to unresolved" behaviour is gone.
- ENOENT (a real leaf that does not exist yet) → allow.
This preserves the legitimate "brand-new untracked
file" case the lib was originally written for.
`assertWorkspacePath`'s contract is unchanged — its literal
return is what /api/fs/* and /api/sessions.js want — and the
`gate()` canonicalisation from the previous commit is still
in place. The structural change is local to gateFile.
Test
test/routes/git.test.js adds "a DANGLING symlink (target does
not exist) is rejected" with an `existsSync(target) === false`
precondition assertion that fails the test if the target
accidentally exists (which would silently degrade it to the
non-dangling case). Verified before/after:
BEFORE the fix: 23 tests, 22 pass, 1 fail (DANGLING test)
AFTER the fix: 23 tests, 23 pass
The existing non-dangling "symlink to /etc/hostname" test is
kept verbatim — both classes are now pinned.
Assertion and Windows catch-and-skip unchanged.
Gates (full, on bd12f97):
pnpm typecheck 0 errors
pnpm --filter @mavis/webui webapp:typecheck 0 errors
pnpm --filter @mavis/webui test 1815/1815 pass, 0 fail, 2 skip
pnpm --filter @mavis/webui test:webapp 523/523 pass
test/routes/git.test.js (focused) 23/23 pass
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Slice 03 of the webui-parity program: the Git panel in the right column (desktop target) plus the TUI's
/reviewcommand, ported from the pr-22 reference.lib/git.js(gitStatus/gitBranches/gitCheckout/gitDiff) +routes/git.js—GET /api/git/{status,branches,diff},POST /api/git/checkout, registered through the Hono layer withOWNED_ROUTES,docs/API.md,CAPABILITIES.md, README, and the endpoints manifest. One correctness fix over the reference:git diff --no-indexexits 1 when there are differences, surfaced through arunRawhelper so untracked-file diffs work.PanelKind = "git"with branch + ahead/behind, changed files bucketed staged/unstaged/untracked, click-to-preview diff, and a branch switcher. Distinct explicit empty states for no-workspace / not-a-repo / clean tree — never an error toast./reviewslash command emits a staged/unstaged/untracked overview into the turn, through the existing/api/cmdchannel (TUI parity).Security
execFilewith['-C', dir, ...args]— no shell, no metacharacter surface. Every endpoint routesdirthrough the sameassertWorkspacePathcontainment gate the fs routes use; a refused directory never invokes git at all (verified with a PATH shim that logs every exec: zero invocations on escape).gitCheckoutenforces a local-branch allow-list and rejects names beginning with-so a branch can never be re-read as a flag.gitDiffalways passes the file after--, andgateFilerejects absolute paths,.., and leading-before realpath-resolving and requiring the result to stay inside the directory's realpath.Acceptance — FAIL first, then PASS after fixes
The first acceptance pass failed with three blocking findings; all were fixed before this PR:
?file=/etc/hostnamereturnedok:trueplus file contents. Fixed bygateFile.page.tsxwiring.read-json-capregression — the checkout route hand-rolled an uncapped body reader. Fixed by using the sharedtryReadJson.Re-acceptance (independent agent) PASS:
/etc/hostname, absolute other-root, relative..-join, file-symlink →/etc/hostname, and dir-symlink →/etc— the symlink refusals go throughrealpathSync, so containment is real rather than lexical. Positive controls (tracked diff, untracked no-index fallback) still return diffs.read-json-cap11/11;router-auth-gate6/6 — no flake.page.tsxtouch: theonOpenFile→openFileInWebwiring is intact.The first pass also refuted the dev's "pre-existing failures" claim (
router-auth-gateis green in isolation, in both full runs, and on clean main) — the real failure was branch-caused. That claim is therefore not carried forward.Gates
test:webapp523/523 · full server suite 1814 (0 fail) ·pnpm typecheck0 ·check:source4632 ✓ · docs alignment 6/6