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:
204
public/diff.ts
204
public/diff.ts
@@ -137,6 +137,70 @@ export async function fetchDiff(repoPath: string, opts: FetchDiffOpts = {}): Pro
|
||||
}
|
||||
}
|
||||
|
||||
/* ── W4 git write: stage / commit / push helpers ─────────────────────────────── */
|
||||
|
||||
/** Server response shape for the three W4 git-write routes (mirrors GitOpResult).
|
||||
* Only `ok` is guaranteed; the rest are route-specific success/failure fields. */
|
||||
export interface GitOpResponse {
|
||||
ok: boolean
|
||||
status?: number
|
||||
error?: string
|
||||
staged?: boolean
|
||||
count?: number
|
||||
commit?: string
|
||||
branch?: string
|
||||
remote?: string
|
||||
}
|
||||
|
||||
/** Narrow an untrusted JSON body to a GitOpResponse (mirrors isWorktreeResult). */
|
||||
export function isGitOpResult(v: unknown): v is GitOpResponse {
|
||||
return v !== null && typeof v === 'object' && typeof (v as Record<string, unknown>)['ok'] === 'boolean'
|
||||
}
|
||||
|
||||
/** POST JSON to a same-origin git-write route; returns the parsed GitOpResponse
|
||||
* or null on any transport/parse error. Same-origin, so the browser attaches the
|
||||
* Origin header the server's CSRF guard checks — no manual header needed. */
|
||||
async function postGitOp(url: string, body: unknown): Promise<GitOpResponse | null> {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
let data: unknown
|
||||
try {
|
||||
data = await res.json()
|
||||
} catch {
|
||||
data = null
|
||||
}
|
||||
if (isGitOpResult(data)) return data
|
||||
// Non-JSON / unexpected body (e.g. a 403/429 with no body): synthesize a
|
||||
// failure carrying the HTTP status so callers can show a message.
|
||||
return { ok: false, status: res.status }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Stage (`stage=true`) or unstage the given repo-relative files. */
|
||||
export function postStage(
|
||||
repoPath: string,
|
||||
files: string[],
|
||||
stage: boolean,
|
||||
): Promise<GitOpResponse | null> {
|
||||
return postGitOp('/projects/git/stage', { path: repoPath, files, stage })
|
||||
}
|
||||
|
||||
/** Commit the staged changes with `message`. */
|
||||
export function postCommit(repoPath: string, message: string): Promise<GitOpResponse | null> {
|
||||
return postGitOp('/projects/git/commit', { path: repoPath, message })
|
||||
}
|
||||
|
||||
/** Push the current branch to its upstream (or `-u <sole-remote> <branch>`). */
|
||||
export function postPush(repoPath: string): Promise<GitOpResponse | null> {
|
||||
return postGitOp('/projects/git/push', { path: repoPath })
|
||||
}
|
||||
|
||||
/* ── renderDiffFile ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/** CSS class prefix for diff line kinds. */
|
||||
@@ -148,13 +212,21 @@ const LINE_KIND_CLASS: Record<DiffLine['kind'], string> = {
|
||||
meta: 'df-meta',
|
||||
}
|
||||
|
||||
/** Optional per-file stage/unstage control (W4). `staged` picks the button label
|
||||
* (Unstage in the staged view, Stage otherwise); `onToggle` fires on click. */
|
||||
export interface StageControl {
|
||||
staged: boolean
|
||||
onToggle: (file: DiffFile) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single DiffFile into an HTMLElement.
|
||||
* Render a single DiffFile into an HTMLElement. When `stageCtl` is provided (W4,
|
||||
* working/staged views only) a Stage/Unstage button is added to the file header.
|
||||
*
|
||||
* Security: ALL text content is set via textContent — zero innerHTML.
|
||||
* <script>, ANSI sequences, & etc. are rendered as literal characters.
|
||||
*/
|
||||
export function renderDiffFile(file: DiffFile): HTMLElement {
|
||||
export function renderDiffFile(file: DiffFile, stageCtl?: StageControl): HTMLElement {
|
||||
const section = el('div', 'df-file')
|
||||
|
||||
// ── file header ──────────────────────────────────────────────────────────
|
||||
@@ -175,6 +247,13 @@ export function renderDiffFile(file: DiffFile): HTMLElement {
|
||||
const statusEl = el('span', `df-status df-status-${file.status}`, file.status)
|
||||
|
||||
header.append(pathEl, statsEl, statusEl)
|
||||
|
||||
if (stageCtl !== undefined) {
|
||||
const stageBtn = el('button', 'df-file-stage', stageCtl.staged ? 'Unstage' : 'Stage')
|
||||
stageBtn.addEventListener('click', () => stageCtl.onToggle(file))
|
||||
header.append(stageBtn)
|
||||
}
|
||||
|
||||
section.append(header)
|
||||
|
||||
// ── binary indicator ─────────────────────────────────────────────────────
|
||||
@@ -215,10 +294,15 @@ function renderLine(line: DiffLine): HTMLElement {
|
||||
|
||||
/**
|
||||
* Render a full DiffResult: all files grouped, with empty state and truncated warning.
|
||||
* When `onToggleStage` is provided (W4, working/staged views), each file row gets a
|
||||
* Stage/Unstage button whose direction is derived from `result.staged`.
|
||||
*
|
||||
* Security: ALL content via textContent — zero innerHTML.
|
||||
*/
|
||||
export function renderDiff(result: DiffResult): HTMLElement {
|
||||
export function renderDiff(
|
||||
result: DiffResult,
|
||||
onToggleStage?: (file: DiffFile) => void,
|
||||
): HTMLElement {
|
||||
const container = el('div', 'df-result')
|
||||
|
||||
// Truncated warning
|
||||
@@ -234,8 +318,11 @@ export function renderDiff(result: DiffResult): HTMLElement {
|
||||
return container
|
||||
}
|
||||
|
||||
const stageCtl: StageControl | undefined =
|
||||
onToggleStage !== undefined ? { staged: result.staged, onToggle: onToggleStage } : undefined
|
||||
|
||||
for (const file of result.files) {
|
||||
container.append(renderDiffFile(file))
|
||||
container.append(renderDiffFile(file, stageCtl))
|
||||
}
|
||||
|
||||
return container
|
||||
@@ -319,21 +406,124 @@ export function mountDiffViewer(
|
||||
toolbar.append(closeBtn)
|
||||
root.append(toolbar)
|
||||
|
||||
// In base mode the Working/Staged tabs are inert (a base diff can't be staged).
|
||||
// In base mode the Working/Staged tabs are inert (a base diff can't be staged)
|
||||
// and the commit/push bar is hidden (you can only commit the working index).
|
||||
function updateTabState(): void {
|
||||
const inBase = base !== null
|
||||
workingBtn.disabled = inBase
|
||||
stagedBtn.disabled = inBase
|
||||
workingBtn.classList.toggle('df-tab-disabled', inBase)
|
||||
stagedBtn.classList.toggle('df-tab-disabled', inBase)
|
||||
commitBar.style.display = inBase ? 'none' : ''
|
||||
}
|
||||
|
||||
// Content area
|
||||
const content = el('div', 'df-content')
|
||||
root.append(content)
|
||||
|
||||
// ── W4 commit / push bar (working/staged mode only) ─────────────────────────
|
||||
// Message textarea + Commit + Push. Hidden in base-compare mode. All rendered
|
||||
// status/error text goes through textContent (SEC-H4) — zero innerHTML.
|
||||
const commitBar = el('div', 'df-commitbar')
|
||||
const commitMsg = el('textarea', 'df-commit-msg')
|
||||
commitMsg.placeholder = 'Commit message'
|
||||
commitMsg.rows = 2
|
||||
const commitBtn = el('button', 'df-commit-btn', 'Commit')
|
||||
commitBtn.disabled = true // enabled once the message is non-empty
|
||||
const pushBtn = el('button', 'df-push-btn', 'Push')
|
||||
const opStatus = el('div', 'df-op-status')
|
||||
opStatus.style.display = 'none'
|
||||
commitBar.append(commitMsg, commitBtn, pushBtn, opStatus)
|
||||
root.append(commitBar)
|
||||
|
||||
container.append(root)
|
||||
|
||||
let busy = false
|
||||
|
||||
/** Reflect an in-flight git-write op: disable buttons, mark the root busy. */
|
||||
function setBusy(v: boolean): void {
|
||||
busy = v
|
||||
root.classList.toggle('df-op-busy', v)
|
||||
pushBtn.disabled = v
|
||||
commitBtn.disabled = v || commitMsg.value.trim() === ''
|
||||
}
|
||||
|
||||
/** Show a status line (error or notice) via textContent only (SEC-H4). */
|
||||
function showOp(msg: string, kind: 'error' | 'notice'): void {
|
||||
opStatus.textContent = msg
|
||||
opStatus.className = kind === 'error' ? 'df-op-status df-op-error' : 'df-op-status df-op-notice'
|
||||
opStatus.style.display = ''
|
||||
}
|
||||
function clearOp(): void {
|
||||
opStatus.textContent = ''
|
||||
opStatus.style.display = 'none'
|
||||
}
|
||||
|
||||
/** Stage (working view) or unstage (staged view) one file, then reload. */
|
||||
async function toggleStage(file: DiffFile): Promise<void> {
|
||||
if (busy || destroyed) return
|
||||
const files =
|
||||
file.status === 'renamed' && file.oldPath !== file.newPath
|
||||
? [file.oldPath, file.newPath]
|
||||
: [file.newPath]
|
||||
setBusy(true)
|
||||
clearOp()
|
||||
const r = await postStage(repoPath, files, !staged) // working → add; staged → unstage
|
||||
if (destroyed) return
|
||||
setBusy(false)
|
||||
if (r === null || !r.ok) {
|
||||
showOp(r?.error ?? 'Stage failed.', 'error')
|
||||
return
|
||||
}
|
||||
void loadDiff()
|
||||
}
|
||||
|
||||
/** Commit the staged changes with the textarea message, then reload. */
|
||||
async function doCommit(): Promise<void> {
|
||||
if (busy || destroyed) return
|
||||
const message = commitMsg.value
|
||||
if (message.trim() === '') return
|
||||
setBusy(true)
|
||||
clearOp()
|
||||
const r = await postCommit(repoPath, message)
|
||||
if (destroyed) return
|
||||
setBusy(false)
|
||||
if (r === null || !r.ok) {
|
||||
showOp(r?.error ?? 'Commit failed.', 'error')
|
||||
return
|
||||
}
|
||||
commitMsg.value = ''
|
||||
commitBtn.disabled = true
|
||||
showOp(r.commit !== undefined && r.commit !== '' ? `Committed ${r.commit}` : 'Committed.', 'notice')
|
||||
void loadDiff()
|
||||
}
|
||||
|
||||
/** Push the current branch; surface a safe success/error notice (no reload). */
|
||||
async function doPush(): Promise<void> {
|
||||
if (busy || destroyed) return
|
||||
setBusy(true)
|
||||
clearOp()
|
||||
const r = await postPush(repoPath)
|
||||
if (destroyed) return
|
||||
setBusy(false)
|
||||
if (r === null || !r.ok) {
|
||||
showOp(r?.error ?? 'Push failed.', 'error')
|
||||
return
|
||||
}
|
||||
const to = [r.branch, r.remote].filter((x): x is string => typeof x === 'string' && x !== '').join(' → ')
|
||||
showOp(to !== '' ? `Pushed ${to}` : 'Pushed.', 'notice')
|
||||
}
|
||||
|
||||
commitMsg.addEventListener('input', () => {
|
||||
if (!busy) commitBtn.disabled = commitMsg.value.trim() === ''
|
||||
})
|
||||
commitBtn.addEventListener('click', () => {
|
||||
void doCommit()
|
||||
})
|
||||
pushBtn.addEventListener('click', () => {
|
||||
void doPush()
|
||||
})
|
||||
|
||||
// ── event handlers ────────────────────────────────────────────────────────
|
||||
workingBtn.addEventListener('click', () => {
|
||||
if (!staged) return
|
||||
@@ -369,7 +559,9 @@ export function mountDiffViewer(
|
||||
if (result === null) {
|
||||
content.append(el('div', 'df-error', 'Failed to load diff.'))
|
||||
} else {
|
||||
content.append(renderDiff(result))
|
||||
// Stage toggles only in working/staged mode (never for a base-compare diff).
|
||||
const onToggle = base !== null ? undefined : (file: DiffFile) => void toggleStage(file)
|
||||
content.append(renderDiff(result, onToggle))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2385,3 +2385,87 @@ body.home-open #term {
|
||||
.gp-save-btn:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
/* ── W4 git write: per-file Stage/Unstage toggle + commit/push bar ─────────── */
|
||||
.df-file-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.df-file-stage {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
padding: 3px 10px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.df-file-stage:hover {
|
||||
background: var(--surface-3);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.df-commitbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface-1);
|
||||
}
|
||||
.df-commit-msg {
|
||||
flex: 1 1 220px;
|
||||
min-width: 0;
|
||||
resize: vertical;
|
||||
padding: 8px;
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.df-commit-msg:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.df-commit-btn,
|
||||
.df-push-btn {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 16px;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: #1a1712;
|
||||
cursor: pointer;
|
||||
}
|
||||
.df-push-btn {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.df-commit-btn:disabled,
|
||||
.df-push-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.df-op-status {
|
||||
flex: 1 1 100%;
|
||||
font-size: 13px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.df-op-error {
|
||||
color: var(--red);
|
||||
}
|
||||
.df-op-notice {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.df-op-busy {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,10 @@ const DEFAULT_GH_TIMEOUT_MS = 8_000
|
||||
const DEFAULT_STATUSLINE_TTL_MS = 30_000
|
||||
// B3 worktrees
|
||||
const DEFAULT_WORKTREE_TIMEOUT_MS = 10_000
|
||||
// W4 git write (stage / commit / push)
|
||||
const DEFAULT_GIT_OPS_TIMEOUT_MS = 10_000 // stage/commit exec bound (local, like worktree)
|
||||
const DEFAULT_GIT_PUSH_TIMEOUT_MS = 120_000 // push is network-bound → longer bound
|
||||
const DEFAULT_COMMIT_MSG_MAX_LEN = 5_000 // commit-message length cap
|
||||
// W2 inject queue
|
||||
const DEFAULT_QUEUE_MAX_ITEMS = 10
|
||||
const DEFAULT_QUEUE_ITEM_MAX_BYTES = 4096
|
||||
@@ -406,6 +410,24 @@ export function loadConfig(env: EnvLike): Config {
|
||||
DEFAULT_WORKTREE_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
// W4 git write (stage / commit / push) — the highest-risk write channel
|
||||
const gitOpsEnabled = parseBool(env['GIT_OPS_ENABLED'], true) // master kill-switch
|
||||
const gitOpsTimeoutMs = parseNonNegativeInt(
|
||||
env['GIT_OPS_TIMEOUT_MS'],
|
||||
'GIT_OPS_TIMEOUT_MS',
|
||||
DEFAULT_GIT_OPS_TIMEOUT_MS,
|
||||
)
|
||||
const gitPushTimeoutMs = parseNonNegativeInt(
|
||||
env['GIT_PUSH_TIMEOUT_MS'],
|
||||
'GIT_PUSH_TIMEOUT_MS',
|
||||
DEFAULT_GIT_PUSH_TIMEOUT_MS,
|
||||
)
|
||||
const commitMsgMaxLen = parseNonNegativeInt(
|
||||
env['COMMIT_MSG_MAX_LEN'],
|
||||
'COMMIT_MSG_MAX_LEN',
|
||||
DEFAULT_COMMIT_MSG_MAX_LEN,
|
||||
)
|
||||
|
||||
// B4 permission mode relay
|
||||
const defaultPermissionMode = parsePermissionMode(
|
||||
env['DEFAULT_PERMISSION_MODE'],
|
||||
@@ -475,6 +497,11 @@ export function loadConfig(env: EnvLike): Config {
|
||||
worktreeEnabled,
|
||||
worktreeRoot,
|
||||
worktreeTimeoutMs,
|
||||
// W4 git write (stage / commit / push)
|
||||
gitOpsEnabled,
|
||||
gitOpsTimeoutMs,
|
||||
gitPushTimeoutMs,
|
||||
commitMsgMaxLen,
|
||||
defaultPermissionMode,
|
||||
allowAutoMode,
|
||||
// W2 inject queue
|
||||
|
||||
361
src/http/git-ops.ts
Normal file
361
src/http/git-ops.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* src/http/git-ops.ts (w4-commit-push) — the git WRITE engine (stage/commit/push).
|
||||
*
|
||||
* The highest-risk side-channel in the app: it stages files, commits, and pushes
|
||||
* the current branch. It mirrors the read-only diff.ts / worktrees.ts execFile
|
||||
* pattern exactly and adds three non-negotiable safety spines:
|
||||
*
|
||||
* 1. NO SHELL, EVER — execFile('git', argv, …) only. Untrusted strings (the
|
||||
* commit message, every file path) are argv ELEMENTS, never interpolated. A
|
||||
* trailing `--` terminates options before any pathspec, and file paths that
|
||||
* start with `-` are rejected, so a path can never be read as a flag.
|
||||
* 2. REALPATH CONTAINMENT — every file in `files[]` is realpath-resolved and
|
||||
* proven inside the repo (git-path.ts, the M2 pattern). Defeats `../` and
|
||||
* pre-planted symlink escapes even though the route's isValidGitDir is lighter.
|
||||
* 3. SERVER-DERIVED push target — push NEVER accepts a remote or refspec from the
|
||||
* client; both branch and remote are read back from the repo, and it is never
|
||||
* a force-push (no `--force`, no `+refspec`).
|
||||
*
|
||||
* Every export never throws — failures return a structured GitOpResult with a
|
||||
* SAFE message (never raw git stderr, SEC-M10), classified by classifyGitError.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import type { GitOpResult } from '../types.js'
|
||||
import { resolveRealPath, isContained } from './git-path.js'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const GIT_MAX_BUFFER = 1024 * 1024
|
||||
// GIT_TERMINAL_PROMPT=0 + ssh BatchMode make a credential/host-key prompt fail
|
||||
// FAST (→ classified 401) instead of hanging until the exec timeout backstop.
|
||||
const NO_PROMPT_ENV: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
GIT_SSH_COMMAND: 'ssh -o BatchMode=yes',
|
||||
}
|
||||
|
||||
export interface GitOpOptions {
|
||||
readonly timeoutMs: number
|
||||
/** Max number of files a single stage call may touch (reuses cfg.diffMaxFiles). */
|
||||
readonly maxFiles?: number
|
||||
}
|
||||
|
||||
export interface CommitOptions {
|
||||
readonly timeoutMs: number
|
||||
/** Commit-message length cap (cfg.commitMsgMaxLen). */
|
||||
readonly maxLen: number
|
||||
}
|
||||
|
||||
export interface PushOptions {
|
||||
readonly timeoutMs: number
|
||||
}
|
||||
|
||||
// ── error classification (pure, SEC-M10) ──────────────────────────────────────
|
||||
|
||||
/** Pull a best-effort diagnostic string off a child_process error (never throws).
|
||||
* Combines stderr AND stdout because git writes some conditions ("nothing to
|
||||
* commit") to stdout, others ("! [rejected]", auth) to stderr; message is the
|
||||
* fallback. Only substrings are matched — the raw text is never surfaced. */
|
||||
function extractStderr(err: unknown): string {
|
||||
if (typeof err === 'object' && err !== null) {
|
||||
const e = err as { stderr?: unknown; stdout?: unknown; message?: unknown }
|
||||
const parts: string[] = []
|
||||
if (typeof e.stderr === 'string') parts.push(e.stderr)
|
||||
if (typeof e.stdout === 'string') parts.push(e.stdout)
|
||||
if (parts.join('').trim() !== '') return parts.join('\n')
|
||||
if (typeof e.message === 'string') return e.message
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a git stderr string to a structured {status, error}. The returned `error`
|
||||
* is ALWAYS a hard-coded safe phrase — raw git stderr (which can contain repo
|
||||
* paths / remote URLs) is never surfaced (SEC-M10). Ordered most-specific first.
|
||||
* Pure + unit-tested. Unknown → 500.
|
||||
*/
|
||||
export function classifyGitError(stderr: string): { status: number; error: string } {
|
||||
const s = (typeof stderr === 'string' ? stderr : '').toLowerCase()
|
||||
|
||||
if (s.includes('index.lock') || (s.includes('unable to create') && s.includes('.lock'))) {
|
||||
return { status: 409, error: 'Another git operation is in progress.' }
|
||||
}
|
||||
if (
|
||||
s.includes('nothing to commit') ||
|
||||
s.includes('nothing added to commit') ||
|
||||
s.includes('no changes added to commit')
|
||||
) {
|
||||
return { status: 409, error: 'Nothing staged to commit.' }
|
||||
}
|
||||
if (s.includes('please tell me who you are')) {
|
||||
return { status: 400, error: 'Set a git author identity (user.name / user.email) first.' }
|
||||
}
|
||||
if (s.includes('rejected') || s.includes('non-fast-forward') || s.includes('fetch first')) {
|
||||
return { status: 409, error: 'Push rejected — pull or rebase first.' }
|
||||
}
|
||||
if (
|
||||
s.includes('authentication failed') ||
|
||||
s.includes('could not read from remote') ||
|
||||
s.includes('could not read username') ||
|
||||
s.includes('terminal prompts disabled') ||
|
||||
s.includes('permission denied (publickey)') ||
|
||||
s.includes('no anonymous write access')
|
||||
) {
|
||||
return { status: 401, error: 'Push authentication required on the host.' }
|
||||
}
|
||||
if (s.includes('not a git repository')) {
|
||||
return { status: 404, error: 'Not a git repository.' }
|
||||
}
|
||||
return { status: 500, error: 'Git operation failed.' }
|
||||
}
|
||||
|
||||
/** Wrap a caught child_process error as a failed GitOpResult (never throws). */
|
||||
function failFromError(err: unknown): GitOpResult {
|
||||
const { status, error } = classifyGitError(extractStderr(err))
|
||||
return { ok: false, status, error }
|
||||
}
|
||||
|
||||
// ── repo + file validation ─────────────────────────────────────────────────────
|
||||
|
||||
/** Three-prong entry check: absolute + isDirectory + has a `.git` entry. */
|
||||
async function isGitDir(repoPath: string): Promise<boolean> {
|
||||
if (typeof repoPath !== 'string' || !path.isAbsolute(repoPath)) return false
|
||||
try {
|
||||
const st = await fs.stat(repoPath)
|
||||
if (!st.isDirectory()) return false
|
||||
await fs.stat(path.join(repoPath, '.git'))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an untrusted `files[]` for a stage call. Returns the ACCEPTED relative
|
||||
* paths (verbatim, to hand to `git <verb> -- <paths>`), or null on ANY rejection.
|
||||
* `repoRealPath` MUST already be the realpath of the repo. Rejections:
|
||||
* - empty list / more than `maxFiles` entries (DoS bound)
|
||||
* - non-string / empty entry
|
||||
* - absolute path (a stage path is always repo-relative)
|
||||
* - leading '-' (flag injection — even after `--`, belt-and-braces)
|
||||
* - realpath escapes the repo (../ traversal or a symlink pointing outside, M2)
|
||||
* A deleted file's path (absent on disk) is fine: resolveRealPath resolves the
|
||||
* existing prefix and re-appends the missing tail, still contained. Never throws.
|
||||
*/
|
||||
export async function validateRepoFiles(
|
||||
repoRealPath: string,
|
||||
files: readonly unknown[],
|
||||
maxFiles: number,
|
||||
): Promise<string[] | null> {
|
||||
if (!Array.isArray(files) || files.length === 0) return null
|
||||
if (files.length > maxFiles) return null
|
||||
|
||||
const accepted: string[] = []
|
||||
for (const raw of files) {
|
||||
if (typeof raw !== 'string' || raw.length === 0) return null
|
||||
if (path.isAbsolute(raw)) return null
|
||||
if (raw.startsWith('-')) return null
|
||||
const realCandidate = await resolveRealPath(path.join(repoRealPath, raw))
|
||||
// Must be strictly INSIDE the repo — never the repo root itself (e.g. '.').
|
||||
if (realCandidate === repoRealPath || !isContained(repoRealPath, realCandidate)) {
|
||||
return null
|
||||
}
|
||||
accepted.push(raw)
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
|
||||
// ── stage / unstage ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Stage (`git add`) or unstage (`git restore --staged`) the given repo-relative
|
||||
* files. `stage=true` → add; `false` → restore --staged. Validates the repo and
|
||||
* realpath-contains every file before running git (no shell, `--` terminates
|
||||
* options). Returns {ok, staged, count} on success. Never throws.
|
||||
*/
|
||||
export async function stageFiles(
|
||||
repoPath: string,
|
||||
files: readonly unknown[],
|
||||
stage: boolean,
|
||||
opts: GitOpOptions,
|
||||
): Promise<GitOpResult> {
|
||||
if (!(await isGitDir(repoPath))) {
|
||||
return { ok: false, status: 404, error: 'Not a git repository.' }
|
||||
}
|
||||
const repoReal = await resolveRealPath(repoPath)
|
||||
const maxFiles = opts.maxFiles ?? 300
|
||||
const valid = await validateRepoFiles(repoReal, files, maxFiles)
|
||||
if (valid === null) {
|
||||
return { ok: false, status: 400, error: 'Invalid file selection.' }
|
||||
}
|
||||
|
||||
const args = stage ? ['add', '--', ...valid] : ['restore', '--staged', '--', ...valid]
|
||||
try {
|
||||
await execFileAsync('git', args, {
|
||||
cwd: repoPath,
|
||||
timeout: opts.timeoutMs,
|
||||
maxBuffer: GIT_MAX_BUFFER,
|
||||
})
|
||||
return { ok: true, staged: stage, count: valid.length }
|
||||
} catch (err: unknown) {
|
||||
return failFromError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── commit ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Commit the STAGED changes with `message`. The message is validated (non-empty
|
||||
* after trim, ≤ maxLen) and passed as the single value of `-m` via argv — never
|
||||
* a shell string, never a pathspec — so newlines / leading '-' / emoji are all
|
||||
* safe. Returns {ok, commit:<short sha>}. Empty index → 409, identity unset →
|
||||
* 400 (classified). Never throws.
|
||||
*/
|
||||
export async function commit(
|
||||
repoPath: string,
|
||||
message: string,
|
||||
opts: CommitOptions,
|
||||
): Promise<GitOpResult> {
|
||||
if (!(await isGitDir(repoPath))) {
|
||||
return { ok: false, status: 404, error: 'Not a git repository.' }
|
||||
}
|
||||
if (typeof message !== 'string' || message.trim() === '') {
|
||||
return { ok: false, status: 400, error: 'Commit message is required.' }
|
||||
}
|
||||
if (message.length > opts.maxLen) {
|
||||
return { ok: false, status: 400, error: 'Commit message is too long.' }
|
||||
}
|
||||
|
||||
try {
|
||||
await execFileAsync('git', ['commit', '-m', message], {
|
||||
cwd: repoPath,
|
||||
timeout: opts.timeoutMs,
|
||||
maxBuffer: GIT_MAX_BUFFER,
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
return failFromError(err)
|
||||
}
|
||||
|
||||
// Read back the new commit's short SHA (best-effort; commit already succeeded).
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], {
|
||||
cwd: repoPath,
|
||||
timeout: opts.timeoutMs,
|
||||
maxBuffer: GIT_MAX_BUFFER,
|
||||
})
|
||||
return { ok: true, commit: stdout.trim() }
|
||||
} catch {
|
||||
return { ok: true, commit: '' }
|
||||
}
|
||||
}
|
||||
|
||||
// ── push ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Read the current branch, or null if git failed / detached (output 'HEAD'). */
|
||||
async function currentBranch(repoPath: string, timeoutMs: number): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
||||
cwd: repoPath,
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: GIT_MAX_BUFFER,
|
||||
})
|
||||
const branch = stdout.trim()
|
||||
return branch === '' || branch === 'HEAD' ? null : branch
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** The upstream remote name (e.g. 'origin') for the current branch, or null. */
|
||||
async function upstreamRemote(repoPath: string, timeoutMs: number): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'],
|
||||
{ cwd: repoPath, timeout: timeoutMs, maxBuffer: GIT_MAX_BUFFER },
|
||||
)
|
||||
const ref = stdout.trim() // e.g. "origin/main"
|
||||
const slash = ref.indexOf('/')
|
||||
return slash > 0 ? ref.slice(0, slash) : null
|
||||
} catch {
|
||||
return null // no upstream configured
|
||||
}
|
||||
}
|
||||
|
||||
/** List configured remotes (one per line), or [] on error. */
|
||||
async function listRemotes(repoPath: string, timeoutMs: number): Promise<string[]> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['remote'], {
|
||||
cwd: repoPath,
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: GIT_MAX_BUFFER,
|
||||
})
|
||||
return stdout.split('\n').map((l) => l.trim()).filter((l) => l !== '')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the CURRENT branch. If it already has an upstream → plain `git push`
|
||||
* (git pushes the current branch to its configured upstream ref; never other
|
||||
* branches, never a force). If it has NO upstream: exactly one remote →
|
||||
* `git push -u <remote> <branch>` (sets it); zero remotes → 400; ≥2 remotes →
|
||||
* 409 (ambiguous, set an upstream first). The remote and branch are ALWAYS
|
||||
* derived server-side from the repo — never taken from the client — and it is
|
||||
* NEVER a force-push. Returns {ok, branch, remote}. Never throws.
|
||||
*/
|
||||
export async function push(repoPath: string, opts: PushOptions): Promise<GitOpResult> {
|
||||
if (!(await isGitDir(repoPath))) {
|
||||
return { ok: false, status: 404, error: 'Not a git repository.' }
|
||||
}
|
||||
const branch = await currentBranch(repoPath, opts.timeoutMs)
|
||||
if (branch === null) {
|
||||
return { ok: false, status: 400, error: 'Cannot push a detached HEAD.' }
|
||||
}
|
||||
// Defence in depth: a real checked-out branch never starts with '-', but a
|
||||
// branch/remote that did could be read as a flag in the -u form below.
|
||||
if (branch.startsWith('-')) {
|
||||
return { ok: false, status: 400, error: 'Cannot push this branch.' }
|
||||
}
|
||||
|
||||
const remote = await upstreamRemote(repoPath, opts.timeoutMs)
|
||||
let args: string[]
|
||||
let targetRemote: string
|
||||
|
||||
if (remote !== null) {
|
||||
// Upstream already set → plain push (safe; no remote/refspec from client).
|
||||
args = ['push']
|
||||
targetRemote = remote
|
||||
} else {
|
||||
const remotes = await listRemotes(repoPath, opts.timeoutMs)
|
||||
if (remotes.length === 0) {
|
||||
return { ok: false, status: 400, error: 'No remote configured.' }
|
||||
}
|
||||
if (remotes.length > 1) {
|
||||
return { ok: false, status: 409, error: 'Set an upstream first (multiple remotes).' }
|
||||
}
|
||||
const sole = remotes[0] as string
|
||||
if (sole.startsWith('-')) {
|
||||
return { ok: false, status: 400, error: 'Cannot push to this remote.' }
|
||||
}
|
||||
args = ['push', '-u', sole, branch]
|
||||
targetRemote = sole
|
||||
}
|
||||
|
||||
try {
|
||||
await execFileAsync('git', args, {
|
||||
cwd: repoPath,
|
||||
timeout: opts.timeoutMs,
|
||||
maxBuffer: GIT_MAX_BUFFER,
|
||||
env: NO_PROMPT_ENV,
|
||||
})
|
||||
return { ok: true, branch, remote: targetRemote }
|
||||
} catch (err: unknown) {
|
||||
return failFromError(err)
|
||||
}
|
||||
}
|
||||
49
src/http/git-path.ts
Normal file
49
src/http/git-path.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* src/http/git-path.ts (w4-commit-push) — realpath-based path containment.
|
||||
*
|
||||
* Extracted so the git-write engine (git-ops.ts) shares ONE containment routine
|
||||
* instead of duplicating the M2 pattern from worktrees.ts. Following the plan,
|
||||
* only the two primitives live here; refactoring worktrees.ts onto them is a
|
||||
* deferred follow-up (out of this task's lane).
|
||||
*
|
||||
* Security (M2 / SEC-H3): a path is proven contained by realpath-resolving BOTH
|
||||
* the base and the candidate (symlinks followed) and then doing a prefix compare.
|
||||
* This defeats `../` traversal AND pre-planted-symlink escapes — the lighter
|
||||
* three-prong isValidGitDir check at the route does NOT follow symlinks, so this
|
||||
* is the defense-in-depth backstop for every untrusted file path.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Resolve symlinks on the longest existing prefix of `target`, re-appending any
|
||||
* not-yet-existing trailing segments. Lets us canonicalise a path that may not
|
||||
* exist on disk (e.g. a staged deletion whose file is already gone) without it
|
||||
* having to exist. Mirrors worktrees.ts resolveRealPath (M2). Never throws.
|
||||
*/
|
||||
export async function resolveRealPath(target: string): Promise<string> {
|
||||
let current = path.resolve(target)
|
||||
const tail: string[] = []
|
||||
for (;;) {
|
||||
try {
|
||||
const real = await fs.realpath(current)
|
||||
return tail.length === 0 ? real : path.join(real, ...tail.slice().reverse())
|
||||
} catch {
|
||||
const parent = path.dirname(current)
|
||||
if (parent === current) return path.resolve(target) // reached an unresolvable root
|
||||
tail.push(path.basename(current))
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff `realCandidate` is `realBase` itself or lives strictly inside it. Both
|
||||
* arguments must ALREADY be realpath-resolved (call resolveRealPath first). The
|
||||
* `realBase + path.sep` guard prevents a sibling-prefix escape (`/repo-evil` is
|
||||
* NOT inside `/repo`). Pure.
|
||||
*/
|
||||
export function isContained(realBase: string, realCandidate: string): boolean {
|
||||
return realCandidate === realBase || realCandidate.startsWith(realBase + path.sep)
|
||||
}
|
||||
115
src/server.ts
115
src/server.ts
@@ -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 = {
|
||||
|
||||
23
src/types.ts
23
src/types.ts
@@ -72,6 +72,11 @@ export interface Config {
|
||||
readonly worktreeEnabled: boolean; // WORKTREE_ENABLED, default true
|
||||
readonly worktreeRoot: string | undefined; // WORKTREE_ROOT (undefined → computed)
|
||||
readonly worktreeTimeoutMs: number; // WORKTREE_TIMEOUT_MS, default 10000
|
||||
// W4 git write (stage / commit / push) — the highest-risk write channel
|
||||
readonly gitOpsEnabled: boolean; // GIT_OPS_ENABLED, default true (kill-switch → all 3 routes 403)
|
||||
readonly gitOpsTimeoutMs: number; // GIT_OPS_TIMEOUT_MS, default 10000 (stage/commit exec bound)
|
||||
readonly gitPushTimeoutMs: number; // GIT_PUSH_TIMEOUT_MS, default 120000 (network-bound push)
|
||||
readonly commitMsgMaxLen: number; // COMMIT_MSG_MAX_LEN, default 5000 (message length cap)
|
||||
// B4 permission-mode relay
|
||||
readonly defaultPermissionMode: PermissionMode; // DEFAULT_PERMISSION_MODE, default 'default'
|
||||
readonly allowAutoMode: boolean; // ALLOW_AUTO_MODE, default false (SEC-M5)
|
||||
@@ -614,6 +619,24 @@ export interface PruneWorktreesResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/* ── W4 git write: stage / commit / push (§w4-commit-push) ── */
|
||||
|
||||
/** Result of the three W4 git-write routes (POST /projects/git/{stage,commit,push}).
|
||||
* ONE interface for all three (like CreateWorktreeResult) to keep the shared
|
||||
* contract small. `error` carries a SAFE message only — never raw git stderr
|
||||
* (SEC-M10); `status` is the HTTP status to return on failure. Success payloads
|
||||
* are route-specific and all optional. */
|
||||
export interface GitOpResult {
|
||||
ok: boolean;
|
||||
status?: number; // HTTP status on failure
|
||||
error?: string; // SAFE message only (never raw git stderr)
|
||||
staged?: boolean; // stage: direction applied (true = added, false = unstaged)
|
||||
count?: number; // stage: number of files affected
|
||||
commit?: string; // commit: short SHA of the new commit
|
||||
branch?: string; // push: branch that was pushed
|
||||
remote?: string; // push: remote it was pushed to
|
||||
}
|
||||
|
||||
/* ── v0.6 Projects UI preferences (server-persisted, cross-device) ── */
|
||||
|
||||
/** Cross-device UI preferences for the Projects launcher (impl: src/http/prefs-store.ts).
|
||||
|
||||
@@ -650,6 +650,39 @@ describe('loadConfig — v0.7 B3 worktree', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadConfig — W4 git write (stage / commit / push)', () => {
|
||||
beforeEach(() => {
|
||||
mockNetworkInterfaces.mockReturnValue({})
|
||||
mockHomedir.mockReturnValue('/home/testuser')
|
||||
})
|
||||
|
||||
it('GIT_OPS_ENABLED: default true, parses on/off', () => {
|
||||
expect(loadConfig({}).gitOpsEnabled).toBe(true)
|
||||
expect(loadConfig({ GIT_OPS_ENABLED: '0' }).gitOpsEnabled).toBe(false)
|
||||
expect(loadConfig({ GIT_OPS_ENABLED: 'false' }).gitOpsEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('GIT_OPS_TIMEOUT_MS: default 10000, reads from env', () => {
|
||||
expect(loadConfig({}).gitOpsTimeoutMs).toBe(10_000)
|
||||
expect(loadConfig({ GIT_OPS_TIMEOUT_MS: '5000' }).gitOpsTimeoutMs).toBe(5_000)
|
||||
})
|
||||
|
||||
it('GIT_PUSH_TIMEOUT_MS: default 120000, reads from env', () => {
|
||||
expect(loadConfig({}).gitPushTimeoutMs).toBe(120_000)
|
||||
expect(loadConfig({ GIT_PUSH_TIMEOUT_MS: '30000' }).gitPushTimeoutMs).toBe(30_000)
|
||||
})
|
||||
|
||||
it('COMMIT_MSG_MAX_LEN: default 5000, reads from env', () => {
|
||||
expect(loadConfig({}).commitMsgMaxLen).toBe(5_000)
|
||||
expect(loadConfig({ COMMIT_MSG_MAX_LEN: '200' }).commitMsgMaxLen).toBe(200)
|
||||
})
|
||||
|
||||
it('throws for a non-integer numeric var', () => {
|
||||
expect(() => loadConfig({ GIT_OPS_TIMEOUT_MS: 'slow' })).toThrow(/GIT_OPS_TIMEOUT_MS/)
|
||||
expect(() => loadConfig({ COMMIT_MSG_MAX_LEN: 'lots' })).toThrow(/COMMIT_MSG_MAX_LEN/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadConfig — v0.7 B4 permission mode', () => {
|
||||
beforeEach(() => {
|
||||
mockNetworkInterfaces.mockReturnValue({})
|
||||
|
||||
@@ -19,6 +19,10 @@ const {
|
||||
renderDiffFile,
|
||||
renderDiff,
|
||||
mountDiffViewer,
|
||||
isGitOpResult,
|
||||
postStage,
|
||||
postCommit,
|
||||
postPush,
|
||||
} = await import('../public/diff.js')
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
@@ -615,3 +619,227 @@ describe('mountDiffViewer', () => {
|
||||
handle.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
// ── W4 git-write POST helpers ─────────────────────────────────────────────────
|
||||
|
||||
describe('isGitOpResult', () => {
|
||||
it('accepts an object with a boolean ok, rejects everything else', () => {
|
||||
expect(isGitOpResult({ ok: true })).toBe(true)
|
||||
expect(isGitOpResult({ ok: false, error: 'x' })).toBe(true)
|
||||
expect(isGitOpResult(null)).toBe(false)
|
||||
expect(isGitOpResult({ ok: 'yes' })).toBe(false)
|
||||
expect(isGitOpResult('nope')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('postStage / postCommit / postPush', () => {
|
||||
it('postStage POSTs the path/files/stage body to /projects/git/stage', async () => {
|
||||
const mockFetch = vi.fn(async () => ({ ok: true, json: async () => ({ ok: true, staged: true, count: 1 }) }))
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const r = await postStage('/repo', ['a.ts', 'b.ts'], true)
|
||||
expect(r).toMatchObject({ ok: true, staged: true, count: 1 })
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('/projects/git/stage')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(JSON.parse(init.body as string)).toEqual({ path: '/repo', files: ['a.ts', 'b.ts'], stage: true })
|
||||
})
|
||||
|
||||
it('postCommit POSTs the message to /projects/git/commit', async () => {
|
||||
const mockFetch = vi.fn(async () => ({ ok: true, json: async () => ({ ok: true, commit: 'abc1234' }) }))
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const r = await postCommit('/repo', 'hello')
|
||||
expect(r).toMatchObject({ ok: true, commit: 'abc1234' })
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('/projects/git/commit')
|
||||
expect(JSON.parse(init.body as string)).toEqual({ path: '/repo', message: 'hello' })
|
||||
})
|
||||
|
||||
it('postPush POSTs the path to /projects/git/push', async () => {
|
||||
const mockFetch = vi.fn(async () => ({ ok: true, json: async () => ({ ok: true, branch: 'main', remote: 'origin' }) }))
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const r = await postPush('/repo')
|
||||
expect(r).toMatchObject({ ok: true, branch: 'main', remote: 'origin' })
|
||||
expect((mockFetch.mock.calls[0] as [string])[0]).toBe('/projects/git/push')
|
||||
})
|
||||
|
||||
it('synthesizes a failure {ok:false,status} when the body is not a GitOpResult', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 429, json: async () => ({}) })))
|
||||
const r = await postStage('/repo', ['a'], true)
|
||||
expect(r).toMatchObject({ ok: false, status: 429 })
|
||||
})
|
||||
|
||||
it('returns null on a network error', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('net') }))
|
||||
expect(await postPush('/repo')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ── W4 mountDiffViewer: stage toggles + commit/push bar ───────────────────────
|
||||
|
||||
describe('mountDiffViewer — W4 stage / commit / push', () => {
|
||||
function makeContainer(): HTMLDivElement {
|
||||
return document.createElement('div')
|
||||
}
|
||||
|
||||
/** A fetch mock that routes GET /projects/diff → a diff, and each POST git route
|
||||
* → a caller-supplied response (defaults to ok:true). */
|
||||
function routingFetch(gitResponses: Record<string, unknown> = {}): ReturnType<typeof vi.fn> {
|
||||
return vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'POST') {
|
||||
const body = gitResponses[url] ?? { ok: true }
|
||||
return { ok: true, status: 200, json: async () => body }
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => makeResult({ staged: false }) }
|
||||
})
|
||||
}
|
||||
|
||||
it('renders a "Stage" button on each file row in the working view', async () => {
|
||||
vi.stubGlobal('fetch', routingFetch())
|
||||
const container = makeContainer()
|
||||
const handle = mountDiffViewer(container, '/repo', {})
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
const btn = container.querySelector('.df-file-stage')
|
||||
expect(btn).not.toBeNull()
|
||||
expect(btn?.textContent).toBe('Stage')
|
||||
handle.destroy()
|
||||
})
|
||||
|
||||
it('labels the toggle "Unstage" in the staged view', async () => {
|
||||
const mockFetch = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'POST') return { ok: true, status: 200, json: async () => ({ ok: true }) }
|
||||
return { ok: true, status: 200, json: async () => makeResult({ staged: true }) }
|
||||
})
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
const container = makeContainer()
|
||||
const handle = mountDiffViewer(container, '/repo', {})
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
handle.showStaged()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(container.querySelector('.df-file-stage')?.textContent).toBe('Unstage')
|
||||
handle.destroy()
|
||||
})
|
||||
|
||||
it('clicking Stage POSTs {path,files,stage:true} then re-fetches the diff', async () => {
|
||||
const mockFetch = routingFetch()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
const container = makeContainer()
|
||||
const handle = mountDiffViewer(container, '/repo', {})
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
const initialCalls = mockFetch.mock.calls.length
|
||||
|
||||
;(container.querySelector('.df-file-stage') as HTMLButtonElement).click()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
const post = mockFetch.mock.calls.find((c) => (c[1] as RequestInit | undefined)?.method === 'POST')
|
||||
expect(post?.[0]).toBe('/projects/git/stage')
|
||||
expect(JSON.parse((post?.[1] as RequestInit).body as string)).toMatchObject({
|
||||
path: '/repo',
|
||||
files: ['src/foo.ts'],
|
||||
stage: true,
|
||||
})
|
||||
// Re-fetched the diff after the successful stage.
|
||||
expect(mockFetch.mock.calls.length).toBeGreaterThan(initialCalls + 1)
|
||||
handle.destroy()
|
||||
})
|
||||
|
||||
it('disables Commit until the message is non-empty, then POSTs the commit', async () => {
|
||||
const mockFetch = routingFetch({ '/projects/git/commit': { ok: true, commit: 'deadbee' } })
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
const container = makeContainer()
|
||||
const handle = mountDiffViewer(container, '/repo', {})
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
const commitBtn = container.querySelector('.df-commit-btn') as HTMLButtonElement
|
||||
const msg = container.querySelector('.df-commit-msg') as HTMLTextAreaElement
|
||||
expect(commitBtn.disabled).toBe(true)
|
||||
|
||||
msg.value = 'phone commit'
|
||||
msg.dispatchEvent(new Event('input'))
|
||||
expect(commitBtn.disabled).toBe(false)
|
||||
|
||||
commitBtn.click()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
const post = mockFetch.mock.calls.find((c) => c[0] === '/projects/git/commit')
|
||||
expect(post).toBeDefined()
|
||||
expect(JSON.parse((post?.[1] as RequestInit).body as string)).toEqual({ path: '/repo', message: 'phone commit' })
|
||||
// Message cleared on success.
|
||||
expect(msg.value).toBe('')
|
||||
handle.destroy()
|
||||
})
|
||||
|
||||
it('shows a commit failure via textContent with zero innerHTML injection (SEC-H4)', async () => {
|
||||
const evil = '<script>alert(1)</script>'
|
||||
const mockFetch = routingFetch({ '/projects/git/commit': { ok: false, status: 409, error: evil } })
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
const container = makeContainer()
|
||||
const handle = mountDiffViewer(container, '/repo', {})
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
const msg = container.querySelector('.df-commit-msg') as HTMLTextAreaElement
|
||||
msg.value = 'x'
|
||||
msg.dispatchEvent(new Event('input'))
|
||||
;(container.querySelector('.df-commit-btn') as HTMLButtonElement).click()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
const status = container.querySelector('.df-op-status') as HTMLElement
|
||||
expect(status.textContent).toContain(evil)
|
||||
expect(container.querySelectorAll('script').length).toBe(0)
|
||||
handle.destroy()
|
||||
})
|
||||
|
||||
it('Push POSTs /projects/git/push and reflects busy/disabled while in flight', async () => {
|
||||
let resolvePush: (v: unknown) => void = () => undefined
|
||||
const pending = new Promise((r) => {
|
||||
resolvePush = r
|
||||
})
|
||||
const mockFetch = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'POST' && url === '/projects/git/push') {
|
||||
await pending
|
||||
return { ok: true, status: 200, json: async () => ({ ok: true, branch: 'main', remote: 'origin' }) }
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => makeResult() }
|
||||
})
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const container = makeContainer()
|
||||
const handle = mountDiffViewer(container, '/repo', {})
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
const pushBtn = container.querySelector('.df-push-btn') as HTMLButtonElement
|
||||
pushBtn.click()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
// In flight → disabled.
|
||||
expect(pushBtn.disabled).toBe(true)
|
||||
|
||||
resolvePush(undefined)
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(pushBtn.disabled).toBe(false)
|
||||
expect((container.querySelector('.df-op-status') as HTMLElement).textContent).toContain('main')
|
||||
handle.destroy()
|
||||
})
|
||||
|
||||
it('hides the commit bar in base-compare mode', async () => {
|
||||
vi.stubGlobal('fetch', routingFetch())
|
||||
const container = makeContainer()
|
||||
const handle = mountDiffViewer(container, '/repo', { bases: ['main'] })
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
const bar = container.querySelector('.df-commitbar') as HTMLElement
|
||||
expect(bar.style.display).toBe('') // visible in working mode
|
||||
|
||||
const select = container.querySelector('.df-base-select') as HTMLSelectElement
|
||||
select.value = 'main'
|
||||
select.dispatchEvent(new Event('change'))
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(bar.style.display).toBe('none') // hidden in base mode
|
||||
// No stage buttons in base mode either.
|
||||
expect(container.querySelector('.df-file-stage')).toBeNull()
|
||||
handle.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
322
test/http/git-ops.test.ts
Normal file
322
test/http/git-ops.test.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* test/http/git-ops.test.ts (w4-commit-push) — the git WRITE engine.
|
||||
*
|
||||
* Covers src/http/git-ops.ts:
|
||||
* - classifyGitError (pure: stderr → safe {status,error}, SEC-M10)
|
||||
* - validateRepoFiles (realpath containment: rejects escapes/absolute/flags)
|
||||
* - stageFiles (git add / restore --staged, real temp repos)
|
||||
* - commit (staged commit, empty/identity/length guards)
|
||||
* - push (upstream vs no-upstream argv, bare-remote real push)
|
||||
*
|
||||
* Pure functions are unit-tested; the git verbs run against real throwaway git
|
||||
* repos in os.tmpdir() (no network — a local bare repo is the push remote),
|
||||
* mirroring test/http/worktrees-create.test.ts.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import fs from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import {
|
||||
classifyGitError,
|
||||
validateRepoFiles,
|
||||
stageFiles,
|
||||
commit,
|
||||
push,
|
||||
} from '../../src/http/git-ops.js'
|
||||
|
||||
const execFileP = promisify(execFile)
|
||||
const TIMEOUT_MS = 15000
|
||||
const OPTS = { timeoutMs: TIMEOUT_MS, maxFiles: 300 }
|
||||
|
||||
let gitAvailable = false
|
||||
beforeAll(async () => {
|
||||
try {
|
||||
await execFileP('git', ['--version'])
|
||||
gitAvailable = true
|
||||
} catch {
|
||||
gitAvailable = false
|
||||
}
|
||||
})
|
||||
|
||||
/** Init a temp git repo with one commit; returns its REALPATH (macOS /var fix). */
|
||||
async function makeRepo(): Promise<string> {
|
||||
const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-')))
|
||||
const git = async (args: string[]): Promise<void> => {
|
||||
await execFileP('git', args, { cwd: dir })
|
||||
}
|
||||
await git(['init', '-q', '-b', 'main'])
|
||||
await git(['config', 'user.email', 't@t.local'])
|
||||
await git(['config', 'user.name', 'tester'])
|
||||
await git(['config', 'commit.gpgsign', 'false'])
|
||||
await fs.writeFile(path.join(dir, 'tracked.txt'), 'v1\n', 'utf8')
|
||||
await git(['add', '.'])
|
||||
await git(['commit', '-q', '-m', 'init'])
|
||||
return dir
|
||||
}
|
||||
|
||||
/** A bare repo to serve as a push `origin`. Returns its REALPATH. */
|
||||
async function makeBareRemote(): Promise<string> {
|
||||
const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-bare-')))
|
||||
await execFileP('git', ['init', '-q', '--bare', '-b', 'main', dir])
|
||||
return dir
|
||||
}
|
||||
|
||||
// ── classifyGitError (pure, SEC-M10) ────────────────────────────────────────────
|
||||
|
||||
describe('classifyGitError', () => {
|
||||
const cases: Array<[string, number]> = [
|
||||
['nothing to commit, working tree clean', 409],
|
||||
['no changes added to commit (use "git add")', 409],
|
||||
['Please tell me who you are', 400],
|
||||
[' ! [rejected] main -> main (non-fast-forward)', 409],
|
||||
['Updates were rejected because the tip of your current branch is behind\nfetch first', 409],
|
||||
['fatal: Authentication failed for \'https://github.com/x/y.git/\'', 401],
|
||||
["fatal: could not read Username for 'https://github.com': terminal prompts disabled", 401],
|
||||
['git@github.com: Permission denied (publickey).', 401],
|
||||
["fatal: Unable to create '/repo/.git/index.lock': File exists.", 409],
|
||||
['some totally unexpected failure', 500],
|
||||
]
|
||||
|
||||
it('maps each stderr signature to the expected status', () => {
|
||||
for (const [stderr, status] of cases) {
|
||||
expect(classifyGitError(stderr).status).toBe(status)
|
||||
}
|
||||
})
|
||||
|
||||
it('never leaks raw git stderr (no "fatal:" in any safe message)', () => {
|
||||
for (const [stderr] of cases) {
|
||||
expect(classifyGitError(stderr).error.toLowerCase()).not.toContain('fatal:')
|
||||
}
|
||||
})
|
||||
|
||||
it('is case-insensitive and tolerant of a non-string input', () => {
|
||||
expect(classifyGitError('NOTHING TO COMMIT').status).toBe(409)
|
||||
// @ts-expect-error — defensive: non-string must not throw
|
||||
expect(classifyGitError(undefined).status).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
// ── validateRepoFiles (realpath containment) ────────────────────────────────────
|
||||
|
||||
describe('validateRepoFiles', () => {
|
||||
it('rejects an empty list', async () => {
|
||||
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
|
||||
expect(await validateRepoFiles(repo, [], 300)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects more than maxFiles entries', async () => {
|
||||
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
|
||||
expect(await validateRepoFiles(repo, ['a', 'b', 'c'], 2)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects non-string, absolute, and leading-dash entries', async () => {
|
||||
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
|
||||
expect(await validateRepoFiles(repo, [123], 300)).toBeNull()
|
||||
expect(await validateRepoFiles(repo, [''], 300)).toBeNull()
|
||||
expect(await validateRepoFiles(repo, ['/etc/passwd'], 300)).toBeNull()
|
||||
expect(await validateRepoFiles(repo, ['-rf'], 300)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a ../ traversal escape', async () => {
|
||||
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
|
||||
expect(await validateRepoFiles(repo, ['../escape'], 300)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects the repo root itself (".")', async () => {
|
||||
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
|
||||
expect(await validateRepoFiles(repo, ['.'], 300)).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts in-repo relative paths (existing AND not-yet-on-disk / deleted)', async () => {
|
||||
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
|
||||
await fs.writeFile(path.join(repo, 'exists.txt'), 'x', 'utf8')
|
||||
const ok = await validateRepoFiles(repo, ['exists.txt', 'sub/deleted.txt'], 300)
|
||||
expect(ok).toEqual(['exists.txt', 'sub/deleted.txt'])
|
||||
})
|
||||
|
||||
it('rejects a pre-planted symlink that escapes the repo after realpath (M2)', async () => {
|
||||
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
|
||||
const outside = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-out-')))
|
||||
await fs.writeFile(path.join(outside, 'secret.txt'), 'top', 'utf8')
|
||||
await fs.symlink(path.join(outside, 'secret.txt'), path.join(repo, 'link.txt'))
|
||||
expect(await validateRepoFiles(repo, ['link.txt'], 300)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ── stageFiles (real temp repos) ────────────────────────────────────────────────
|
||||
|
||||
describe('stageFiles', () => {
|
||||
it('stages the given files (git add) and unstages them (restore --staged)', async () => {
|
||||
if (!gitAvailable) return
|
||||
const repo = await makeRepo()
|
||||
await fs.writeFile(path.join(repo, 'tracked.txt'), 'v2\n', 'utf8') // modify tracked
|
||||
await fs.writeFile(path.join(repo, 'new.txt'), 'new\n', 'utf8') // add untracked
|
||||
|
||||
const staged = await stageFiles(repo, ['tracked.txt', 'new.txt'], true, OPTS)
|
||||
expect(staged).toMatchObject({ ok: true, staged: true, count: 2 })
|
||||
const cached = (await execFileP('git', ['diff', '--cached', '--name-only'], { cwd: repo })).stdout
|
||||
expect(cached).toContain('tracked.txt')
|
||||
expect(cached).toContain('new.txt')
|
||||
|
||||
const unstaged = await stageFiles(repo, ['tracked.txt'], false, OPTS)
|
||||
expect(unstaged).toMatchObject({ ok: true, staged: false, count: 1 })
|
||||
const cached2 = (await execFileP('git', ['diff', '--cached', '--name-only'], { cwd: repo })).stdout
|
||||
expect(cached2).not.toContain('tracked.txt')
|
||||
})
|
||||
|
||||
it('returns 404 for a non-git directory', async () => {
|
||||
const plain = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-plain-')))
|
||||
const res = await stageFiles(plain, ['x.txt'], true, OPTS)
|
||||
expect(res).toMatchObject({ ok: false, status: 404 })
|
||||
})
|
||||
|
||||
it('returns 400 for an escaping file path (never runs git)', async () => {
|
||||
if (!gitAvailable) return
|
||||
const repo = await makeRepo()
|
||||
const res = await stageFiles(repo, ['../../etc/passwd'], true, OPTS)
|
||||
expect(res).toMatchObject({ ok: false, status: 400 })
|
||||
})
|
||||
})
|
||||
|
||||
// ── commit (real temp repos) ────────────────────────────────────────────────────
|
||||
|
||||
describe('commit', () => {
|
||||
it('commits staged changes and returns a short SHA', async () => {
|
||||
if (!gitAvailable) return
|
||||
const repo = await makeRepo()
|
||||
await fs.writeFile(path.join(repo, 'tracked.txt'), 'v2\n', 'utf8')
|
||||
await execFileP('git', ['add', 'tracked.txt'], { cwd: repo })
|
||||
|
||||
const res = await commit(repo, 'second commit', { timeoutMs: TIMEOUT_MS, maxLen: 5000 })
|
||||
expect(res.ok).toBe(true)
|
||||
expect(typeof res.commit).toBe('string')
|
||||
const head = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim()
|
||||
expect(head.startsWith(res.commit as string)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns 409 when nothing is staged', async () => {
|
||||
if (!gitAvailable) return
|
||||
const repo = await makeRepo()
|
||||
const res = await commit(repo, 'noop', { timeoutMs: TIMEOUT_MS, maxLen: 5000 })
|
||||
expect(res).toMatchObject({ ok: false, status: 409 })
|
||||
expect(res.error).not.toContain('fatal:')
|
||||
})
|
||||
|
||||
it('rejects an empty message with 400 (never runs git)', async () => {
|
||||
if (!gitAvailable) return
|
||||
const repo = await makeRepo()
|
||||
expect(await commit(repo, '', { timeoutMs: TIMEOUT_MS, maxLen: 5000 })).toMatchObject({
|
||||
ok: false,
|
||||
status: 400,
|
||||
})
|
||||
expect(await commit(repo, ' \n ', { timeoutMs: TIMEOUT_MS, maxLen: 5000 })).toMatchObject({
|
||||
ok: false,
|
||||
status: 400,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an over-long message with 400', async () => {
|
||||
if (!gitAvailable) return
|
||||
const repo = await makeRepo()
|
||||
const res = await commit(repo, 'x'.repeat(51), { timeoutMs: TIMEOUT_MS, maxLen: 50 })
|
||||
expect(res).toMatchObject({ ok: false, status: 400 })
|
||||
})
|
||||
|
||||
it('returns 400 when the author identity is unset', async () => {
|
||||
if (!gitAvailable) return
|
||||
// Init WITHOUT any local identity, then hide the global/system config so no
|
||||
// identity resolves — git must fail with "Please tell me who you are" → 400.
|
||||
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-noid-')))
|
||||
await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: repo })
|
||||
// useConfigOnly=true stops git auto-detecting an identity from the OS user, so
|
||||
// with no configured identity it hard-fails "Please tell me who you are".
|
||||
await execFileP('git', ['config', '--local', 'user.useConfigOnly', 'true'], { cwd: repo })
|
||||
await fs.writeFile(path.join(repo, 'f.txt'), 'x\n', 'utf8')
|
||||
await execFileP('git', ['add', 'f.txt'], { cwd: repo }) // stage on unborn HEAD
|
||||
|
||||
const prevG = process.env['GIT_CONFIG_GLOBAL']
|
||||
const prevS = process.env['GIT_CONFIG_SYSTEM']
|
||||
process.env['GIT_CONFIG_GLOBAL'] = '/dev/null'
|
||||
process.env['GIT_CONFIG_SYSTEM'] = '/dev/null'
|
||||
try {
|
||||
const res = await commit(repo, 'msg', { timeoutMs: TIMEOUT_MS, maxLen: 5000 })
|
||||
expect(res).toMatchObject({ ok: false, status: 400 })
|
||||
expect(res.error).not.toContain('fatal:')
|
||||
} finally {
|
||||
if (prevG === undefined) delete process.env['GIT_CONFIG_GLOBAL']
|
||||
else process.env['GIT_CONFIG_GLOBAL'] = prevG
|
||||
if (prevS === undefined) delete process.env['GIT_CONFIG_SYSTEM']
|
||||
else process.env['GIT_CONFIG_SYSTEM'] = prevS
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ── push (real bare-repo remote, no network) ────────────────────────────────────
|
||||
|
||||
describe('push', () => {
|
||||
it('first push sets upstream (-u) and the bare remote receives the ref', async () => {
|
||||
if (!gitAvailable) return
|
||||
const repo = await makeRepo()
|
||||
const bare = await makeBareRemote()
|
||||
await execFileP('git', ['remote', 'add', 'origin', bare], { cwd: repo })
|
||||
|
||||
const res = await push(repo, { timeoutMs: TIMEOUT_MS })
|
||||
expect(res).toMatchObject({ ok: true, branch: 'main', remote: 'origin' })
|
||||
const remoteHead = (await execFileP('git', ['--git-dir', bare, 'rev-parse', 'main'], {})).stdout.trim()
|
||||
const localHead = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim()
|
||||
expect(remoteHead).toBe(localHead)
|
||||
})
|
||||
|
||||
it('a second push (upstream now set) succeeds via a plain push', async () => {
|
||||
if (!gitAvailable) return
|
||||
const repo = await makeRepo()
|
||||
const bare = await makeBareRemote()
|
||||
await execFileP('git', ['remote', 'add', 'origin', bare], { cwd: repo })
|
||||
expect((await push(repo, { timeoutMs: TIMEOUT_MS })).ok).toBe(true)
|
||||
|
||||
await fs.writeFile(path.join(repo, 'tracked.txt'), 'v2\n', 'utf8')
|
||||
await execFileP('git', ['add', 'tracked.txt'], { cwd: repo })
|
||||
await execFileP('git', ['commit', '-q', '-m', 'second'], { cwd: repo })
|
||||
|
||||
const res = await push(repo, { timeoutMs: TIMEOUT_MS })
|
||||
expect(res).toMatchObject({ ok: true, branch: 'main', remote: 'origin' })
|
||||
const remoteHead = (await execFileP('git', ['--git-dir', bare, 'rev-parse', 'main'], {})).stdout.trim()
|
||||
const localHead = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim()
|
||||
expect(remoteHead).toBe(localHead)
|
||||
})
|
||||
|
||||
it('returns 400 for a detached HEAD', async () => {
|
||||
if (!gitAvailable) return
|
||||
const repo = await makeRepo()
|
||||
const sha = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim()
|
||||
await execFileP('git', ['checkout', '-q', sha], { cwd: repo }) // detach
|
||||
const res = await push(repo, { timeoutMs: TIMEOUT_MS })
|
||||
expect(res).toMatchObject({ ok: false, status: 400 })
|
||||
})
|
||||
|
||||
it('returns 400 when the repo has zero remotes', async () => {
|
||||
if (!gitAvailable) return
|
||||
const repo = await makeRepo()
|
||||
const res = await push(repo, { timeoutMs: TIMEOUT_MS })
|
||||
expect(res).toMatchObject({ ok: false, status: 400 })
|
||||
})
|
||||
|
||||
it('returns 409 with two remotes and no upstream (ambiguous)', async () => {
|
||||
if (!gitAvailable) return
|
||||
const repo = await makeRepo()
|
||||
const bare1 = await makeBareRemote()
|
||||
const bare2 = await makeBareRemote()
|
||||
await execFileP('git', ['remote', 'add', 'origin', bare1], { cwd: repo })
|
||||
await execFileP('git', ['remote', 'add', 'backup', bare2], { cwd: repo })
|
||||
const res = await push(repo, { timeoutMs: TIMEOUT_MS })
|
||||
expect(res).toMatchObject({ ok: false, status: 409 })
|
||||
})
|
||||
|
||||
it('returns 404 for a non-git directory', async () => {
|
||||
const plain = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-nrepo-')))
|
||||
expect(await push(plain, { timeoutMs: TIMEOUT_MS })).toMatchObject({ ok: false, status: 404 })
|
||||
})
|
||||
})
|
||||
237
test/integration/git-ops.test.ts
Normal file
237
test/integration/git-ops.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* test/integration/git-ops.test.ts (w4-commit-push) — the three git-WRITE routes
|
||||
* against a real startServer:
|
||||
* POST /projects/git/stage — Origin guard, kill-switch, missing field, non-git,
|
||||
* real stage in a temp repo
|
||||
* POST /projects/git/commit — same guards + real commit + safe 409/400 bodies
|
||||
* POST /projects/git/push — same guards + real push to a temp BARE remote
|
||||
*
|
||||
* Mirrors test/integration/worktree.test.ts (startServer + fetch + Origin + real
|
||||
* temp repos). No network — a local bare repo is the push remote.
|
||||
*/
|
||||
|
||||
import net from 'node:net'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs/promises'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { loadConfig } from '../../src/config.js'
|
||||
import { startServer } from '../../src/server.js'
|
||||
|
||||
const GIT_AVAILABLE = (() => {
|
||||
try {
|
||||
execFileSync('git', ['--version'], { stdio: 'ignore' })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})()
|
||||
const itGit = GIT_AVAILABLE ? it : it.skip
|
||||
|
||||
function getFreePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const srv = net.createServer()
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const addr = srv.address()
|
||||
if (addr === null || typeof addr === 'string') {
|
||||
srv.close()
|
||||
reject(new Error('bad addr'))
|
||||
return
|
||||
}
|
||||
const port = addr.port
|
||||
srv.close(() => resolve(port))
|
||||
})
|
||||
srv.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
const handles: { close(): Promise<void> }[] = []
|
||||
const tmpDirs: string[] = []
|
||||
|
||||
async function spawnServer(
|
||||
overrides: Record<string, string | undefined> = {},
|
||||
): Promise<{ port: number; origin: string }> {
|
||||
const port = await getFreePort()
|
||||
const cfg = loadConfig({
|
||||
PORT: String(port),
|
||||
BIND_HOST: '127.0.0.1',
|
||||
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
|
||||
ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
|
||||
USE_TMUX: '0',
|
||||
IDLE_TTL: '86400',
|
||||
...overrides,
|
||||
})
|
||||
const handle = startServer(cfg)
|
||||
handles.push(handle)
|
||||
await new Promise<void>((r) => setTimeout(r, 80))
|
||||
return { port, origin: `http://127.0.0.1:${port}` }
|
||||
}
|
||||
|
||||
/** Temp git repo on `main` with one committed file. Returns its absolute path. */
|
||||
async function makeRealRepo(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-gitops-'))
|
||||
tmpDirs.push(dir)
|
||||
const git = (args: string[]): void => {
|
||||
execFileSync('git', args, { cwd: dir, stdio: 'ignore' })
|
||||
}
|
||||
git(['init', '-b', 'main'])
|
||||
git(['config', 'user.email', 'test@localhost'])
|
||||
git(['config', 'user.name', 'Test'])
|
||||
git(['config', 'commit.gpgsign', 'false'])
|
||||
await fs.writeFile(path.join(dir, 'file.txt'), 'line1\nline2\n', 'utf8')
|
||||
git(['add', '.'])
|
||||
git(['commit', '-m', 'init'])
|
||||
return dir
|
||||
}
|
||||
|
||||
/** A bare repo to serve as a push `origin`. Returns its absolute path. */
|
||||
async function makeBareRemote(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-gitops-bare-'))
|
||||
tmpDirs.push(dir)
|
||||
execFileSync('git', ['init', '--bare', '-b', 'main', dir], { stdio: 'ignore' })
|
||||
return dir
|
||||
}
|
||||
|
||||
async function postJson(
|
||||
port: number,
|
||||
route: string,
|
||||
body: unknown,
|
||||
headers: Record<string, string>,
|
||||
): Promise<Response> {
|
||||
return fetch(`http://127.0.0.1:${port}${route}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
while (handles.length > 0) await handles.pop()?.close()
|
||||
for (const d of tmpDirs.splice(0)) await fs.rm(d, { recursive: true, force: true }).catch(() => undefined)
|
||||
await new Promise<void>((r) => setTimeout(r, 30))
|
||||
})
|
||||
|
||||
// ── shared guard matrix ─────────────────────────────────────────────────────────
|
||||
|
||||
const ROUTES = ['/projects/git/stage', '/projects/git/commit', '/projects/git/push'] as const
|
||||
|
||||
describe('git-write routes — shared guards (CSRF / kill-switch)', () => {
|
||||
for (const route of ROUTES) {
|
||||
it(`${route}: rejects a foreign Origin with 403`, async () => {
|
||||
const { port } = await spawnServer()
|
||||
const res = await postJson(port, route, { path: '/tmp/x' }, { Origin: 'http://evil.example' })
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it(`${route}: rejects a missing Origin with 403 (default-deny)`, async () => {
|
||||
const { port } = await spawnServer()
|
||||
const res = await postJson(port, route, { path: '/tmp/x' }, {})
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it(`${route}: returns 403 when GIT_OPS_ENABLED=0`, async () => {
|
||||
const { port, origin } = await spawnServer({ GIT_OPS_ENABLED: '0' })
|
||||
const res = await postJson(port, route, { path: '/tmp/x' }, { Origin: origin })
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ── POST /projects/git/stage ──────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /projects/git/stage', () => {
|
||||
it('returns 400 when files are missing', async () => {
|
||||
const { port, origin } = await spawnServer()
|
||||
const res = await postJson(port, '/projects/git/stage', { path: '/tmp/x' }, { Origin: origin })
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 for a non-git directory', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-nogit-'))
|
||||
tmpDirs.push(dir)
|
||||
const { port, origin } = await spawnServer()
|
||||
const res = await postJson(port, '/projects/git/stage', { path: dir, files: ['a'] }, { Origin: origin })
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
itGit('stages a modified file in a real repo (200) and git records it', async () => {
|
||||
const repo = await makeRealRepo()
|
||||
await fs.writeFile(path.join(repo, 'file.txt'), 'line1\nCHANGED\n', 'utf8')
|
||||
const { port, origin } = await spawnServer()
|
||||
|
||||
const res = await postJson(port, '/projects/git/stage', { path: repo, files: ['file.txt'] }, { Origin: origin })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { ok: boolean; staged: boolean; count: number }
|
||||
expect(body).toMatchObject({ ok: true, staged: true, count: 1 })
|
||||
const cached = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: repo }).toString()
|
||||
expect(cached).toContain('file.txt')
|
||||
})
|
||||
})
|
||||
|
||||
// ── POST /projects/git/commit ─────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /projects/git/commit', () => {
|
||||
itGit('returns 400 for an empty message', async () => {
|
||||
const repo = await makeRealRepo()
|
||||
const { port, origin } = await spawnServer()
|
||||
const res = await postJson(port, '/projects/git/commit', { path: repo, message: ' ' }, { Origin: origin })
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
itGit('returns a safe 409 (no "fatal:") when nothing is staged', async () => {
|
||||
const repo = await makeRealRepo()
|
||||
const { port, origin } = await spawnServer()
|
||||
const res = await postJson(port, '/projects/git/commit', { path: repo, message: 'noop' }, { Origin: origin })
|
||||
expect(res.status).toBe(409)
|
||||
const body = (await res.json()) as { ok: boolean; error: string }
|
||||
// Failure body carries ok:false + a SAFE message so the FE guard surfaces it.
|
||||
expect(body.ok).toBe(false)
|
||||
expect(body.error).not.toContain('fatal:')
|
||||
expect(body.error).toBe('Nothing staged to commit.')
|
||||
})
|
||||
|
||||
itGit('commits staged changes and returns a short SHA (200)', async () => {
|
||||
const repo = await makeRealRepo()
|
||||
await fs.writeFile(path.join(repo, 'file.txt'), 'line1\nCHANGED\n', 'utf8')
|
||||
execFileSync('git', ['add', 'file.txt'], { cwd: repo })
|
||||
const { port, origin } = await spawnServer()
|
||||
|
||||
const res = await postJson(port, '/projects/git/commit', { path: repo, message: 'phone commit' }, { Origin: origin })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { ok: boolean; commit: string }
|
||||
expect(body.ok).toBe(true)
|
||||
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo }).toString().trim()
|
||||
expect(head.startsWith(body.commit)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── POST /projects/git/push ───────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /projects/git/push', () => {
|
||||
itGit('returns a safe 400 when the repo has no remote', async () => {
|
||||
const repo = await makeRealRepo()
|
||||
const { port, origin } = await spawnServer()
|
||||
const res = await postJson(port, '/projects/git/push', { path: repo }, { Origin: origin })
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as { error: string }
|
||||
expect(body.error).not.toContain('fatal:')
|
||||
})
|
||||
|
||||
itGit('pushes the current branch to a bare remote and sets upstream (200)', async () => {
|
||||
const repo = await makeRealRepo()
|
||||
const bare = await makeBareRemote()
|
||||
execFileSync('git', ['remote', 'add', 'origin', bare], { cwd: repo })
|
||||
const { port, origin } = await spawnServer()
|
||||
|
||||
const res = await postJson(port, '/projects/git/push', { path: repo }, { Origin: origin })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { ok: boolean; branch: string; remote: string }
|
||||
expect(body).toMatchObject({ ok: true, branch: 'main', remote: 'origin' })
|
||||
const remoteHead = execFileSync('git', ['--git-dir', bare, 'rev-parse', 'main']).toString().trim()
|
||||
const localHead = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo }).toString().trim()
|
||||
expect(remoteHead).toBe(localHead)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user