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:
203
test/integration/worktree.test.ts
Normal file
203
test/integration/worktree.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* T-server-wire — integration tests for the B3 worktree-create and B1 diff routes.
|
||||
*
|
||||
* Covers (against a real startServer):
|
||||
* - POST /projects/worktree → Origin guard (403), WORKTREE_ENABLED=0 (403),
|
||||
* invalid branch (400), missing fields (400),
|
||||
* real creation in a temp git repo (git-gated)
|
||||
* - GET /projects/diff → 400 missing path, 404 non-git path, structured
|
||||
* DiffResult for a real repo with verbatim content
|
||||
*/
|
||||
|
||||
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'
|
||||
import type { DiffResult } from '../../src/types.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}` }
|
||||
}
|
||||
|
||||
/** Create a temp git repo with one committed file. Returns its absolute path. */
|
||||
async function makeRealRepo(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-wt-'))
|
||||
tmpDirs.push(dir)
|
||||
const git = (args: string[]): void => {
|
||||
execFileSync('git', args, { cwd: dir, stdio: 'ignore' })
|
||||
}
|
||||
git(['init'])
|
||||
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
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
|
||||
// ── POST /projects/worktree ──────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /projects/worktree (B3)', () => {
|
||||
it('rejects a foreign Origin with 403 (SEC-C3)', async () => {
|
||||
const { port } = await spawnServer()
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' },
|
||||
body: JSON.stringify({ path: '/tmp/x', branch: 'feature' }),
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('rejects a missing Origin with 403', async () => {
|
||||
const { port } = await spawnServer()
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: '/tmp/x', branch: 'feature' }),
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 403 when WORKTREE_ENABLED=0', async () => {
|
||||
const { port, origin } = await spawnServer({ WORKTREE_ENABLED: '0' })
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: origin },
|
||||
body: JSON.stringify({ path: '/tmp/x', branch: 'feature' }),
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('rejects an invalid branch name with 400 (no git executed, SEC-H2)', async () => {
|
||||
const { port, origin } = await spawnServer()
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: origin },
|
||||
body: JSON.stringify({ path: '/tmp/x', branch: '../evil' }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects a missing branch field with 400', async () => {
|
||||
const { port, origin } = await spawnServer()
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: origin },
|
||||
body: JSON.stringify({ path: '/tmp/x' }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
itGit('creates a worktree in a real git repo and returns its path', async () => {
|
||||
const repo = await makeRealRepo()
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-wtroot-'))
|
||||
tmpDirs.push(root)
|
||||
const { port, origin } = await spawnServer({ WORKTREE_ROOT: root })
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: origin },
|
||||
body: JSON.stringify({ path: repo, branch: 'feature-x' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { ok: boolean; path?: string; branch?: string }
|
||||
expect(body.ok).toBe(true)
|
||||
expect(body.branch).toBe('feature-x')
|
||||
expect(typeof body.path).toBe('string')
|
||||
// The created dir actually exists and is a worktree.
|
||||
const list = execFileSync('git', ['worktree', 'list'], { cwd: repo }).toString()
|
||||
expect(list).toContain(body.path!)
|
||||
})
|
||||
})
|
||||
|
||||
// ── GET /projects/diff ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /projects/diff (B1)', () => {
|
||||
it('returns 400 when the path query parameter is missing', async () => {
|
||||
const { port } = await spawnServer()
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects/diff`)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 for a non-git directory (SEC-H7)', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-nogit-'))
|
||||
tmpDirs.push(dir)
|
||||
const { port } = await spawnServer()
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(dir)}`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
itGit('returns a structured DiffResult with verbatim content (AC-B1.4)', async () => {
|
||||
const repo = await makeRealRepo()
|
||||
// Introduce a tracked change containing a would-be XSS payload.
|
||||
await fs.writeFile(path.join(repo, 'file.txt'), 'line1\n<script>x</script>\n', 'utf8')
|
||||
const { port } = await spawnServer()
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(repo)}`)
|
||||
expect(res.status).toBe(200)
|
||||
const diff = (await res.json()) as DiffResult
|
||||
expect(diff.staged).toBe(false)
|
||||
expect(diff.files.length).toBeGreaterThan(0)
|
||||
const flat = JSON.stringify(diff)
|
||||
// The diff carries the raw text verbatim (the FE renders it inert via textContent).
|
||||
expect(flat).toContain('<script>x</script>')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user