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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user