feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build

Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.

Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.

New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).

Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).

Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
This commit is contained in:
Yaojia Wang
2026-06-30 17:42:18 +02:00
parent 4f1d3ebc6b
commit d6809c65c4
54 changed files with 13171 additions and 200 deletions

365
src/http/diff.ts Normal file
View File

@@ -0,0 +1,365 @@
/**
* src/http/diff.ts (N-diff-be, B1) — read-only structured git diff.
*
* The server stays a byte-shuttle: this is an out-of-band side-channel that runs
* `git diff` in a directory and PARSES its text into DiffFile/DiffLine. Parsing
* lives ONLY here (public/diff.ts is render-only — review #2). The frontend never
* re-derives diff structure; it only renders these objects with textContent.
*
* Security (SP4, §B1.4):
* - execFile('git', [...]) with NO shell; timeout + maxBuffer bound DoS (SEC-M9).
* - the trailing `--` terminates options so a path can't be read as a flag.
* (path → repo three-way validation lives in the ROUTE layer, SEC-H7.)
* - parsers NEVER throw: garbage lines degrade to `context`; getDiff is
* best-effort and returns an empty result rather than rejecting (house style).
* - diff content is carried verbatim in DiffLine.text — the FE renders it as
* inert text (AC-B1.4), never HTML.
*
* FR-B1.9 (`?base=<rev>`) is intentionally deferred to P2 (review #13): it needs
* a `git rev-parse --verify` allow-list before any revision reaches the CLI.
*/
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import type {
Config,
DiffFile,
DiffHunk,
DiffLine,
DiffLineKind,
DiffResult,
FileStatus,
} from '../types.js'
const execFileAsync = promisify(execFile)
// ── numstat (pure) ──────────────────────────────────────────────────────────
/** One `git diff --numstat` row: `<added>\t<removed>\t<path>`; binary = `-\t-`. */
export interface NumstatEntry {
added: number
removed: number
binary: boolean
}
/** Non-negative integer or 0 for `-`/junk (never NaN). */
function toCount(field: string): number {
const n = Number.parseInt(field, 10)
return Number.isFinite(n) && n >= 0 ? n : 0
}
/**
* Expand a numstat path field into its old/new forms. A rename is shown either
* as `old => new` or, with a shared prefix/suffix, as `pre/{old => new}/suf`.
* Non-renames return the same path for both.
*/
function expandNumstatPath(raw: string): { oldPath: string; newPath: string } {
const braced = /^(.*)\{(.*) => (.*)\}(.*)$/.exec(raw)
if (braced !== null) {
const [, pre = '', oldMid = '', newMid = '', suf = ''] = braced
const collapse = (s: string): string => (pre + s + suf).replace(/\/{2,}/g, '/')
return { oldPath: collapse(oldMid), newPath: collapse(newMid) }
}
const arrow = raw.split(' => ')
if (arrow.length === 2) {
return { oldPath: arrow[0]?.trim() ?? raw, newPath: arrow[1]?.trim() ?? raw }
}
return { oldPath: raw, newPath: raw }
}
/**
* Parse `git diff --numstat` output into a path → counts map. Renames are keyed
* under BOTH old and new paths so the unified-diff parser can find them by
* whichever path it derived. Malformed lines are skipped; never throws.
*/
export function parseNumstat(out: string): Map<string, NumstatEntry> {
const map = new Map<string, NumstatEntry>()
if (typeof out !== 'string') return map
for (const line of out.split('\n')) {
if (line.trim() === '') continue
const parts = line.split('\t')
if (parts.length < 3) continue
const addStr = parts[0] ?? ''
const remStr = parts[1] ?? ''
const rawPath = parts.slice(2).join('\t')
const binary = addStr === '-' && remStr === '-'
const entry: NumstatEntry = {
added: binary ? 0 : toCount(addStr),
removed: binary ? 0 : toCount(remStr),
binary,
}
const { oldPath, newPath } = expandNumstatPath(rawPath)
map.set(newPath, entry)
if (oldPath !== newPath) map.set(oldPath, entry)
}
return map
}
// ── unified diff (pure) ──────────────────────────────────────────────────────
/** Strip git's `a/`/`b/` prefix and optional C-quoting; `/dev/null` is kept. */
function stripDiffPath(raw: string): string {
let s = raw.trim()
if (s === '/dev/null') return s
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
s = s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
}
if (s.startsWith('a/') || s.startsWith('b/')) s = s.slice(2)
return s
}
/** Best-effort path extraction from a `diff --git a/x b/y` header line. The
* authoritative paths come from ---/+++/rename lines, which override this. */
function parseDiffGitLine(line: string): { oldPath: string; newPath: string } {
const rest = line.slice('diff --git '.length)
const sep = rest.indexOf(' b/')
if (sep !== -1) {
return { oldPath: stripDiffPath(rest.slice(0, sep)), newPath: stripDiffPath(rest.slice(sep + 1)) }
}
const p = stripDiffPath(rest)
return { oldPath: p, newPath: p }
}
/** Classify one in-hunk line by its leading marker; unknown → context (spec). */
function classifyHunkLine(line: string): DiffLine {
const marker = line.charAt(0)
if (marker === '+') return { kind: 'added', text: line.slice(1) }
if (marker === '-') return { kind: 'removed', text: line.slice(1) }
if (marker === ' ') return { kind: 'context', text: line.slice(1) }
if (marker === '\\') return { kind: 'meta', text: line.slice(1).trim() }
return { kind: 'context', text: line }
}
interface BlockState {
oldPath: string
newPath: string
binary: boolean
isNew: boolean
isDeleted: boolean
isRename: boolean
}
/** Apply one pre-hunk header line to the accumulating block state. */
function applyHeaderLine(st: BlockState, line: string): void {
if (line.startsWith('diff --git ')) {
const p = parseDiffGitLine(line)
st.oldPath = p.oldPath
st.newPath = p.newPath
} else if (line.startsWith('new file')) st.isNew = true
else if (line.startsWith('deleted file')) st.isDeleted = true
else if (line.startsWith('rename from ')) {
st.oldPath = stripDiffPath(line.slice('rename from '.length))
st.isRename = true
} else if (line.startsWith('rename to ')) {
st.newPath = stripDiffPath(line.slice('rename to '.length))
st.isRename = true
} else if (line.startsWith('copy from ')) st.oldPath = stripDiffPath(line.slice('copy from '.length))
else if (line.startsWith('copy to ')) st.newPath = stripDiffPath(line.slice('copy to '.length))
else if (line.startsWith('--- ')) applyOldPath(st, line.slice(4))
else if (line.startsWith('+++ ')) applyNewPath(st, line.slice(4))
else if (line.startsWith('Binary files')) st.binary = true
}
function applyOldPath(st: BlockState, raw: string): void {
if (raw.trim() === '/dev/null') st.isNew = true
else st.oldPath = stripDiffPath(raw)
}
function applyNewPath(st: BlockState, raw: string): void {
if (raw.trim() === '/dev/null') st.isDeleted = true
else st.newPath = stripDiffPath(raw)
}
function deriveStatus(st: BlockState, binary: boolean): FileStatus {
if (st.isRename) return 'renamed'
if (st.isNew) return 'added'
if (st.isDeleted) return 'deleted'
if (binary) return 'binary'
return 'modified'
}
function countKind(hunks: readonly DiffHunk[], kind: DiffLineKind): number {
let n = 0
for (const h of hunks) for (const l of h.lines) if (l.kind === kind) n += 1
return n
}
function finalizeFile(
st: BlockState,
hunks: DiffHunk[],
numstat?: Map<string, NumstatEntry>,
): DiffFile {
const stat = numstat?.get(st.newPath) ?? numstat?.get(st.oldPath)
const binary = st.binary || stat?.binary === true
const added = stat?.added ?? countKind(hunks, 'added')
const removed = stat?.removed ?? countKind(hunks, 'removed')
return {
oldPath: st.oldPath,
newPath: st.newPath,
status: deriveStatus(st, binary),
added,
removed,
binary,
hunks,
}
}
/** Parse one `diff --git` block (header lines + hunks) into a DiffFile. */
function parseFileBlock(block: readonly string[], numstat?: Map<string, NumstatEntry>): DiffFile | null {
const st: BlockState = {
oldPath: '',
newPath: '',
binary: false,
isNew: false,
isDeleted: false,
isRename: false,
}
const hunks: DiffHunk[] = []
let current: DiffHunk | null = null
for (const line of block) {
if (line.startsWith('@@')) {
current = { header: line, lines: [] }
hunks.push(current)
} else if (current === null) {
applyHeaderLine(st, line)
} else {
current.lines.push(classifyHunkLine(line))
}
}
if (st.oldPath === '' && st.newPath === '') return null
return finalizeFile(st, hunks, numstat)
}
/**
* Parse a full unified `git diff` patch into DiffFile[]. `numstat` (optional)
* supplies authoritative +/- counts and binary flags; without it counts are
* derived from the hunk bodies. Empty / non-diff input → []; never throws.
*/
export function parseUnifiedDiff(patch: string, numstat?: Map<string, NumstatEntry>): DiffFile[] {
if (typeof patch !== 'string' || patch.length === 0) return []
const lines = patch.replace(/\n$/, '').split('\n')
const files: DiffFile[] = []
let i = 0
while (i < lines.length) {
if (lines[i]?.startsWith('diff --git ') !== true) {
i += 1
continue
}
const start = i
i += 1
while (i < lines.length && lines[i]?.startsWith('diff --git ') !== true) i += 1
const file = parseFileBlock(lines.slice(start, i), numstat)
if (file !== null) files.push(file)
}
return files
}
// ── getDiff (git runner) ─────────────────────────────────────────────────────
/** Just the diff limits getDiff needs; the full Config satisfies this Pick. */
export interface GetDiffOptions {
staged: boolean
cfg: Pick<Config, 'diffTimeoutMs' | 'diffMaxBytes' | 'diffMaxFiles'>
}
interface ExecErrorShape {
code?: string
stdout?: string
}
function asExecError(err: unknown): ExecErrorShape {
if (err === null || typeof err !== 'object') return {}
const e = err as { code?: unknown; stdout?: unknown }
return {
code: typeof e.code === 'string' ? e.code : undefined,
stdout: typeof e.stdout === 'string' ? e.stdout : undefined,
}
}
interface GitOutput {
out: string
truncated: boolean
}
/**
* Run a read-only git command, capturing stdout. Bounded by timeout + maxBuffer
* (DoS guard). On a maxBuffer overflow the partial stdout is returned with
* `truncated:true`; any other failure yields empty output (best-effort).
*/
async function runGit(
cwd: string,
args: readonly string[],
timeoutMs: number,
maxBytes: number,
): Promise<GitOutput> {
try {
const { stdout } = await execFileAsync('git', args, {
cwd,
timeout: timeoutMs,
maxBuffer: maxBytes,
})
return { out: stdout, truncated: stdout.length >= maxBytes }
} catch (err: unknown) {
const { code, stdout } = asExecError(err)
if (code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
return { out: stdout ?? '', truncated: true }
}
return { out: '', truncated: false }
}
}
/** Minimal unquote of a C-quoted porcelain path (`"a\"b"` → `a"b`). */
function unquotePorcelain(raw: string): string {
const s = raw.trim()
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
return s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
}
return s
}
/** Untracked files (`git status --porcelain` `??` rows) as `untracked` DiffFiles.
* Content is not diffed (we avoid the `--no-index` two-operand trap, L3). */
async function listUntracked(cwd: string, timeoutMs: number, maxBytes: number): Promise<DiffFile[]> {
const { out } = await runGit(cwd, ['status', '--porcelain', '--'], timeoutMs, maxBytes)
const files: DiffFile[] = []
for (const line of out.split('\n')) {
if (!line.startsWith('?? ')) continue
const p = unquotePorcelain(line.slice(3))
if (p === '') continue
files.push({
oldPath: p,
newPath: p,
status: 'untracked',
added: 0,
removed: 0,
binary: false,
hunks: [],
})
}
return files
}
/**
* Read a repo's diff (working tree or `--staged`) as structured DiffResult.
* `repoPath` must already be a validated absolute git directory (route layer,
* SEC-H7). Best-effort: git failures yield an empty result rather than throwing.
*/
export async function getDiff(repoPath: string, opts: GetDiffOptions): Promise<DiffResult> {
const { staged, cfg } = opts
const { diffTimeoutMs: timeout, diffMaxBytes: maxBytes, diffMaxFiles } = cfg
const stagedArg = staged ? ['--staged'] : []
const patch = await runGit(repoPath, ['diff', '--no-color', ...stagedArg, '--'], timeout, maxBytes)
const num = await runGit(repoPath, ['diff', '--numstat', ...stagedArg, '--'], timeout, maxBytes)
const files = parseUnifiedDiff(patch.out, parseNumstat(num.out))
if (!staged) {
files.push(...(await listUntracked(repoPath, timeout, maxBytes)))
}
let truncated = patch.truncated || num.truncated
const bounded =
files.length > diffMaxFiles ? ((truncated = true), files.slice(0, diffMaxFiles)) : files
return { files: bounded, staged, truncated }
}

View File

@@ -1,15 +1,20 @@
/**
* src/http/hook.ts (H2) — map a Claude Code hook POST to a session status update.
*
* Pure + never throws. The web-terminal sessionId arrives in the
* Pure + never throws (SEC-M7). The web-terminal sessionId arrives in the
* `X-Webterm-Session` header (injected as $WEBTERM_SESSION on spawn); the hook
* payload arrives as JSON in the body. We translate the event into a coarse
* ClaudeStatus the UI can show. The server is still a byte-shuttle for the
* terminal stream — this is an out-of-band side-channel.
*
* T-hook-intake (v0.7): parseHookEvent now returns HookEventFull, which adds
* `at` (server timestamp), `eventClass` (raw hook name), `toolInput` (raw
* passthrough), and `gate` ('plan' | 'tool') for permission-mode relay (B4).
*/
import type { ClaudeStatus } from '../types.js';
import type { ClaudeStatus, PermissionGate } from '../types.js';
/** Base shape kept for backward compat; new callers should use HookEventFull. */
export interface HookEvent {
sessionId: string;
status: ClaudeStatus;
@@ -17,16 +22,56 @@ export interface HookEvent {
}
/**
* @param sessionId value of the `X-Webterm-Session` header
* @param body parsed JSON body of the hook POST (untrusted)
* @returns a status update, or null if the payload is unusable
* Full event shape returned by parseHookEvent (A4 / B4).
*
* `at` — server Date.now() at parse time (timeline anchor).
* `eventClass` — the raw hook_event_name from the whitelist, passed as-is
* so callers (timeline, manager) can make semantic decisions
* without re-parsing.
* `toolInput` — raw tool_input value from the JSON body, unknown type,
* passed through verbatim for timeline / future use.
* `gate` — 'plan' for an ExitPlanMode PermissionRequest (three-way
* approve / auto / keep-planning); 'tool' for any other
* PermissionRequest; absent for all other events.
*/
export function parseHookEvent(sessionId: string | undefined, body: unknown): HookEvent | null {
export interface HookEventFull extends HookEvent {
at: number;
eventClass: string;
toolInput?: unknown;
gate?: PermissionGate;
}
/** Whitelist of hook_event_name values we recognise. Unknown names → null. */
const ALLOWED_EVENTS = new Set([
'PreToolUse',
'PostToolUse',
'UserPromptSubmit',
'PermissionRequest',
'Stop',
'SessionEnd',
'Notification',
]);
/**
* Parse a Claude Code hook POST into a HookEventFull.
*
* @param sessionId Value of the `X-Webterm-Session` header.
* @param body Parsed JSON body of the hook POST (untrusted, unknown).
* @returns A HookEventFull, or null when the payload is unusable.
* Never throws (SEC-M7: unknown + narrowing).
*/
export function parseHookEvent(
sessionId: string | undefined,
body: unknown,
): HookEventFull | null {
if (typeof sessionId !== 'string' || sessionId.length === 0) return null;
if (body === null || typeof body !== 'object') return null;
if (body === null || typeof body !== 'object' || Array.isArray(body)) return null;
const b = body as Record<string, unknown>;
const event = typeof b['hook_event_name'] === 'string' ? b['hook_event_name'] : '';
if (!ALLOWED_EVENTS.has(event)) return null;
const notif = typeof b['notification_type'] === 'string' ? b['notification_type'] : '';
const tool = typeof b['tool_name'] === 'string' ? b['tool_name'] : undefined;
@@ -48,10 +93,32 @@ export function parseHookEvent(sessionId: string | undefined, body: unknown): Ho
status = notif === 'permission_prompt' ? 'waiting' : 'idle';
break;
default:
// Unreachable: ALLOWED_EVENTS guard above catches unknown names.
return null;
}
return status === 'waiting' && tool !== undefined
? { sessionId, status, detail: tool }
: { sessionId, status };
// detail: only when waiting + a tool name is present.
const detail = status === 'waiting' && tool !== undefined ? tool : undefined;
// toolInput: pass through verbatim when the key is present (even if null).
const hasToolInput = 'tool_input' in b;
const toolInput = hasToolInput ? b['tool_input'] : undefined;
// gate: classify the permission gate type (B4).
const gate: PermissionGate | undefined =
event === 'PermissionRequest'
? tool === 'ExitPlanMode'
? 'plan'
: 'tool'
: undefined;
return {
sessionId,
status,
at: Date.now(),
eventClass: event,
...(detail !== undefined ? { detail } : {}),
...(hasToolInput ? { toolInput } : {}),
...(gate !== undefined ? { gate } : {}),
};
}

131
src/http/statusline.ts Normal file
View File

@@ -0,0 +1,131 @@
/**
* src/http/statusline.ts — parse a Claude Code statusLine JSON body (B2).
*
* Pure function, never throws. Maps the statusLine stdin JSON (R0-confirmed
* field names, see StatusTelemetry comment in types.ts) into a StatusTelemetry.
*
* Security: SEC-M7 — unknown input narrowed field-by-field; string lengths capped.
*
* Field mapping (JSON → StatusTelemetry):
* context_window.used_percentage → contextUsedPct
* cost.total_cost_usd → costUsd
* cost.total_lines_added → linesAdded
* cost.total_lines_removed → linesRemoved
* model.display_name → model
* effort.level → effort
* pr → pr (same shape, required: number+url)
* rate_limits → rate (fiveHourPct, sevenDayPct)
*/
import type { StatusTelemetry } from '../types.js';
// String length limits (SEC-M7 — prevent DoS through overly long strings)
const MODEL_MAX_LEN = 200;
const EFFORT_MAX_LEN = 100;
const URL_MAX_LEN = 2000;
const REVIEW_STATE_MAX_LEN = 100;
/**
* Parse the body of a POST /hook/status request into a StatusTelemetry.
*
* @param body - raw untrusted JSON body (unknown type)
* @returns StatusTelemetry with at always set to Date.now(), or null when
* body is not a plain object. Missing/dirty fields → undefined.
* Never throws.
*/
export function parseStatusLine(body: unknown): StatusTelemetry | null {
// Non-objects (including null and arrays) → null
if (body === null || typeof body !== 'object' || Array.isArray(body)) return null;
const b = body as Record<string, unknown>;
const contextUsedPct = safeFiniteNumber(nestedValue(b, 'context_window', 'used_percentage'));
const costObj = asObjectOrNull(b['cost']);
const costUsd = safeFiniteNumber(costObj?.['total_cost_usd']);
const linesAdded = safeFiniteNumber(costObj?.['total_lines_added']);
const linesRemoved = safeFiniteNumber(costObj?.['total_lines_removed']);
const modelObj = asObjectOrNull(b['model']);
const model = safeBoundedString(modelObj?.['display_name'], MODEL_MAX_LEN);
const effortObj = asObjectOrNull(b['effort']);
const effort = safeBoundedString(effortObj?.['level'], EFFORT_MAX_LEN);
const pr = parsePr(b['pr']);
const rate = parseRate(b['rate_limits']);
return {
at: Date.now(),
...(contextUsedPct !== undefined && { contextUsedPct }),
...(costUsd !== undefined && { costUsd }),
...(linesAdded !== undefined && { linesAdded }),
...(linesRemoved !== undefined && { linesRemoved }),
...(model !== undefined && { model }),
...(effort !== undefined && { effort }),
...(pr !== undefined && { pr }),
...(rate !== undefined && { rate }),
};
}
// ─── Private helpers ─────────────────────────────────────────────────────────
/** Get obj[key1][key2], or undefined if either level is not a plain object. */
function nestedValue(obj: Record<string, unknown>, key1: string, key2: string): unknown {
const inner = asObjectOrNull(obj[key1]);
return inner !== null ? inner[key2] : undefined;
}
/** Return v if it is a finite number; otherwise undefined. */
function safeFiniteNumber(v: unknown): number | undefined {
return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
}
/** Return v if it is a string within maxLen; otherwise undefined. */
function safeBoundedString(v: unknown, maxLen: number): string | undefined {
if (typeof v !== 'string') return undefined;
return v.length <= maxLen ? v : undefined;
}
/** Cast v to a Record if it is a non-null, non-array object; otherwise null. */
function asObjectOrNull(v: unknown): Record<string, unknown> | null {
if (v === null || typeof v !== 'object' || Array.isArray(v)) return null;
return v as Record<string, unknown>;
}
/** Parse the pr field. Returns undefined unless both number (finite) and url (bounded) are valid. */
function parsePr(
v: unknown,
): { number: number; url: string; reviewState?: string } | undefined {
const obj = asObjectOrNull(v);
if (obj === null) return undefined;
const num = safeFiniteNumber(obj['number']);
if (num === undefined) return undefined;
const url = safeBoundedString(obj['url'], URL_MAX_LEN);
if (url === undefined) return undefined;
const reviewState = safeBoundedString(obj['reviewState'], REVIEW_STATE_MAX_LEN);
return {
number: num,
url,
...(reviewState !== undefined && { reviewState }),
};
}
/** Parse rate_limits → rate. Returns undefined if rate_limits is not an object.
* Returns a (possibly empty) object with undefined sub-fields when the object
* exists but sub-field values fail Number.isFinite validation. */
function parseRate(v: unknown): { fiveHourPct?: number; sevenDayPct?: number } | undefined {
const obj = asObjectOrNull(v);
if (obj === null) return undefined;
const fiveHourPct = safeFiniteNumber(obj['fiveHourPct']);
const sevenDayPct = safeFiniteNumber(obj['sevenDayPct']);
return {
...(fiveHourPct !== undefined && { fiveHourPct }),
...(sevenDayPct !== undefined && { sevenDayPct }),
};
}

View File

@@ -5,15 +5,22 @@
* record is the main worktree. Best-effort: a non-repo / git error yields [].
*/
import fs from 'node:fs/promises'
import path from 'node:path'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import type { WorktreeInfo } from '../types.js'
import type { CreateWorktreeResult, WorktreeInfo } from '../types.js'
const execFileAsync = promisify(execFile)
const WORKTREE_TIMEOUT_MS = 2000
const WORKTREE_MAX_BUFFER = 1024 * 1024
const MAX_BRANCH_LEN = 250
// git ref-name forbidden punctuation subset + backslash (SEC-H2). Control chars
// and whitespace are matched separately via \s and the \x00-\x1f\x7f range.
const FORBIDDEN_BRANCH_CHARS = /[\x00-\x1f\x7f\s~^:?*[\\]/
/**
* Parse `git worktree list --porcelain`. `currentPath` flags which worktree is
* the requested project (isCurrent). The first record is the main worktree.
@@ -71,3 +78,174 @@ export async function listWorktrees(repoPath: string): Promise<WorktreeInfo[]> {
return []
}
}
// ── B3: create a worktree (validate → contain → execFile, no shell) ─────────────
/**
* Validate a user-supplied branch name against a git-ref-name subset (SEC-H2).
* Rejects: empty / >250 chars / leading '-' (flag injection) / '..' / trailing
* '.lock' or '.' / control chars / whitespace / ~^:?*[\ / '@{' / leading,
* trailing or consecutive '/'. Pure + unit-tested.
*/
export function validateBranchName(branch: string): boolean {
if (typeof branch !== 'string') return false
if (branch.length === 0 || branch.length > MAX_BRANCH_LEN) return false
if (branch.startsWith('-')) return false
if (branch.startsWith('/') || branch.endsWith('/') || branch.includes('//')) return false
if (branch.includes('..')) return false
if (branch.endsWith('.lock') || branch.endsWith('.')) return false
if (branch.includes('@{')) return false
if (FORBIDDEN_BRANCH_CHARS.test(branch)) return false
return true
}
/**
* Reduce a branch name to a single safe filesystem directory segment: '/' → '-',
* any non-[A-Za-z0-9._-] char → '-', then trim leading/trailing dashes. Pure.
*/
export function sanitizeBranchForDir(branch: string): string {
return branch
.replace(/\//g, '-')
.replace(/[^A-Za-z0-9._-]/g, '-')
.replace(/^-+|-+$/g, '')
}
/**
* Resolve symlinks on the longest existing prefix of `target`, re-appending any
* not-yet-existing trailing segments. Lets us realpath a path we're about to
* create without it existing yet (SEC-H3/M2).
*/
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
}
}
}
/**
* Compute the absolute directory for a new worktree and prove it is contained in
* the controlled base. base = `root ?? <dirname(repo)>/<basename(repo)>-worktrees`.
* Both base and the candidate are realpath-resolved (symlinks followed) before a
* `startsWith(realBase + sep)` containment check — defeating symlinked-root /
* pre-planted-symlink escapes (M2). Throws when the candidate escapes the base.
*/
export async function computeWorktreeDir(
repoPath: string,
sanitized: string,
root?: string,
): Promise<string> {
const base =
root ?? path.join(path.dirname(repoPath), path.basename(repoPath) + '-worktrees')
const candidate = path.join(base, sanitized)
const realBase = await resolveRealPath(base)
const realCandidate = await resolveRealPath(candidate)
if (realCandidate !== realBase && realCandidate.startsWith(realBase + path.sep)) {
return candidate
}
throw new Error('worktree path escapes the controlled root')
}
/** True iff `<dir>/.git` exists (file or directory). */
async function hasGitEntry(dir: string): Promise<boolean> {
try {
await fs.stat(path.join(dir, '.git'))
return true
} catch {
return false
}
}
/** Three-prong entry check: absolute + isDirectory + has a .git entry. */
async function isGitRepo(repoPath: string): Promise<boolean> {
if (typeof repoPath !== 'string' || !path.isAbsolute(repoPath)) return false
try {
const stat = await fs.stat(repoPath)
if (!stat.isDirectory()) return false
} catch {
return false
}
return hasGitEntry(repoPath)
}
/** Pull a best-effort stderr string off a child_process error (never throws). */
function extractStderr(err: unknown): string {
if (typeof err === 'object' && err !== null) {
const e = err as { stderr?: unknown; message?: unknown }
if (typeof e.stderr === 'string') return e.stderr
if (typeof e.message === 'string') return e.message
}
return ''
}
/**
* Map a `git worktree add` failure to a structured result with a SAFE message
* (never raw git stderr, SEC-M10). Branch/path-exists collisions → 409, else 500.
*/
function classifyWorktreeError(err: unknown): CreateWorktreeResult {
const s = extractStderr(err).toLowerCase()
if (s.includes('already checked out') || s.includes('already used by worktree')) {
return { ok: false, status: 409, error: 'That branch is already checked out in another worktree.' }
}
if (s.includes('already exists') && s.includes('branch')) {
return { ok: false, status: 409, error: 'A branch with that name already exists.' }
}
if (s.includes('already exists')) {
return { ok: false, status: 409, error: 'The target directory already exists.' }
}
return { ok: false, status: 500, error: 'Failed to create the worktree.' }
}
export interface CreateWorktreeOptions {
readonly base?: string // optional commit-ish to branch from (FR-B3.9)
readonly worktreeRoot?: string // cfg.worktreeRoot; undefined → <repo>-worktrees
readonly timeoutMs: number // cfg.worktreeTimeoutMs
}
/**
* Create a git worktree on a new branch. Validates the branch, three-prong
* checks the repo, computes a contained target dir, then runs
* `git worktree add -b <branch> -- <dir> [<base>]` via execFile (no shell, `--`
* terminates options). Returns a structured CreateWorktreeResult; never throws.
*/
export async function createWorktree(
repoPath: string,
branch: string,
opts: CreateWorktreeOptions,
): Promise<CreateWorktreeResult> {
if (!validateBranchName(branch)) {
return { ok: false, status: 400, error: 'Invalid branch name.' }
}
if (!(await isGitRepo(repoPath))) {
return { ok: false, status: 404, error: 'Not a git repository.' }
}
const sanitized = sanitizeBranchForDir(branch)
let dir: string
try {
dir = await computeWorktreeDir(repoPath, sanitized, opts.worktreeRoot)
} catch {
return { ok: false, status: 400, error: 'Resolved worktree path is out of bounds.' }
}
try {
const args = ['worktree', 'add', '-b', branch, '--', dir]
if (opts.base !== undefined && opts.base !== '') args.push(opts.base)
await execFileAsync('git', args, {
cwd: repoPath,
timeout: opts.timeoutMs,
maxBuffer: WORKTREE_MAX_BUFFER,
})
return { ok: true, path: dir, branch }
} catch (err: unknown) {
return classifyWorktreeError(err)
}
}