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

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)
}
}