Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/webui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ IDE integrations match on these strings.
| `bilingual-ui` | zh-CN / en locale toggle via typed `t(MessageKey)` lookup |
| `lan-sharing` | Loopback default; LAN exposure via explicit opt-in (`HOST` env / `lanBind` setting) + runtime on/off toggle |
| `token-auth` | `?token=` / `Authorization: Bearer` for non-local requests |
| `git-panel` | Right-panel git surface: status + branches + diff + destructive-confirmed branch switch |
| `mobile-responsive` | Drawer at <900px, single column at <600px |

CI asserts every one of these names is mentioned in this README and in
Expand Down
122 changes: 122 additions & 0 deletions packages/webui/docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,128 @@ a regular file; 413 over the 20 MiB cap.

---

## Git

The git endpoints drive the right-panel Git panel (slice 03 —
`webapp/components/panels.tsx#GitPanel`) and the `/review` slash
command. They share the same containment boundary as the fs endpoints
(`/api/fs/*`): a candidate `dir` is `resolve()`d, symlink-resolved
(`realpath`), and must land within an allowed workspace root (default
home + default workspace + tmp; `MCODE_WEBUI_WORKSPACE_ROOTS` fully
replaces the set). Out-of-root directories are answered with a
`{ok:false, error:"…不在允许根内…"}` payload — the panel surfaces
that as an empty state rather than as a red toast.

Security invariants (pinned by `test/routes/git.test.js`):

* `git` is invoked through `execFile` with `['-C', dir, ...args]` —
no shell, no metacharacter surface.
* `gitCheckout` matches the branch name against `^[A-Za-z0-9._/-]+$`
and additionally rejects names that start with `-` (a branch named
`--upload-pack=…` would otherwise be re-interpreted as a `git
checkout` option by the binary itself).
* `gitDiff` always passes the user-supplied file after a `--` token,
so a filename like `--output=/etc/x` cannot be re-interpreted as a
`git diff` option. The same input is rejected up front by an
explicit `startsWith('-')` guard.

### `GET /api/git/status?dir=<workspace>`

Workspace status for the panel header. `dir` is required.

`status --porcelain=v1 -b` gives a deterministic stream: one header
line (`## <branch>[...<upstream>] [ahead N, behind M]`) followed by
the per-file entries. The route parses both halves; a detached HEAD
or a branch with no upstream simply produces a `null` upstream /
zero ahead/behind without an error.

**Response 200**
```json
{
"ok": true,
"isRepo": true,
"branch": "feat/git-panel",
"upstream": "origin/feat/git-panel",
"ahead": 0,
"behind": 0,
"files": [
{ "x": "M", "y": " ", "path": "README.md", "origPath": null, "staged": true },
{ "x": "?", "y": "?", "path": "untracked.txt", "origPath": null, "staged": false }
]
}
```

`x` / `y` are the raw porcelain status codes (see `git status --help`
§ "porcelain v1 format"); `staged` is `x !== ' ' && x !== '?'`
(includes `M`, `A`, `D`, `R`, `C` in the index position). Renames
carry `origPath` (the pre-rename path) alongside `path` (the new
path). `isRepo:false` answers a non-git directory without an error.

**Errors** — 400 missing `dir`; the body is `{ok:false, error}` and
the status stays `200` (the panel reads `ok` rather than the HTTP
code, so a non-git directory is a normal state).

### `GET /api/git/branches?dir=<workspace>`

Local branches plus a `current` marker. The panel renders this list
as the branch switcher — `gitCheckout` requires the picked name to
match the same set, so the switcher never has a choice it cannot
honour.

**Response 200**
```json
{
"ok": true,
"branches": [
{ "name": "feat/git-panel", "current": true },
{ "name": "main", "current": false }
]
}
```

**Errors** — 400 missing `dir`; `{ok:false, error}` on git failure.

### `GET /api/git/diff?dir=<workspace>&file=<path>`

Single-file diff against `HEAD`. Untracked files (`?` in porcelain)
fall back to `git diff --no-index -- /dev/null <file>`, which
produces a synthetic all-add diff so the panel can preview them too.
The fallback returns `{ok:true, diff}` (never an error) when the
input file exists; an `ok:false` is reserved for the gate rejection
or for a `git` invocation failure.

**Response 200**
```json
{ "ok": true, "diff": "diff --git a/README.md b/README.md\n…" }
```

**Errors** — 400 missing `dir`/`file`; `{ok:false, error}` for
containment or invalid path. The HTTP status stays `200` for
soft-fail paths; the panel reads `ok`.

### `POST /api/git/checkout`

Switch to a local branch. **Destructive** — the panel gates the
button behind a confirmation prompt before sending. Server-side
defence in depth: the branch name is matched against
`^[A-Za-z0-9._/-]+$` and rejected if it starts with `-`, so a
forged client cannot smuggle an option through.

**Request**
```json
{ "dir": "C:\\Users\\you\\projects\\foo", "branch": "feat/git-panel" }
```

**Response 200** `{ok:true}` on success; `{ok:false, error}` on
gate / allow-list rejection or `git` failure. The HTTP status
stays `200`; the panel reads `ok`.

**Errors** — 400 missing `dir`/`branch`, invalid JSON;
`{ok:false, error:"非法分支名"}` on allow-list rejection;
`{ok:false, error}` on `git` failure.

---

## Settings

### `GET /api/settings`
Expand Down
19 changes: 17 additions & 2 deletions packages/webui/docs/CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ doc where the feature is broken down by status.
| `bilingual-ui` | §10 UI / UX |
| `lan-sharing` | §11 Network & access control |
| `token-auth` | §11 Network & access control |
| `git-panel` | §12 Git panel |
| `mobile-responsive` | §10 UI / UX |

CI asserts on every one of these names appearing in this document
Expand Down Expand Up @@ -189,7 +190,21 @@ single index that satisfies the check.
| mTLS / client cert | ❌ | same as above; documentation in `docs/HTTPS-REVERSE-PROXY.md` |
| Rate limiting | ✅ | v2.0.0 (lease C03): `server/lib/rate-limit.js` (252 lines) — token-bucket per-ip with 60/min default + 100 burst + 2× multiplier for token holders. Router gate 4 returns 429 when exceeded. `lib-rate-limit.test.js` (339 lines, 21 unit tests). |

## 12. Operations
## 12. Git panel

| Feature | Status | Why / where |
|---|---|---|
| Workspace status (`git status --porcelain=v1 -b`) | ✅ | `GET /api/git/status` — `server/lib/git.js#gitStatus`. Returns branch + upstream + ahead/behind + per-file `{x, y, path, origPath, staged}`. Non-git directories answer `{ok:false, isRepo:false}` and the panel renders an empty state, not a red toast. |
| Local-branch list + current marker | ✅ | `GET /api/git/branches` — `server/lib/git.js#gitBranches`. `branch --list --format=%(refname:short)`; the leading `* ` (the default `--list` marker) becomes the `current` flag. |
| Single-file diff against HEAD | ✅ | `GET /api/git/diff?dir=&file=` — `server/lib/git.js#gitDiff`. Tries `git diff HEAD -- <file>` first; falls back to `git diff --no-index -- /dev/null <file>` for untracked files (synthetic all-add diff). The `--` separator is the option-injection boundary. |
| Branch switch (destructive, confirmed client-side) | ✅ | `POST /api/git/checkout {dir, branch}` — `server/lib/git.js#gitCheckout`. Branch name matched against `^[A-Za-z0-9._/-]+$` and rejected when it starts with `-`; containment gate enforces an allowed root; `execFile` keeps `git`'s argv literal. |
| Right-panel Git surface (`GitPanel`) | ✅ | `webapp/components/panels.tsx#GitPanel` (slice 03). Current branch + changed-file list with click-to-preview diff; branch switcher with a confirmation prompt; non-git or out-of-root directory shows an empty state. |
| `/review` slash command (TUI parity) | ✅ | `server/lib/interaction/commands.js#bodyReview` + `handleLocalSlash`/`handleCmdCommand`. Emits a `staged / unstaged / untracked` overview into the chat, sourced from the shared `gitStatus` helper. |
| Containment gate shared with `/api/fs/*` | ✅ | `assertWorkspacePath` (server/lib/workspace.js). Every git entry point funnels the requested `dir` through it; out-of-root answers `{ok:false, error:"…不在允许根内…"}` and the panel reads `ok` rather than the HTTP code. |
| execFile, no shell | ✅ | `run(dir, args)` in `lib/git.js` uses `execFile('git', ['-C', dir, ...args], …)` so every argv element is a literal child argv. No shell, no metacharacter surface. |
| Local-branch allow-list (regex + leading-dash guard) | ✅ | `BRANCH_RE` and `branch.startsWith('-')` in `gitCheckout`. The panel only offers branches from the server's `/api/git/branches` list; the server-side allow-list is the defence-in-depth that survives a forged request. |

## 13. Operations

| Feature | Status | Why / where |
|---|---|---|
Expand All @@ -208,7 +223,7 @@ single index that satisfies the check.
| SBOM + local CVE gates | ✅ | `pnpm --filter @mavis/webui sbom` → CycloneDX 1.5 (`scripts/gen-sbom.mjs`) + `pnpm audit --omit=dev` + the repo-level `docs/verification.md` matrix. The webui itself has no plugin-level CI workflow; the only enforcement is `pnpm --filter @mavis/webui check` (the docs-alignment gate) plus the monorepo `pnpm verify`. |
| `token.first_run` SSE event | ✅ | `server/lib/state-bus.js#pushTokenFirstRun` broadcasts `{event: "token.first_run", data: {token, persistPath}}` to all `sseByCid` on first boot. Replay-guarded by `auth.js#isFirstRun()` + persistent `tokenAcknowledged` flag. |

## 13. What mcode would need to add to enable the ❌ rows
## 14. What mcode would need to add to enable the ❌ rows

- `set_mode` / `set_config_option` → mid-session permission switch in the UI
- `cancel` → true mid-flight cancellation, not just SIGTERM
Expand Down
5 changes: 5 additions & 0 deletions packages/webui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@
"name": "token-auth",
"description": "When `TOKEN` env is set (or the server auto-generates a 32-hex token on first start with no env set), all non-local requests to `/api/*` must include `?token=<value>` or `Authorization: Bearer <value>`. Local requests always bypass. The token can be rotated live from the settings card."
},
{
"name": "git-panel",
"description": "Right-panel Git surface (slice 03 of webui-parity): workspace status (branch + upstream + per-file porcelain), local-branch list, single-file diff against HEAD with a no-index fallback for untracked files, and a destructive branch switch gated by a local-branch allow-list. All `git` invocations go through `execFile` (no shell) and every `dir` is checked by the shared `assertWorkspacePath` containment gate; an out-of-root directory answers `{ok:false, isRepo:false}` and the panel renders an empty state rather than an error."
},
{
"name": "mobile-responsive",
"description": "Layout adapts at <900px (drawer pattern for the sidebar and right panel) and collapses to a single column at <600px. Dark mode follows `prefers-color-system` at boot. Touch targets and font sizes are tuned for phone use."
Expand All @@ -111,6 +115,7 @@
"model": "GET /api/models, POST /api/set-model|permissions|answer",
"providers": "GET|PUT /api/providers, POST /api/providers/test, GET /api/providers/presets, POST /api/providers/preset/:id/enable",
"usage": "GET|POST /api/usage[-real|-trigger|/refresh]",
"git": "GET /api/git/status|branches|diff, POST /api/git/checkout",
"protocol": "GET|POST /api/protocol/* (acp shim)",
"debug": "GET|POST /api/debug/* (DEBUG_INJECT gated)"
}
Expand Down
22 changes: 22 additions & 0 deletions packages/webui/server/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import * as modelRoute from "./routes/model.js";
import * as debugRoute from "./routes/debug.js";
import * as protocolRoute from "./routes/protocol.js";
import * as providersRoute from "./routes/providers.js";
import * as gitRoute from "./routes/git.js";
import * as authorizeRoute from "./lib/authorize.js";

/**
Expand Down Expand Up @@ -121,6 +122,13 @@ export const OWNED_ROUTES = new Set([
"GET /api/fs/read-file",
"GET /api/fs/raw",
"POST /api/fs/mkdir",
// Git panel (slice 03): right-panel git surface + `/review` parity
// surfaces. Containment-gated; execFile (no shell); branch
// checkout is allow-list gated. See lib/git.js header.
"GET /api/git/status",
"GET /api/git/branches",
"GET /api/git/diff",
"POST /api/git/checkout",
// Settings.
"GET /api/settings",
"POST /api/settings",
Expand Down Expand Up @@ -486,6 +494,20 @@ export function createHonoApp() {
invokeHandler(c, c.get(CAPTURE_KEY), fsRoute.handleFsMkdir),
);

// ----- Git panel (slice 03) -----
app.get("/api/git/status", (c) =>
invokeHandler(c, c.get(CAPTURE_KEY), gitRoute.handleGitStatus),
);
app.get("/api/git/branches", (c) =>
invokeHandler(c, c.get(CAPTURE_KEY), gitRoute.handleGitBranches),
);
app.get("/api/git/diff", (c) =>
invokeHandler(c, c.get(CAPTURE_KEY), gitRoute.handleGitDiff),
);
app.post("/api/git/checkout", (c) =>
invokeHandler(c, c.get(CAPTURE_KEY), gitRoute.handleGitCheckout),
);

// ----- Settings -----
app.get("/api/settings", (c) =>
invokeHandler(c, c.get(CAPTURE_KEY), settingsRoute.handleGetSettings),
Expand Down
Loading
Loading