feat(git): stage / commit / push from the diff viewer (W4)

The walk-away endgame — review a diff on your phone, then land it without typing
git into a mobile terminal. MVP bounded to per-file stage/unstage, commit, and
push the current branch; discard/checkout/reset deliberately deferred (nothing here
mutates working-tree file contents).

- src/http/git-ops.ts (new): stageFiles/commit/push (execFile, no shell, never
  throws). Path containment: every files[] entry realpath-contained under the repo,
  argv after `--`, capped at diffMaxFiles. Commit message: empty/over-5000 → 400,
  single -m argv. Push: current branch only — has-upstream → plain `git push`;
  no-upstream + one remote → `git push -u <remote> <branch>`; 0 remotes → 400,
  ≥2 → 409, detached HEAD → 400. Remote AND branch read from the repo, never the
  client. NEVER --force / +refspec. GIT_TERMINAL_PROMPT=0 + ssh BatchMode fail auth
  fast (401). Errors classified to fixed safe strings (raw stderr never surfaced).
- POST /projects/git/{stage,commit,push} — all behind requireAllowedOrigin +
  GIT_OPS_ENABLED kill-switch + per-IP rate limits (stage/commit 30/min, push 6/min)
  + isValidGitDir. public/diff.ts: per-file Stage/Unstage + a commit/push bar
  (all text via textContent; re-loads the diff on success).

Verified: typecheck + build:web clean, git-ops tests 213 pass (unit covers escape
rejection / empty+overlong commit / push argv upstream-vs-no-upstream / classified
errors), full suite green at --test-timeout=30000.
This commit is contained in:
Yaojia Wang
2026-07-12 22:09:43 +02:00
parent 552f35c690
commit 19f241d7a3
11 changed files with 1677 additions and 6 deletions

View File

@@ -45,6 +45,7 @@ import { buildDigest, clampSince } from './http/digest.js'
import { getPrStatus } from './http/gh.js'
import { parseStatusLine } from './http/statusline.js'
import { createWorktree, removeWorktree, pruneWorktrees } from './http/worktrees.js'
import { stageFiles, commit as gitCommit, push as gitPush } from './http/git-ops.js'
import { createSessionManager } from './session/manager.js'
import { detachWs, writeInput, setClientDims } from './session/session.js'
import { loadSubscriptionStore } from './push/subscription-store.js'
@@ -80,6 +81,8 @@ const RATE_LIMIT_WINDOW_MS = 60_000
const DECISION_RATE_MAX = 10 // POST /hook/decision ≤ 10/min/IP
const SUBSCRIBE_RATE_MAX = 5 // POST/DELETE /push/subscribe ≤ 5/min/IP
const QUEUE_RATE_MAX = 20 // W2: POST/DELETE /live-sessions/:id/queue ≤ 20/min/IP
const GIT_WRITE_RATE_MAX = 30 // W4: POST /projects/git/{stage,commit} ≤ 30/min/IP
const GIT_PUSH_RATE_MAX = 6 // W4: POST /projects/git/push ≤ 6/min/IP (network-bound)
/** The four permission modes the WS approve relay accepts (B4); else undefined. */
const PERMISSION_MODES: ReadonlySet<string> = new Set<PermissionMode>([
@@ -224,6 +227,8 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
const decisionLimiter = createRateLimiter(DECISION_RATE_MAX, RATE_LIMIT_WINDOW_MS)
const subscribeLimiter = createRateLimiter(SUBSCRIBE_RATE_MAX, RATE_LIMIT_WINDOW_MS)
const queueLimiter = createRateLimiter(QUEUE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W2
const gitWriteLimiter = createRateLimiter(GIT_WRITE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W4 stage/commit
const gitPushLimiter = createRateLimiter(GIT_PUSH_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W4 push
// W2: per-session settle timers for the idle-drain. A Stop/SessionEnd hook
// schedules a debounced drain of one queue entry after cfg.queueSettleMs, so
@@ -980,6 +985,116 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
res.status(result.status ?? 500).json({ error: result.error ?? 'Failed to prune worktrees.' })
})
// ── W4 git write: stage / commit / push (the HIGHEST-risk channel) ───────────
// Every route: requireAllowedOrigin (CSRF — this is a WRITE channel, unlike the
// read-only /projects/diff) → gitOpsEnabled kill-switch (403) → per-IP rate
// limit (429) → isValidGitDir three-prong (404). The delegates in git-ops.ts
// re-validate and realpath-CONTAIN every untrusted path/message (defense in
// depth) and return SAFE, classified errors only (never raw git stderr).
// Stage / unstage specific files. `stage` (default true) → git add; false →
// git restore --staged. `files[]` is capped + realpath-contained in git-ops.
app.post('/projects/git/stage', express.json({ limit: '64kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!cfg.gitOpsEnabled) {
res.status(403).json({ error: 'Git operations are disabled.' })
return
}
if (!gitWriteLimiter(req.socket.remoteAddress ?? '', Date.now())) {
res.status(429).json({ error: 'Too many requests.' })
return
}
const body = (req.body ?? {}) as Record<string, unknown>
const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined
const files = Array.isArray(body['files']) ? (body['files'] as unknown[]) : undefined
const stage = body['stage'] === undefined ? true : body['stage'] === true
if (repoPath === undefined || files === undefined) {
res.status(400).json({ error: 'path and files are required' })
return
}
if (!(await isValidGitDir(repoPath))) {
res.status(404).json({ error: 'project not found' }) // SEC-H7 three-prong
return
}
const result = await stageFiles(repoPath, files, stage, {
timeoutMs: cfg.gitOpsTimeoutMs,
maxFiles: cfg.diffMaxFiles,
})
if (result.ok) {
res.status(200).json({ ok: true, staged: result.staged, count: result.count })
return
}
res.status(result.status ?? 500).json({ ok: false, error: result.error ?? 'Git operation failed.' })
})
// Commit the STAGED changes. Message is length-capped + non-empty checked and
// passed as a single `-m <msg>` argv (never a shell string, never a pathspec).
app.post('/projects/git/commit', express.json({ limit: '16kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!cfg.gitOpsEnabled) {
res.status(403).json({ error: 'Git operations are disabled.' })
return
}
if (!gitWriteLimiter(req.socket.remoteAddress ?? '', Date.now())) {
res.status(429).json({ error: 'Too many requests.' })
return
}
const body = (req.body ?? {}) as Record<string, unknown>
const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined
const message = typeof body['message'] === 'string' ? body['message'] : ''
if (repoPath === undefined) {
res.status(400).json({ error: 'path is required' })
return
}
if (!(await isValidGitDir(repoPath))) {
res.status(404).json({ error: 'project not found' })
return
}
// Audit (write): who/what, sanitized + truncated (never raw control chars).
console.error(`[server] git commit: path=${sanitizeForLog(repoPath)}`)
const result = await gitCommit(repoPath, message, {
timeoutMs: cfg.gitOpsTimeoutMs,
maxLen: cfg.commitMsgMaxLen,
})
if (result.ok) {
res.status(200).json({ ok: true, commit: result.commit })
return
}
res.status(result.status ?? 500).json({ ok: false, error: result.error ?? 'Git operation failed.' })
})
// Push the CURRENT branch to its existing upstream, or `-u <sole-remote>
// <branch>` if none. Remote/branch are derived server-side; never a force-push.
// Tighter per-IP rate limit than stage/commit (network-bound).
app.post('/projects/git/push', express.json({ limit: '4kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!cfg.gitOpsEnabled) {
res.status(403).json({ error: 'Git operations are disabled.' })
return
}
if (!gitPushLimiter(req.socket.remoteAddress ?? '', Date.now())) {
res.status(429).json({ error: 'Too many requests.' })
return
}
const body = (req.body ?? {}) as Record<string, unknown>
const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined
if (repoPath === undefined) {
res.status(400).json({ error: 'path is required' })
return
}
if (!(await isValidGitDir(repoPath))) {
res.status(404).json({ error: 'project not found' })
return
}
console.error(`[server] git push: path=${sanitizeForLog(repoPath)}`)
const result = await gitPush(repoPath, { timeoutMs: cfg.gitPushTimeoutMs })
if (result.ok) {
res.status(200).json({ ok: true, branch: result.branch, remote: result.remote })
return
}
res.status(result.status ?? 500).json({ ok: false, error: result.error ?? 'Git operation failed.' })
})
// ── GET /config/ui (review #4) — client-readable UI config (read-only) ────
app.get('/config/ui', (_req, res) => {
const uiConfig: UiConfig = {